import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { builtBundleProblem, builtHomepageAt, builtHomepageProblem, builtHubProblem, builtScopeProblem, builtSiteIdIn, builtSiteProblem, citedBuildProblem, deployAudienceProblem, PAGES_MAX_FILE_BYTES, PAGES_MAX_FILES, PUBLISH_MAX_FILE_BYTES, publishFileSizeProblem, reportHistoryProblem, isTombstonePostsTree, hubReportDataIn, } from "./builtExport"; function tempOut(siteJson?: string): { dir: string; cleanup: () => void } { const dir = mkdtempSync(path.join(tmpdir(), "built-export-")); const out = path.join(dir, "out"); mkdirSync(out, { recursive: true }); if (siteJson !== undefined) { writeFileSync(path.join(out, "site.json"), siteJson); } return { dir: out, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; } test("builtSiteIdIn reads the site id out of the composed contract", () => { const { dir, cleanup } = tempOut( JSON.stringify({ contract: 1, siteId: "anilyzer", siteTitle: "Anilyzer" }), ); try { assert.equal(builtSiteIdIn(dir), "anilyzer"); } finally { cleanup(); } }); test("builtSiteIdIn answers null for every flavour of 'nothing was built here'", () => { // No directory at all. assert.equal(builtSiteIdIn(path.join(tmpdir(), "no-such-out-dir-ever")), null); // A directory with no site.json. const empty = tempOut(); try { assert.equal(builtSiteIdIn(empty.dir), null); } finally { empty.cleanup(); } // Unparseable, or parseable but not a site. for (const body of ["{", "null", "[]", '"anilyzer"', "{}", '{"siteId":""}', '{"siteId":3}']) { const t = tempOut(body); try { assert.equal(builtSiteIdIn(t.dir), null, `expected null for ${body}`); } finally { t.cleanup(); } } }); test("builtSiteProblem passes a build of the site being deployed", () => { const { dir, cleanup } = tempOut(JSON.stringify({ siteId: "anilyzer" })); // compose writes corpus.json beside site.json, from the same descriptor. writeFileSync(path.join(dir, "corpus.json"), JSON.stringify({ site: { id: "anilyzer" } })); try { assert.equal(builtSiteProblem(dir, "anilyzer"), null); // Trimmed, so a padded id from a form is not itself the complaint. assert.equal(builtSiteProblem(dir, " anilyzer "), null); } finally { cleanup(); } }); test("builtSiteProblem names the OTHER site when out/ holds somebody else's build", () => { // THE BUG: export/out is one shared directory whichever site built into it, // so this is deploying jeralyzer's bundle to anilyzer's Pages project. const { dir, cleanup } = tempOut(JSON.stringify({ siteId: "jeralyzer" })); try { const problem = builtSiteProblem(dir, "anilyzer"); assert.ok(problem); assert.match(problem, /holds a build of "jeralyzer", not "anilyzer"/); assert.match(problem, /build anilyzer first/); } finally { cleanup(); } }); test("builtSiteProblem says nothing was built rather than naming a mismatch", () => { const { dir, cleanup } = tempOut(); try { const problem = builtSiteProblem(dir, "anilyzer"); assert.ok(problem); assert.match(problem, /holds no built site — build anilyzer first/); } finally { cleanup(); } }); // The hub shares export/out with every site build. A hub bundle carries // hub-sites.json and no site.json (the hub build removes it); anything with a // site.json is some site's bundle, even when a stale hub-sites.json sits beside // it. test("builtHubProblem accepts only a hub bundle", () => { const hub = tempOut(); const site = tempOut(JSON.stringify({ siteId: "jeralyzer" })); const none = tempOut(); try { writeFileSync(path.join(hub.dir, "hub-sites.json"), "[]"); assert.equal(builtHubProblem(hub.dir), null); writeFileSync(path.join(site.dir, "hub-sites.json"), "[]"); assert.equal( builtHubProblem(site.dir), 'export/out holds a build of "jeralyzer", not the hub — build the hub first', ); assert.equal( builtHubProblem(none.dir), "export/out holds no hub build — build the hub first", ); // Release 18: a posts/ of TOMBSTONES only is the hub's own (compose-hub // writes them for withdrawn X posts) — and one real post in it is not. mkdirSync(path.join(hub.dir, "posts", "jer-x"), { recursive: true }); writeFileSync(path.join(hub.dir, "posts", "manifest.json"), '{"channels":[],"totalCount":0}'); writeFileSync( path.join(hub.dir, "posts", "jer-x", "manifest.json"), '{"channelSlug":"jer-x","pageCount":0,"slugToPage":{}}', ); writeFileSync(path.join(hub.dir, "posts", "jer-x", "page-0000.json"), "[]"); assert.equal(isTombstonePostsTree(path.join(hub.dir, "posts")), true); assert.equal(builtHubProblem(hub.dir), null); writeFileSync(path.join(hub.dir, "posts", "jer-x", "page-0000.json"), '[{"id":"1"}]'); assert.equal(isTombstonePostsTree(path.join(hub.dir, "posts")), false); assert.equal( builtHubProblem(hub.dir), "export/out holds a hub build that still carries a site's data (posts) — build the hub again", ); writeFileSync(path.join(hub.dir, "posts", "jer-x", "page-0000.json"), "[]"); writeFileSync(path.join(hub.dir, "posts", "manifest.json"), '{"channels":[{"slug":"jer-x"}]}'); assert.equal(isTombstonePostsTree(path.join(hub.dir, "posts")), false); rmSync(path.join(hub.dir, "posts"), { recursive: true }); // Release 17 XP: a hub bundle composed over a site's data is refused. mkdirSync(path.join(hub.dir, "posts", "jer-x"), { recursive: true }); mkdirSync(path.join(hub.dir, "summaries")); assert.equal( builtHubProblem(hub.dir), "export/out holds a hub build that still carries a site's data (summaries, posts) — build the hub again", ); } finally { hub.cleanup(); site.cleanup(); none.cleanup(); } }); // Found on main 2026-10-05: the export app renders its report and moment // routes into EVERY build — `reports/index.html`, the `_none` placeholders — // so a hub bundle always has `reports/` and `m/`, and listing them as site // data refused every hub. What is refused now is the report DATA a site's // compose writes there. test("builtHubProblem: the shell's reports/ and m/ pages pass; report data, moment data and a real post do not", () => { const hub = tempOut(); try { writeFileSync(path.join(hub.dir, "hub-sites.json"), "[]"); const put = (rel: string, body = "x") => { mkdirSync(path.dirname(path.join(hub.dir, rel)), { recursive: true }); writeFileSync(path.join(hub.dir, rel), body); }; // What `next build` renders for the export app's own routes. for (const rel of [ "reports/index.html", "reports/index.txt", "reports/_none/index.html", "reports/_none/history/index.html", "m/_none/index.html", "m/_none/index.txt", ]) { put(rel); } assert.equal(hubReportDataIn(hub.dir).length, 0); assert.equal(builtHubProblem(hub.dir), null); const refusedFor = (rel: string, body?: string) => { put(rel, body); const problem = builtHubProblem(hub.dir); rmSync(path.join(hub.dir, rel)); return problem; }; for (const rel of [ "reports/index.json", "reports/r1/page.json", "reports/r1/citations.json", "reports/r1/citations.csv", "reports/r1/evidence-pack.zip", "reports/r1/history/history.json", "reports/r1/history/repo/HEAD", "m/index.json", "m/jer/v1/0-10/moment.json", ]) { const problem = refusedFor(rel); assert.match(problem ?? "", /still carries a site's data/, rel); assert.ok(problem!.includes(rel.endsWith("/HEAD") ? "reports/r1/history/repo/" : rel), `${rel}: ${problem}`); } // (The history clone is a directory: removing its HEAD leaves repo/.) rmSync(path.join(hub.dir, "reports", "r1"), { recursive: true }); assert.equal(builtHubProblem(hub.dir), null, "nothing left behind"); // A tombstone-only posts/ is the hub's own; one real post is a site's. put("posts/manifest.json", '{"channels":[]}'); put("posts/jer-x/manifest.json", '{"pageCount":0,"slugToPage":{}}'); put("posts/jer-x/page-0000.json", "[]"); assert.equal(builtHubProblem(hub.dir), null); put("posts/jer-x/page-0000.json", '[{"id":"1","text":"a post"}]'); assert.match(builtHubProblem(hub.dir) ?? "", /still carries a site's data \(posts\)/); } finally { hub.cleanup(); } }); // The homepage package builds into its own homepage/out, which nothing else // writes, so "built" is one question: is there an index.html? The homepage's // deploy stage (deployStage.ts) refuses on exactly that file. test("builtHomepageProblem refuses an out dir with no index.html, and only that", () => { const built = tempOut(); const empty = tempOut(); try { writeFileSync(path.join(built.dir, "index.html"), ""); assert.equal(builtHomepageProblem(built.dir), null); const problem = "homepage/out holds no build — build the homepage first"; assert.equal(builtHomepageProblem(empty.dir), problem); // No directory at all is the same answer, not a throw. assert.equal(builtHomepageProblem(path.join(tmpdir(), "no-such-homepage-out-ever")), problem); // A stray file that is not the page is still no build. writeFileSync(path.join(empty.dir, "404.html"), ""); assert.equal(builtHomepageProblem(empty.dir), problem); } finally { built.cleanup(); empty.cleanup(); } }); test("builtHomepageAt is index.html's mtime, and null with no build", () => { const built = tempOut(); const empty = tempOut(); try { const index = path.join(built.dir, "index.html"); writeFileSync(index, ""); const when = new Date("2026-09-28T00:53:00Z"); utimesSync(index, when, when); assert.equal(builtHomepageAt(built.dir), when.getTime()); assert.equal(builtHomepageAt(empty.dir), null); assert.equal(builtHomepageAt(path.join(tmpdir(), "no-such-homepage-out-ever")), null); } finally { built.cleanup(); empty.cleanup(); } }); // A per-site container build publishes its own out/ (docker/build-site.sh). It // once published the public/ baked into the image instead of the one compose // had just written — so out/ carried no data, or another site's. Both identity // files must be there and both must name the site. function bundle(files: { site?: unknown; corpus?: unknown }): { dir: string; cleanup: () => void } { const t = tempOut(files.site === undefined ? undefined : JSON.stringify(files.site)); if (files.corpus !== undefined) { writeFileSync(path.join(t.dir, "corpus.json"), JSON.stringify(files.corpus)); } return t; } test("builtBundleProblem passes a bundle whose site.json and corpus.json both name the site", () => { const t = bundle({ site: { siteId: "anilyzer" }, corpus: { spec: 4, kind: "site", site: { id: "anilyzer" } } }); try { assert.equal(builtBundleProblem(t.dir, "anilyzer"), null); assert.equal(builtBundleProblem(t.dir, " anilyzer "), null); } finally { t.cleanup(); } }); test("builtBundleProblem refuses another site's bundle, by either file", () => { const other = bundle({ site: { siteId: "jeralyzer" }, corpus: { site: { id: "jeralyzer" } } }); const torn = bundle({ site: { siteId: "anilyzer" }, corpus: { site: { id: "jeralyzer" } } }); try { assert.match(builtBundleProblem(other.dir, "anilyzer")!, /holds a build of "jeralyzer", not "anilyzer" \(site\.json\)$/); assert.match(builtBundleProblem(torn.dir, "anilyzer")!, /describes "jeralyzer", not "anilyzer" \(corpus\.json\)$/); } finally { other.cleanup(); torn.cleanup(); } }); test("builtBundleProblem refuses a bundle missing either identity file, naming the directory", () => { const none = bundle({}); const noCorpus = bundle({ site: { siteId: "anilyzer" } }); const unnamed = bundle({ site: { siteId: "anilyzer" }, corpus: { site: {} } }); try { assert.equal(builtBundleProblem(none.dir, "anilyzer"), `${none.dir} has no site.json naming a site — it is not a build of "anilyzer"`); assert.match(builtBundleProblem(noCorpus.dir, "anilyzer")!, /has no corpus\.json naming a site/); assert.match(builtBundleProblem(unnamed.dir, "anilyzer")!, /has no corpus\.json naming a site/); } finally { none.cleanup(); noCorpus.cleanup(); unnamed.cleanup(); } }); // builtSiteProblem is the looser question; the deploy stage refuses with // builtBundleProblem just before wrangler. The two must never disagree about a bundle: a legitimately built // site refused only at the last step, or a bad one let through to it. test("builtSiteProblem refuses exactly what builtBundleProblem refuses", () => { const cases: { name: string; site?: unknown; corpus?: unknown }[] = [ { name: "a good build", site: { siteId: "anilyzer" }, corpus: { site: { id: "anilyzer" } } }, { name: "nothing built" }, { name: "another site", site: { siteId: "jeralyzer" }, corpus: { site: { id: "jeralyzer" } } }, { name: "no corpus.json", site: { siteId: "anilyzer" } }, { name: "a torn build", site: { siteId: "anilyzer" }, corpus: { site: { id: "jeralyzer" } } }, { name: "a corpus.json that names no site", site: { siteId: "anilyzer" }, corpus: { spec: 4 } }, { name: "a hub build", corpus: { kind: "hub" } }, ]; for (const c of cases) { const t = bundle(c); try { const site = builtSiteProblem(t.dir, "anilyzer"); const deploy = builtBundleProblem(t.dir, "anilyzer"); assert.equal(site === null, deploy === null, `${c.name}: builtSiteProblem ${site} / builtBundleProblem ${deploy}`); if (site) assert.match(site, /^export\/out holds .* — build anilyzer first$/, c.name); } finally { t.cleanup(); } } const torn = bundle({ site: { siteId: "anilyzer" }, corpus: { site: { id: "jeralyzer" } } }); try { assert.equal( builtSiteProblem(torn.dir, "anilyzer"), 'export/out holds an incomplete build of "anilyzer" (its corpus.json does not name it) — build anilyzer first', ); } finally { torn.cleanup(); } }); // ─── The cited out/ audit ─── // A cited build as next build leaves it: compose's files, Next's shell, the // reports, moments and cited media. function citedOut(extra: string[] = []): { dir: string; cleanup: () => void } { const t = bundle({ site: { siteId: "reports-site", channels: [] }, corpus: { spec: 5, kind: "site", site: { id: "reports-site", scope: "cited", audience: "public" } }, }); const files = [ "_next/static/chunks/app.js", "_not-found/index.html", "404/index.html", "404.html", "index.html", "index.txt", "__next._tree.txt", "__next.!KHdvcmtzcGFjZSk.__PAGE__.txt", "ask/index.html", "changelog/index.html", "downloads/index.html", "duplicates/index.html", "offline/index.html", "reports/index.json", "reports/index.html", "reports/_none/index.html", "reports/demo-report/index.html", "reports/demo-report/page.json", "reports/demo-report/stills/a01.png", "m/index.json", "m/_none/index.html", "m/demo-channel/abc123/10.00-20.00/index.html", "media/clips/demo-channel/abc123/10.00-20.00.mp4", "media/posts/demo-social/3kabc/shot.png", "icons/icon-192.png", "favicon.ico", "manifest.webmanifest", "llms.txt", "robots.txt", "sitemap.xml", "_headers", "next.svg", ...extra, ]; for (const f of files) { mkdirSync(path.dirname(path.join(t.dir, f)), { recursive: true }); writeFileSync(path.join(t.dir, f), "x"); } return t; } test("a cited build holding only reports, moments, cited media and the shell passes, and deploys", () => { const t = citedOut(); try { assert.equal(citedBuildProblem(t.dir), null); assert.equal(builtBundleProblem(t.dir, "reports-site"), null); assert.equal(builtSiteProblem(t.dir, "reports-site"), null); } finally { t.cleanup(); } }); test("a cited build holding anything corpus-shaped is refused — by the audit and by both deploy checks", () => { for (const extra of [ "summaries/manifest.json", "transcripts/demo-channel/page-0000.json", "posts/demo-social/manifest.json", "archives/manifest.json", "search-aliases.json", "tags.json", "sw.js", "media/thumbnails/a.jpg", "media/stray.mp4", ]) { const t = citedOut([extra]); try { const problem = citedBuildProblem(t.dir); assert.ok(problem, extra); assert.match(problem, /is a cited build, which publishes only reports and their moments, but it also holds/); assert.ok(problem.includes(extra.startsWith("media/") ? extra.split("/").slice(0, 2).join("/") : extra.split("/")[0]), `${extra}: ${problem}`); assert.equal(builtBundleProblem(t.dir, "reports-site"), problem, extra); assert.equal(builtSiteProblem(t.dir, "reports-site"), problem, extra); } finally { t.cleanup(); } } }); test("the audit leaves a full build alone, whatever it holds", () => { const t = bundle({ site: { siteId: "anilyzer" }, corpus: { spec: 5, site: { id: "anilyzer" } } }); mkdirSync(path.join(t.dir, "transcripts", "x"), { recursive: true }); try { assert.equal(citedBuildProblem(t.dir), null); assert.equal(builtBundleProblem(t.dir, "anilyzer"), null); } finally { t.cleanup(); } }); test("a cited build over the Pages file limit is refused", () => { const t = citedOut(); try { const dir = path.join(t.dir, "m", "many"); mkdirSync(dir, { recursive: true }); for (let i = 0; i <= PAGES_MAX_FILES; i++) writeFileSync(path.join(dir, String(i)), ""); assert.match(citedBuildProblem(t.dir)!, /over Pages' limit of 20000/); } finally { t.cleanup(); } }); // A report's published history clone (publish/reportHistory.ts). const HISTORY_REPO_FILES = [ "reports/demo-report/history/index.html", "reports/demo-report/history/history.json", "reports/demo-report/history/repo/HEAD", "reports/demo-report/history/repo/packed-refs", "reports/demo-report/history/repo/info/refs", "reports/demo-report/history/repo/objects/info/packs", "reports/demo-report/history/repo/refs/heads/main", `reports/demo-report/history/repo/objects/pack/pack-${"a".repeat(40)}.pack`, `reports/demo-report/history/repo/objects/pack/pack-${"a".repeat(40)}.idx`, ]; test("a report's history clone passes the audit; anything else in it is refused, full build or cited", () => { const t = citedOut(HISTORY_REPO_FILES); try { assert.equal(reportHistoryProblem(t.dir), null); assert.equal(builtBundleProblem(t.dir, "reports-site"), null); assert.equal(builtSiteProblem(t.dir, "reports-site"), null); for (const leak of ["config", "hooks/pre-commit", "logs/HEAD", "description", "FETCH_HEAD"]) { const f = path.join(t.dir, "reports", "demo-report", "history", "repo", leak); mkdirSync(path.dirname(f), { recursive: true }); writeFileSync(f, "x"); assert.match(reportHistoryProblem(t.dir)!, new RegExp(`history clone that are not published: reports/demo-report/history/repo/${leak}`)); assert.match(builtBundleProblem(t.dir, "reports-site")!, /history clone/); rmSync(f); } } finally { t.cleanup(); } const full = bundle({ site: { siteId: "anilyzer" }, corpus: { spec: 5, site: { id: "anilyzer" } } }); try { const f = path.join(full.dir, "reports", "r1", "history", "repo", "config"); mkdirSync(path.dirname(f), { recursive: true }); writeFileSync(f, "x"); assert.match(builtSiteProblem(full.dir, "anilyzer")!, /history clone/); } finally { full.cleanup(); } }); test("a history clone's pack over the publish limit is refused; its files count toward Pages' file limit", () => { const t = citedOut(HISTORY_REPO_FILES); try { const pack = path.join(t.dir, HISTORY_REPO_FILES[7]); writeFileSync(pack, Buffer.alloc(PUBLISH_MAX_FILE_BYTES + 1)); assert.match(reportHistoryProblem(t.dir)!, /pack-a+\.pack is 24\.0 MiB, over the publish limit/); writeFileSync(pack, "x"); const dir = path.join(t.dir, "m", "many"); mkdirSync(dir, { recursive: true }); for (let i = 0; i <= PAGES_MAX_FILES; i++) writeFileSync(path.join(dir, String(i)), ""); assert.match(citedBuildProblem(t.dir)!, /\(7 in report history clones\), over Pages' limit of 20000/); } finally { t.cleanup(); } }); test("a site configured cited with a full build is refused at deploy; a cited build of it is not", () => { const full = bundle({ site: { siteId: "reports-site" }, corpus: { spec: 5, site: { id: "reports-site" } } }); const cited = citedOut(); try { const site = { siteId: "reports-site", search: false }; assert.match(builtScopeProblem(site, full.dir)!, /publishes only its reports \(search: false\), but .* holds a full build/); assert.equal(deployAudienceProblem(site, full.dir), builtScopeProblem(site, full.dir)); assert.equal(builtScopeProblem(site, cited.dir), null); assert.equal(builtScopeProblem({ siteId: "reports-site" }, full.dir), null); // Nothing built: the identity checks answer that, not this one. const empty = tempOut(); assert.equal(builtScopeProblem(site, empty.dir), null); empty.cleanup(); } finally { full.cleanup(); cited.cleanup(); } }); test("the publish limit: 24 MiB, inside Pages' 25, one sentence naming the file over it", () => { assert.equal(PUBLISH_MAX_FILE_BYTES, 24 * 1024 * 1024); assert.ok(PUBLISH_MAX_FILE_BYTES < PAGES_MAX_FILE_BYTES); assert.equal(publishFileSizeProblem("evidence-pack.zip", PUBLISH_MAX_FILE_BYTES), null); assert.equal( publishFileSizeProblem("evidence-pack.zip", PUBLISH_MAX_FILE_BYTES + 1), "evidence-pack.zip is 24.0 MiB, over the publish limit of 24.0 MiB (Pages allows 25 MiB per file)", ); });