Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 22d32c938e47f7df811bdf327fa1487e0d522c23
parent 84fea46778622d71c1d13fecffcbc9bfd0f4bf40
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 05:31:23 -0400

Merge report-r8-e2e (cited report-site e2e: 19 specs against a real static build of a fixture site, the out/ audit on that build; pnpm --filter export run e2e:report)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	PUBLISH.md

Diffstat:
M.gitignore | 3+++
MENVIRONMENT.md | 2++
MPUBLISH.md | 10++++++++++
MWORKTREES.md | 4++--
Mcommon/bin/compose-site.ts | 8++++++--
Mcommon/lib/envVars.ts | 1+
Mcommon/lib/ports.mjs | 1+
Mexport/app/lib/reports.test.ts | 5+++--
Aexport/e2e-report/audit.spec.ts | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e-report/contract.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e-report/fixtures/sites/reportsite/site.json | 9+++++++++
Aexport/e2e-report/helpers.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e-report/report-site.spec.ts | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e-report/serve.mjs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e-report/stage.ts | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/fixtures/report-site/fixture.ts | 13+++++++++++--
Mexport/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json | 2+-
Mexport/fixtures/report-site/public/m/demo-social/1234567890/moment.json | 3++-
Aexport/fixtures/report-site/public/media/clips/demo-channel/abc123/3126.00-3151.00.mp4 | 0
Aexport/fixtures/report-site/public/media/clips/demo-podcast/ep-042/610.50-628.00.m4a | 0
Aexport/fixtures/report-site/public/media/posts/demo-social/1234567890/shot.png | 0
Mexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 5+++--
Mexport/fixtures/report-site/source/demo-factcheck/report.json | 2+-
Mexport/fixtures/report-site/write.ts | 5+++--
Mexport/package.json | 2++
Aexport/playwright.report.config.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
26 files changed, 733 insertions(+), 15 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -138,6 +138,9 @@ yarn-error.log* /export/test-results-hub/ /export/test-results-2origin/ /export/.2origin/ +/export/test-results-report/ +# the cited report-site suite's stage project (export/e2e-report/stage.ts) +/.e2e-report-site/ /export/blob-report/ # homepage's own Playwright output (its suite runs from homepage/) /homepage/test-results/ diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md @@ -117,6 +117,7 @@ Every local server's default port, from `common/lib/ports.mjs`. The primary chec | `HOMEPAGE_PORT` | `3031` | Homepage static `serve out` (start:homepage). A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs | | `HOMEPAGE_E2E_PORT` | `3040` | Homepage's own Playwright suite. A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs | | `HUB_PORT` | `3041` | Export's hub Playwright suite (e2e:hub). A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs | +| `REPORT_SITE_E2E_PORT` | `3042` | Export's cited report-site Playwright suite (e2e:report). A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs | | `UMTOOL_PORT` | `3050` | Umtool real dev/start (`pnpm dev:umtool`). A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs | | `UMTOOL_E2E_PORT` | `3051` | Umtool's own Playwright suite. A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs | | `EDITOR_STUB_PORT` | `3052` | Stub editor the umtool e2e suite fetches clips from. A worktree adds its offset (`pnpm wt list`). | common/lib/ports.mjs | @@ -189,6 +190,7 @@ Read only by a test harness, a fake binary or a test-mode branch. Never set one | `E2E_OLLAMA_STUB_MODEL` | `qwen2.5:7b` | The model the ollama stub claims to serve. | editor/e2e/fixtures/ollama-stub.mjs | | `E2E_RACK_SHOTS` | off (spec skipped) | Runs the `/channels` rack screenshot audit. | editor/e2e/channels-rack-audit.spec.ts | | `E2E_TWO_ORIGIN_REBUILD` | off | `1` rebuilds the two-origin suite's cached hub bundle. | export/e2e-2origin/globalSetup.ts | +| `E2E_REPORT_SITE_REUSE` | off (build again) | `1` serves the cited report-site suite's existing stage build (`.e2e-report-site/out`) instead of staging and building the fixture site again — for iterating on the specs alone; it does not see a change to the app. | export/playwright.report.config.ts | | `E2E_SHARDS` | min(max(2, cpus/2), 8) | How many containers `pnpm e2e:sharded` splits the editor suite across (`--shards N` wins). | scripts/run-sharded-e2e.mjs | | `E2E_RETRIES` | `0` | Retries per shard (`--retries N` wins); 0 keeps a sharded run comparable to a serial one. | scripts/run-sharded-e2e.mjs | | `E2E_IMAGE` | `yt-dlp-transcript-browser-e2e` | The sharded e2e run's image tag. | scripts/run-sharded-e2e.mjs | diff --git a/PUBLISH.md b/PUBLISH.md @@ -108,6 +108,16 @@ reports: the MCP server's `list_reports` / `get_report` read them (and its `list_channels` says "cited-only site: N report(s)"), and umtool's cue walk refuses one by name — it has no transcripts to cut a report video from. +`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/common/bin/compose-site.ts b/common/bin/compose-site.ts @@ -89,7 +89,11 @@ import { copyPublicFile, ownDir, writePublicFile } from "./_publicFile"; // copyPublicFile, and every tree it writes into is ownDir'd first, so nothing // is written THROUGH a link: in a git worktree public/'s entries are links into // the primary checkout (_publicFile.ts). -async function emitFederationFiles( +// +// Exported, with emitAiFiles, for the cited fixture site's e2e staging +// (export/e2e-report/stage.ts), which writes a cited site's contract around +// fixture report views exactly as this compose would. +export async function emitFederationFiles( site: Site, paths: ReturnType<typeof getPaths>, ): Promise<void> { @@ -205,7 +209,7 @@ export const ALLOW_MISSING_MEDIA_ENV = "REPORTS_ALLOW_MISSING_MEDIA"; // navigate the already-served paginated shards; they never enumerate per-video // files, so the count is constant regardless of corpus size. Runs after // site.json and the archives are composed (both feed into these files). -async function emitAiFiles( +export async function emitAiFiles( site: Site, paths: ReturnType<typeof getPaths>, composed: ComposedReports, diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts @@ -188,6 +188,7 @@ const DECLARED: EnvVarDecl[] = [ { name: "E2E_OLLAMA_STUB_MODEL", audience: "test", default: "`qwen2.5:7b`", readBy: "editor/e2e/fixtures/ollama-stub.mjs", doc: "The model the ollama stub claims to serve." }, { name: "E2E_RACK_SHOTS", audience: "test", default: "off (spec skipped)", readBy: "editor/e2e/channels-rack-audit.spec.ts", doc: "Runs the `/channels` rack screenshot audit." }, { name: "E2E_TWO_ORIGIN_REBUILD", audience: "test", default: "off", readBy: "export/e2e-2origin/globalSetup.ts", doc: "`1` rebuilds the two-origin suite's cached hub bundle." }, + { name: "E2E_REPORT_SITE_REUSE", audience: "test", default: "off (build again)", readBy: "export/playwright.report.config.ts", doc: "`1` serves the cited report-site suite's existing stage build (`.e2e-report-site/out`) instead of staging and building the fixture site again — for iterating on the specs alone; it does not see a change to the app." }, { name: "E2E_SHARDS", audience: "test", default: "min(max(2, cpus/2), 8)", readBy: "scripts/run-sharded-e2e.mjs", doc: "How many containers `pnpm e2e:sharded` splits the editor suite across (`--shards N` wins)." }, { name: "E2E_RETRIES", audience: "test", default: "`0`", readBy: "scripts/run-sharded-e2e.mjs", doc: "Retries per shard (`--retries N` wins); 0 keeps a sharded run comparable to a serial one." }, { name: "E2E_IMAGE", audience: "test", default: "`yt-dlp-transcript-browser-e2e`", readBy: "scripts/run-sharded-e2e.mjs", doc: "The sharded e2e run's image tag." }, diff --git a/common/lib/ports.mjs b/common/lib/ports.mjs @@ -42,6 +42,7 @@ export const PORTS = Object.freeze({ HOMEPAGE_PORT: { base: 3031, what: "homepage static `serve out` (start:homepage)" }, HOMEPAGE_E2E_PORT: { base: 3040, what: "homepage's own Playwright suite" }, HUB_PORT: { base: 3041, what: "export's hub Playwright suite (e2e:hub)" }, + REPORT_SITE_E2E_PORT: { base: 3042, what: "export's cited report-site Playwright suite (e2e:report)" }, UMTOOL_PORT: { base: 3050, what: "umtool real dev/start (`pnpm dev:umtool`)" }, UMTOOL_E2E_PORT: { base: 3051, what: "umtool's own Playwright suite" }, EDITOR_STUB_PORT: { base: 3052, what: "stub editor the umtool e2e suite fetches clips from" }, diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts @@ -130,16 +130,17 @@ test("a video moment: the clip, quote, verification, cue lines, original link, c assert.match(html, /href="\/reports\/demo-factcheck\/#claim-5"/); }); -test("an audio moment plays audio; a post moment shows its text and author", async () => { +test("an audio moment plays audio; a post moment shows its shot, text and author", async () => { const audio = render( await momentPage.default({ params: Promise.resolve({ moment: ["demo-podcast", "ep-042", "610.50-628.00"] }) }), ); - assert.match(audio, /<audio data-evidence-clip=""/); + assert.match(audio, /<audio data-evidence-clip=""[^>]*src="\/media\/clips\/demo-podcast\/ep-042\/610.50-628.00.m4a"/); assert.ok(audio.includes("The guest")); const post = render(await momentPage.default({ params: Promise.resolve({ moment: ["demo-social", "1234567890"] }) })); assert.match(post, /data-moment-kind="post"/); assert.ok(post.includes("@demo_account")); assert.ok(post.includes("Posted to settle it.")); + assert.match(post, /<img[^>]*src="\/media\/posts\/demo-social\/1234567890\/shot.png"/); assert.ok(!post.includes("<video")); }); 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/contract.ts b/export/e2e-report/contract.ts @@ -0,0 +1,46 @@ +// The cited fixture site's compose step (stage.ts runs it, in a child process +// with the stage's environment): what compose's reports stage writes beside +// the views — the citation downloads — and the cited site's contract, by +// compose's own functions (bin/compose-site.ts emitFederationFiles / +// emitAiFiles), so the stage's public/ is what compose would leave for this +// site. The view JSON, stills and media are the fixture's, already in place. +// +// Every import is dynamic: getPaths() reads the environment on first use. + +import fs from "node:fs"; +import path from "node:path"; +import type { MomentIndexView, ReportIndexView, ReportPageView } from "yt-dlp-transcript-common/lib/report/views"; + +const { getPaths } = await import("yt-dlp-transcript-common/lib/paths"); +const { getSite } = await import("yt-dlp-transcript-common/lib/site"); +const { emitAiFiles, emitFederationFiles } = await import("yt-dlp-transcript-common/bin/compose-site"); +const { citationSet, citationsCsv } = await import("yt-dlp-transcript-common/publish/composeReports"); +const { MOMENTS_INDEX_PATH, REPORTS_INDEX_PATH, reportCitationsDownloadPath, reportViewPath } = await import( + "yt-dlp-transcript-common/lib/report/views" +); +const { readFixtureReport } = await import("../fixtures/report-site/fixture"); + +const paths = getPaths(); +const siteId = process.env.SITE_ID; +if (!siteId) throw new Error("contract.ts: SITE_ID is not set (run it through stage.ts)"); +const site = getSite(siteId, paths); + +const pub = (urlPath: string) => path.join(paths.exportPublicDir, ...urlPath.split("/").filter(Boolean)); +const readJson = <T>(urlPath: string): T => JSON.parse(fs.readFileSync(pub(urlPath), "utf8")) as T; + +const index = readJson<ReportIndexView>(REPORTS_INDEX_PATH); +const moments = readJson<MomentIndexView>(MOMENTS_INDEX_PATH).moments; +const report = readFixtureReport(); +for (const entry of index.reports) { + if (entry.id !== report.id) throw new Error(`contract.ts: the fixture has no report "${entry.id}"`); + const view = readJson<ReportPageView>(reportViewPath(entry.id)); + fs.writeFileSync( + pub(reportCitationsDownloadPath(entry.id, "json")), + `${JSON.stringify(citationSet(report, view), null, 2)}\n`, + ); + fs.writeFileSync(pub(reportCitationsDownloadPath(entry.id, "csv")), citationsCsv(view)); +} + +await emitFederationFiles(site, paths); +await emitAiFiles(site, paths, { reports: index.reports, moments, allowed: [] }); +console.log(`[report-site] composed the contract of cited site "${site.siteId}" into ${paths.exportPublicDir}`); diff --git a/export/e2e-report/fixtures/sites/reportsite/site.json b/export/e2e-report/fixtures/sites/reportsite/site.json @@ -0,0 +1,9 @@ +{ + "siteId": "reportsite", + "siteTitle": "Demo Reports", + "siteDescription": "A cited fixture site: one fact-check and the moments it cites.", + "headerTitle": "Demo Reports", + "siteUrl": "https://reports.example.org", + "publish": "cited", + "reports": ["demo-factcheck"] +} 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 @@ -0,0 +1,128 @@ +// 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 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. +// +// THE STAGE. `next build` copies the public/ BESIDE ITS PROJECT into out/, and +// export/public (and export/out) are the checkout's own — in the primary +// checkout, the operator's real site build. So the build never runs in +// export/: it runs in a stage project at the repo root (`.e2e-report-site/`, +// gitignored) holding a copy of export/app and export's config files, a link to +// export's node_modules, and its own public/. The stage sits at the same depth +// as export/, so every relative path in the copies (globals.css's +// `../../common/…`, tsconfig's `../tsconfig.base.json`, next.config's +// workspace root) resolves exactly as it does there. +// +// THE PUBLIC DIR is what compose's reports stage would write for the site: +// - the fixture report site (export/fixtures/report-site/public): the view +// JSON R4's builder made, the stills, the post's shot, the evidence clips; +// - the citation downloads and the cited site's contract (site.json, +// corpus.json, llms.txt, robots.txt, sitemap.xml, _headers), written by +// compose's own functions in a child process (contract.ts); +// - export/public's checked-in assets (git-tracked files only — the +// checkout's composed corpus is never read). +// +// Every server-side read points into the stage too (TRANSCRIPTS_DIR, +// SITES_DIR, SETTINGS_FILE, EXPORT_*): the build never sees a real corpus. + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { builtBundleProblem } from "yt-dlp-transcript-common/lib/builtExport"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const EXPORT_DIR = path.resolve(HERE, ".."); +const REPO = path.resolve(EXPORT_DIR, ".."); + +export const REPORT_SITE_ID = "reportsite"; +export const REPORT_SITE_STAGE = path.join(REPO, ".e2e-report-site"); +export const REPORT_SITE_OUT = path.join(REPORT_SITE_STAGE, "out"); +export const REPORT_SITE_SITES_DIR = path.join(HERE, "fixtures", "sites"); +const FIXTURE_PUBLIC = path.join(EXPORT_DIR, "fixtures", "report-site", "public"); + +// export's files the stage project copies, beside app/ less its tests (the +// build type-checks what it holds, and a test's relative imports reach outside +// app/). The changelog page reads CHANGELOG.md from the project's cwd. +const PROJECT_FILES = ["next.config.ts", "tsconfig.json", "postcss.config.mjs", "package.json", "CHANGELOG.md"]; + +// The environment every stage step runs with. +export function reportSiteEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...base, + NODE_ENV: "production", + SITE_ID: REPORT_SITE_ID, + SITES_DIR: REPORT_SITE_SITES_DIR, + SETTINGS_FILE: path.join(REPORT_SITE_STAGE, "settings.json"), + TRANSCRIPTS_DIR: path.join(REPORT_SITE_STAGE, "transcripts"), + EXPORT_PUBLIC_DIR: path.join(REPORT_SITE_STAGE, "public"), + EXPORT_INDEX_DIR: path.join(REPORT_SITE_STAGE, ".export-index"), + EXPORT_BUILDS_DIR: path.join(REPORT_SITE_STAGE, ".export-builds"), + NEXT_TELEMETRY_DISABLED: "1", + }; + delete env.INSTANCE_MODE; + return env; +} + +function copyApp(): void { + fs.cpSync(path.join(EXPORT_DIR, "app"), path.join(REPORT_SITE_STAGE, "app"), { + recursive: true, + filter: (src) => !/\.test\.tsx?$/.test(src), + }); + for (const f of PROJECT_FILES) { + fs.copyFileSync(path.join(EXPORT_DIR, f), path.join(REPORT_SITE_STAGE, f)); + } + fs.symlinkSync(path.join(EXPORT_DIR, "node_modules"), path.join(REPORT_SITE_STAGE, "node_modules"), "dir"); +} + +function stagePublic(): void { + const pub = path.join(REPORT_SITE_STAGE, "public"); + const tracked = execFileSync("git", ["ls-files", "-z", "--", "public"], { cwd: EXPORT_DIR, encoding: "utf8" }) + .split("\0") + .filter(Boolean); + for (const rel of tracked) { + const dest = path.join(REPORT_SITE_STAGE, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(path.join(EXPORT_DIR, rel), dest); + } + fs.cpSync(FIXTURE_PUBLIC, pub, { recursive: true }); +} + +function run(command: string, args: string[], cwd: string): void { + execFileSync(command, args, { cwd, env: reportSiteEnv(), stdio: "inherit" }); +} + +/** + * Stage, compose and build the cited fixture site; returns its out/. + * `reuse` keeps a stage that already holds a build (E2E_REPORT_SITE_REUSE=1, + * for iterating on the specs alone — it does not see a change to the app). + */ +export function stageReportSite(opts: { reuse?: boolean } = {}): string { + if (opts.reuse && fs.existsSync(path.join(REPORT_SITE_OUT, "corpus.json"))) return REPORT_SITE_OUT; + fs.rmSync(REPORT_SITE_STAGE, { recursive: true, force: true }); + fs.mkdirSync(path.join(REPORT_SITE_STAGE, "transcripts"), { recursive: true }); + fs.writeFileSync(path.join(REPORT_SITE_STAGE, "settings.json"), "{}\n"); + copyApp(); + stagePublic(); + const bin = path.join(EXPORT_DIR, "node_modules", ".bin"); + run(path.join(bin, "tsx"), [path.join(HERE, "contract.ts")], EXPORT_DIR); + run(path.join(bin, "next"), ["build"], REPORT_SITE_STAGE); + return REPORT_SITE_OUT; +} + +// The audit every deploy path asks of a bundle (builtBundleProblem: identity, +// then the cited allowlist and Pages' limits), of the staged build. +export function reportSiteAuditProblem(outDir = REPORT_SITE_OUT): string | null { + return builtBundleProblem(outDir, REPORT_SITE_ID); +} + +// `tsx e2e-report/stage.ts`: stage and build the site, then audit it. +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const out = stageReportSite({ reuse: process.argv.includes("--reuse") }); + const problem = reportSiteAuditProblem(out); + console.log(problem ? `[report-site] AUDIT REFUSES: ${problem}` : `[report-site] ${out} passes the cited audit`); + process.exitCode = problem ? 1 : 0; +} diff --git a/export/fixtures/report-site/fixture.ts b/export/fixtures/report-site/fixture.ts @@ -68,6 +68,12 @@ const RECORDS: Record<string, RecordView> = { const POSTER = "/reports/demo-factcheck/stills/poster.png"; +// A post's captured screenshot, where compose copies a cited capture +// (publish/citedPostCaptures.ts): committed under public/media/posts/. +function shotFor(c: { channel: string; id: string }): string { + return `/media/posts/${c.channel}/${c.id}/shot.png`; +} + export function readFixtureReport(): Report { const raw = JSON.parse( fs.readFileSync(path.join(FIXTURE_DIR, "source", FIXTURE_REPORT_ID, "report.json"), "utf8"), @@ -93,6 +99,7 @@ export function buildFixtureReportView(report = readFixtureReport()): ReportPage post: (c) => ({ author: "@demo_account", text: `${c.quote}\n\nPosted to settle it.`, + shot: shotFor(c), }), downloads: { json: reportCitationsDownloadPath(report.id, "json"), @@ -147,7 +154,9 @@ export function buildFixtureMomentViews( start: span.start, end: span.end, clip: { - src: evidenceClipPath(span), + // A clip cut as sound is an .m4a beside the video clips (compose's + // clipPath). + src: c.kind === "audio" ? evidenceClipPath(span).replace(/\.mp4$/, ".m4a") : evidenceClipPath(span), start: Math.max(0, span.start - before), end: span.end + after, ...(c.kind === "video" ? { poster: POSTER } : {}), @@ -167,7 +176,7 @@ export function buildFixtureMomentViews( record: recordFor(c.channel, c.id), quote: c.quote, ...(c.date ? { date: c.date } : {}), - post: { author: "@demo_account", text: `${c.quote}\n\nPosted to settle it.` }, + post: { author: "@demo_account", text: `${c.quote}\n\nPosted to settle it.`, shot: shotFor(c) }, citedIn: entries, }); } diff --git a/export/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json b/export/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json @@ -20,7 +20,7 @@ "start": 610.5, "end": 628, "clip": { - "src": "/media/clips/demo-podcast/ep-042/610.50-628.00.mp4", + "src": "/media/clips/demo-podcast/ep-042/610.50-628.00.m4a", "start": 610.5, "end": 628 }, diff --git a/export/fixtures/report-site/public/m/demo-social/1234567890/moment.json b/export/fixtures/report-site/public/m/demo-social/1234567890/moment.json @@ -15,7 +15,8 @@ "date": "2025-03-14", "post": { "author": "@demo_account", - "text": "Never said I would move. Not once.\n\nPosted to settle it." + "text": "Never said I would move. Not once.\n\nPosted to settle it.", + "shot": "/media/posts/demo-social/1234567890/shot.png" }, "citedIn": [ { diff --git a/export/fixtures/report-site/public/media/clips/demo-channel/abc123/3126.00-3151.00.mp4 b/export/fixtures/report-site/public/media/clips/demo-channel/abc123/3126.00-3151.00.mp4 Binary files differ. diff --git a/export/fixtures/report-site/public/media/clips/demo-podcast/ep-042/610.50-628.00.m4a b/export/fixtures/report-site/public/media/clips/demo-podcast/ep-042/610.50-628.00.m4a Binary files differ. diff --git a/export/fixtures/report-site/public/media/posts/demo-social/1234567890/shot.png b/export/fixtures/report-site/public/media/posts/demo-social/1234567890/shot.png Binary files differ. diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json @@ -146,7 +146,8 @@ "originalUrl": "https://media.example.org/demo-social/1234567890" }, "author": "@demo_account", - "text": "Never said I would move. Not once.\n\nPosted to settle it." + "text": "Never said I would move. Not once.\n\nPosted to settle it.", + "shot": "/media/posts/demo-social/1234567890/shot.png" } }, "sections": [ @@ -187,7 +188,7 @@ "text": "He has said many times that he would move away.", "verdict": "CONTRADICTED", "sourceQuote": "a02", - "findings": "He [denies it outright](cite:p01).", + "findings": "The article [puts it as a habit](cite:a02); he [denies it outright](cite:p01).", "citations": [ "p01" ] diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json @@ -109,7 +109,7 @@ "text": "He has said many times that he would move away.", "verdict": "CONTRADICTED", "sourceQuote": { "citation": "a02" }, - "findings": "He [denies it outright](cite:p01).", + "findings": "The article [puts it as a habit](cite:a02); he [denies it outright](cite:p01).", "citations": ["p01"] }, { diff --git a/export/fixtures/report-site/write.ts b/export/fixtures/report-site/write.ts @@ -3,8 +3,9 @@ // // pnpm --filter export exec tsx fixtures/report-site/write.ts // -// The stills (public/reports/demo-factcheck/stills/*.png) are committed images, -// not generated here. +// The stills (public/reports/demo-factcheck/stills/*.png), the post's shot and +// the two evidence clips (public/media/: a few seconds of lavfi test pattern +// and tone, H.264/AAC) are committed files, not generated here. import fs from "node:fs"; import path from "node:path"; diff --git a/export/package.json b/export/package.json @@ -20,6 +20,8 @@ "e2e": "node ../scripts/queue-lock.mjs --ports EXPORT_E2E_PORT:3020 -- playwright test", "e2e:hub": "node ../scripts/queue-lock.mjs --ports HUB_PORT:3041 -- playwright test --config playwright.hub.config.ts", "e2e:2origin": "node ../scripts/queue-lock.mjs --ports ORIGIN_B_PORT:4610,HUB_A_PORT:4611 -- playwright test --config playwright.2origin.config.ts", + "e2e:report": "node ../scripts/queue-lock.mjs --ports REPORT_SITE_E2E_PORT:3042 -- playwright test --config playwright.report.config.ts", + "build:report-fixture": "tsx e2e-report/stage.ts", "e2e:ui": "playwright test --ui", "deploy": "tsx ../common/bin/archilyzer.ts deploy site" }, diff --git a/export/playwright.report.config.ts b/export/playwright.report.config.ts @@ -0,0 +1,54 @@ +import { defineConfig, devices } from "@playwright/test"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; +import { REPORT_SITE_OUT, stageReportSite } from "./e2e-report/stage"; + +// Test-only environment (declared in common/lib/envVars.ts): +// E2E_REPORT_SITE_REUSE=1 serve the stage's existing build instead of +// building again (iterating on the specs alone). + +// 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 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 +// here at config load, before the webServer starts, and once — in the runner, +// not again in each worker (Playwright sets TEST_WORKER_INDEX there). +const PORT = portFor("REPORT_SITE_E2E_PORT"); +const baseURL = `http://localhost:${PORT}`; + +if (!process.env.TEST_WORKER_INDEX) { + stageReportSite({ reuse: process.env.E2E_REPORT_SITE_REUSE === "1" }); +} + +export default defineConfig({ + testDir: "./e2e-report", + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + outputDir: "test-results-report/", + fullyParallel: false, + workers: 1, + webServer: { + command: `node e2e-report/serve.mjs ${REPORT_SITE_OUT} ${PORT}`, + url: `${baseURL}/site.json`, + timeout: 60_000, + reuseExistingServer: !process.env.CI, + }, + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 1000 } }, + }, + ], +});