import { test, expect } from "@playwright/test"; import { caps, NO_SONG_DATA } from "./capabilities"; import { execFileSync } from "node:child_process"; import { readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // --------------------------------------------------------------------------- // The registry, and the claim that adding a kind is cheap. // // Most of this suite is the usual sort of test: render a page, assert what is // on it. Three of them are not -- they read the repo's own source at test time, // because the thing being asserted is a PROPERTY OF THE CODE ("a kind id never // appears outside the registry") rather than of any page. Those are the ones // that fail when somebody special-cases a kind in a page, which is the exact // regression this design exists to prevent and the one a page test cannot see. // // The fixture (make-fixture.mjs) holds, deliberately: // report-fixture 4 clips; c01 ends mid-sentence, c04 is lockEnd and must // stay silent, c03's source has no punctuation at all // no-origin-fixture no siteOrigin -> blocking // localhost-fixture localhost origin -> blocking // bike-fixture a sweep report with no manifest -- the third kind // find/ shadowed by the /browse/find tool page // deep/nested/solo a pass-through chain, for the collapse // --------------------------------------------------------------------------- const HERE = path.dirname(fileURLToPath(import.meta.url)); const UMTOOL = path.join(HERE, ".."); test("the index lists every kind, and says which state each project is in", async ({ page }) => { await page.goto("/browse"); // The chip's number must EQUAL the number of cards, because the counts come // from the unfiltered set. Asserting that relationship rather than a magic // number is what stops every new fixture project from editing this spec. const cards = await page.locator("[data-kind='report-video']").count(); expect(cards).toBeGreaterThan(1); await expect(page.getByRole("link", { name: /^report video \d+$/ })).toHaveText( `report video ${cards}`, ); // NOT an exact count: browse.spec.ts creates a song through /api/browse/init, // so the number here depends on what else has run. What matters is that the // kind is present and that the fixture's own songs are in it. await expect(page.locator("[data-kind='song']").first()).toBeVisible(); await expect(page.locator("[data-project='reports/videos/alpha']")).toHaveAttribute( "data-kind", "song", ); // The third kind, which exists to prove a kind with no decisions, no build // and no rich read still gets a card, a chip and a page. await expect(page.locator("[data-kind='sweep-report']")).toHaveCount(1); await expect(page.locator("[data-project='reports/report-fixture']")).toHaveAttribute( "data-state", // It has a cached source window and no output: not just "windows written". "fetched", ); await expect(page.locator("[data-project='reports/bike-fixture']")).toHaveAttribute( "data-state", "draft", ); }); test("a kind chip filters, and the counts do not move when it does", async ({ page }) => { await page.goto("/browse"); const chip = page.getByRole("link", { name: /^report video \d+$/ }); const before = await chip.textContent(); await chip.click(); await expect(page).toHaveURL(/kind=report-video/); await expect(page.locator("[data-kind='song']")).toHaveCount(0); await expect(page.locator("[data-kind='report-video']").first()).toBeVisible(); // Counts come from the UNFILTERED set on purpose: a chip whose number changes // when you click a different chip moves under the cursor. await expect(page.getByRole("link", { name: /^report video \d+$/ })).toHaveText(before ?? ""); await expect(page.getByRole("link", { name: /^report video \d+$/ })).toHaveAttribute( "aria-current", "true", ); }); test("?q= survives the filters it was typed under, and is one pasteable URL", async ({ page }) => { await page.goto("/browse?kind=report-video"); await page.getByLabel("filter projects").fill("localhost"); await page.getByRole("button", { name: "filter" }).click(); await expect(page).toHaveURL(/kind=report-video/); await expect(page).toHaveURL(/q=localhost/); await expect(page.locator("[data-project]")).toHaveCount(1); await expect(page.locator("[data-project='reports/localhost-fixture']")).toBeVisible(); }); test("the two siteOrigin defects are BLOCKING, and nothing else is", async ({ page }) => { await page.goto("/browse/decisions?severity=blocking"); // These are the two that shipped in real videos: one manifest with no // siteOrigin (19 QR codes reading `undefined/?v=…`) and one pointing at // localhost (codes that resolve to nothing on a phone). const origins = page.locator("[data-decision='manifest-invalid'][data-target='provenance.siteOrigin']"); await expect(origins).toHaveCount(2); await expect( page.locator("[data-project='reports/no-origin-fixture'] [data-decision='manifest-invalid']"), ).toContainText("undefined"); await expect( page.locator("[data-project='reports/localhost-fixture'] [data-decision='manifest-invalid']"), ).toContainText("localhost"); }); test("a clip that ends mid-sentence is reported once, and lockEnd acknowledges it", async ({ page, }) => { await page.goto("/browse/decisions?kind=clip-mid-sentence"); // Scoped to one project rather than counted globally: the bench specs get // their own writable copy of this manifest, so a global count would be // asserting how many fixtures exist rather than what the rule does. // // c01 ends inside "And this one runs on and because". c04 ends inside // "trailing off and then" but sets lockEnd -- which is the author saying "I // meant to cut here", so it must NOT appear. const rows = page.locator( "[data-project='reports/report-fixture'] [data-decision='clip-mid-sentence']", ); await expect(rows).toHaveCount(1); await expect(rows).toHaveAttribute("data-target", "c01"); }); test("an unpunctuated source is said ONCE, not once per clip", async ({ page }) => { await page.goto("/browse/decisions?kind=no-punctuation"); // vid2 has no terminator anywhere, and one clip cites it. Six real projects // produced forty-odd of these rows before they were collapsed, which is an // inbox whose blocking rows have scrolled off the top -- so the assertion is // ONE row per project, not one per source. await expect( page.locator("[data-project='reports/report-fixture'] [data-decision='no-punctuation']"), ).toHaveCount(1); }); test("a project named for a tool page is BLOCKING, and the tool page still wins", async ({ page, }) => { await page.goto("/browse"); const card = page.locator("[data-project='find']"); await expect(card).toHaveAttribute("data-routing", "shadowed"); // Its link goes to the escape hatch, not to an address that renders something // else. Before this it linked to /browse/find and failed silently. await expect(card).toHaveAttribute("href", /\/browse\/at\?path=find/); await page.goto("/browse/find"); // The phrase console, not the project. await expect(page.locator("[data-project='find']")).toHaveCount(0); await page.goto("/browse/at?path=find"); // The banner says it and so does the decision row -- both are correct, so the // assertion takes the first rather than pretending only one exists. await expect(page.getByText(/is a tool page/).first()).toBeVisible(); await expect(page.getByText("Shadowed By A Tool Page")).toBeVisible(); }); test("a pass-through folder chain collapses for DISPLAY and never in the URL", async ({ page }) => { await page.goto("/browse"); // `deep` holds no projects and one child, so the heading reads as one label. await expect(page.locator("[data-folder='reports/deep/nested']")).toContainText("deep / nested"); // The URL is not collapsed, and every level of it resolves. await page.goto("/browse/reports/deep/nested/solo-fixture"); await expect(page.getByText("Down A Pass-Through Chain")).toBeVisible(); await page.goto("/browse/reports/deep/nested"); await expect(page.locator("[data-project='reports/deep/nested/solo-fixture']")).toBeVisible(); }); test("the song URLs that already existed still mean the same thing", async ({ page }) => { // THE SONG PROJECT IS THE SUBJECT. `/browse/alpha` lists cuts built from the // candidate corpus, and make-fixture copies a candidate only when its wav is // present — with no song bulk data the page this asserts the URL of has // nothing on it. test.skip(!caps.song, NO_SONG_DATA); // A project id is a path now, but /browse/alpha and /browse/alpha/wide are // the URLs in every decision href, every spec, and whatever anybody has open. await page.goto("/browse/alpha"); await expect(page.locator("[data-cut=wide]")).toBeVisible(); await page.goto("/browse/alpha/wide"); await expect(page).toHaveURL(/\/browse\/alpha\/wide/); // The cut page's own breadcrumb, which is the thing that proves the VIEW // resolved rather than the project page having been served for both URLs. await expect(page.getByText(/^every note \(\d+\)$/)).toBeVisible(); // And the canonical path works too. await page.goto("/browse/reports/videos/alpha"); await expect(page.locator("[data-cut=wide]")).toBeVisible(); }); test("every tool page under /browse still wins its route", async ({ page }) => { for (const p of ["/browse/decisions", "/browse/find", "/browse/sources", "/browse/faces"]) { const res = await page.goto(p); expect(res?.status(), `${p} should still be 200`).toBe(200); } }); // --------------------------------------------------------------------------- // The three source-level assertions. // --------------------------------------------------------------------------- test("no page or lib outside the registry special-cases a kind", () => { const ids = JSON.parse( execFileSync( "node", ["-e", "import('./lib/projects/kinds.mjs').then(m=>console.log(JSON.stringify(m.PROJECT_KINDS.map(k=>k.id))))"], { cwd: UMTOOL, encoding: "utf8" }, ).trim(), ) as string[]; // What is being caught is a BRANCH ON A KIND -- `if (p.kind === "song")`, // `kind: "report-video"`, a lookup keyed by one -- not the mere appearance of // the word. That distinction has to be drawn: `song` is also a query // parameter name in eight routes and a directory name in three modules, and a // bare grep for it reports eleven files that are entirely correct. // // So a line is an offender when it carries a kind id AS A STRING and mentions // `kind` on the same line. It is a heuristic and worth saying so: a // sufficiently indirect special-case (assigning the id to a const first) would // slip past. It catches the shape people actually write. const offenders: string[] = []; for (const id of ids) { let out = ""; try { out = execFileSync( "grep", [ "-rn", "--include=*.ts", "--include=*.tsx", "--include=*.mjs", "-e", `"${id}"`, "-e", `'${id}'`, "app", "lib", "components", ], { cwd: UMTOOL, encoding: "utf8" }, ); } catch { out = ""; // grep exits 1 when it finds nothing, which is the good case } for (const line of out.split("\n").filter(Boolean)) { const file = line.split(":")[0]; if (file.startsWith("lib/projects/") || file.startsWith("components/projects/")) continue; if (!/kind/i.test(line.slice(file.length))) continue; offenders.push(line); } } // This is the mechanical form of "adding a kind costs a registry entry and one // view". An `if (kind === "report-video")` in a page lands here. expect(offenders, `kind ids leaked outside the registry:\n${offenders.join("\n")}`).toEqual([]); }); test("the reserved names are exactly the static pages under app/browse", () => { const real = readdirSync(path.join(UMTOOL, "app", "browse"), { withFileTypes: true }) .filter((e) => e.isDirectory() && !e.name.startsWith("[")) .map((e) => e.name) .sort(); const declared = JSON.parse( execFileSync( "node", ["-e", "import('./lib/projects/kinds.mjs').then(m=>console.log(JSON.stringify(m.RESERVED_BROWSE)))"], { cwd: UMTOOL, encoding: "utf8" }, ).trim(), ) as string[]; // A tenth tool page must not silently make a project unreachable. expect([...declared].sort()).toEqual(real); }); test("a kind the registry has never seen appears everywhere, with no code edit", () => { // The extensibility claim, tested rather than asserted. E2E_UMTOOL_EXTRA_KINDS is // read only by kinds.mjs; if a new kind needs an edit anywhere else, this // fails. const extra = JSON.stringify([ { id: "fixture-kind", template: "fixture", label: "fixture kind", badge: "fix", marker: "FIXTURE.marker" }, ]); const out = execFileSync( "node", [ "-e", "import('./lib/projects/kinds.mjs').then(m=>console.log(JSON.stringify({" + "ids:m.PROJECT_KINDS.map(k=>k.id)," + "meta:m.KIND_META().map(k=>k.badge)," + "detected:m.detectKind(new Set(['FIXTURE.marker']))})))", ], { cwd: UMTOOL, encoding: "utf8", env: { ...process.env, E2E_UMTOOL_EXTRA_KINDS: extra } }, ); const j = JSON.parse(out.trim()); expect(j.ids).toContain("fixture-kind"); expect(j.meta).toContain("fix"); expect(j.detected).toEqual({ kind: "fixture-kind", template: "fixture" }); }); // --------------------------------------------------------------------------- // The CLI, against the same fixture. // // It shares lib/projects/*.mjs with the app, so these are not really testing a // second implementation -- they are testing that there ISN'T one. // --------------------------------------------------------------------------- // Same path playwright.config.ts builds it at. 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: "", }; const umtool = (args: string[]) => execFileSync("node", ["bin/umtool.mjs", ...args], { cwd: UMTOOL, encoding: "utf8", env: cliEnv }); test("umtool ls sees the same projects the index does", () => { const rows = JSON.parse(umtool(["ls", "--json"])) as { id: string; kind: string }[]; const ids = rows.map((r) => r.id); expect(ids).toContain("reports/report-fixture"); expect(ids).toContain("reports/bike-fixture"); // Including the one that has no URL of its own -- it is listed, never dropped. expect(ids).toContain("find"); expect(rows.find((r) => r.id === "reports/bike-fixture")?.kind).toBe("sweep-report"); }); test("umtool check exits 1 on a manifest that would ship dead QR codes", () => { let code = 0; let stdout = ""; try { stdout = umtool(["check", "no-origin-fixture"]); } catch (e) { const err = e as { status: number; stdout: string }; code = err.status; stdout = err.stdout; } // A build script can gate on this, which is the whole reason it exists. expect(code).toBe(1); expect(stdout).toContain("BLOCKING"); expect(stdout).toContain("siteOrigin"); // And a manifest with a real origin does not. const ok = umtool(["check", "report-fixture", "--json"]); expect(JSON.parse(ok).ok).toBe(true); }); test("umtool show reports the same window facts the page draws", () => { const j = JSON.parse(umtool(["show", "report-fixture", "--json"])); const byId = Object.fromEntries(j.entries.map((e: { id: string }) => [e.id, e])); // c01 ends inside a run-on cue, and the widener would carry it to the next // sentence end at 9.0. Knowing that BEFORE running --write is the point. expect(byId.c01.endsSentence).toBe(false); expect(byId.c01.proposed).toEqual({ start: 3, end: 9 }); // c02 ends on a full stop, so there is nothing to propose. expect(byId.c02.endsSentence).toBe(true); expect(byId.c02.proposed).toBeNull(); // c03's source has no punctuation at all: the question cannot be answered, and // `null` says so rather than a confident `false`. expect(byId.c03.endsSentence).toBeNull(); expect(byId.c03.noPunctuation).toBe(true); // The cached window is the one the build would fetch, found by containment. expect(byId.c01.cached.name).toBe("vid1_0.00-9.00.mp4"); expect(byId.c02.cached).toBeNull(); }); test("umtool refuses a name that means two projects rather than picking one", () => { // `alpha` is unique here, so it resolves -- the refusal path is exercised by // asking for something that is not there at all, which must also not guess. expect(JSON.parse(umtool(["show", "alpha", "--json"])).kind).toBe("song"); let code = 0; try { umtool(["show", "definitely-not-a-project"]); } catch (e) { code = (e as { status: number }).status; } expect(code).toBe(2); }); // --------------------------------------------------------------------------- // The index. // // Its whole contract is that it changes NOTHING except latency. These assert // that by breaking it in the two ways it can be broken and checking the pages // still say the same thing. // --------------------------------------------------------------------------- test("the index is observable, and reports how much of a load it served", async ({ request }) => { // First load populates it; the second should be served from it. await request.get("/api/browse/projects"); const r = await request.get("/api/browse/projects"); const health = r.headers()["x-index"]; expect(health).toBeTruthy(); if (health !== "off") { const [fresh, total] = health.split(" ")[0].split("/").map(Number); expect(total).toBeGreaterThan(0); expect(fresh).toBe(total); } }); test("deleting the index changes nothing but latency", async ({ request }) => { const before = (await (await request.get("/api/browse/projects")).json()) as { projects: { id: string; state: string; title: string }[]; }; // CACHE_DIR is documented as derived output, safe to delete at any time. This // is that promise, tested. rmSync(path.join(FIXTURE, "cache", "index"), { recursive: true, force: true, }); const after = (await (await request.get("/api/browse/projects")).json()) as typeof before; expect(after.projects.map((p) => `${p.id}:${p.state}:${p.title}`)).toEqual( before.projects.map((p) => `${p.id}:${p.state}:${p.title}`), ); }); test("a project that changed on disk is re-read, not served stale", async ({ request }) => { const idOf = (j: { projects: { id: string; facts: string[] }[] }, id: string) => j.projects.find((p) => p.id === id)!; const before = (await (await request.get("/api/browse/projects")).json()) as { projects: { id: string; facts: string[] }[]; }; const wasClips = idOf(before, "reports/gone-fixture").facts.find((f) => f.endsWith("clip")); expect(wasClips).toBe("1 clip"); // Add a clip behind the index's back. Signatures are over INPUTS -- the // manifest's own mtime and size -- so this must invalidate the record. const file = path.join(FIXTURE, "reports", "gone-fixture", "video.manifest.json"); const m = JSON.parse(readFileSync(file, "utf8")) as { timeline: unknown[] }; m.timeline.push({ type: "clip", id: "c02", video: "gone1", start: 3, end: 6, cite: 3, section: 0, lock: true, quote: "second", }); writeFileSync(file, JSON.stringify(m, null, 2) + "\n"); const after = (await (await request.get("/api/browse/projects")).json()) as typeof before; expect(idOf(after, "reports/gone-fixture").facts).toContain("2 clips"); }); test("umtool new scaffolds a project that check immediately blocks", () => { const dir = path.join(FIXTURE, "scaffold-root"); rmSync(dir, { recursive: true, force: true }); const env = { ...cliEnv, REPORTS_DIR: dir }; const run = (args: string[]) => execFileSync("node", ["bin/umtool.mjs", ...args], { cwd: UMTOOL, encoding: "utf8", env }); run(["new", "scaffolded", "--json"]); const m = JSON.parse( readFileSync(path.join(dir, "scaffolded", "video.manifest.json"), "utf8"), ) as { timeline: unknown[]; provenance: { siteOrigin: string } }; // An EMPTY timeline on purpose. A report records ONE second per citation; a // window needs a start and an end from the cue file, and generating guesses // would look finished and be wrong. expect(m.timeline).toHaveLength(0); // And an empty siteOrigin, so the field that shipped broken twice cannot be // left plausible-looking. expect(m.provenance.siteOrigin).toBe(""); let code = 0; try { run(["check", "scaffolded"]); } catch (e) { code = (e as { status: number }).status; } expect(code).toBe(1); rmSync(dir, { recursive: true, force: true }); }); test("a timeline entry of an unknown type renders, rather than crashing the page", async ({ page, }) => { // The manifest's vocabulary is OPEN. A real one carries `scroll` and `chart` // beside its cards; code that assumed card-or-clip put `undefined` into // path.join() and 500'd the project page. The rule is that a CLIP is // `type === "clip"` and everything else renders generically. const res = await page.goto("/browse/reports/report-fixture"); expect(res?.status()).toBe(200); // The TIMELINE's entries, which carry a kind. The On-screen section's table // marks its rows with data-entry too (one per entry, any type), so an // unscoped [data-entry] counts every entry twice. const entry = (id?: string) => page.locator(id ? `[data-entry=${id}][data-kind]` : "[data-entry][data-kind]"); await expect(entry("z01")).toHaveAttribute("data-kind", "zz-unknown"); await expect(entry("z01")).toContainText("An entry type from the future"); await expect(entry("k01")).toHaveAttribute("data-kind", "card"); // And it is COUNTED, not silently dropped: a card saying "4 clips · 1 card" // about a 6-entry timeline would be lying by omission. await expect(entry()).toHaveCount(6); await expect(entry("c01")).toHaveAttribute("data-kind", "clip"); }); // The brand preset (report-to-video/brand.mjs, `render.brand`) is offered where // a report video's render block is born: the new-project form and `umtool new`. // No brand writes exactly the skeleton it always did. test("new project offers the brand preset, and only a branded scaffold carries render.brand", async ({ page, }) => { const dir = path.join(FIXTURE, "scaffold-brand"); rmSync(dir, { recursive: true, force: true }); const env = { ...cliEnv, REPORTS_DIR: dir }; const run = (args: string[]) => execFileSync("node", ["bin/umtool.mjs", ...args], { cwd: UMTOOL, encoding: "utf8", env }); type Render = { brand?: string; palette: Record }; const renderOf = (root: string, slug: string) => (JSON.parse(readFileSync(path.join(root, slug, "video.manifest.json"), "utf8")) as { render: Render }).render; const out = JSON.parse(run(["new", "branded", "--brand", "archilyzer-media", "--json"])) as { brand?: string }; expect(out.brand).toBe("archilyzer-media"); expect(renderOf(dir, "branded").brand).toBe("archilyzer-media"); // The palette is still written: dropping `brand` later leaves a cut that renders. expect(renderOf(dir, "branded").palette.bg).toBeTruthy(); const plain = JSON.parse(run(["new", "plain", "--json"])) as Record; expect("brand" in plain).toBe(false); expect("brand" in renderOf(dir, "plain")).toBe(false); let code = 0; try { run(["new", "acme", "--brand", "acme"]); } catch (e) { code = (e as { status: number }).status; } expect(code).not.toBe(0); rmSync(dir, { recursive: true, force: true }); // The form: the preset is a choice, "no brand" the default. const slug = "brand-ui-probe"; const created = path.join(FIXTURE, slug); rmSync(created, { recursive: true, force: true }); await page.goto("/"); await page.locator("[data-new-project]").click(); const brand = page.locator("[data-new-brand]"); await expect(brand).toHaveValue(""); await expect(brand.locator("option")).toHaveText(["no brand", "Archilyzer Media"]); await page.locator("[data-new-slug]").fill(slug); await brand.selectOption("archilyzer-media"); await page.locator("[data-new-create]").click(); await page.waitForURL(`**/browse/${slug}`); expect(renderOf(FIXTURE, slug).brand).toBe("archilyzer-media"); rmSync(created, { recursive: true, force: true }); });