import { test, expect } from "@playwright/test"; import { execFileSync } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // --------------------------------------------------------------------------- // The build driver. // // Every one of these runs OFFLINE. The fixture writes stub YTDLP_BIN and // QRENCODE_BIN and playwright.config points the server at them, so the chain -- // preflight, resolve, build, verify -- exercises its real code with no network // and a deterministic answer. The stub reports `gone1` removed the way a deleted // upload is, which is what gives the preflight's blocking path a true answer // rather than a plausible one. // // build-fixture is its own project. The bench specs write windows and the index // specs assert them; a build stamps deliverables aside and gets refused when one // is newer than its manifest. Sharing would make each suite's result depend on // the other's order. // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const UMTOOL = path.join(HERE, ".."); const FIXTURE = path.join(UMTOOL, ".e2e-song"); const PROJECT = "reports/build-fixture"; const OUT = path.join(FIXTURE, "reports", "build-fixture", "out"); const FINAL = path.join(OUT, "build-fixture.mp4"); type Job = { id: string; state: string; error: string | null }; async function waitFor( request: { get: (u: string) => Promise<{ json: () => Promise }> }, id: string, ms = 120_000, ) { const until = Date.now() + ms; while (Date.now() < until) { const j = (await (await request.get(`/api/report/build?job=${id}`)).json()) as Job & { events: { ev: string; id?: string }[]; }; if (j.state !== "running") return j; await new Promise((r) => setTimeout(r, 400)); } throw new Error("the job never finished"); } test("a dry run prints the exact chain, and runs nothing", async ({ request }) => { const r = await request.post("/api/report/build?dry=1", { data: { project: PROJECT, preset: "fast" }, }); const j = (await r.json()) as { dry: boolean; steps: { label: string; argv: string[]; timeoutMs: number }[] }; expect(j.dry).toBe(true); // Preflight is a STEP, not a preamble: it is the one fact that goes stale in // both directions, and without it a dead source is found twenty minutes and a // dozen paid-for fetches in. expect(j.steps[0].argv.join(" ")).toContain("check-availability.mjs"); expect(j.steps[1].argv.join(" ")).toContain("resolve-windows.mjs"); // Dry: the widener is never handed --write from here. Applying is explicit. expect(j.steps[1].argv).not.toContain("--write"); expect(j.steps[2].argv.join(" ")).toContain("build-video.mjs"); expect(j.steps[2].argv).toContain("--continue-on-error"); expect(j.steps.at(-1)!.argv.join(" ")).toContain("verify-build.mjs"); // The build asks for more than the 15-minute default, which exists to catch // the accidental hour-long job and would SIGKILL a real 19-clip run. expect(j.steps[2].timeoutMs).toBeGreaterThanOrEqual(15 * 60_000); }); test("the options the driver used to hide land in the argv", async ({ request }) => { const dry = (o: Record) => request.post("/api/report/build?dry=1", { data: { project: PROJECT, preset: "final", options: o } }); // chapters-only skips the preflight and the resolve: neither touches a source. const co = (await (await dry({ chaptersOnly: true })).json()) as { steps: { argv: string[]; timeoutMs: number }[] }; expect(co.steps[0].argv).toContain("--chapters-only"); expect(co.steps.map((s) => s.argv.join(" ")).join("\n")).not.toContain("check-availability.mjs"); expect(co.steps[0].timeoutMs).toBe(5 * 60_000); const full = (await (await dry({ variant: "full", xfade: false })).json()) as { steps: { argv: string[] }[] }; const build = full.steps.find((s) => s.argv.join(" ").includes("build-video.mjs"))!; expect(build.argv.slice(build.argv.indexOf("--variant"), build.argv.indexOf("--variant") + 2)).toEqual(["--variant", "full"]); expect(build.argv).toContain("--no-xfade"); // verify is told which cut to measure. expect(full.steps.at(-1)!.argv).toContain("full"); const pv = (await (await dry({ preview: { at: 4, dur: 10 } })).json()) as { steps: { argv: string[] }[] }; expect(pv.steps).toHaveLength(1); expect(pv.steps[0].argv.join(" ")).toContain("--preview 4 10"); // An unknown variant is refused before anything runs. expect((await dry({ variant: "nope" })).status()).toBe(400); expect((await dry({ chaptersOnly: true, preview: { at: 0, dur: 1 } })).status()).toBe(400); }); test("a build runs end to end, offline, and the file is verified", async ({ request }) => { const start = await request.post("/api/report/build", { data: { project: PROJECT, preset: "fast" }, }); expect(start.ok()).toBeTruthy(); const { job } = (await start.json()) as { job: Job }; const done = await waitFor(request, job.id); expect(done.state, done.error ?? "").toBe("done"); expect(existsSync(FINAL)).toBe(true); // Per-clip progress, from the pipeline's own NDJSON. "Step 3 of 4, running" // is not progress when step 3 is the twenty-minute one. const events = (done as unknown as { events: { ev: string; id?: string }[] }).events ?? []; expect(events.some((e) => e.ev === "segment" && e.id === "c01")).toBe(true); expect(events.some((e) => e.ev === "segment" && e.id === "c02")).toBe(true); expect(events.some((e) => e.ev === "done")).toBe(true); // c01's window is already cached, so the build must REUSE it rather than ask // the stub for it -- the same containment rule the bench's wide fetch relies on. const fetches = events.filter((e) => e.ev === "fetch"); expect(fetches.find((e) => e.id === "c01")).toMatchObject({ cached: true }); // EXTENT vs CUT. c01 was reviewed at 3.00-6.00 and cut to 3.50-4.20, so the // build renders the cut (plus the 0.4 s lead-in) and not the extent -- while // the FETCH above still asked for the extent, which is what makes a re-cut // free. const cut = events.find((e) => e.ev === "cut" && e.id === "c01") as | { seconds: number; cut: [number, number] } | undefined; expect(cut, "the build should say it is cutting inside the extent").toBeTruthy(); expect(cut!.cut[0]).toBeCloseTo(3.1, 2); expect(cut!.cut[1]).toBeCloseTo(4.2, 2); // And the encoded segment is that length, not the extent's three seconds. // Snapping still moves the start to the silence at 2.90; the end is more // than snapWindow from the next one and stays where the cut put it. const snap = events.find((e) => e.ev === "snap" && e.id === "c01") as | { seconds: number } | undefined; expect(snap!.seconds).toBeGreaterThan(0.8); expect(snap!.seconds).toBeLessThan(2.5); }); test("an existing deliverable is not destroyed to make a new one", async ({ request }) => { // The previous test built it, so out/ is now newer than the manifest. expect(existsSync(FINAL)).toBe(true); const refused = await request.post("/api/report/build", { data: { project: PROJECT, preset: "fast" }, }); expect(refused.status()).toBe(409); const j = (await refused.json()) as { needsReplace: boolean; error: string }; expect(j.needsReplace).toBe(true); expect(j.error).toContain("newer than the manifest"); // With replace=1 the old file is STAMPED ASIDE, never overwritten -- it cost // an hour of network fetches in the real case. const ok = await request.post("/api/report/build?replace=1", { data: { project: PROJECT, preset: "fast" }, }); expect(ok.ok()).toBeTruthy(); const stamped = readdirSync(OUT).filter((n) => /^build-fixture\.\d{8}-\d{4}\.mp4$/.test(n)); expect(stamped.length).toBeGreaterThan(0); const { job } = (await ok.json()) as { job: Job }; const done = await waitFor(request, job.id); expect(done.state, done.error ?? "").toBe("done"); }); test("a source that is gone blocks the build at step 1", async ({ request }) => { const start = await request.post("/api/report/build", { data: { project: "reports/gone-fixture", preset: "fast" }, }); expect(start.ok()).toBeTruthy(); const { job } = (await start.json()) as { job: Job }; const done = await waitFor(request, job.id); // Failed at the preflight, having encoded nothing. expect(done.state).toBe("failed"); expect(existsSync(path.join(FIXTURE, "reports", "gone-fixture", "out", "gone-fixture.mp4"))).toBe( false, ); // And it wrote down what it found, so the inbox can report it without running // yt-dlp itself. const avail = path.join(FIXTURE, "reports", "gone-fixture", "out", "availability.json"); expect(existsSync(avail)).toBe(true); }); test("a second build is refused while one is running, and cancel leaves no orphan", async ({ request, }) => { const first = await request.post("/api/report/build?replace=1", { data: { project: PROJECT, preset: "final" }, }); expect(first.ok()).toBeTruthy(); const { job } = (await first.json()) as { job: Job }; // One job, process-wide. Two builds would interleave in one out/segments, and // two in different projects would still fight over yt-dlp and the CPU. const second = await request.post("/api/report/build", { data: { project: "reports/report-fixture", preset: "fast" }, }); expect(second.status()).toBe(409); await request.post(`/api/report/build?cancel=${job.id}`); const done = await waitFor(request, job.id, 60_000); expect(done.state).toBe("failed"); // The GRANDCHILDREN are the point. build-video shells out, so child.kill() // reaps the node process and leaves yt-dlp and ffmpeg running -- the same // failure the diarize backfill had. Killing the process GROUP is what stops it. // // Scoped to THIS suite's own children, two ways, and both are needed. // // It runs pgrep without a shell: going through `bash -lc` puts the pattern // into bash's own command line and pgrep matches the shell that is asking. // And it keeps only lines that are an actual `node …/build-video.mjs` // invocation against THIS FIXTURE -- because this is a shared machine, and a // concurrent agent running its own builds (or merely waiting on one with // `pgrep -f build-video.mjs` in its command line) is not an orphan of ours. // Asserting the machine holds no build-video process at all is an assertion // about somebody else's work. // // Polled rather than asserted once: the kill is SIGTERM to the group and // SIGKILL five seconds later, so "gone" is a state it reaches rather than one // it is in the instant the job reports failed. const ours = () => { let out = ""; try { out = execFileSync("pgrep", ["-af", "build-video.mjs"]).toString(); } catch { return ""; // pgrep exits 1 when nothing matches, which is the good case } return out .split("\n") .filter(Boolean) .filter((line) => { const cmd = line.slice(line.indexOf(" ") + 1); return /^\S*node\b/.test(cmd) && cmd.includes(FIXTURE); }) .join("\n"); }; let strays = ""; for (let i = 0; i < 30; i += 1) { strays = ours(); if (!strays) break; await new Promise((r) => setTimeout(r, 400)); } expect(strays, `left running: ${strays}`).toBe(""); });