import { test, expect, type APIRequestContext, type Locator, type Page } 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"; // --------------------------------------------------------------------------- // ON-SCREEN: the report cut's deck (render.chrome), as umtool edits it. // // NOT deck.spec.ts -- that is the song kind's deck, an unrelated thing that // owns the word in this app; see the header of OnscreenSection.tsx. // // Two projects, both made by make-fixture.mjs at 1920x1080 with the deck on: // // onscreen-fixture the section and the bench WRITE here. Never built, // so its preview is drawn from the estimate. // onscreen-build-fixture built by the last test (offline, ~10 s), then // re-rendered on-screen over its own segments. // // The renderer is a stub (HYPERFRAMES_BIN, see make-fixture.mjs): it writes the // frame sequence the build checks, alternating RGB and RGBA. The true still is // the system chromium's own screenshot, not a stub. // // The report fixtures need none of the song project's bulk data, so nothing // here skips on SONG_DIR. What the deck DOES need is a font (it refuses to draw // without its faces) and, for the still, chromium -- each test that needs one // says so and skips when this machine has none. // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(HERE, "..", ".e2e-song"); const PROJECT = "reports/onscreen-fixture"; const DIR = path.join(FIXTURE, "reports", "onscreen-fixture"); const BUILD_PROJECT = "reports/onscreen-build-fixture"; const BUILD_DIR = path.join(FIXTURE, "reports", "onscreen-build-fixture"); const INVOCATIONS = path.join(FIXTURE, "bin", "hyperframes.invocations"); // The still route's browser, as compose-chrome resolves it. The server inherits // this process's environment, so the two agree. const CHROME = process.env.CHROME ?? "/usr/bin/chromium"; const DECK_ON = { engine: "hyperframes", layout: "deck", deck: {} }; type Entry = { id: string; type: string; onscreen?: { title?: string; subtitle?: string } }; type Manifest = { render: Record & { chrome?: unknown }; timeline: Entry[] }; const readManifest = (dir = DIR): Manifest => JSON.parse(readFileSync(path.join(dir, "video.manifest.json"), "utf8")); const entry = (id: string, dir = DIR) => readManifest(dir).timeline.find((e) => e.id === id)!; const enc = encodeURIComponent; test.beforeEach(() => { // The first page of a run compiles in dev mode (~20 s under load), and the // section composes and still-shoots on top of that. test.setTimeout(120_000); test.skip( !readManifest().render.fontRegular, "the deck refuses to draw without render.fontRegular/fontBold, and make-fixture.mjs found no font on this machine", ); }); // ---- state through the routes, so each test starts from a known manifest ---- async function chromeToken(request: APIRequestContext, project: string): Promise { const j = (await (await request.get(`/api/report/chrome?project=${enc(project)}`)).json()) as { token: string }; return j.token; } async function putChrome(request: APIRequestContext, project: string, chrome: unknown) { const r = await request.put("/api/report/chrome", { data: { project, chrome, token: await chromeToken(request, project) }, }); expect(r.ok(), await r.text()).toBeTruthy(); } async function putOnscreen( request: APIRequestContext, project: string, onscreen: Record, ) { const r = await request.put("/api/report/onscreen", { data: { project, onscreen, token: await chromeToken(request, project) }, }); expect(r.ok(), await r.text()).toBeTruthy(); } /** The section, read and settled: the manifest loaded and the table filled. */ async function openSection(page: Page, project = PROJECT) { await page.goto(`/browse/${project}`); const section = page.getByTestId("onscreen-section"); await expect(section).toHaveAttribute("data-onscreen", /on|off/); await expect(page.getByTestId("onscreen-row").first()).toBeAttached(); return section; } const row = (page: Page, id: string) => page.locator(`[data-testid="onscreen-row"][data-entry="${id}"]`); /** * fill() on a controlled input right after a goto can lose its first * keystroke to a hydration re-render (docs/e2e.md, "Gotchas"), so assert the * value and retry before anything commits on it. */ async function fillSure(input: Locator, value: string) { await expect(async () => { await input.fill(value); await expect(input).toHaveValue(value, { timeout: 1000 }); }).toPass({ timeout: 15_000 }); } // --------------------------------------------------------------------------- test("the switch writes render.chrome, and turning it off takes the key out", async ({ page, request }) => { await putChrome(request, PROJECT, DECK_ON); const section = await openSection(page); const toggle = page.getByTestId("onscreen-toggle"); await expect(section).toHaveAttribute("data-onscreen", "on"); await expect(toggle).toBeChecked(); await expect(toggle).toBeEnabled(); await toggle.click(); await expect(section).toHaveAttribute("data-onscreen", "off"); // Off is the key REMOVED, not set to null: every manifest without it builds // as before, and that is a promise about the absence of the key. await expect.poll(() => "chrome" in readManifest().render).toBe(false); // Nothing else in render moved. expect(readManifest().render.width).toBe(1920); await page.reload(); await expect(section).toHaveAttribute("data-onscreen", "off"); await expect(toggle).not.toBeChecked(); await expect(toggle).toBeEnabled(); await toggle.click(); await expect(section).toHaveAttribute("data-onscreen", "on"); await expect.poll(() => readManifest().render.chrome).toEqual(DECK_ON); await page.reload(); await expect(section).toHaveAttribute("data-onscreen", "on"); await expect(toggle).toBeChecked(); }); test("settings save only what differs from the defaults; a refused value shows the validator's sentence", async ({ page, request, }) => { await putChrome(request, PROJECT, DECK_ON); await openSection(page); const defaults = (await (await request.get(`/api/report/chrome?project=${enc(PROJECT)}`)).json()) as { defaults: { title: { maxChars: number } }; }; const maxChars = page.getByTestId("onscreen-setting-title.maxChars"); await expect(maxChars).toHaveValue(String(defaults.defaults.title.maxChars)); const want = defaults.defaults.title.maxChars === 20 ? 21 : 20; await fillSure(maxChars, String(want)); await page.getByTestId("onscreen-settings-save").click(); await expect.poll(() => (readManifest().render.chrome as { deck: unknown }).deck).toEqual({ title: { maxChars: want }, }); // The table's counter follows the saved setting. await expect(page.getByTestId("onscreen-count").first()).toContainText(`/${want}`); // Refused: the build's own sentence, beside the form, and nothing written. await fillSure(page.getByTestId("onscreen-setting-height"), "999"); await page.getByTestId("onscreen-settings-save").click(); await expect(page.getByTestId("onscreen-errors")).toContainText( "render.chrome.deck.height must be a whole number of pixels from 120 to 400", ); expect((readManifest().render.chrome as { deck: unknown }).deck).toEqual({ title: { maxChars: want } }); await putChrome(request, PROJECT, DECK_ON); }); test("a table edit saves onscreen; an emptied row deletes the key", async ({ page, request }) => { await putChrome(request, PROJECT, DECK_ON); await putOnscreen(request, PROJECT, { c01: null, c02: null, k01: null }); await openSection(page); const title = row(page, "c01").getByTestId("onscreen-title"); await fillSure(title, "County approves the pre-application"); await expect(row(page, "c01")).toHaveAttribute("data-dirty", "1"); // Any entry, not only a clip -- and the writer trims. await fillSure(row(page, "k01").getByTestId("onscreen-subtitle"), " a card's own line "); await page.getByTestId("onscreen-save").click(); await expect.poll(() => entry("c01").onscreen).toEqual({ title: "County approves the pre-application" }); expect(entry("k01").onscreen).toEqual({ subtitle: "a card's own line" }); await expect(row(page, "c01")).toHaveAttribute("data-dirty", "0"); await expect(row(page, "k01").getByTestId("onscreen-subtitle")).toHaveValue("a card's own line"); // The counter warns past maxChars; it does not refuse. const limit = ((await (await request.get(`/api/report/onscreen?project=${enc(PROJECT)}`)).json()) as { maxChars: number; }).maxChars; await fillSure(title, "x".repeat(limit + 1)); await expect(row(page, "c01").getByTestId("onscreen-count")).toHaveAttribute("data-over", "1"); // Emptied: the key goes, it is not left as {} or {title: ""}. await fillSure(title, ""); await expect(row(page, "c01")).toHaveAttribute("data-dirty", "1"); await page.getByTestId("onscreen-save").click(); await expect.poll(() => "onscreen" in entry("c01")).toBe(false); await expect(row(page, "c01")).toHaveAttribute("data-dirty", "0"); expect(entry("k01").onscreen).toEqual({ subtitle: "a card's own line" }); await putOnscreen(request, PROJECT, { k01: null }); }); test("a stale token is a 409; reloading keeps the edit and does not undo the other writer", async ({ page, request, }) => { await putChrome(request, PROJECT, DECK_ON); await putOnscreen(request, PROJECT, { c01: null, c02: null }); await openSection(page); // Somebody else writes after this page read its token. await putOnscreen(request, PROJECT, { c02: { title: "Written elsewhere" } }); await fillSure(row(page, "c01").getByTestId("onscreen-title"), "Mine"); const refused = page.waitForResponse( (r) => r.url().includes("/api/report/onscreen") && r.request().method() === "PUT", ); await page.getByTestId("onscreen-save").click(); expect((await refused).status()).toBe(409); await expect(page.getByTestId("onscreen-note")).toContainText("the manifest changed since you opened this"); expect("onscreen" in entry("c01")).toBe(false); expect(entry("c02").onscreen).toEqual({ title: "Written elsewhere" }); await page.getByTestId("onscreen-reload").click(); await expect(page.getByTestId("onscreen-reload")).toHaveCount(0); // The unsaved edit survives the reload... await expect(row(page, "c01").getByTestId("onscreen-title")).toHaveValue("Mine"); await expect(row(page, "c01")).toHaveAttribute("data-dirty", "1"); // ...and a row nobody touched here shows what the other writer saved. Kept // as the old draft, it would read as an edit and the next save would quietly // revert the very write the token exists to protect. await expect(row(page, "c02").getByTestId("onscreen-title")).toHaveValue("Written elsewhere"); await expect(row(page, "c02")).toHaveAttribute("data-dirty", "0"); const saved = page.waitForResponse( (r) => r.url().includes("/api/report/onscreen") && r.request().method() === "PUT", ); await page.getByTestId("onscreen-save").click(); expect((await saved).status()).toBe(200); await expect.poll(() => entry("c01").onscreen).toEqual({ title: "Mine" }); expect(entry("c02").onscreen).toEqual({ title: "Written elsewhere" }); await putOnscreen(request, PROJECT, { c01: null, c02: null }); }); test("the preview is the composition: it reports ready, and typing reaches it before a save", async ({ page, request, }) => { await putChrome(request, PROJECT, DECK_ON); await putOnscreen(request, PROJECT, { c01: null, c02: null, k01: null }); await openSection(page); await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); // Never built, so the clock is the manifest's estimate, and says so. await expect(page.getByTestId("onscreen-estimated")).toBeVisible(); const frame = page.frameLocator('[data-testid="onscreen-preview-iframe"]'); // One node per entry, and a card's title is its heading -- the same text the // table offers as the placeholder. for (const id of ["c01", "c02", "k01"]) await expect(frame.locator(`[data-seg="${id}"]`)).toHaveCount(1); const cardAuto = await row(page, "k01").getByTestId("onscreen-title").getAttribute("placeholder"); expect(cardAuto).toBe("A card"); await expect(frame.locator('[data-seg="k01"] .deck-title')).toHaveText(cardAuto!); // A teaser is a card row named by its lines, here and on the report page's timeline. const teaserTitle = "Next Season — The Big Build in the Valley — Spring 2027 ?"; await expect(frame.locator('[data-seg="t01"]')).toHaveCount(1); expect(await row(page, "t01").getByTestId("onscreen-title").getAttribute("placeholder")).toBe(teaserTitle); await expect(page.locator('li[data-entry="t01"][data-kind="teaser"]')).toContainText(teaserTitle); // Typing is patched into the frame by postMessage: nothing is saved. await fillSure(row(page, "c02").getByTestId("onscreen-title"), "Typed, not saved"); await expect(frame.locator('[data-seg="c02"] .deck-title')).toHaveText("Typed, not saved"); await fillSure(row(page, "c02").getByTestId("onscreen-subtitle"), "a line under it"); await expect(frame.locator('[data-seg="c02"] .deck-sub')).toHaveText("a line under it"); // Focusing a row moves the scrubber to that entry. await expect(page.getByTestId("onscreen-current")).toHaveText("c02"); expect("onscreen" in entry("c02")).toBe(false); // The scrubber is the composition's clock: its end is the schedule's total. const max = Number(await page.getByTestId("onscreen-scrubber").getAttribute("max")); const pv = (await ( await request.post("/api/report/chrome/preview", { data: { project: PROJECT } }) ).json()) as { schedule: { total: number } }; expect(max).toBeCloseTo(pv.schedule.total, 3); }); test("a true still is the render browser's PNG of the deck region", async ({ page, request }) => { test.skip(!existsSync(CHROME), `the true still needs chromium at ${CHROME} (set CHROME)`); await putChrome(request, PROJECT, DECK_ON); // The route: a PNG exactly the deck region's size, at the entry's middle. const pv = (await ( await request.post("/api/report/chrome/preview", { data: { project: PROJECT } }) ).json()) as { geometry: { deck: { width: number; height: number } }; schedule: { segments: { id: string; start: number; duration: number }[] }; }; const r = await request.get(`/api/report/still?project=${enc(PROJECT)}&clip=c01`); expect(r.status(), await r.text()).toBe(200); expect(r.headers()["content-type"]).toBe("image/png"); const png = await r.body(); expect(png.subarray(1, 4).toString("latin1")).toBe("PNG"); expect(png.readUInt32BE(16)).toBe(pv.geometry.deck.width); expect(png.readUInt32BE(20)).toBe(pv.geometry.deck.height); const c01 = pv.schedule.segments.find((s) => s.id === "c01")!; expect(Number(r.headers()["x-still-at"])).toBeCloseTo(c01.start + c01.duration / 2, 2); // And the button shows it. await openSection(page); await expect(page.getByTestId("onscreen-preview")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); await page.getByTestId("onscreen-true-still").click(); const img = page.getByTestId("onscreen-still"); await expect(img).toBeVisible({ timeout: 30_000 }); await expect .poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth)) .toBe(pv.geometry.deck.width); }); test("the clip bench's on-screen fields preview live and save through the window route", async ({ page, request, }) => { await putChrome(request, PROJECT, DECK_ON); await putOnscreen(request, PROJECT, { c01: null }); await page.goto(`/browse/${PROJECT}/clip/c01`); await expect(page.getByTestId("bench-onscreen")).toHaveAttribute("data-deck-on", "1"); await expect(page.getByTestId("bench-deck-strip")).toHaveAttribute("data-deck-ready", "1", { timeout: 30_000 }); const strip = page.frameLocator('[data-testid="bench-deck-strip-iframe"]'); const title = page.getByTestId("bench-onscreen-title"); await fillSure(title, "From the bench"); await expect(strip.locator('[data-seg="c01"] .deck-title')).toHaveText("From the bench"); expect("onscreen" in entry("c01")).toBe(false); const put = page.waitForRequest((r) => r.url().includes("/api/report/window") && r.method() === "PUT"); await title.press("Enter"); expect((await put).postDataJSON().onscreen).toEqual({ title: "From the bench" }); await expect.poll(() => entry("c01").onscreen).toEqual({ title: "From the bench" }); // Emptied, the key goes. await fillSure(title, ""); const cleared = page.waitForRequest((r) => r.url().includes("/api/report/window") && r.method() === "PUT"); await title.press("Enter"); expect((await cleared).postDataJSON().onscreen).toBeNull(); await expect.poll(() => "onscreen" in entry("c01")).toBe(false); }); // ---- built: the deck rendered by a build, then re-rendered on its own -------- type Job = { id: string; state: string; error: string | null; steps: { label: string; argv: string[] }[]; events: { ev: string; phase?: string }[]; }; async function waitForJob(request: APIRequestContext, id: string, ms = 180_000): Promise { const until = Date.now() + ms; while (Date.now() < until) { const j = (await (await request.get(`/api/report/build?job=${id}`)).json()) as Job; if (j.state !== "running") return j; await new Promise((r) => setTimeout(r, 400)); } throw new Error("the job never finished"); } const renders = () => existsSync(INVOCATIONS) ? readFileSync(INVOCATIONS, "utf8") .split("\n") .filter((l) => l.includes("onscreen-build-fixture")) : []; const VARIANT_OUT = path.join(BUILD_DIR, "out", "sourced"); const FRAMES = path.join(VARIANT_OUT, "chrome", "deck-frames"); const FINAL = path.join(BUILD_DIR, "out", "onscreen-build-fixture.mp4"); const readSchedule = () => JSON.parse(readFileSync(path.join(VARIANT_OUT, "schedule.json"), "utf8")) as { kind: string; estimated: boolean; fps: number; transition: number; total: number; segments: { id: string; title: string }[]; }; const frameFiles = () => readdirSync(FRAMES).filter((f) => /^frame_\d{6}\.png$/.test(f)); const probeSeconds = (file: string) => Number( execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file]) .toString() .trim(), ); test("re-render on-screen runs --chrome-only over a built cut, to done, rebuilding no segment", async ({ page, request, }) => { test.setTimeout(300_000); // The chain it asks for: no preflight, no resolve, --chrome-only, verified. const dry = (await ( await request.post("/api/report/build?dry=1", { data: { project: BUILD_PROJECT, preset: "final", options: { chromeOnly: true } }, }) ).json()) as { steps: { label: string; argv: string[] }[] }; const chain = dry.steps.map((s) => s.argv.join(" ")).join("\n"); expect(chain).not.toContain("check-availability.mjs"); expect(chain).not.toContain("resolve-windows.mjs"); const dryBuild = dry.steps.find((s) => s.argv.join(" ").includes("build-video.mjs"))!; expect(dryBuild.argv).toContain("--chrome-only"); expect(dryBuild.label).toBe("re-render on-screen"); expect(dry.steps.at(-1)!.argv.join(" ")).toContain("verify-build.mjs"); // A full build first, as hard cuts: the deck is composed, rendered by the // stub and laid over the concat in one command. const before = renders().length; const start = await request.post("/api/report/build?replace=1", { data: { project: BUILD_PROJECT, preset: "fast" }, }); expect(start.ok(), await start.text()).toBeTruthy(); const built = await waitForJob(request, ((await start.json()) as { job: Job }).job.id); expect(built.state, built.error ?? "").toBe("done"); expect(renders().length).toBe(before + 1); const argv = renders().at(-1)!; expect(argv).toContain("render --format png-sequence"); expect(argv).toContain("--fps 15"); expect(argv).toContain("--no-browser-gpu"); expect(argv).toContain(`--output ${FRAMES}`); expect(built.events.filter((e) => e.ev === "chrome").map((e) => e.phase)).toEqual( expect.arrayContaining(["schedule", "compose", "render", "overlay"]), ); const first = readSchedule(); expect(first.kind).toBe("deck"); expect(first.estimated).toBe(false); expect(first.transition).toBe(0); expect(frameFiles().length).toBe(Math.round(first.total * first.fps)); // The stub's frames alternate RGB and RGBA. Without `-reinit_filter 0` the // overlay drops frames at every format change and the cut comes out short; // the build asserts the length, and so does this. expect(Math.abs(probeSeconds(FINAL) - first.total)).toBeLessThan(2 / first.fps); // A saved title moves the deck's cache key, so the re-render must render. await putOnscreen(request, BUILD_PROJECT, { c02: { title: "Re-rendered title" } }); const segs = ["c01", "c02"].map((id) => path.join(VARIANT_OUT, "segments", `${id}.mp4`)); const segMtimes = segs.map((s) => statSync(s).mtimeMs); const finalMtime = statSync(FINAL).mtimeMs; await openSection(page, BUILD_PROJECT); await expect(page.getByTestId("onscreen-final-video")).toBeVisible(); // As final: the crossfaded concat, so the overlay rides the xfade chain -- // the other half of the -reinit_filter fix from the hard cut above. await page.getByTestId("onscreen-rerender-preset").selectOption("final"); const posted = page.waitForResponse( (r) => r.url().endsWith("/api/report/build") && r.request().method() === "POST", ); await page.getByTestId("onscreen-rerender").click(); const res = await posted; expect(res.request().postDataJSON().options).toMatchObject({ chromeOnly: true }); const { job } = (await res.json()) as { job: Job }; const realBuild = job.steps.find((s) => s.argv.join(" ").includes("build-video.mjs"))!; expect(realBuild.argv).toContain("--chrome-only"); expect(job.steps.map((s) => s.argv.join(" ")).join("\n")).not.toContain("check-availability.mjs"); await expect(page.getByTestId("onscreen-job")).toHaveAttribute("data-job-state", /done|failed/, { timeout: 240_000, }); const done = await waitForJob(request, job.id); expect(done.state, `${done.error ?? ""}\n${(done as unknown as { log: string[] }).log?.slice(-20).join("\n")}`).toBe( "done", ); await expect(page.getByTestId("onscreen-job")).toHaveAttribute("data-job-state", "done"); // It rendered again, and over the SAME segments. expect(renders().length).toBe(before + 2); expect(segs.map((s) => statSync(s).mtimeMs)).toEqual(segMtimes); expect(statSync(FINAL).mtimeMs).toBeGreaterThan(finalMtime); const second = readSchedule(); expect(second.transition).toBeGreaterThan(0); expect(second.total).toBeLessThan(first.total); expect(second.segments.find((s) => s.id === "c02")!.title).toBe("Re-rendered title"); expect(frameFiles().length).toBe(Math.round(second.total * second.fps)); expect(Math.abs(probeSeconds(FINAL) - second.total)).toBeLessThan(2 / second.fps); await expect(page.getByTestId("onscreen-final-video")).toBeVisible(); });