import { test, expect } from "@playwright/test"; import { execFileSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // --------------------------------------------------------------------------- // DELIVER: what happens after the last clip is judged. // // The bench's own specs cover judging. This covers the other half — cutting the // confirmed clips out of the cache, packaging a batch, and running the report's // own fold-back scripts — and its strongest assertions are on DISK, because // every one of these produces a file somebody else will open. // // The clips are MEASURED, not merely present. A cut that lands on the wrong // keyframe still writes an mp4; what makes it wrong is that it is not the // seconds that were asked for, and only ffprobe can say so. // // Nothing here reaches the network: the fixture's window was generated by // ffmpeg, the cut reads it, and the panel has no fetch of its own by design. // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(HERE, "..", ".e2e-song"); const PROJECT = "reports/deliver-fixture"; const DIR = path.join(FIXTURE, "reports", "deliver-fixture"); const STOP_DIR = path.join(FIXTURE, "reports", "deliver-stop-fixture"); const seconds = (file: string): number => Number( execFileSync("ffprobe", [ "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", file, ]).toString().trim(), ); /** The job the panel started, polled to a terminal state. */ async function waitForJob(page: import("@playwright/test").Page, timeout = 120_000) { await expect(page.locator("[data-deliver-job]")).toHaveAttribute( "data-deliver-state", /done|failed/, { timeout }, ); const state = await page.locator("[data-deliver-job]").getAttribute("data-deliver-state"); const log = (await page.locator("[data-deliver-log]").textContent()) ?? ""; return { state, log }; } test("the deliver panel counts verdicts and folds them by id prefix", async ({ page }) => { await page.goto(`/browse/${PROJECT}`); const panel = page.locator("[data-deliver]"); await expect(panel).toBeVisible(); // 4 confirmed (a01 a02 a03 b01), 1 incorrect (b02), 1 unjudged (b03). await expect(panel).toHaveAttribute("data-deliver-confirmed", "4"); await expect(panel).toHaveAttribute("data-deliver-incorrect", "1"); await expect(panel).toHaveAttribute("data-deliver-unreviewed", "1"); // TWO SECTIONS, folded on the letter. The whole reason this is not one // number: section B is where a clip was thrown out. const a = panel.locator('[data-section="A"]'); const b = panel.locator('[data-section="B"]'); await expect(a).toHaveAttribute("data-section-confirmed", "3"); await expect(a).toHaveAttribute("data-section-incorrect", "0"); await expect(b).toHaveAttribute("data-section-confirmed", "1"); await expect(b).toHaveAttribute("data-section-incorrect", "1"); await expect(b).toHaveAttribute("data-section-unreviewed", "1"); // And the heading came out of the report's own prose, not out of the id. await expect(a).toContainText("The first fixture section"); // b01 is confirmed AND already has a file, so it is not waiting on a cut; // a03 has nothing cached, so it is not a cut either. Two left. await expect(panel).toHaveAttribute("data-deliver-need-cut", "2"); }); test("a confirmed clip with no cached window is listed as not fetched, not cut", async ({ page, }) => { await page.goto(`/browse/${PROJECT}`); const notFetched = page.locator("[data-not-fetched]"); await expect(notFetched).toBeVisible(); // a03's window is 30–33 s and the cache holds 0–24. Nothing here downloads // it: the link goes to the bench, where the editor fetch lives. await expect(notFetched.locator('[data-not-fetched-id="a03"]')).toBeVisible(); await expect(notFetched.locator('[data-not-fetched-id="a03"]')).toHaveAttribute( "href", `/browse/${PROJECT}/clip/a03`, ); // And it is NOT in the cut list, which is the distinction the two headings // exist to make. await expect(page.locator('[data-need-cut] [data-need-cut-id="a03"]')).toHaveCount(0); expect(existsSync(path.join(DIR, "clips", "a03.mp4"))).toBe(false); }); test("cutting from cache writes clips/.mp4 of exactly the clip's length", async ({ page, }) => { test.setTimeout(180_000); await page.goto(`/browse/${PROJECT}`); await page.locator('[data-action="deliver-cut"]').click(); // `k of n` is the JOB's count -- one step per clip -- so the readout and the // work cannot disagree. // Either "1 of 2" while it runs or "done · 2 steps" if it beat the first // poll: what is asserted is that the readout is the job's step count. await expect(page.locator("[data-deliver-progress]")).toContainText(/of 2|2 steps/); const { state, log } = await waitForJob(page); expect(state, log).toBe("done"); expect(log).toContain("CUT-OK a01"); expect(log).toContain("CUT-OK a02"); // THE MEASUREMENT. a01 starts on one of the window's keyframes and a02 does // not; both must come out to the length the manifest asked for. expect(seconds(path.join(DIR, "clips", "a01.mp4"))).toBeCloseTo(3.0, 1); expect(seconds(path.join(DIR, "clips", "a02.mp4"))).toBeCloseTo(3.0, 1); // And the panel has nothing left to cut. await page.reload(); await expect(page.locator("[data-deliver]")).toHaveAttribute("data-deliver-need-cut", "0"); }); test("the share batch skips every id an existing LIST.md already shipped", async ({ page }) => { test.setTimeout(180_000); await page.goto(`/browse/${PROJECT}`); await page.locator("[data-batch-name]").fill("second"); await page.locator('[data-action="deliver-share"]').click(); const { state, log } = await waitForJob(page); expect(state, log).toBe("done"); const root = path.join(DIR, "share-second"); const folder = "A-The-first-fixture-section"; for (const variant of ["orig", "std", "small"]) { expect( existsSync(path.join(root, variant, folder, "a01_2025-01-01_A-Fixture-Stream.mp4")), `${variant}/a01`, ).toBe(true); } // THE EXCLUSIONS, and there are three kinds. // // b01 is in share-first by FILE NAME. a02 is in share-first only as a // SENTENCE -- it went out in an earlier set under another id, which is the // real project's case for six clips (em01/ie01 are a separate cut of // b02/c08/f01/f05/h01/h02) and the only record that ties the two. b02 was // ruled incorrect. The folders and their lists are the whole record: no // second file to drift from them. const list = readFileSync(path.join(root, "LIST.md"), "utf8"); expect(list).toContain("a01_2025-01-01"); expect(list).not.toContain("a02_"); expect(list).not.toContain("b01_"); expect(list).not.toContain("b02_"); expect(list).toContain("2 already shared (a02 b01)"); expect(list).toContain("1 ruled incorrect (b02)"); expect( existsSync(path.join(root, "orig", folder, "a02_2025-01-02_A-Fixture-Stream.mp4")), ).toBe(false); expect(existsSync(path.join(root, "orig", "B-The-second-fixture-section"))).toBe(false); // …and the list this wrote says it the same way, so the NEXT batch reading // it back excludes all three without anybody restating them. // The std encode is the one people download, and it is EXACTLY 1280x720. const size = execFileSync("ffprobe", [ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", path.join(root, "std", folder, "a01_2025-01-01_A-Fixture-Stream.mp4"), ]).toString().trim(); expect(size).toBe("1280,720"); }); test("apply rulings is refused while the walk is unfinished, and runs the report's own scripts when it is meant", async ({ page, }) => { test.setTimeout(120_000); await page.goto(`/browse/${PROJECT}`); // b03 has never been judged. apply-manifest.py deletes the mp4 of every clip // whose window moved, so running it over a half-walked cut bakes "nobody has // looked at this" into the deliverable as though it were a verdict. await page.locator('[data-action="deliver-apply"]').click(); await expect(page.locator("[data-deliver-error]")).toContainText("have not been judged"); expect(existsSync(path.join(DIR, "applied.marker"))).toBe(false); // Ticked, it proceeds -- and the second step is `umtool corrections`, which // is the same list the page shows, in the form the next sweep's prompt wants. await page.locator('[data-action="apply-partial"]').check(); await page.locator('[data-action="deliver-apply"]').click(); const { state, log } = await waitForJob(page); expect(state, log).toBe("done"); expect(existsSync(path.join(DIR, "applied.marker"))).toBe(true); // VERBATIM. The lines of prose that cite the clip the walk threw out are the // whole point of running it, and the panel must not paraphrase them. expect(log).toContain("content.py:6:"); expect(log).toContain("the speaker is the guest, not the host"); // And step 2's output is a FILE, because that is what the loop consumes: // finish-sweep.sh opens with the same redirect and the overnight review // reads corrections.md, not a log somebody copies out of a browser. const corrections = readFileSync(path.join(DIR, "corrections.md"), "utf8"); expect(corrections).toContain("Corrections for the next pass"); expect(corrections).toContain("b02"); }); test("the panel shows the prose lines citing a clip the walk ruled incorrect", async ({ page, }) => { await page.goto(`/browse/${PROJECT}`); const cited = page.locator('[data-incorrect="b02"]'); await expect(cited).toBeVisible(); await expect(cited).toContainText("content.py:6"); await expect(cited).toContainText("The claim rests on [clip:b02]"); }); test("rebuild runs build.py once per content variant present", async ({ page }) => { test.setTimeout(120_000); await page.goto(`/browse/${PROJECT}`); await page.locator('[data-action="deliver-rebuild"]').click(); const { state, log } = await waitForJob(page); expect(state, log).toBe("done"); // content.py -> the default stem; content_lawyer.py -> report-lawyer. Two // steps, two files, and neither overwrote the other's. expect(log).toContain("BUILD-OK content -> report.html"); expect(log).toContain("BUILD-OK content_lawyer -> report-lawyer.html"); expect(readFileSync(path.join(DIR, "report.html"), "utf8")).toContain("built from content"); expect(readFileSync(path.join(DIR, "report-lawyer.html"), "utf8")).toContain( "built from content_lawyer", ); }); // --------------------------------------------------------------------------- // STOP, and what "resume" means. // // One step per clip is not a presentation choice: it is what makes Stop kill // the running ffmpeg's process group and abandon the REST, and what makes the // next press pick up the clips that have no file rather than re-cutting the // ones that do. Both halves are asserted on DISK -- a count, and then the // mtimes of the files the first job wrote, which must not have moved. // // Deterministic rather than racy: the fixture's server sets // E2E_UMTOOL_CUT_DELAY_MS=2000 (playwright.config.ts), the job is six clips long, // and the click waits for the first CUT-OK. Three clips of work -- six // seconds -- therefore cannot have happened by the time Stop lands. // --------------------------------------------------------------------------- const stopCuts = () => readdirSync(path.join(STOP_DIR, "clips")) .filter((n) => /^s\d+\.mp4$/.test(n)) .sort(); test("Stop abandons the rest of the cut, and the next one resumes without re-cutting", async ({ page, }) => { test.setTimeout(240_000); await page.goto("/browse/reports/deliver-stop-fixture"); await expect(page.locator("[data-deliver]")).toHaveAttribute("data-deliver-need-cut", "6"); expect(stopCuts()).toHaveLength(0); await page.locator('[data-action="deliver-cut"]').click(); await expect(page.locator("[data-deliver-progress]")).toContainText(/of 6/); // WAIT FOR REAL WORK. Stopping before anything finished would prove only // that a job can be cancelled, not that what it had already done survives. await expect(page.locator("[data-deliver-log]")).toContainText("CUT-OK", { timeout: 90_000 }); await page.locator('[data-action="deliver-stop"]').click(); const stopped = await waitForJob(page); // A cancelled step is a failed one, and the log says who asked. expect(stopped.state, stopped.log).toBe("failed"); expect(stopped.log).toContain("cancel"); const done = stopCuts(); expect(done.length).toBeGreaterThanOrEqual(1); expect(done.length).toBeLessThan(6); const stamps = new Map( done.map((n) => [n, statSync(path.join(STOP_DIR, "clips", n)).mtimeMs]), ); // THE SERVER'S LIST IS THE RESUME. It is "confirmed, no file", so the second // job is exactly the remainder -- nothing in the browser had to remember // where the first one stopped. await page.reload(); await expect(page.locator("[data-deliver]")).toHaveAttribute( "data-deliver-need-cut", String(6 - done.length), ); await page.locator('[data-action="deliver-cut"]').click(); const second = await waitForJob(page); expect(second.state, second.log).toBe("done"); expect(stopCuts()).toHaveLength(6); for (const [name, ms] of stamps) { expect(statSync(path.join(STOP_DIR, "clips", name)).mtimeMs, `${name} was re-cut`).toBe(ms); expect(second.log, `${name} was re-cut`).not.toContain(`CUT-OK ${name.replace(".mp4", "")}`); } });