import { test, expect, type Locator } from "@playwright/test"; import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { WARM_TIMEOUT, warm } from "./warm"; // --------------------------------------------------------------------------- // Where the two tracks meet on the article page: // // * the report's own video carries timed notes, written to the ARTICLE's // notes through the reader's one handle (a mark and a text note do not // 409 each other); // * "Add to video" puts a cited span at the end of the linked project's // timeline; the project is GENERATED, so an `edit` note is left for the // agent, and the change is undoable from the auto snapshot. // // priv/polemic-alpha video.mp4 (4 s); c1 → sitechan/sv1@3-6 // sitews/polemic-alpha the linked project (generatedBy set), one clip a1 // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const FIX = path.join(HERE, "..", ".e2e-song"); const NOTES = path.join(FIX, "sites", "priv", "reports", "polemic-alpha", "notes.json"); const PROJ = path.join(FIX, "sitews", "polemic-alpha"); const MANIFEST = path.join(PROJ, "video.manifest.json"); const SAVED = path.join(PROJ, ".manifest.e2e-integration"); const PROJ_NOTES = path.join(PROJ, "notes.json"); const readJson = (f: string) => JSON.parse(readFileSync(f, "utf8")); async function seek(video: Locator, t: number) { await video.evaluate(async (el: HTMLVideoElement, at: number) => { if (el.readyState < 1) await new Promise((r) => el.addEventListener("loadedmetadata", r, { once: true })); el.currentTime = at; await new Promise((r) => el.addEventListener("seeked", r, { once: true })); }, t); } function restoreProject() { if (existsSync(SAVED)) { copyFileSync(SAVED, MANIFEST); rmSync(SAVED, { force: true }); } rmSync(PROJ_NOTES, { force: true }); rmSync(`${MANIFEST}.bak`, { force: true }); const rev = path.join(PROJ, "revisions"); if (existsSync(rev)) for (const f of readdirSync(rev)) if (f.includes("auto-before")) rmSync(path.join(rev, f), { recursive: true, force: true }); } test.beforeAll(async ({ playwright }) => { test.setTimeout(WARM_TIMEOUT); await warm(playwright, ["/sites/priv/polemic-alpha", "/api/notes?article=priv/polemic-alpha", "/api/report/timeline?project=sitews/polemic-alpha"]); }); test.beforeEach(() => { rmSync(NOTES, { force: true }); copyFileSync(MANIFEST, SAVED); }); test.afterEach(() => { rmSync(NOTES, { force: true }); restoreProject(); }); test("the article's video takes timed notes, on the article's notes, beside a text note", async ({ page }) => { await page.goto("/sites/priv/polemic-alpha"); const video = page.getByTestId("article-video"); await expect(video).toBeVisible(); const scope = page.locator("[data-timed-notes='video.mp4']"); await seek(video, 1); await scope.locator("[data-action='mark']").click(); const input = scope.getByTestId("timed-note-input"); await input.fill("the cut is late here"); await input.press("Enter"); await expect(scope.locator("[data-mark-at]")).toHaveCount(1); // The rail's handle is the same one: a whole-article note right after does not 409. await page.getByRole("button", { name: "+ whole article" }).click(); await page.getByLabel("note text").fill("Retitle it."); await page.getByRole("button", { name: "save note" }).click(); await expect.poll(() => (existsSync(NOTES) ? readJson(NOTES).notes.length : 0)).toBe(2); const doc = readJson(NOTES); expect(doc.subject).toEqual({ kind: "article", site: "priv", report: "polemic-alpha" }); const moment = doc.notes.find((n: { anchor: { kind: string } }) => n.anchor.kind === "moment"); expect(moment.anchor).toMatchObject({ kind: "moment", file: "video.mp4", t: 1 }); expect(moment.author).toBe("operator"); }); test("Add to video: the cited span lands at the end of the linked project, with an edit note; undo restores it", async ({ page, request }) => { const before = readJson(MANIFEST).timeline.length; await page.goto("/sites/priv/polemic-alpha"); await page.locator("button[data-cite='c1']").first().click(); const add = page.locator("[data-evidence='c1'] [data-add-to-video]"); await expect(add).toContainText("sitews/polemic-alpha"); await add.getByRole("button", { name: "Add to video" }).click(); await expect(add.getByRole("status")).toContainText("cite-c1"); const m = readJson(MANIFEST); expect(m.timeline.length).toBe(before + 1); expect(m.timeline.at(-1)).toMatchObject({ type: "clip", id: "cite-c1", channel: "sitechan", video: "sv1", start: 3, end: 6 }); const notes = readJson(PROJ_NOTES).notes; expect(notes.some((n: { anchor: { kind: string } }) => n.anchor.kind === "edit")).toBe(true); const g = await (await request.get("/api/report/timeline?project=sitews/polemic-alpha")).json(); const undo = await request.post("/api/report/timeline", { data: { project: "sitews/polemic-alpha", token: g.token, op: "undo" } }); expect(undo.ok()).toBe(true); expect(readJson(MANIFEST).timeline.length).toBe(before); });