import { test, expect } from "@playwright/test"; import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, rmSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // --------------------------------------------------------------------------- // The long-form pass: sources, revisions, notes and exports of a report video. // // longform-fixture read-only: a clip past the end of its transcript, a // citeUrl override, a legacy .bak, a hand-written // chapters.ffmeta, a build-notes.md // longform-edit-fixture the snapshot / edit / diff round trip WRITES here // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const UMTOOL = path.join(HERE, ".."); const FIXTURE = path.join(UMTOOL, ".e2e-song"); const cliEnv = { ...process.env, SONG_REPORTS_DIR: path.join(FIXTURE, "reports"), SONG_DIR: path.join(FIXTURE, "data"), CHANNELS_DIR: path.join(FIXTURE, "channels"), // The fixture's cache, as playwright.config.ts gives the app (never ~/.cache). UMTOOL_CACHE_DIR: path.join(FIXTURE, "cache"), // Never the real media root, whatever this shell exports (empty = unset). UMTOOL_MEDIA_DIR: "", YTDLP_BIN: path.join(FIXTURE, "bin", "yt-dlp"), }; const umtool = (args: string[]) => execFileSync("node", ["bin/umtool.mjs", ...args], { cwd: UMTOOL, encoding: "utf8", env: cliEnv }); test("a clip that outruns its transcript is a cue gap, on the page and in the inbox", async ({ page }) => { await page.goto("/browse/reports/longform-fixture"); const row = page.locator("[data-source='testchan/short1']"); await expect(row).toHaveAttribute("data-cue-gap", "c02"); await expect(row).toContainText("cues end 6 s ยท c02 needs 12 s"); // The Rumble case: a citeUrl that points somewhere other than the derived moment. await expect(row).toHaveAttribute("data-cite-override", "1"); await expect(page.locator("[data-source='testchan/vid1']")).toHaveAttribute("data-cite-override", "0"); await expect(page.locator("[data-source='testchan/vid1']")).toHaveAttribute("data-cue-gap", ""); await expect(page.locator("[data-decision='clip-cue-gap'][data-target=c02]")).toHaveCount(1); await expect(page.locator("[data-decision='clip-cue-gap'][data-target=c02]")).toHaveAttribute("data-severity", "open"); }); test("umtool show prints the same sources table", () => { const j = JSON.parse(umtool(["show", "longform-fixture", "--json"])); const short = j.sources.find((r: { key: string }) => r.key === "testchan/short1"); expect(short.coverageGap).toEqual({ clip: "c02", needs: 12, cuesEnd: 6 }); expect(short.cite.differs).toBe(true); expect(short.availability).toBeNull(); expect(j.attrs["cue-gaps"]).toBe("1"); expect(j.attrs["sources-checked-at"]).toBeUndefined(); }); test("a legacy .bak is listed as a snapshot and diffed, never renamed", async ({ page }) => { await page.goto("/browse/reports/longform-fixture"); const bak = page.locator("[data-snapshot='video.manifest.json.bak']"); await expect(bak).toHaveAttribute("data-legacy", "1"); // The .bak had c02 ending at 9; the manifest has 12: exactly one change. await expect(bak).toHaveAttribute("data-diff", "1"); await expect(bak).toContainText("1 window"); expect(existsSync(path.join(FIXTURE, "reports", "longform-fixture", "video.manifest.json.bak"))).toBe(true); expect(existsSync(path.join(FIXTURE, "reports", "longform-fixture", "revisions"))).toBe(false); }); test("README and sibling notes render, and long provenance reads as prose", async ({ page }) => { await page.goto("/browse/reports/longform-fixture"); await expect(page.locator("[data-note=coverage]")).toContainText("well over one hundred and twenty"); await expect(page.locator("[data-note-file='build-notes.md']")).toBeVisible(); await page.locator("[data-note-file='build-notes.md'] summary").click(); await expect(page.locator("[data-note-file='build-notes.md']")).toContainText("outruns its transcript"); // siteOrigin is a scalar and stays in the table. await expect(page.locator("[data-provenance]")).toContainText("https://archive.example"); }); test("snapshot, then a window edit, then the diff shows that one change", async ({ page, request }) => { const project = "reports/longform-edit-fixture"; const snap = await request.post("/api/report/snapshot", { data: { project, label: "before-edit" } }); expect(snap.ok()).toBeTruthy(); const { rel } = (await snap.json()) as { rel: string }; expect(rel).toMatch(/^revisions\/\d{8}-\d{6}-before-edit\.manifest\.json$/); // Nothing changed yet. const same = await (await request.get(`/api/report/snapshot?project=${project}&diff=${encodeURIComponent(rel)}`)).json(); expect(same.same).toBe(true); umtool(["window", "longform-edit-fixture", "c02", "--end", "13"]); const d = await (await request.get(`/api/report/snapshot?project=${project}&diff=${encodeURIComponent(rel)}`)).json(); expect(d.same).toBe(false); expect(d.changes).toHaveLength(1); expect(d.changes[0]).toMatchObject({ kind: "window", id: "c02", fields: ["end"] }); expect(d.changes[0].from.end).toBe(12); expect(d.changes[0].to.end).toBe(13); await page.goto(`/browse/${project}`); const row = page.locator(`[data-snapshot="${rel}"]`); await expect(row).toHaveAttribute("data-diff", "1"); await expect(row).toHaveAttribute("data-legacy", "0"); // The CLI reads the same diff. const out = umtool(["diff", "longform-edit-fixture", rel]); expect(out).toContain("c02 window: 9โ€“12 -> 9โ€“13"); // A snapshot directory never reads as a project. const ids = JSON.parse(umtool(["ls", "--json"])).map((r: { id: string }) => r.id); expect(ids.filter((id: string) => id.includes("revisions"))).toEqual([]); }); test("export toc-bbcode matches the golden string, from the build's own offsets", async ({ request }) => { const golden = [ "[b]The Longform Fixture โ€” a fixture[/b]", "", "[table]", "[tr][th]Time[/th][th]Clip[/th][th]Source[/th][/tr]", "[tr][td]0:00[/td][td]First chapter[/td][td][url=https://archive.example/?v=testchan%2Fvid1&t=3]open[/url][/td][/tr]", "[tr][td]0:03[/td][td]Second chapter[/td][td][url=https://mirror.example/watch?v=other&t=2]open[/url][/td][/tr]", "[/table]", "", ].join("\n"); expect(umtool(["export", "longform-fixture", "--format", "toc-bbcode"])).toBe(golden); const r = await request.get("/api/report/export?project=reports/longform-fixture&format=toc-bbcode"); expect(r.ok()).toBeTruthy(); expect(r.headers()["x-offsets"]).toBe("ffmeta"); expect(await r.text()).toBe(golden); const chapters = await (await request.get("/api/report/export?project=reports/longform-fixture&format=chapters")).text(); expect(chapters).toBe("00:00 First chapter\n00:03 Second chapter\n"); // Nothing built: refused with the reason, not an empty table. const none = await request.get("/api/report/export?project=reports/longform-edit-fixture&format=toc-bbcode"); expect(none.status()).toBe(409); expect(await none.text()).toContain("nothing built"); }); test("check-sources over two projects writes both availability files, through the job runner", async ({ request }) => { const projects = ["reports/longform-fixture", "reports/longform-edit-fixture"]; for (const p of projects) rmSync(path.join(FIXTURE, p, "out", "availability.json"), { force: true }); const dry = await (await request.post("/api/report/check-sources?dry=1", { data: { projects } })).json(); expect(dry.steps).toHaveLength(2); expect(dry.steps[0].argv.join(" ")).toContain("check-availability.mjs"); expect(dry.steps[0].argv).toContain("--allow-missing"); const start = await request.post("/api/report/check-sources", { data: { projects } }); expect(start.ok()).toBeTruthy(); const { job } = (await start.json()) as { job: { id: string } }; const until = Date.now() + 60_000; let state = "running"; while (Date.now() < until) { const j = (await (await request.get(`/api/report/build?job=${job.id}`)).json()) as { state: string; error: string | null }; state = j.state; if (state !== "running") { expect(j.error).toBeNull(); break; } await new Promise((r) => setTimeout(r, 300)); } expect(state).toBe("done"); for (const p of projects) { const file = path.join(FIXTURE, p, "out", "availability.json"); expect(existsSync(file), file).toBe(true); const doc = JSON.parse(readFileSync(file, "utf8")); expect(doc.sources.length).toBeGreaterThan(0); } // And the index sees it: the project is no longer "never checked". const j = JSON.parse(umtool(["show", "longform-fixture", "--json"])); expect(Number(j.attrs["sources-checked-at"])).toBeGreaterThan(0); const short = j.sources.find((r: { key: string }) => r.key === "testchan/short1"); expect(short.availability.ok).toBe(true); }); test("a song is not something check-sources can preflight", async ({ request }) => { const r = await request.post("/api/report/check-sources", { data: { projects: ["reports/videos/alpha"] } }); expect(r.status()).toBe(400); });