diff --git a/changelog.d/added-prebuild-script-inprocess-tests.md b/changelog.d/added-prebuild-script-inprocess-tests.md new file mode 100644 index 0000000..5d2a741 --- /dev/null +++ b/changelog.d/added-prebuild-script-inprocess-tests.md @@ -0,0 +1 @@ +- Add in-process tests for the prebuild scripts `scripts/generate-shared-config.ts` and `scripts/update-meeting-recordings.ts` so coverage attributes them (previously only exercised through a spawned `tsx` child), and ratchet the Vitest coverage thresholds. diff --git a/scripts/generate-shared-config.inprocess.test.ts b/scripts/generate-shared-config.inprocess.test.ts new file mode 100644 index 0000000..2c2cd5d --- /dev/null +++ b/scripts/generate-shared-config.inprocess.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PROJECTS } from "../src/config/versions"; + +/** + * generate-shared-config.ts runs at import time and resolves its input and + * output paths from its own file location, so generate-shared-config.test.ts + * copies it into a temp repo and spawns it via tsx — correct, but invisible + * to v8 coverage. Here the script is imported in-process with the default + * `node:fs` export mocked: reads of shared.json are served from a fixture and + * the write is captured, so the repo's real public/config/shared.json is + * never touched while the script body is attributed in the coverage report. + */ + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + ".." +); +const sharedJsonPath = path.join(repoRoot, "public", "config", "shared.json"); + +const fsState = vi.hoisted(() => ({ + existing: null as string | null, + written: [] as Array<{ file: string; data: string }>, +})); + +vi.mock("node:fs", async importOriginal => { + const actual = await importOriginal(); + const mocked = { + ...actual, + existsSync: (file: import("node:fs").PathLike) => + String(file) === sharedJsonPath + ? fsState.existing !== null + : actual.existsSync(file), + readFileSync: (( + file: import("node:fs").PathOrFileDescriptor, + ...rest: unknown[] + ) => + String(file) === sharedJsonPath && fsState.existing !== null + ? fsState.existing + : (actual.readFileSync as (...a: unknown[]) => unknown)( + file, + ...rest + )) as typeof actual.readFileSync, + writeFileSync: (( + file: import("node:fs").PathOrFileDescriptor, + data: string | NodeJS.ArrayBufferView + ) => { + fsState.written.push({ file: String(file), data: String(data) }); + }) as typeof actual.writeFileSync, + }; + return { ...mocked, default: mocked }; +}); + +async function runScript() { + vi.resetModules(); + await import("./generate-shared-config"); + await vi.waitFor(() => { + expect(fsState.written.length).toBeGreaterThan(0); + }); + const [write] = fsState.written; + return { write, config: JSON.parse(write.data) }; +} + +let logSpy: ReturnType; + +beforeEach(() => { + fsState.existing = null; + fsState.written = []; + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("generate-shared-config (in-process)", () => { + it("writes shared.json next to public/config with versions derived from versions.ts", async () => { + const { write, config } = await runScript(); + + expect(write.file).toBe(sharedJsonPath); + expect(write.data.endsWith("\n")).toBe(true); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Generated ${path.join("public", "config", "shared.json")} from versions.ts` + ) + ); + + expect(Object.keys(config.versions).sort()).toEqual( + Object.keys(PROJECTS).sort() + ); + for (const [id, project] of Object.entries(PROJECTS)) { + expect(config.projects[id]).toEqual({ + name: project.name, + basePath: project.basePath, + currentVersion: project.currentVersion, + }); + for (const [key, v] of Object.entries(project.versions)) { + const entry = config.versions[id][key]; + expect(entry).toMatchObject({ + label: v.label, + branch: v.branch, + isDefault: v.isDefault, + }); + // Optional fields are only emitted when set, so the JSON stays minimal. + if (v.externalUrl) expect(entry.externalUrl).toBe(v.externalUrl); + else expect(entry).not.toHaveProperty("externalUrl"); + if (v.isDev) expect(entry.isDev).toBe(true); + else expect(entry).not.toHaveProperty("isDev"); + } + } + expect(Number.isNaN(Date.parse(config.updatedAt))).toBe(false); + }); + + it("starts from empty hand-maintained fields when no shared.json exists", async () => { + fsState.existing = null; + const { config } = await runScript(); + + expect(config.surveyUrl).toBeUndefined(); + expect(config.relatedProjects).toEqual([]); + expect(config.editBaseUrls).toEqual({}); + }); + + it("preserves surveyUrl, relatedProjects and editBaseUrls from the existing file", async () => { + fsState.existing = JSON.stringify({ + surveyUrl: "https://survey.example.test", + relatedProjects: [ + { name: "Sibling", url: "https://sibling.example.test" }, + ], + editBaseUrls: { custom: "https://github.com/example/custom/edit/main" }, + versions: { stale: {} }, + projects: { stale: {} }, + }); + + const { config } = await runScript(); + + expect(config.surveyUrl).toBe("https://survey.example.test"); + expect(config.relatedProjects).toEqual([ + { name: "Sibling", url: "https://sibling.example.test" }, + ]); + expect(config.editBaseUrls).toEqual({ + custom: "https://github.com/example/custom/edit/main", + }); + // Version/project maps always come from versions.ts, never the old file. + expect(config.versions).not.toHaveProperty("stale"); + expect(config.projects).not.toHaveProperty("stale"); + }); + + it("tolerates an existing file without editBaseUrls", async () => { + fsState.existing = JSON.stringify({ surveyUrl: "https://s.example.test" }); + + const { config } = await runScript(); + + expect(config.editBaseUrls).toEqual({}); + expect(config.relatedProjects).toEqual([]); + }); +}); diff --git a/scripts/update-meeting-recordings.inprocess.test.ts b/scripts/update-meeting-recordings.inprocess.test.ts new file mode 100644 index 0000000..412ec64 --- /dev/null +++ b/scripts/update-meeting-recordings.inprocess.test.ts @@ -0,0 +1,353 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * update-meeting-recordings.ts has no exported entry point — it resolves + * DATA_PATH from process.cwd(), calls global fetch, and ends in `void main()`. + * update-meeting-recordings.test.ts drives it through a spawned tsx child, + * which exercises the real CLI but leaves v8 coverage blind to the script. + * + * These cases import the module in-process (vi.resetModules() per case) after + * pointing cwd at a throwaway directory and stubbing fetch, so the same + * behaviour is attributed to the script in the coverage report. + */ + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + ".." +); +const workRoot = path.join( + repoRoot, + ".test-work", + "meeting-recordings-inprocess" +); +const FEED_URL = + "https://www.youtube.com/feeds/videos.xml?channel_id=UCIA3fKJFv2nLoG6vKK65xLg"; +const CHANNEL_ID = "UCIA3fKJFv2nLoG6vKK65xLg"; + +let caseDir: string; +let originalCwd: string; +let logSpy: ReturnType; +let warnSpy: ReturnType; + +function writeExisting(store: unknown) { + fs.writeFileSync( + path.join(caseDir, "data", "meeting-recordings.json"), + JSON.stringify(store) + ); +} + +function readData() { + return JSON.parse( + fs.readFileSync( + path.join(caseDir, "data", "meeting-recordings.json"), + "utf8" + ) + ); +} + +function stubFeed(xml: string, ok = true, status = 200) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok, status, text: async () => xml })) + ); +} + +/** Import the script fresh and wait for its fire-and-forget main() to settle. */ +async function runInProcess() { + vi.resetModules(); + await import("./update-meeting-recordings"); + await vi.waitFor(() => { + expect( + logSpy.mock.calls.length + warnSpy.mock.calls.length + ).toBeGreaterThan(0); + }); +} + +beforeEach(() => { + fs.mkdirSync(workRoot, { recursive: true }); + caseDir = fs.mkdtempSync(path.join(workRoot, "case-")); + fs.mkdirSync(path.join(caseDir, "data")); + originalCwd = process.cwd(); + process.chdir(caseDir); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + process.chdir(originalCwd); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + fs.rmSync(caseDir, { recursive: true, force: true }); +}); + +describe("update-meeting-recordings (in-process)", () => { + it("merges feed entries over committed data and normalises source/channel", async () => { + writeExisting({ + updatedAt: "2026-01-01T00:00:00.000Z", + source: "old-source", + channelId: "old-channel", + recordings: [ + { + id: "oldvid00001", + title: "Community Meeting #1", + publishedAt: "2026-01-01T00:00:00Z", + url: "https://example.test/old", + thumbnail: "https://i.ytimg.com/vi/oldvid00001/hqdefault.jpg", + duration: "PT30M", + }, + ], + }); + stubFeed(` + + newvid00001 + Hive Commons Community Call & demo + 2026-02-01T00:00:00Z + 2026-02-02T00:00:00Z + + + News & notes + + skipUnrelated demo + `); + + await runInProcess(); + + expect(fetch).toHaveBeenCalledWith(FEED_URL); + expect(logSpy).toHaveBeenCalledWith("meeting recordings: 2"); + const data = readData(); + expect(data.source).toBe(FEED_URL); + expect(data.channelId).toBe(CHANNEL_ID); + expect(data.recordings.map((r: { id: string }) => r.id)).toEqual([ + "newvid00001", + "oldvid00001", + ]); + expect(data.recordings[0]).toMatchObject({ + title: "Hive Commons Community Call & demo", + url: "https://youtu.be/newvid00001", + thumbnail: "https://i.ytimg.com/vi/newvid00001/hqdefault.jpg", + description: "News & notes", + updatedAt: "2026-02-02T00:00:00Z", + }); + expect(data.recordings[1].duration).toBe("PT30M"); + expect(Date.parse(data.updatedAt)).toBeGreaterThan( + Date.parse("2026-01-01T00:00:00.000Z") + ); + }); + + it("keeps committed data untouched when the fetch throws", async () => { + const existing = { + updatedAt: "2026-01-01T00:00:00.000Z", + source: FEED_URL, + channelId: CHANNEL_ID, + recordings: [ + { + id: "keepvid0001", + title: "Community meeting fallback", + publishedAt: "2026-01-01T00:00:00Z", + url: "https://example.test/keep", + thumbnail: "https://i.ytimg.com/vi/keepvid0001/hqdefault.jpg", + }, + ], + }; + writeExisting(existing); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down"); + }) + ); + + await runInProcess(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "meeting recordings feed unavailable; keeping committed data: network down" + ) + ); + expect(logSpy).toHaveBeenCalledWith("meeting recordings: 1 (unchanged)"); + expect(readData()).toEqual(existing); + }); + + it("treats a non-2xx feed response as unavailable and reports the status", async () => { + writeExisting({ + updatedAt: "2026-01-01T00:00:00.000Z", + source: FEED_URL, + channelId: CHANNEL_ID, + recordings: [], + }); + stubFeed("", false, 503); + + await runInProcess(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("YouTube feed returned 503") + ); + expect(logSpy).toHaveBeenCalledWith("meeting recordings: 0 (unchanged)"); + }); + + it("stringifies non-Error rejections in the unavailable warning", async () => { + writeExisting({ + updatedAt: "2026-01-01T00:00:00.000Z", + source: FEED_URL, + channelId: CHANNEL_ID, + recordings: [], + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw "plain string failure"; + }) + ); + + await runInProcess(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("plain string failure") + ); + }); + + it("starts from an empty store when the committed file is missing or unparsable", async () => { + fs.writeFileSync( + path.join(caseDir, "data", "meeting-recordings.json"), + "{not json" + ); + stubFeed(` + + freshvid001 + Meeting recording: kickoff + 2026-03-01T00:00:00Z + + + nodatevid01 + Meeting recording: undated + + `); + + await runInProcess(); + + expect(logSpy).toHaveBeenCalledWith("meeting recordings: 2"); + const data = readData(); + expect(data.recordings).toContainEqual({ + id: "freshvid001", + title: "Meeting recording: kickoff", + publishedAt: "2026-03-01T00:00:00Z", + url: "https://www.youtube.com/watch?v=freshvid001", + thumbnail: "https://i.ytimg.com/vi/freshvid001/hqdefault.jpg", + description: "", + }); + expect(data.recordings).toContainEqual( + expect.objectContaining({ id: "nodatevid01", publishedAt: "" }) + ); + }); + + it("drops malformed video ids and falls back to canonical urls for unsafe hosts/schemes", async () => { + writeExisting({ + updatedAt: "2026-01-01T00:00:00.000Z", + source: FEED_URL, + channelId: CHANNEL_ID, + recordings: [], + }); + stubFeed(` + + ../evilpath + Community meeting bad id + 2026-02-01T00:00:00Z + + + goodvid0001 + Community meeting bad urls + 2026-02-01T00:00:00Z + + + + + goodvid0002 + Community meeting http link + 2026-02-03T00:00:00Z + + + + + goodvid0003 + Community meeting ytimg subdomain + 2026-02-02T00:00:00Z + + + + `); + + await runInProcess(); + + const data = readData(); + expect(data.recordings.map((r: { id: string }) => r.id)).toEqual([ + "goodvid0002", + "goodvid0003", + "goodvid0001", + ]); + expect(data.recordings[2]).toMatchObject({ + url: "https://www.youtube.com/watch?v=goodvid0001", + thumbnail: "https://i.ytimg.com/vi/goodvid0001/hqdefault.jpg", + }); + expect(data.recordings[0]).toMatchObject({ + url: "https://www.youtube.com/watch?v=goodvid0002", + thumbnail: "https://i.ytimg.com/vi/goodvid0002/hqdefault.jpg", + }); + expect(data.recordings[1]).toMatchObject({ + url: "https://youtube.com/watch?v=goodvid0003", + thumbnail: "https://i9.ytimg.com/vi/goodvid0003/hqdefault.jpg", + }); + }); + + it("decodes entities once so double-encoded markup stays escaped", async () => { + writeExisting({ + updatedAt: "2026-01-01T00:00:00.000Z", + source: FEED_URL, + channelId: CHANNEL_ID, + recordings: [], + }); + stubFeed(` + + encvideo001 + Community meeting &lt;demo&gt; "q" 'a' + 2026-02-01T00:00:00Z + Notes &amp; more + + `); + + await runInProcess(); + + expect(readData().recordings[0]).toMatchObject({ + title: `Community meeting <demo> "q" 'a'`, + description: "Notes & more", + }); + }); + + it("rewrites the store when only source/channel metadata drifted", async () => { + const recordings = [ + { + id: "samevid0001", + title: "Community meeting same", + publishedAt: "2026-01-01T00:00:00Z", + url: "https://www.youtube.com/watch?v=samevid0001", + thumbnail: "https://i.ytimg.com/vi/samevid0001/hqdefault.jpg", + }, + ]; + writeExisting({ + updatedAt: "2026-01-01T00:00:00.000Z", + source: "legacy-source", + channelId: CHANNEL_ID, + recordings, + }); + stubFeed(""); + + await runInProcess(); + + expect(logSpy).toHaveBeenCalledWith("meeting recordings: 1"); + const data = readData(); + expect(data.source).toBe(FEED_URL); + expect(data.recordings).toEqual(recordings); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 27f14e1..d6d8d14 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,12 +28,12 @@ export default defineConfig({ include: ['src/**/*.{ts,tsx}', 'scripts/**/*.ts', 'netlify/**/*.{ts,mts}'], exclude: ['**/*.test.ts', '**/*.test.tsx', '**/*.d.ts'], // Ratchet: raise these as coverage grows; never lower without a reason - // in the PR. Measured at the last ratchet: 85.2 / 84.8 / 79.6 / 84.2. + // in the PR. Measured at the last ratchet: 87.9 / 87.8 / 81.8 / 87.0. thresholds: { - lines: 82, - functions: 82, - branches: 77, - statements: 81, + lines: 85, + functions: 85, + branches: 79, + statements: 84, }, }, },