import { test, expect, type Page } from "@playwright/test"; import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // --------------------------------------------------------------------------- // Structural edits to a report video (lib/report/manifest.mjs "STRUCTURE", // POST /api/report/timeline): // // timeline-fixture hand-written: teaser t1, clips a01 a02 a03, post p1 on // a02. WRITES its manifest and revisions/; every test // starts from the fixture's manifest. // // Re-order with alt+↓ and by dragging, undo, the row menu (duplicate, remove, // insert after), a refusal in the build's words, and the teaser, posts and // fact-check editors. // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const DIR = path.join(HERE, "..", ".e2e-song", "reports", "timeline-fixture"); const MANIFEST = path.join(DIR, "video.manifest.json"); const PRISTINE = path.join(DIR, "video.manifest.pristine"); const PAGE = "/browse/reports/timeline-fixture"; type Entry = Record & { id: string }; const manifest = () => JSON.parse(readFileSync(MANIFEST, "utf8")) as { timeline: Entry[]; posts?: Entry[]; render: Record }; const order = () => manifest().timeline.map((e) => e.id); const rows = (page: Page) => page.locator("li[data-entry][data-kind]"); const shownOrder = (page: Page) => rows(page).evaluateAll((els) => els.map((e) => e.getAttribute("data-entry"))); test.beforeAll(() => { if (!existsSync(PRISTINE)) copyFileSync(MANIFEST, PRISTINE); }); test.beforeEach(() => { copyFileSync(PRISTINE, MANIFEST); rmSync(path.join(DIR, "revisions"), { recursive: true, force: true }); rmSync(path.join(DIR, "notes.json"), { force: true }); }); async function open(page: Page) { await page.goto(PAGE); await expect(page.getByTestId("timeline-undo")).toBeEnabled(); // The list has read its token once the page is hydrated. await page.waitForLoadState("networkidle"); } test("alt+↓ moves a row, recomputes sectionEnter, and Undo puts it back byte for byte", async ({ page }) => { const before = readFileSync(MANIFEST, "utf8"); await open(page); await page.locator("li[data-entry='a01'][data-kind]").focus(); await page.keyboard.press("Alt+ArrowDown"); await expect.poll(order).toEqual(["t1", "a02", "a01", "a03"]); await expect.poll(() => shownOrder(page)).toEqual(["t1", "a02", "a01", "a03"]); const m = manifest(); expect(m.timeline[1].sectionEnter).toBe(true); expect("sectionEnter" in m.timeline[2]).toBe(false); expect(readdirSync(path.join(DIR, "revisions")).some((n) => n.includes("auto-before-move"))).toBe(true); await page.getByTestId("timeline-undo").click(); await expect.poll(() => readFileSync(MANIFEST, "utf8")).toBe(before); await expect.poll(() => shownOrder(page)).toEqual(["t1", "a01", "a02", "a03"]); }); test("a row dragged by its handle lands where it is dropped", async ({ page }) => { await open(page); await page.locator("[data-drag-handle='a03']").dragTo(page.locator("li[data-entry='a01'][data-kind]")); await expect.poll(order).toEqual(["t1", "a03", "a01", "a02"]); }); test("the row menu duplicates, removes, inserts — and a refusal is in the build's words", async ({ page }) => { await open(page); await page.locator("[data-row-menu='a03']").click(); await page.locator("[data-row-action='duplicate']").click(); await expect.poll(order).toEqual(["t1", "a01", "a02", "a03", "a03-copy"]); await expect(page.locator("[data-row-menu='a03-copy']")).toBeVisible(); await page.locator("[data-row-menu='a03-copy']").click(); await page.locator("[data-row-action='remove']").click(); await expect.poll(order).toEqual(["t1", "a01", "a02", "a03"]); await page.locator("[data-row-menu='a03']").click(); await page.locator("[data-row-action='insert']").click(); await page.getByTestId("insert-after-a03").fill("testchan/vid1@3-6"); await page.getByTestId("insert-after-a03").press("Enter"); await expect.poll(order).toEqual(["t1", "a01", "a02", "a03", "vid1-3"]); expect(manifest().timeline[4]).toEqual({ type: "clip", id: "vid1-3", channel: "testchan", video: "vid1", start: 3, end: 6 }); // p1 rides on a02: removing it is refused, and nothing is written. const was = readFileSync(MANIFEST, "utf8"); await page.locator("[data-row-menu='a02']").click(); await page.locator("[data-row-action='remove']").click(); await expect(page.getByTestId("timeline-error")).toContainText("attachTo"); expect(readFileSync(MANIFEST, "utf8")).toBe(was); }); test("the teaser's lines and beat, edited in place; an empty teaser is refused", async ({ page }) => { await open(page); await page.getByTestId("structure-folded").locator("summary").click(); await page.getByTestId("teaser-lines-t1").fill("THE PROMISE\nAND WHAT HAPPENED"); await page.getByTestId("teaser-beat-t1").fill("1.2"); await page.getByTestId("teaser-save-t1").click(); await expect.poll(() => manifest().timeline[0].lines).toEqual(["THE PROMISE", "AND WHAT HAPPENED"]); expect(manifest().timeline[0].beat).toBe(1.2); await page.getByTestId("teaser-lines-t1").fill(""); await page.getByTestId("teaser-save-t1").click(); await expect(page.getByTestId("structure-error")).toContainText("lines must be a list"); }); test("a post added, then removed; the fact-check's labels once the deck is on", async ({ page }) => { // The fact-check is drawn by the deck: turn it on in the fixture first. const m = manifest(); m.render.chrome = { engine: "hyperframes", layout: "deck" }; writeFileSync(MANIFEST, JSON.stringify(m, null, 2) + "\n"); await open(page); await page.getByTestId("structure-folded").locator("summary").click(); await page.getByTestId("post-add").click(); const fresh = page.locator("[data-post-row='new']"); await fresh.getByTestId("post-id").fill("p2"); await fresh.getByTestId("post-date").fill("2024-02-03"); await fresh.getByTestId("post-url").fill("https://x.com/someone/status/2"); await fresh.getByTestId("post-text").fill("Another post."); await fresh.getByTestId("post-save").click(); await expect.poll(() => (manifest().posts ?? []).map((p) => p.id)).toEqual(["p1", "p2"]); await page.locator("[data-post-row='p2']").getByTestId("post-remove").click(); await expect.poll(() => (manifest().posts ?? []).map((p) => p.id)).toEqual(["p1"]); await page.getByTestId("fc-label-CONTRADICTED").fill("NOPE"); await page.getByTestId("fc-color-CONTRADICTED").fill("#ff0000"); await page.getByTestId("fc-save").click(); await expect .poll(() => (manifest().render.chrome as { factcheck?: unknown }).factcheck) .toEqual({ verdicts: { CONTRADICTED: { label: "NOPE", color: "#ff0000" } } }); });