import { test, expect, type Page } from "@playwright/test"; import { existsSync, readFileSync, rmSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { WARM_TIMEOUT, warm } from "./warm"; // --------------------------------------------------------------------------- // Notes on an article: written beside report.json (the one corpus file umtool // writes), anchored to a quote and found again, resolved, deleted -- and the // last delete removes the file. // // priv/polemic-alpha WRITES sites/priv/reports/polemic-alpha/notes.json // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const NOTES = path.join(HERE, "..", ".e2e-song", "sites", "priv", "reports", "polemic-alpha", "notes.json"); const PAGE = "/sites/priv/polemic-alpha"; test.beforeAll(async ({ playwright }) => { test.setTimeout(WARM_TIMEOUT); await warm(playwright, ["/sites/priv/polemic-alpha", "/api/notes?article=priv/polemic-alpha", "/sites", "/browse/decisions?kind=open-note", "/api/sites/evidence?site=priv&report=polemic-alpha&cite=c1"]); }); test.beforeEach(() => rmSync(NOTES, { force: true })); test.afterAll(() => rmSync(NOTES, { force: true })); async function select(page: Page, block: string, text: string) { await page.evaluate( ([block, text]) => { const root = document.querySelector(`[data-block="${block}"]`)!; const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); for (let n = w.nextNode() as Text | null; n; n = w.nextNode() as Text | null) { const i = n.data.indexOf(text); if (i < 0) continue; const r = document.createRange(); r.setStart(n, i); r.setEnd(n, i + text.length); const s = getSelection()!; s.removeAllRanges(); s.addRange(r); return; } throw new Error(`no "${text}" in ${block}`); }, [block, text], ); await page.locator(`[data-block="${block}"]`).dispatchEvent("mouseup"); } test("select text, Note, save: a mark on the quote, a note beside report.json", async ({ page }) => { await page.goto(PAGE); await select(page, "first", "Nobody checked the claim"); await page.getByRole("button", { name: "Note", exact: true }).click(); await page.getByLabel("note text").fill("Source this sentence."); await page.getByRole("button", { name: "save note" }).click(); await expect(page.locator("mark[data-note]")).toHaveText("Nobody checked the claim"); const doc = JSON.parse(readFileSync(NOTES, "utf8")); expect(doc.subject).toEqual({ kind: "article", site: "priv", report: "polemic-alpha" }); expect(doc.source.draft).toMatch(/sitews\/polemics\/drafts\/alpha\.json$/); expect(doc.notes[0]).toMatchObject({ status: "open", author: "operator", text: "Source this sentence." }); expect(doc.notes[0].anchor).toMatchObject({ kind: "text", section: "first", quote: "Nobody checked the claim" }); expect(doc.notes[0].anchor.prefix).toContain("first stream."); // Found again after a reload, and hovering the card lights the mark. await page.reload(); const card = page.locator("[data-note-card]"); await expect(card).toHaveCount(1); await card.hover(); await expect(page.locator("mark[data-note]")).toHaveAttribute("data-active", "true"); }); // A drag that runs past the end of a section into the next one: the note is // anchored in the section it STARTED in, from there to that section's end (a // text anchor names one section). It used to get no Note button at all. test("a selection across two sections gets a Note, anchored in the first", async ({ page }) => { await page.goto(PAGE); await page.evaluate(() => { const at = (block: string, text: string): [Text, number] => { const root = document.querySelector(`[data-block="${block}"]`)!; const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); for (let n = w.nextNode() as Text | null; n; n = w.nextNode() as Text | null) { const i = n.data.indexOf(text); if (i >= 0) return [n, i]; } throw new Error(`no "${text}" in ${block}`); }; const [a, i] = at("first", "Nobody checked the claim"); const [b, j] = at("later", "on a different show"); const r = document.createRange(); r.setStart(a, i); r.setEnd(b, j + "on a different".length); const s = getSelection()!; s.removeAllRanges(); s.addRange(r); }); await page.locator('[data-block="later"]').dispatchEvent("mouseup"); await page.getByRole("button", { name: "Note", exact: true }).click(); await page.getByLabel("note text").fill("This runs on."); await page.getByRole("button", { name: "save note" }).click(); // The mark is drawn once the note is saved: then the file is there. await expect(page.locator('[data-block="first"] mark[data-note]').first()).toBeVisible(); await expect(page.locator('[data-block="later"] mark[data-note]')).toHaveCount(0); const anchor = JSON.parse(readFileSync(NOTES, "utf8")).notes[0].anchor; expect(anchor).toMatchObject({ kind: "text", section: "first" }); expect(anchor.quote.startsWith("Nobody checked the claim")).toBe(true); expect(anchor.quote).toContain("for the record."); expect(anchor.quote).not.toContain("different show"); }); test("section, whole-article and citation notes; resolve, reopen, reply, filters", async ({ page }) => { await page.goto(PAGE); await page.getByRole("button", { name: "note on Later" }).click(); await page.getByLabel("note text").fill("Section note."); await page.keyboard.press("Control+Enter"); await expect(page.locator("[data-note-card]")).toHaveCount(1); await page.getByRole("button", { name: "+ whole article" }).click(); await page.getByLabel("note text").fill("Whole note."); await page.getByRole("button", { name: "save note" }).click(); await expect(page.locator("[data-note-card]")).toHaveCount(2); await page.locator("button[data-cite='c1']").first().click(); await expect(page.locator("[data-evidence='c1']")).toBeVisible(); await page.getByRole("button", { name: "+ note on this citation" }).click(); await page.getByLabel("note text").fill("Right second?"); await page.getByRole("button", { name: "save note" }).click(); await expect(page.locator("[data-evidence-rail] [data-note-card]")).toHaveCount(1); await expect(page.locator("button[data-cite='c1'][data-has-note='true']").first()).toBeVisible(); const notes = JSON.parse(readFileSync(NOTES, "utf8")).notes; expect(notes.map((n: { anchor: { kind: string } }) => n.anchor.kind).sort()).toEqual(["cite", "section", "whole"]); // resolve the whole-article note; the open filter drops it const whole = page.locator("[data-note-card]", { hasText: "Whole note." }); await whole.getByRole("button", { name: "resolve" }).click(); await expect(page.locator("[data-note-card]", { hasText: "Whole note." })).toHaveCount(0); await page.getByRole("button", { name: /^resolved 1$/ }).click(); const resolved = page.locator("[data-note-card]", { hasText: "Whole note." }); await expect(resolved).toHaveAttribute("data-status", "resolved"); await resolved.getByRole("button", { name: "reopen" }).click(); await page.getByRole("button", { name: /^all 3$/ }).click(); const reopened = page.locator("[data-note-card]", { hasText: "Whole note." }); await expect(reopened).toHaveAttribute("data-status", "open"); await reopened.getByRole("button", { name: "reply" }).click(); await page.getByLabel("note text").fill("A reply."); await reopened.getByRole("button", { name: "reply", exact: true }).click(); await expect(reopened.locator("[data-reply='operator']")).toContainText("A reply."); }); test("keys: j selects, r resolves, n notes the selection", async ({ page }) => { await page.goto(PAGE); await select(page, "later", "on a different show"); await expect(page.getByRole("button", { name: "Note", exact: true })).toBeVisible(); await page.keyboard.press("n"); await page.getByLabel("note text").fill("Which show?"); await page.keyboard.press("Control+Enter"); await expect(page.locator("mark[data-note]")).toHaveText("on a different show"); await page.locator("main").click({ position: { x: 5, y: 5 } }); await page.keyboard.press("j"); await expect(page.locator("[data-note-card]")).toHaveAttribute("aria-current", "true"); await page.keyboard.press("r"); await expect(page.locator("[data-note-card]")).toHaveCount(0); expect(JSON.parse(readFileSync(NOTES, "utf8")).notes[0].status).toBe("resolved"); }); test("a quote that is gone is still listed, flagged orphaned", async ({ page, request }) => { const res = await request.post("/api/notes?article=priv/polemic-alpha", { data: { op: { op: "add", text: "About a sentence the agent rewrote.", anchor: { kind: "text", section: "first", quote: "a sentence that is not in the article", prefix: "", suffix: "" } } }, }); expect(res.status()).toBe(200); await page.goto(PAGE); await expect(page.locator("[data-note-card][data-orphaned='true']")).toHaveCount(1); await expect(page.locator("mark[data-note]")).toHaveCount(0); }); test("a stale token is a 409 and the page re-reads; the last delete removes notes.json", async ({ page, request }) => { await page.goto(PAGE); await page.getByRole("button", { name: "+ whole article" }).click(); await page.getByLabel("note text").fill("Mine."); await page.getByRole("button", { name: "save note" }).click(); await expect(page.locator("[data-note-card]")).toHaveCount(1); // An agent writes in between (no token: the CLI's way). const id = JSON.parse(readFileSync(NOTES, "utf8")).notes[0].id; const stale = await request.post("/api/notes?article=priv/polemic-alpha", { data: { token: "absent", op: { op: "status", id, status: "resolved" } } }); expect(stale.status()).toBe(409); await request.post("/api/notes?article=priv/polemic-alpha", { data: { op: { op: "reply", id, text: "seen" } } }); await page.locator("[data-note-card]").getByRole("button", { name: "resolve" }).click(); await expect(page.getByText(/reloaded; check and try again/)).toBeVisible(); await expect(page.locator("[data-note-card] [data-reply]")).toContainText("seen"); page.on("dialog", (d) => d.accept()); await page.locator("[data-note-card]").getByRole("button", { name: "delete" }).click(); await expect(page.locator("[data-note-card]")).toHaveCount(0); expect(existsSync(NOTES)).toBe(false); }); test("open notes are open decisions, linked to the note; ?note= opens on it", async ({ page, request }) => { await request.post("/api/notes?article=priv/polemic-alpha", { data: { op: { op: "add", text: "For the inbox.", anchor: { kind: "section", section: "later" } } } }); const id = JSON.parse(readFileSync(NOTES, "utf8")).notes[0].id; await page.goto("/sites?notes=open"); await expect(page.locator("[data-article='priv/polemic-alpha'] [data-open-notes]")).toHaveAttribute("data-open-notes", "1"); await page.goto("/browse/decisions?kind=open-note"); const row = page.locator("[data-project='sites/priv/polemic-alpha'] [data-decision='open-note']"); await expect(row).toContainText("For the inbox."); await row.getByRole("link").click(); await expect(page).toHaveURL(new RegExp(`/sites/priv/polemic-alpha\\?note=${id}`)); await expect(page.locator(`[data-note-card='${id}']`)).toHaveAttribute("aria-current", "true"); }); test("writes outside a report are refused", async ({ request }) => { for (const q of ["article=priv/../pub", "article=priv/nope", "article=priv", "article=../x/y"]) { const r = await request.post(`/api/notes?${q}`, { data: { op: { op: "add", text: "x", anchor: { kind: "whole" } } } }); expect([400, 403, 404]).toContain(r.status()); } const bad = await request.post("/api/notes?article=priv/polemic-alpha", { data: { op: { op: "add", text: "x", anchor: { kind: "moment", file: "../../x.mp4", t: 1 } } } }); expect(bad.status()).toBe(400); expect(existsSync(NOTES)).toBe(false); });