commit 1a344483b455afc222a539a8cae736e0ca26cd0f
parent c726c16e75c8b2981b196bb2e7bdadb6031cd224
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 05:28:49 -0400
export: the cited report-site suite — the report index as home, the report (verdicts, tally, still, numbered cites with hover/focus previews, Escape, reference anchors, downloads), the three moment kinds with media that 200s, the reports-only shell pages; every page held to no corpus request and no missing file; the audit against the real out/
Served by e2e-report/serve.mjs: serve lists a span moment's directory (its
name has decimals) instead of serving its page.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 464 insertions(+), 7 deletions(-)
diff --git a/PUBLISH.md b/PUBLISH.md
@@ -100,6 +100,16 @@ shell's own pages and files, or a file over 25 MiB, or more than 20,000 files, f
the build, and every deploy path refuses it. A site switched to cited is refused at
deploy until it is built again.
+`pnpm --filter export run e2e:report` builds a cited fixture site for real (one
+fact-check citing every kind, its moments and their media) in a stage project at the
+repo root, `.e2e-report-site/` — never in `export/`, whose `public/` and `out/` are
+the checkout's — audits its `out/` as above and reads it in a browser.
+`pnpm --filter export run build:report-fixture` builds and audits it without the
+browser. The suite serves the build with `export/e2e-report/serve.mjs`, not `serve`:
+`serve` (export's `start`, the container's `site` service) takes a video or audio
+moment's directory name, `<start>-<end>` in seconds with decimals, for a file with an
+extension, and lists the directory instead of serving its page.
+
Deploy-only ships whatever is in `export/out`, which the basic build composes one
site at a time into a single shared directory — so it **refuses, before starting a
job, if `export/out` holds a build of another site** (or no build at all), naming the
diff --git a/WORKTREES.md b/WORKTREES.md
@@ -78,7 +78,7 @@ queue-lock: still waiting (5m00s)
**A long wait here is normal, not a hang** — the serial suite is ~24 minutes.
Queued: `pnpm e2e`, `pnpm e2e:sharded` (including its `docker build`), export's `e2e`,
-`e2e:hub`, `e2e:2origin`, and homepage's and umtool's `e2e`. Wrapping is at the *package*
+`e2e:hub`, `e2e:2origin`, `e2e:report`, and homepage's and umtool's `e2e`. Wrapping is at the *package*
level, so `pnpm --filter editor run e2e` is covered too, and a nested invocation passes
through instead of deadlocking. Not queued on purpose: a raw `pnpm --filter editor exec
playwright test`, the escape hatch for debugging a single spec, and the `e2e:ui`
@@ -94,7 +94,7 @@ meant. Every other package's suite goes through its own filter:
| Suite | Command |
|---|---|
| editor | `pnpm e2e [spec…]` |
-| export | `pnpm --filter export run e2e` (also `e2e:hub`, `e2e:2origin`) |
+| export | `pnpm --filter export run e2e` (also `e2e:hub`, `e2e:2origin`, `e2e:report`) |
| homepage | `pnpm --filter homepage run e2e` |
| umtool | `SONG_DIR=~/reports/quartering-uh-song/data pnpm --filter umtool run e2e [spec…]` |
diff --git a/export/e2e-report/audit.spec.ts b/export/e2e-report/audit.spec.ts
@@ -0,0 +1,69 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { expect, test } from "@playwright/test";
+import { citedBuildProblem } from "yt-dlp-transcript-common/lib/builtExport";
+import { CORPUS_ROOT_ENTRIES } from "./helpers";
+import { REPORT_SITE_OUT, reportSiteAuditProblem } from "./stage";
+
+// The cited out/ allowlist audit (common/lib/builtExport.ts), held to a REAL
+// `next build` of a cited site — the one this suite serves. The audit's list
+// was written from what compose puts in public/ and what Next was expected to
+// emit; this is where the two meet: if Next emits a file the list does not
+// name, the build fails here, and every deploy of it would be refused.
+
+const out = REPORT_SITE_OUT;
+const has = (rel: string) => fs.existsSync(path.join(out, ...rel.split("/")));
+
+test("the built cited site passes the audit every deploy path asks", () => {
+ expect(reportSiteAuditProblem()).toBeNull();
+});
+
+test("it holds the reports, the moments, the cited media and the contract", () => {
+ for (const rel of [
+ "index.html",
+ "reports/index.html",
+ "reports/index.json",
+ "reports/demo-factcheck/index.html",
+ "reports/demo-factcheck/page.json",
+ "reports/demo-factcheck/citations.json",
+ "reports/demo-factcheck/citations.csv",
+ "reports/demo-factcheck/stills/a01.png",
+ "m/index.json",
+ "m/demo-channel/abc123/3126.00-3151.00/index.html",
+ "m/demo-channel/abc123/3126.00-3151.00/moment.json",
+ "m/demo-podcast/ep-042/610.50-628.00/index.html",
+ "m/demo-social/1234567890/index.html",
+ "media/clips/demo-channel/abc123/3126.00-3151.00.mp4",
+ "media/clips/demo-podcast/ep-042/610.50-628.00.m4a",
+ "media/posts/demo-social/1234567890/shot.png",
+ "site.json",
+ "corpus.json",
+ "llms.txt",
+ "sitemap.xml",
+ ]) {
+ expect(has(rel), rel).toBe(true);
+ }
+ const corpus = JSON.parse(fs.readFileSync(path.join(out, "corpus.json"), "utf8"));
+ expect(corpus.site).toMatchObject({ id: "reportsite", scope: "cited" });
+ expect(corpus.reports.count).toBe(1);
+ const site = JSON.parse(fs.readFileSync(path.join(out, "site.json"), "utf8"));
+ expect(site).toMatchObject({ siteId: "reportsite", channels: [], pwa: false });
+});
+
+test("it holds nothing of the corpus", () => {
+ expect(CORPUS_ROOT_ENTRIES.filter(has)).toEqual([]);
+});
+
+test("the audit refuses the same build with a corpus entry beside it", () => {
+ const copy = fs.mkdtempSync(path.join(os.tmpdir(), "report-site-audit-"));
+ try {
+ fs.cpSync(out, copy, { recursive: true });
+ expect(citedBuildProblem(copy)).toBeNull();
+ fs.mkdirSync(path.join(copy, "summaries"));
+ fs.writeFileSync(path.join(copy, "summaries", "manifest.json"), "{}");
+ expect(citedBuildProblem(copy)).toMatch(/summaries\//);
+ } finally {
+ fs.rmSync(copy, { recursive: true, force: true });
+ }
+});
diff --git a/export/e2e-report/helpers.ts b/export/e2e-report/helpers.ts
@@ -0,0 +1,47 @@
+import { test as base, expect } from "@playwright/test";
+import { CORPUS_PUBLIC_ENTRIES } from "yt-dlp-transcript-common/bin/compose-site";
+
+// What a cited site must never ask its own origin for: the corpus-shaped
+// entries a full compose writes and a cited compose prunes, less the four a
+// cited compose writes again. A path is matched by its FIRST segment, so a
+// post capture under /media/posts/ is not /posts/.
+const REWRITTEN_FOR_CITED = new Set(["corpus.json", "llms.txt", "robots.txt", "sitemap.xml"]);
+export const CORPUS_ROOT_ENTRIES: readonly string[] = CORPUS_PUBLIC_ENTRIES.filter((e) => !REWRITTEN_FOR_CITED.has(e));
+
+export function isCorpusPath(pathname: string): boolean {
+ const first = pathname.split("/").filter(Boolean)[0] ?? "";
+ return CORPUS_ROOT_ENTRIES.includes(first);
+}
+
+type Traffic = {
+ // Every same-origin path the page requested, in order.
+ requested: string[];
+ // Same-origin responses with an error status, as "<status> <path>".
+ failed: string[];
+};
+
+// Every test records what its page asks the site for and, once it is done,
+// holds that to the cited promise: nothing of the corpus is requested, and
+// nothing the pages ask for is missing from the build.
+export const test = base.extend<{ traffic: Traffic }>({
+ traffic: [
+ async ({ page, baseURL }, use) => {
+ const origin = new URL(baseURL!).origin;
+ const traffic: Traffic = { requested: [], failed: [] };
+ page.on("request", (req) => {
+ const u = new URL(req.url());
+ if (u.origin === origin) traffic.requested.push(u.pathname);
+ });
+ page.on("response", (res) => {
+ const u = new URL(res.url());
+ if (u.origin === origin && res.status() >= 400) traffic.failed.push(`${res.status()} ${u.pathname}`);
+ });
+ await use(traffic);
+ expect(traffic.requested.filter(isCorpusPath), "a cited page requested the corpus").toEqual([]);
+ expect(traffic.failed, "a cited page requested something the build does not hold").toEqual([]);
+ },
+ { auto: true },
+ ],
+});
+
+export { expect };
diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts
@@ -0,0 +1,226 @@
+import { expect, test } from "./helpers";
+import type { Locator, Page } from "@playwright/test";
+
+// A cited site, as a reader meets it: the report index as the home page, the
+// report (verdicts, tally, the source's sentence, numbered inline citations
+// with previews, the reference list), the moment pages each citation opens,
+// and the shell's pages that say this site publishes its reports only. Every
+// test also holds the page's own requests to the cited promise (helpers.ts).
+//
+// The site is export/fixtures/report-site, staged and built by
+// e2e-report/stage.ts: one fact-check citing a video span (c01), an audio
+// span (au1), a post (p01), the article's own sentences (a01, a02) and a web
+// page (w01).
+
+const REPORT = "/reports/demo-factcheck/";
+const VIDEO_MOMENT = "/m/demo-channel/abc123/3126.00-3151.00/";
+const AUDIO_MOMENT = "/m/demo-podcast/ep-042/610.50-628.00/";
+const POST_MOMENT = "/m/demo-social/1234567890/";
+
+const inlineCite = (page: Page, id: string): Locator => page.locator(`[data-inline-cite="${id}"]`).first();
+const preview = (page: Page, id: string): Locator => page.locator(`[data-cite-preview="${id}"]`);
+
+async function expectImageLoaded(img: Locator): Promise<void> {
+ await expect(img).toBeVisible();
+ await expect.poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBeGreaterThan(0);
+}
+
+test("the home page is the report index, and the header links Reports alone", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.locator("[data-report-index]")).toBeVisible();
+ const entry = page.locator('[data-report-entry="demo-factcheck"]');
+ await expect(entry).toContainText("Checking an example article");
+ await expect(page.locator("header nav a")).toHaveText(["Reports"]);
+ await expect(page.locator("header nav a")).toHaveAttribute("href", "/reports/");
+
+ await entry.getByRole("link", { name: /Checking an example article/ }).first().click();
+ await expect(page).toHaveURL(REPORT);
+ await expect(page.locator("h1")).toHaveText("Checking an example article");
+});
+
+test("the report index page lists the report", async ({ page }) => {
+ await page.goto("/reports/");
+ await expect(page.locator('[data-report-entry="demo-factcheck"]')).toContainText("Checking an example article");
+ await expect(page.locator("header nav a")).toHaveText(["Reports"]);
+});
+
+test("the report: verdict chips, the tally, the source's sentence as a still, numbered inline citations", async ({ page }) => {
+ await page.goto(REPORT);
+
+ // The tally names each verdict the report reaches once; each claim carries its chip.
+ const tally = page.locator("[data-verdict-tally]");
+ await expect(tally).toBeVisible();
+ await expect(tally.locator("[data-verdict]")).toHaveCount(4);
+ const claims = page.locator("article[data-claim]");
+ await expect(claims).toHaveCount(5);
+ for (const [claim, verdict] of [
+ ["claim-1", "CONTRADICTED"],
+ ["claim-2", "PARTLY"],
+ ["claim-3", "CONTRADICTED"],
+ ["claim-4", "UNTESTABLE"],
+ ["claim-5", "CORROBORATED"],
+ ]) {
+ await expect(page.locator(`article#${claim} [data-verdict="${verdict}"]`)).toBeVisible();
+ }
+
+ // The claim's sentence in the article, as the reviewed page showed it.
+ await expectImageLoaded(page.locator('[data-source-sentence="a01"] img[src$="/stills/a01.png"]'));
+
+ // Inline citations carry their number in the report, by first appearance.
+ for (const [id, n] of [
+ ["c01", "1"],
+ ["au1", "2"],
+ ["w01", "4"],
+ ["a02", "5"],
+ ["p01", "6"],
+ ]) {
+ await expect(inlineCite(page, id).locator("[data-cite-number]")).toHaveText(`[${n}]`);
+ }
+});
+
+test("hovering an inline citation previews it; leaving closes it", async ({ page }) => {
+ await page.goto(REPORT);
+ for (const [id, kind, text] of [
+ ["c01", "video", "The bridge opened in the spring of twenty nineteen"],
+ ["p01", "post", "Never said I would move. Not once."],
+ ["a02", "source", "He has said many times that he would move away."],
+ ]) {
+ await inlineCite(page, id).hover();
+ const card = preview(page, id);
+ await expect(card).toBeVisible();
+ await expect(card.locator(`[data-citation-kind="${kind}"]`)).toBeVisible();
+ await expect(card).toContainText(text);
+ // Away from the marker and its card, the preview closes.
+ await page.mouse.move(2, 2);
+ await expect(card).toHaveCount(0);
+ }
+});
+
+test("focusing an inline citation's number previews it; Escape closes it", async ({ page }) => {
+ await page.goto(REPORT);
+ for (const [id, kind] of [
+ ["c01", "video"],
+ ["p01", "post"],
+ ["a02", "source"],
+ ]) {
+ const number = inlineCite(page, id).locator("[data-cite-number]");
+ await number.focus();
+ const card = preview(page, id);
+ await expect(card).toBeVisible();
+ await expect(card.locator(`[data-citation-kind="${kind}"]`)).toBeVisible();
+ await expect(number).toHaveAttribute("aria-expanded", "true");
+ await page.keyboard.press("Escape");
+ await expect(card).toHaveCount(0);
+ await expect(number).toHaveAttribute("aria-expanded", "false");
+ }
+});
+
+test("a post citation's preview shows the captured shot", async ({ page }) => {
+ await page.goto(REPORT);
+ await inlineCite(page, "p01").locator("[data-cite-number]").focus();
+ await expectImageLoaded(preview(page, "p01").locator('img[src="/media/posts/demo-social/1234567890/shot.png"]'));
+});
+
+test("a citation's number jumps to its entry in the reference list", async ({ page }) => {
+ await page.goto(REPORT);
+ const refs = page.locator("[data-reference-list] [data-reference]");
+ await expect(refs).toHaveCount(6);
+ await expect
+ .poll(() => refs.evaluateAll((els) => els.map((el) => el.id)))
+ .toEqual(["c-c01", "c-au1", "c-a01", "c-w01", "c-a02", "c-p01"]);
+
+ await inlineCite(page, "w01").locator("[data-cite-number]").click();
+ await expect(page).toHaveURL(`${REPORT}#c-w01`);
+ await expect(page.locator("#c-w01")).toBeInViewport();
+ await expect(page.locator("#c-w01")).toContainText("Opened to traffic: April 2019.");
+});
+
+test("the report's citations download as JSON and CSV", async ({ page, request }) => {
+ await page.goto(REPORT);
+ const links = page.locator("[data-citation-downloads] a[download]");
+ await expect(links).toHaveCount(2);
+ for (const href of await links.evaluateAll((els) => els.map((el) => el.getAttribute("href")!))) {
+ const res = await request.get(href);
+ expect(res.status(), href).toBe(200);
+ if (href.endsWith(".json")) expect((await res.json()).format).toBe("archilyzer-citations");
+ else expect(await res.text()).toContain("c01");
+ }
+});
+
+test("a video citation opens its moment: the clip, the cue lines with the span marked, where it is cited", async ({
+ page,
+ request,
+}) => {
+ await page.goto(REPORT);
+ await inlineCite(page, "c01").getByRole("link", { name: "on stream" }).click();
+ await expect(page).toHaveURL(VIDEO_MOMENT);
+ await expect(page.locator("[data-moment]")).toHaveAttribute("data-moment-kind", "video");
+
+ const video = page.locator("video[data-evidence-clip]");
+ await expect(video).toBeVisible();
+ const src = await video.getAttribute("src");
+ expect(src).toBe("/media/clips/demo-channel/abc123/3126.00-3151.00.mp4");
+ const clip = await request.get(src!);
+ expect(clip.status()).toBe(200);
+ expect(clip.headers()["content-type"]).toMatch(/^video\/mp4/);
+ expect((await clip.body()).length).toBeGreaterThan(1000);
+ // The poster is the report's still.
+ const poster = await video.getAttribute("poster");
+ expect((await request.get(poster!)).status()).toBe(200);
+
+ const cues = page.locator("[data-cue-lines] li");
+ await expect(cues).toHaveCount(6);
+ await expect(page.locator("[data-cue-lines] li[data-in-span]")).toHaveText([
+ /The bridge opened in the spring of twenty nineteen,/,
+ /I was there for it\./,
+ ]);
+
+ const citedIn = page.locator("[data-cited-in] a");
+ await expect(citedIn.and(page.locator(`[href="${REPORT}#claim-1"]`))).toHaveCount(1);
+ await expect(citedIn.and(page.locator(`[href="${REPORT}#claim-5"]`))).toHaveCount(1);
+ await citedIn.and(page.locator(`[href="${REPORT}#claim-1"]`)).click();
+ await expect(page).toHaveURL(`${REPORT}#claim-1`);
+ await expect(page.locator("#claim-1")).toBeInViewport();
+});
+
+test("an audio moment plays its clip as sound", async ({ page, request }) => {
+ await page.goto(AUDIO_MOMENT);
+ await expect(page.locator("[data-moment]")).toHaveAttribute("data-moment-kind", "audio");
+ await expect(page.locator("video")).toHaveCount(0);
+ const src = await page.locator("audio[data-evidence-clip]").getAttribute("src");
+ expect(src).toBe("/media/clips/demo-podcast/ep-042/610.50-628.00.m4a");
+ const clip = await request.get(src!);
+ expect(clip.status()).toBe(200);
+ expect(clip.headers()["content-type"]).toMatch(/^audio\//);
+ await expect(page.locator("[data-cue-lines] li[data-in-span]")).toHaveText([/Like I said before, spring twenty nineteen\./]);
+ await expect(page.locator(`[data-cited-in] a[href="${REPORT}#claim-2"]`)).toHaveCount(1);
+});
+
+test("a post moment shows the captured shot and the post", async ({ page }) => {
+ await page.goto(POST_MOMENT);
+ await expect(page.locator("[data-moment]")).toHaveAttribute("data-moment-kind", "post");
+ await expectImageLoaded(page.locator('img[src="/media/posts/demo-social/1234567890/shot.png"]'));
+ await expect(page.locator("[data-moment]")).toContainText("Posted to settle it.");
+ await expect(page.locator("video, audio")).toHaveCount(0);
+ await expect(page.locator(`[data-cited-in] a[href="${REPORT}#claim-3"]`)).toHaveCount(1);
+});
+
+for (const route of ["/ask/", "/downloads/", "/duplicates/"]) {
+ test(`${route} says this site publishes its reports only`, async ({ page }) => {
+ await page.goto(route);
+ await expect(page.getByText("This site publishes its reports only.")).toBeVisible();
+ await expect(page.getByRole("link", { name: "Read the reports" })).toHaveAttribute("href", "/reports/");
+ await expect(page.locator("header nav a")).toHaveText(["Reports"]);
+ });
+}
+
+test("reading the whole site never asks for the corpus", async ({ page, traffic }) => {
+ for (const route of ["/", "/reports/", REPORT, VIDEO_MOMENT, AUDIO_MOMENT, POST_MOMENT, "/ask/", "/downloads/"]) {
+ await page.goto(route);
+ await page.waitForLoadState("networkidle");
+ }
+ // The recorder saw the pages' own requests (the corpus check runs after
+ // every test, this one included).
+ expect(traffic.requested).toContain(REPORT);
+ expect(traffic.requested.some((p) => p.startsWith("/_next/"))).toBe(true);
+});
diff --git a/export/e2e-report/serve.mjs b/export/e2e-report/serve.mjs
@@ -0,0 +1,103 @@
+// A static server for the staged cited build (playwright.report.config.ts),
+// serving a directory the way Cloudflare Pages and the container's Caddy do:
+// `/dir/` is `dir/index.html`, `/dir` redirects to `/dir/`.
+//
+// Not `serve` (export's `start`): serve-handler stats a path whose last segment
+// looks like it has an extension as a file, and lists the directory instead of
+// serving its index.html — and a span moment's directory is named
+// `<start>-<end>` in seconds with two decimals (`3126.00-3151.00`), so every
+// video and audio moment page came back as a directory listing.
+//
+// Byte ranges are honoured (a media element asks for them).
+//
+// node e2e-report/serve.mjs <dir> <port>
+
+import fs from "node:fs";
+import http from "node:http";
+import path from "node:path";
+
+const [rootArg, portArg] = process.argv.slice(2);
+if (!rootArg || !portArg) {
+ console.error("usage: serve.mjs <dir> <port>");
+ process.exit(2);
+}
+const ROOT = path.resolve(rootArg);
+const PORT = Number(portArg);
+
+const TYPES = {
+ ".html": "text/html; charset=utf-8",
+ ".txt": "text/plain; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".csv": "text/csv; charset=utf-8",
+ ".xml": "application/xml; charset=utf-8",
+ ".webmanifest": "application/manifest+json",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".ico": "image/x-icon",
+ ".woff2": "font/woff2",
+ ".mp4": "video/mp4",
+ ".m4a": "audio/mp4",
+};
+
+function statOrNull(p) {
+ try {
+ return fs.statSync(p);
+ } catch {
+ return null;
+ }
+}
+
+function sendFile(req, res, file, status = 200) {
+ const size = fs.statSync(file).size;
+ const headers = {
+ "Content-Type": TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream",
+ "Accept-Ranges": "bytes",
+ };
+ const range = status === 200 ? /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "") : null;
+ if (range && (range[1] || range[2])) {
+ let start = range[1] ? Number(range[1]) : size - Number(range[2]);
+ let end = range[1] && range[2] ? Number(range[2]) : size - 1;
+ start = Math.max(0, start);
+ end = Math.min(end, size - 1);
+ if (start > end) {
+ res.writeHead(416, { "Content-Range": `bytes */${size}` }).end();
+ return;
+ }
+ res.writeHead(206, { ...headers, "Content-Range": `bytes ${start}-${end}/${size}`, "Content-Length": end - start + 1 });
+ if (req.method === "HEAD") return void res.end();
+ fs.createReadStream(file, { start, end }).pipe(res);
+ return;
+ }
+ res.writeHead(status, { ...headers, "Content-Length": size });
+ if (req.method === "HEAD") return void res.end();
+ fs.createReadStream(file).pipe(res);
+}
+
+http
+ .createServer((req, res) => {
+ const url = new URL(req.url ?? "/", "http://localhost");
+ let pathname;
+ try {
+ pathname = decodeURIComponent(url.pathname);
+ } catch {
+ return void res.writeHead(400).end();
+ }
+ const file = path.join(ROOT, pathname);
+ if (file !== ROOT && !file.startsWith(ROOT + path.sep)) return void res.writeHead(403).end();
+ const st = statOrNull(file);
+ if (st?.isDirectory()) {
+ if (!pathname.endsWith("/")) {
+ return void res.writeHead(308, { Location: `${url.pathname}/${url.search}` }).end();
+ }
+ const index = path.join(file, "index.html");
+ if (statOrNull(index)?.isFile()) return sendFile(req, res, index);
+ } else if (st?.isFile()) {
+ return sendFile(req, res, file);
+ }
+ const notFound = path.join(ROOT, "404.html");
+ if (statOrNull(notFound)?.isFile()) return sendFile(req, res, notFound, 404);
+ res.writeHead(404).end();
+ })
+ .listen(PORT, () => console.log(`[report-site] serving ${ROOT} on http://localhost:${PORT}`));
diff --git a/export/e2e-report/stage.ts b/export/e2e-report/stage.ts
@@ -1,7 +1,7 @@
// THE CITED FIXTURE SITE, BUILT FOR REAL.
//
// The report-site suite (playwright.report.config.ts) runs against a real
-// static export of a cited site, served by `serve` like a deployed one, so
+// static export of a cited site, served like a deployed one (serve.mjs), so
// what it checks is what ships: the routes Next emits, the files it copies,
// the requests the built pages make. The same out/ is held to the cited
// out/ allowlist audit (lib/builtExport.ts) by audit.spec.ts.
diff --git a/export/playwright.report.config.ts b/export/playwright.report.config.ts
@@ -8,9 +8,11 @@ import { REPORT_SITE_OUT, stageReportSite } from "./e2e-report/stage";
// The CITED report-site suite: a real static export of a cited fixture site
// (one fact-check citing every kind, its moments, their media), staged and
-// built OUTSIDE export/ — e2e-report/stage.ts says why — and served by `serve`
-// like a deployed site. What the specs see is what a cited build ships, and
-// audit.spec.ts holds the same out/ to the cited allowlist audit.
+// built OUTSIDE export/ — e2e-report/stage.ts says why — and served as Pages
+// serves a deployed site (e2e-report/serve.mjs; not `serve`, which lists a
+// span moment's directory instead of serving its page). What the specs see is
+// what a cited build ships, and audit.spec.ts holds the same out/ to the cited
+// allowlist audit.
//
// Its own suite (`pnpm --filter export run e2e:report`), never part of the
// default export suite (testDir ./e2e) nor of the editor's `pnpm e2e`. Staged
@@ -32,7 +34,7 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
webServer: {
- command: `pnpm exec serve ${REPORT_SITE_OUT} -l ${PORT} --no-request-logging`,
+ command: `node e2e-report/serve.mjs ${REPORT_SITE_OUT} ${PORT}`,
url: `${baseURL}/site.json`,
timeout: 60_000,
reuseExistingServer: !process.env.CI,