commit 624a06b4e9541a63ee1757de02f88d799b423c9f
parent b26429eb373a8543ee7f57b2c2c557b10c8daa47
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 05:22:55 -0400
export: a stage for the cited fixture site — copies export/app and its config beside a public/ of the fixture's views, media and compose's own contract files, builds it for real outside export/, audits the out/; the e2e:report suite's config and port
compose-site exports emitFederationFiles and emitAiFiles for the stage.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
10 files changed, 250 insertions(+), 2 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/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/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/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 by `serve` like a deployed one, 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/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,52 @@
+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 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.
+//
+// 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: `pnpm exec serve ${REPORT_SITE_OUT} -l ${PORT} --no-request-logging`,
+ 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 } },
+ },
+ ],
+});