Archilyzer · Source

archilyzer

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

commit 6db57f62bd7cd8c2d2fbf5893f7f3197fca25e90
parent 079576e9259ae828de73b3e32467e1a0ff1abb7b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 08:41:43 -0400

publish: tests for the stage core — needs() per row, argv both ways, the bundle install (EXDEV), inputSig, the stages over a scratch corpus, tree-kill, the CLI rows and aliases

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

Diffstat:
Mcommon/bin/_cli.test.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/jobs/runChild.test.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/bundle.test.ts | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/inputSig.test.ts | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/stageRun.test.ts | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/stages.test.ts | 319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1097 insertions(+), 0 deletions(-)

diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts @@ -404,3 +404,83 @@ test("every bin in common/bin is reachable as a subcommand", () => { const paths = COMMANDS.map((c) => c.path.join(" ")); assert.equal(new Set(paths).size, paths.length); }); + +// ── publishing as stages (release 18 slice S1) ────────────────────────────── + +test("the stage row: kind and target, the flags the child's argv carries, nothing else", async () => { + const { STAGE_FLAGS, stageArgv } = await import("../publish/stages"); + const hit = resolveCommand(COMMANDS, ["stage", "deploy-site", "jer"]); + assert.deepEqual(hit?.command.path, ["stage"]); + assert.deepEqual(hit?.rest, ["deploy-site", "jer"]); + assert.deepEqual(hit!.command.flags, { ...STAGE_FLAGS }); + // The argv the editor spawns parses through runCli's own parser, cleanly. + const argv = stageArgv({ + kind: "build-site", + target: "_all", + runId: "run-1", + runner: "docker", + force: true, + skipArchives: true, + indexAfter: 17, + }); + assert.deepEqual(argv, [ + "stage", "build-site", "_all", "--run-id", "run-1", "--runner", "docker", "--force", "--skip-archives", "--index-after", "17", + ]); + const parsed = parseArgv(argv, booleanFlags(COMMANDS)); + assert.deepEqual(parsed.positionals, ["stage", "build-site", "_all"]); + assert.equal(argumentProblem(hit!.command, parsed.flags, ["build-site", "_all"]), null); + assert.match(argumentProblem(hit!.command, {}, ["a", "b", "c"])!, /unexpected argument "c"/); + assert.match(argumentProblem(hit!.command, { nodata: true }, [])!, /unknown flag --nodata/); +}); + +test("the publish rows and their flags (status and now are S3's)", () => { + const row = (...p: string[]) => resolveCommand(COMMANDS, p)?.command; + assert.deepEqual(row("publish", "index")?.flags ?? {}, {}); + assert.deepEqual(row("publish", "build", "jer")?.flags, { runner: "string", force: "boolean", "skip-archives": "boolean" }); + assert.equal(row("publish", "build", "all")?.maxPositionals, 1); + assert.deepEqual(row("publish", "deploy", "all")?.flags, { preview: "string", to: "string", force: "boolean" }); + assert.deepEqual(row("publish", "hub")?.flags, { deploy: "boolean", preview: "string", force: "boolean" }); + assert.deepEqual(row("publish", "homepage")?.flags, { + deploy: "boolean", + preview: "string", + to: "string", + force: "boolean", + }); + const parsed = parseArgv(["publish", "hub", "--deploy", "--preview", "r18"], booleanFlags(COMMANDS)); + assert.deepEqual(parsed, { positionals: ["publish", "hub"], flags: { deploy: true, preview: "r18" } }); + const deploy = row("publish", "deploy")!; + assert.match(argumentProblem(deploy, { preveiw: "x" }, ["jer"])!, /unknown flag --preveiw \(accepts --preview, --to, --force\)/); + assert.match(argumentProblem(deploy, { to: true }, ["jer"])!, /--to needs a value/); + const u = usage(COMMANDS); + assert.match(u, /archilyzer publish index\s+update the index/); + assert.match(u, /archilyzer publish build\s+<id\|all> \[--runner local\|docker\|auto\] \[--force\] \[--skip-archives\]/); + assert.match(u, /archilyzer publish deploy\s+<id\|all> \[--preview <branch>\] \[--to local\] \[--force\]/); + assert.match(u, /archilyzer stage\s+<kind> <target> --run-id <id>/); +}); + +test("build site, build all and deploy site are printed aliases of the publish rows", async () => { + const { ALIASES } = await import("./publish"); + assert.equal(ALIASES.buildSite("jer", false), "archilyzer publish index && archilyzer publish build jer --force"); + assert.equal(ALIASES.buildSite("jer", true), "archilyzer publish build jer --force", "--nodata skips the index"); + assert.equal(ALIASES.buildAll, "archilyzer publish index && archilyzer publish build all --runner auto"); + assert.equal(ALIASES.deploySite("jer"), "archilyzer publish deploy jer"); + assert.equal(ALIASES.deploySite("jer", "r18"), "archilyzer publish deploy jer --preview r18"); + const row = (...p: string[]) => resolveCommand(COMMANDS, p)!.command; + // Same flags as ever: a script that called them still parses. + assert.deepEqual(row("build", "site").flags, { nodata: "boolean", "skip-archives": "boolean", "allow-missing-media": "boolean" }); + assert.deepEqual(row("build", "all").flags, { "skip-archives": "boolean" }); + assert.deepEqual(row("deploy", "site").flags, { preview: "string" }); + for (const r of [row("build", "site"), row("build", "all"), row("deploy", "site")]) { + assert.match(r.usage, /alias: /); + } +}); + +test("publish hub / homepage refuse a deploy's flag without --deploy (usage, nothing run)", async () => { + const { publishHub, publishHomepage } = await import("./publish"); + const errors: string[] = []; + const out = { log: () => {}, error: (s: string) => errors.push(s) }; + assert.equal(await publishHub({ preview: "r18" }, out), 2); + assert.equal(await publishHomepage({ to: "local" }, out), 2); + assert.match(errors.join("\n"), /--preview is a deploy — add --deploy/); + assert.match(errors.join("\n"), /--preview and --to are a deploy's — add --deploy/); +}); diff --git a/common/jobs/runChild.test.ts b/common/jobs/runChild.test.ts @@ -0,0 +1,70 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { killsChildTrees, runChildIntoLog, setKillChildTrees } from "./runChild"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// Release 18: a publish stage child turns on tree-kill, so a cancel takes the +// child's whole process group — `next build`'s workers, wrangler — not just +// the direct child. A shell that starts a background grandchild stands in. + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor(cond: () => boolean, ms = 5_000): Promise<boolean> { + const end = Date.now() + ms; + while (Date.now() < end) { + if (cond()) return true; + await new Promise((r) => setTimeout(r, 25)); + } + return cond(); +} + +async function runWithGrandchild(tree: boolean): Promise<{ grandchild: number; code: number }> { + const dir = mkdtempSync(path.join(os.tmpdir(), "run-child-")); + const pidFile = path.join(dir, "gc.pid"); + setKillChildTrees(tree); + try { + const ac = new AbortController(); + const running = runChildIntoLog(() => {}, ac.signal, { + command: "sh", + args: ["-c", `sleep 30 & echo $! > '${pidFile}'; wait`], + cwd: dir, + }); + let grandchild = 0; + await waitFor(() => { + try { + grandchild = Number(readFileSync(pidFile, "utf8").trim()); + return grandchild > 0; + } catch { + return false; + } + }); + ac.abort(); + const code = await running; + return { grandchild, code }; + } finally { + setKillChildTrees(false); + rmSync(dir, { recursive: true, force: true }); + } +} + +test("tree-kill is off by default", () => { + assert.equal(killsChildTrees(), false); +}); + +test("with tree-kill on, a cancel takes the grandchildren too", { skip: process.platform === "win32" }, async () => { + const { grandchild, code } = await runWithGrandchild(true); + assert.notEqual(code, 0); + assert.ok(await waitFor(() => !alive(grandchild)), `grandchild ${grandchild} survived`); +}); diff --git a/common/publish/bundle.test.ts b/common/publish/bundle.test.ts @@ -0,0 +1,220 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { cp, mkdir, rename, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import { + bundleCounts, + bundleDir, + corpusGeneratedAtIn, + dockerSiteOutDir, + exportOutPath, + installBundle, + pointExportOutAt, + stageSiteArchives, + unlinkExportOut, + type BundleFs, +} from "./build"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// The per-target bundle layout (release 18): a build in export/out is +// installed as <exportBuildsDir>/<target>/out by a swap through out.next, the +// move falls back to a copy across filesystems (an injected EXDEV here), and +// export/out becomes a link to the bundle built last. + +function tmp(): { root: string; paths: Paths } { + const root = mkdtempSync(path.join(os.tmpdir(), "bundle-")); + const exportDir = path.join(root, "export"); + mkdirSync(exportDir, { recursive: true }); + return { + root, + paths: { + exportDir, + exportPublicDir: path.join(exportDir, "public"), + exportBuildsDir: path.join(exportDir, ".export-builds"), + } as Paths, + }; +} + +function writeBuild(dir: string, marker: string): void { + mkdirSync(path.join(dir, "_next"), { recursive: true }); + writeFileSync(path.join(dir, "index.html"), marker); + writeFileSync(path.join(dir, "_next", "app.js"), "x".repeat(10)); + writeFileSync(path.join(dir, "corpus.json"), JSON.stringify({ generatedAt: `at-${marker}` })); +} + +// A filesystem whose renames out of `crossFrom` fail EXDEV, as export/out's do +// in the container (an image layer) when the builds dir is a volume. +function exdevFs(crossFrom: string, calls: string[]): BundleFs { + return { + rename: (async (a: string, b: string) => { + calls.push(`rename ${path.basename(String(a))} -> ${path.basename(String(b))}`); + if (String(a).startsWith(crossFrom)) { + throw Object.assign(new Error("EXDEV: cross-device link not permitted"), { code: "EXDEV" }); + } + return rename(a, b); + }) as typeof rename, + cp: (async (a: string, b: string, o?: object) => { + calls.push(`cp ${path.basename(String(a))} -> ${path.basename(String(b))}`); + return cp(a, b, o); + }) as typeof cp, + rm, + mkdir, + }; +} + +test("bundleDir is <exportBuildsDir>/<target>/out — a site's is dockerSiteOutDir", () => { + const p = { exportBuildsDir: "/e/.export-builds", exportDir: "/e" } as Paths; + assert.equal(bundleDir(p, "jer"), "/e/.export-builds/jer/out"); + assert.equal(bundleDir(p, "jer"), dockerSiteOutDir(p, "jer")); + assert.equal(bundleDir(p, "_hub"), "/e/.export-builds/_hub/out"); + assert.equal(exportOutPath(p), "/e/out"); +}); + +test("installBundle moves the build in through out.next, replacing the last bundle; a leftover out.next goes first", async () => { + const { root, paths } = tmp(); + try { + const dest = bundleDir(paths, "jer"); + writeBuild(dest, "old"); + writeBuild(`${dest}.next`, "leftover"); + const out = exportOutPath(paths); + writeBuild(out, "new"); + assert.equal(await installBundle(out, dest), "rename"); + assert.equal(readFileSync(path.join(dest, "index.html"), "utf8"), "new"); + assert.ok(!existsSync(out), "export/out moved away"); + assert.ok(!existsSync(`${dest}.next`)); + assert.ok(!existsSync(`${dest}.prev`)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("installBundle copies across filesystems (EXDEV), then removes the source", async () => { + const { root, paths } = tmp(); + try { + const dest = bundleDir(paths, "jer"); + writeBuild(dest, "old"); + const out = exportOutPath(paths); + writeBuild(out, "new"); + const calls: string[] = []; + assert.equal(await installBundle(out, dest, exdevFs(out, calls)), "copy"); + assert.equal(readFileSync(path.join(dest, "index.html"), "utf8"), "new"); + assert.equal(readFileSync(path.join(dest, "_next", "app.js"), "utf8"), "x".repeat(10)); + assert.ok(!existsSync(out), "the source is removed after the copy"); + assert.deepEqual(calls, [ + "rename out -> out.next", // EXDEV + "cp out -> out.next", + "rename out -> out.prev", // siblings: same filesystem + "rename out.next -> out", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("export/out becomes a relative link to the bundle; unlinkExportOut removes a link, never a real build", async () => { + const { root, paths } = tmp(); + try { + const out = exportOutPath(paths); + writeBuild(out, "real"); + await unlinkExportOut(paths); + assert.ok(lstatSync(out).isDirectory(), "a real out/ is left for next build"); + const dest = bundleDir(paths, "jer"); + await installBundle(out, dest); + await pointExportOutAt(paths, dest); + assert.ok(lstatSync(out).isSymbolicLink()); + assert.equal(readlinkSync(out), path.join(".export-builds", "jer", "out")); + assert.equal(readFileSync(path.join(out, "index.html"), "utf8"), "real", "reads through the link"); + // Pointing it at another bundle replaces the link, not the bundle. + const hub = bundleDir(paths, "_hub"); + writeBuild(hub, "hub"); + await pointExportOutAt(paths, hub); + assert.equal(readFileSync(path.join(out, "index.html"), "utf8"), "hub"); + assert.ok(existsSync(path.join(dest, "index.html")), "the other bundle is untouched"); + await unlinkExportOut(paths); + assert.ok(!existsSync(out)); + assert.ok(existsSync(path.join(hub, "index.html")), "the link's target survives"); + // A real out/ left behind is replaced by the link. + writeBuild(out, "stray"); + await pointExportOutAt(paths, dest); + assert.ok(lstatSync(out).isSymbolicLink()); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("stageSiteArchives moves the host staging into the bundle's (dockerSiteStagingDir) and counts it", async () => { + const { root, paths } = tmp(); + try { + const host = path.join(paths.exportDir, ".r2-staging", "jer", "archives"); + mkdirSync(host, { recursive: true }); + writeFileSync(path.join(host, "a.zip"), "a"); + writeFileSync(path.join(host, "b.zip"), "b"); + writeFileSync(path.join(host, ".hidden"), ""); + const bundleStaging = path.join(paths.exportBuildsDir, "jer", ".r2-staging", "jer", "archives"); + mkdirSync(bundleStaging, { recursive: true }); + writeFileSync(path.join(bundleStaging, "last-build.zip"), "old"); + assert.equal(await stageSiteArchives(paths, "jer"), 2); + assert.ok(existsSync(path.join(bundleStaging, "a.zip"))); + assert.ok(!existsSync(path.join(bundleStaging, "last-build.zip")), "the last build's staging is replaced"); + assert.ok(!existsSync(host)); + // Nothing staged this build: the bundle has none either. + assert.equal(await stageSiteArchives(paths, "jer"), 0); + assert.ok(!existsSync(bundleStaging)); + // Across filesystems. + mkdirSync(host, { recursive: true }); + writeFileSync(path.join(host, "c.zip"), "c"); + const calls: string[] = []; + assert.equal(await stageSiteArchives(paths, "jer", exdevFs(path.join(paths.exportDir, ".r2-staging"), calls)), 1); + assert.ok(calls.some((c) => c.startsWith("cp "))); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("stageSiteArchives is a no-op where the two are one place (the build container's compose)", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "bundle-same-")); + try { + // EXPORT_PUBLIC_DIR=/site/public and a builds dir whose <id> IS /site. + const site = path.join(root, "jer"); + const paths = { exportPublicDir: path.join(site, "public"), exportBuildsDir: root } as Paths; + const staged = path.join(site, ".r2-staging", "jer", "archives"); + mkdirSync(staged, { recursive: true }); + writeFileSync(path.join(staged, "a.zip"), "a"); + assert.equal(await stageSiteArchives(paths, "jer"), 1); + assert.ok(existsSync(path.join(staged, "a.zip"))); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("bundleCounts and corpusGeneratedAtIn read the bundle; links are not followed", async () => { + const { root } = tmp(); + try { + const dir = path.join(root, "b"); + writeBuild(dir, "m"); + symlinkSync("/etc", path.join(dir, "link")); + const c = await bundleCounts(dir); + assert.equal(c.files, 3); + assert.equal(c.bytes, 1 + 10 + JSON.stringify({ generatedAt: "at-m" }).length); + assert.equal(await corpusGeneratedAtIn(dir), "at-m"); + assert.equal(await corpusGeneratedAtIn(path.join(root, "none")), null); + assert.deepEqual(await bundleCounts(path.join(root, "none")), { files: 0, bytes: 0 }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/common/publish/inputSig.test.ts b/common/publish/inputSig.test.ts @@ -0,0 +1,169 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ChannelConfig } from "../lib/channelConfig"; +import type { Paths } from "../lib/paths"; +import type { Site } from "../lib/site"; +import { hubInputSig, siteInputSig } from "./inputSig"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// A site's inputSig moves exactly with what compose reads (release 18): its +// staged index tree, its PUBLISHED members' shared trees (manifest.json aside, +// a manifest-only tree signed as one), site.json, the corpus-wide files and the +// two settings — and not with another site's channel, a churned manifest, or +// a rewritten-but-identical chart-templates.json. + +function setup() { + const root = mkdtempSync(path.join(os.tmpdir(), "input-sig-")); + const idx = path.join(root, ".export-index"); + const paths = { + transcriptsDir: path.join(root, "transcripts"), + sitesDir: path.join(root, "transcripts", "sites"), + exportSitesIndexDir: path.join(idx, "sites"), + exportSharedTranscriptsDir: path.join(idx, "shared", "transcripts"), + exportSharedSubsDir: path.join(idx, "shared", "subs"), + exportSharedPostsDir: path.join(idx, "shared", "posts"), + exportSharedDigestsDir: path.join(idx, "shared", "digests"), + globalAliasesFile: path.join(root, "transcripts", "search-aliases.json"), + globalTagsFile: path.join(root, "transcripts", "tags.json"), + homepageConfigFile: path.join(root, "transcripts", "sites", "_homepage", "homepage.json"), + } as Paths; + const site = { + siteId: "jer", + siteTitle: "Jer", + channels: [{ slug: "a" }, { slug: "x" }], + } as unknown as Site; + const write = (file: string, body: string) => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, body); + }; + write(path.join(paths.sitesDir, "jer", "site.json"), JSON.stringify({ siteId: "jer" })); + write(path.join(paths.exportSitesIndexDir, "jer", "summaries", "page-0000.json"), "[]"); + write(path.join(paths.exportSitesIndexDir, "jer", "chart-templates.json"), '{"v":1}'); + write(path.join(paths.exportSharedTranscriptsDir, "a", "page-0000.json"), "[1]"); + write(path.join(paths.exportSharedTranscriptsDir, "a", "manifest.json"), '{"generatedAt":"1"}'); + write(path.join(paths.exportSharedPostsDir, "x", "page-0000.json"), "[2]"); + write(path.join(paths.exportSharedTranscriptsDir, "other", "page-0000.json"), "[3]"); + const configs: Record<string, Partial<ChannelConfig>> = { + a: {}, + x: { sourceKind: "social", platform: "twitter" } as Partial<ChannelConfig>, + }; + let settings: { social?: { x?: { visibility?: string } } } = {}; + const sig = () => + siteInputSig({ + paths, + site, + settings: settings as never, + configOf: (slug) => configs[slug] as ChannelConfig, + }); + const bump = (file: string) => { + const t = new Date(Date.now() + 5_000); + utimesSync(file, t, t); + }; + return { + root, + paths, + write, + sig, + bump, + setXPrivate: () => { + settings = { social: { x: { visibility: "private" } } }; + }, + }; +} + +test("inputSig is stable, and ignores what compose ignores", async () => { + const s = setup(); + try { + const base = await s.sig(); + assert.match(base, /^[0-9a-f]{40}$/); + assert.equal(await s.sig(), base, "nothing changed"); + // A churned shared manifest (generatedAt) does not count… + s.write(path.join(s.paths.exportSharedTranscriptsDir, "a", "manifest.json"), '{"generatedAt":"2"}'); + assert.equal(await s.sig(), base); + // …nor another site's channel… + s.write(path.join(s.paths.exportSharedTranscriptsDir, "other", "page-0001.json"), "[4]"); + assert.equal(await s.sig(), base); + // …nor chart-templates.json rewritten with the same bytes. + const tpl = path.join(s.paths.exportSitesIndexDir, "jer", "chart-templates.json"); + s.bump(tpl); + assert.equal(await s.sig(), base); + } finally { + rmSync(s.root, { recursive: true, force: true }); + } +}); + +test("inputSig moves with each input compose reads", async () => { + const s = setup(); + try { + let last = await s.sig(); + const moved = async (what: string) => { + const now = await s.sig(); + assert.notEqual(now, last, what); + last = now; + }; + s.write(path.join(s.paths.exportSharedTranscriptsDir, "a", "page-0001.json"), "[5]"); + await moved("a member's shared transcripts"); + s.write(path.join(s.paths.exportSharedPostsDir, "x", "page-0001.json"), "[6]"); + await moved("a member's shared posts"); + s.write(path.join(s.paths.exportSitesIndexDir, "jer", "summaries", "page-0001.json"), "[]"); + await moved("the site's staged summaries"); + s.write(path.join(s.paths.exportSitesIndexDir, "jer", "chart-templates.json"), '{"v":2}'); + await moved("the chart templates' bytes"); + s.write(path.join(s.paths.sitesDir, "jer", "site.json"), JSON.stringify({ siteId: "jer", t: 1 })); + await moved("site.json"); + s.write(path.join(s.paths.sitesDir, "jer", "tags.json"), "{}"); + await moved("the site's config dir"); + s.write(s.paths.globalAliasesFile, "{}"); + await moved("the global search aliases"); + s.write(s.paths.globalTagsFile, "{}"); + await moved("the curated tags"); + s.write(path.join(s.paths.transcriptsDir, "duplicates.json"), "{}"); + await moved("the duplicates report"); + // X posts private: the X member is withheld from a public site. + s.setXPrivate(); + await moved("social.x.visibility"); + s.write(path.join(s.paths.exportSharedPostsDir, "x", "page-0002.json"), "[7]"); + assert.equal(await s.sig(), last, "a withheld member's tree is not this site's input"); + } finally { + rmSync(s.root, { recursive: true, force: true }); + } +}); + +test("a manifest-only member tree is signed as one, not as absent", async () => { + const s = setup(); + try { + const before = await s.sig(); + s.write(path.join(s.paths.exportSharedSubsDir, "a", "manifest.json"), "{}"); + assert.notEqual(await s.sig(), before); + } finally { + rmSync(s.root, { recursive: true, force: true }); + } +}); + +test("hubSig: the stamp, homepage.json, and each listed site's id + url + title", async () => { + const s = setup(); + try { + const sites = [ + { siteId: "jer", siteTitle: "Jer", siteUrl: "https://jer.pages.dev", channels: [] }, + { siteId: "mine", siteTitle: "Mine", siteUrl: "https://m.pages.dev", audience: "private", channels: [] }, + { siteId: "nourl", siteTitle: "No", channels: [] }, + ] as unknown as Site[]; + const a = await hubInputSig(s.paths, "s1", sites); + assert.equal(await hubInputSig(s.paths, "s1", sites), a); + assert.notEqual(await hubInputSig(s.paths, "s2", sites), a, "a new index stamp"); + const retitled = sites.map((x) => (x.siteId === "jer" ? { ...x, siteTitle: "Jer 2" } : x)); + assert.notEqual(await hubInputSig(s.paths, "s1", retitled), a); + // A private site or one with no url is not listed: changing it moves nothing. + const privRetitled = sites.map((x) => (x.siteId === "mine" ? { ...x, siteTitle: "M2" } : x)); + assert.equal(await hubInputSig(s.paths, "s1", privRetitled), a); + s.write(s.paths.homepageConfigFile, '{"title":"hub"}'); + assert.notEqual(await hubInputSig(s.paths, "s1", sites), a); + } finally { + rmSync(s.root, { recursive: true, force: true }); + } +}); diff --git a/common/publish/stageRun.test.ts b/common/publish/stageRun.test.ts @@ -0,0 +1,239 @@ +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import os from "node:os"; +import path from "node:path"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// The publish stages run for real over a scratch corpus (release 18): the +// update-index body writes the stamp and keeps its id when nothing changed; +// every other stage asks its precondition ON DISK first (exit 3), a fresh +// target is a no-op, `--to local` copies the bundle and records it; the lock +// is waited for and a wait is cancellable (130). No `next build` and no +// wrangler run here: the build and Pages paths are the bundle and build tests'. +// +// Every path getPaths() can resolve to a place this file may write is pinned +// under ROOT before anything calls it (the buildStats.test.ts pattern). +const ROOT = mkdtempSync(path.join(tmpdir(), "stage-run-")); +const PINNED: Record<string, string> = { + TRANSCRIPTS_DIR: path.join(ROOT, "transcripts"), + SAVED_VIDEOS_DIR: path.join(ROOT, "saved-videos"), + SITES_DIR: path.join(ROOT, "transcripts", "sites"), + SETTINGS_FILE: path.join(ROOT, "settings.json"), + EXPORT_PUBLIC_DIR: path.join(ROOT, "export", "public"), + EXPORT_INDEX_DIR: path.join(ROOT, "export", ".export-index"), + EXPORT_BUILDS_DIR: path.join(ROOT, "export", ".export-builds"), + EDITOR_CHANGELOG_FILE: path.join(ROOT, "editor-CHANGELOG.md"), + EXPORT_CHANGELOG_FILE: path.join(ROOT, "export-CHANGELOG.md"), + CHARTS_CONFIG_FILE: path.join(ROOT, "chart-templates.json"), + SEARCH_ALIASES_FILE: path.join(ROOT, "transcripts", "search-aliases.json"), + CURATED_TAGS_FILE: path.join(ROOT, "transcripts", "tags.json"), + ARCHILYZER_CONFIG_DIR: path.join(ROOT, "config"), + ARCHILYZER_SOURCE_SCRATCH: path.join(ROOT, "source-scratch"), +}; +Object.assign(process.env, PINNED); +delete process.env.ARCHILYZER_SITE_OUT; +delete process.env.ARCHIVES_READONLY; +after(() => rmSync(ROOT, { recursive: true, force: true })); + +mkdirSync(path.join(ROOT, "transcripts", "channels"), { recursive: true }); +writeFileSync(PINNED.SETTINGS_FILE, "{}"); +function writeSite(id: string, extra: Record<string, unknown> = {}): void { + mkdirSync(path.join(PINNED.SITES_DIR, id), { recursive: true }); + writeFileSync( + path.join(PINNED.SITES_DIR, id, "site.json"), + JSON.stringify({ siteId: id, siteTitle: id, channels: [], ...extra }), + ); +} +writeSite("jer", { cloudflareProject: "w3c-never-real" }); + +const { getPaths } = await import("../lib/paths"); +const { runStage } = await import("./stageRun"); +const stamps = await import("./stamps"); +const { bundleDir } = await import("./build"); +const { publishLockPath } = await import("./stageLock"); +const paths = getPaths(); + +const quiet = () => {}; +async function stage(kind: string, target: string, extra: Record<string, unknown> = {}, logs?: string[]) { + return runStage( + { kind: kind as never, target, runId: "t", ...extra }, + { paths, onLog: (l) => logs?.push(l) ?? quiet() }, + ); +} + +function plantBundle(siteId: string): void { + const out = bundleDir(paths, siteId); + mkdirSync(out, { recursive: true }); + writeFileSync(path.join(out, "index.html"), "<!doctype html>"); + writeFileSync(path.join(out, "site.json"), JSON.stringify({ siteId })); + writeFileSync(path.join(out, "corpus.json"), JSON.stringify({ site: { id: siteId }, generatedAt: "g1" })); +} + +test("before any index: a build is refused 'update the index first' (exit 3), and the lock is released", async () => { + const logs: string[] = []; + const r = await stage("build-site", "jer", {}, logs); + assert.equal(r.code, 3); + assert.equal(r.message, "update the index first"); + assert.match(logs.join(""), /\[stage\] build-site jer: REFUSED — update the index first/); + assert.ok(!existsSync(publishLockPath(paths))); + assert.equal((await stage("build-hub", "_hub")).code, 3); + assert.equal((await stage("build-homepage", "_homepage")).code, 3); +}); + +test("update-index writes the stamp; a rerun with nothing changed keeps its id; a site.json edit makes a new one", async () => { + const first = await stage("update-index", "_index"); + assert.equal(first.code, 0, first.message ?? ""); + assert.equal(first.outcome?.status, "ran"); + const stamp = await stamps.readIndexStamp(paths); + assert.ok(stamp); + assert.equal(stamp.stampId, first.outcome?.stamp); + assert.deepEqual(Object.keys(stamp.sites), ["jer"]); + assert.match(stamp.sites.jer.inputSig, /^[0-9a-f]{40}$/); + assert.equal(typeof stamp.scannedAt, "number"); + + const again = await stage("update-index", "_index"); + assert.equal(again.code, 0); + assert.equal(again.outcome?.status, "noop"); + const same = await stamps.readIndexStamp(paths); + assert.equal(same?.stampId, stamp.stampId); + assert.equal(same?.sites.jer.inputSig, stamp.sites.jer.inputSig); + assert.ok(same!.builtAt >= stamp.builtAt); + + writeSite("jer", { cloudflareProject: "w3c-never-real", siteDescription: "edited" }); + const edited = await stage("update-index", "_index"); + assert.equal(edited.outcome?.status, "ran"); + const next = await stamps.readIndexStamp(paths); + assert.notEqual(next?.stampId, stamp.stampId); + assert.notEqual(next?.sites.jer.inputSig, stamp.sites.jer.inputSig, "site.json is in the signature"); + assert.notEqual(next?.hubSig, stamp.hubSig, "the hub follows the stamp"); +}); + +test("a site the index has not seen is blocked; a fresh site's build is a no-op; deploys judge the built stamp", async () => { + writeSite("ani"); + assert.match((await stage("build-site", "ani")).message!, /the index has not seen site "ani"/); + rmSync(path.join(PINNED.SITES_DIR, "ani"), { recursive: true }); + + // Never built: a deploy is refused with the command that fixes it. + const none = await stage("deploy-site", "jer"); + assert.equal(none.code, 3); + assert.equal(none.message, "no build of jer — archilyzer publish build jer"); + + // A bundle stamped from the current index: fresh, so building is a no-op. + const stamp = (await stamps.readIndexStamp(paths))!; + plantBundle("jer"); + await stamps.writeBuiltStamp(paths, { + v: 1, + stampId: "b-jer", + target: "jer", + kind: "site", + indexStampId: stamp.stampId, + inputSig: stamp.sites.jer.inputSig, + builtAt: Date.now(), + commit: null, + branch: "main", + runner: "local", + audience: "public", + corpusGeneratedAt: "g1", + files: 3, + bytes: 1, + archivesStaged: 0, + }); + const fresh = await stage("build-site", "jer"); + assert.equal(fresh.code, 0); + assert.equal(fresh.outcome?.status, "noop"); + assert.equal(fresh.outcome?.stamp, "b-jer"); + + // A bad preview name is usage (2), before anything else is asked. + assert.equal((await stage("deploy-site", "jer", { preview: "main" })).code, 2); + assert.equal((await stage("deploy-site", "jer", { preview: "x", to: "local" })).code, 2); + + // --to local needs the site service's directory. + const noOut = await stage("deploy-site", "jer", { to: "local" }); + assert.equal(noOut.code, 3); + assert.match(noOut.message!, /--to local needs ARCHILYZER_SITE_OUT/); + + // …and with it, copies the bundle into it (its CONTENTS replaced) and records the deploy. + const siteOut = path.join(ROOT, "builds", "site"); + mkdirSync(siteOut, { recursive: true }); + writeFileSync(path.join(siteOut, "stale.html"), "old"); + process.env.ARCHILYZER_SITE_OUT = siteOut; + try { + const local = await stage("deploy-site", "jer", { to: "local" }); + assert.equal(local.code, 0, local.message ?? ""); + assert.equal(local.outcome?.status, "ran"); + assert.ok(existsSync(path.join(siteOut, "index.html"))); + assert.ok(!existsSync(path.join(siteOut, "stale.html"))); + const rec = stamps.deployRecordFor(await stamps.readDeployedFile(paths, "jer"), "local"); + assert.equal(rec?.builtStampId, "b-jer"); + assert.equal(rec?.liveCheck, null); + // Deployed already: a no-op, until --force. + assert.equal((await stage("deploy-site", "jer", { to: "local" })).outcome?.status, "noop"); + assert.equal((await stage("deploy-site", "jer", { to: "local", force: true })).outcome?.status, "ran"); + // builtAfter: the build this run waits on has not happened. + const waiting = await stage("deploy-site", "jer", { to: "local", builtAfter: Date.now() + 60_000 }); + assert.equal(waiting.code, 3); + assert.match(waiting.message!, /waiting for the build of jer/); + } finally { + delete process.env.ARCHILYZER_SITE_OUT; + } + + // Production refuses a build made on another branch. + const built = (await stamps.readBuiltStamp(paths, "jer"))!; + await stamps.writeBuiltStamp(paths, { ...built, branch: "r18/stage-core" }); + const off = await stage("deploy-site", "jer"); + assert.equal(off.code, 3); + assert.match(off.message!, /production ships only a build of main/); +}); + +test("a private site is never deployed, here either (exit 3)", async () => { + writeSite("mine", { audience: "private", cloudflareProject: "w3c-never-real" }); + try { + const r = await stage("deploy-site", "mine", { to: "local" }); + assert.equal(r.code, 3); + assert.match(r.message!, /Site "mine" is private/); + } finally { + rmSync(path.join(PINNED.SITES_DIR, "mine"), { recursive: true }); + } +}); + +test("a live holder of the publish lock is waited for; a cancel during the wait exits 130", async () => { + mkdirSync(path.dirname(publishLockPath(paths)), { recursive: true }); + writeFileSync( + publishLockPath(paths), + JSON.stringify({ pid: process.pid, host: os.hostname(), kind: "build-site", target: "x", since: 1 }), + ); + try { + const ac = new AbortController(); + const logs: string[] = []; + const running = runStage( + { kind: "build-site", target: "jer", runId: "t" }, + { paths, signal: ac.signal, onLog: (l) => logs.push(l), lockEnv: { pollMs: 5 } }, + ); + setTimeout(() => ac.abort(), 50); + const r = await running; + assert.equal(r.code, 130); + assert.match(logs.join(""), /waiting for the publish lock — held by build-site x/); + assert.match(logs.join(""), /cancelled while waiting/); + } finally { + rmSync(publishLockPath(paths), { force: true }); + } +}); + +test("a lock left by a dead process on this host is taken over", async () => { + writeFileSync( + publishLockPath(paths), + JSON.stringify({ pid: 2 ** 30, host: os.hostname(), kind: "update-index", target: "_index", since: 1 }), + ); + const old = new Date(Date.now() - 1000); + utimesSync(publishLockPath(paths), old, old); + const logs: string[] = []; + const r = await stage("build-site", "jer", {}, logs); + assert.equal(r.code, 0, r.message ?? ""); + assert.match(logs.join(""), /taking over a stale publish lock/); + assert.ok(!existsSync(publishLockPath(paths))); + assert.equal(readFileSync(path.join(bundleDir(paths, "jer"), "index.html"), "utf8"), "<!doctype html>"); +}); diff --git a/common/publish/stages.test.ts b/common/publish/stages.test.ts @@ -0,0 +1,319 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseArgv } from "../bin/_parseFlags"; +import type { Paths } from "../lib/paths"; +import { builtStamp, indexStamp } from "./__fixtures__/stamps"; +import type { BuiltStamp, DeployedFile, DeployRecord } from "./stamps"; +import { STAGE_EXIT, stageCommand } from "./stageRun"; +import { + STAGES, + STAGE_FLAGS, + STAGE_KINDS, + deployKindOf, + parseStageArgs, + stageArgv, + type Freshness, + type NeedsInput, + type StageRequest, + type TargetState, +} from "./stages"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// The publish stages' needs() — one case (at least) per row of the plan's +// "Stages" table (plans/release-18.md), plus `changedChannels`, `--force` and +// "code newer is not stale" — and the child's argv, both ways. + +function target(over: Partial<TargetState> = {}): TargetState { + return { + built: null, + deployed: null, + changedChannels: [], + configChangedAt: null, + bundleProblem: null, + ...over, + }; +} + +function input(over: Partial<NeedsInput> = {}): NeedsInput { + return { + index: { stamp: indexStamp(), lastIngestDoneAt: null, configChangedAt: null }, + sites: { jer: target({ built: builtStamp() }) }, + hub: target({ built: builtStamp({ target: "_hub", kind: "hub", inputSig: "hub-1" }) }), + homepage: { + ...target({ + built: builtStamp({ target: "_homepage", kind: "homepage", inputSig: "s1", sourceCommit: "main-1" }), + }), + mainHead: "main-1", + }, + ...over, + }; +} + +const req = (kind: StageRequest["kind"], tgt: string, over: Partial<StageRequest> = {}): StageRequest => ({ + kind, + target: tgt, + runId: "run-1", + ...over, +}); + +const needs = (s: NeedsInput, r: StageRequest): Freshness => STAGES[r.kind].needs(s, r); +const state = (f: Freshness) => f.state; +const reason = (f: Freshness) => (f.state === "fresh" ? "" : f.reason); + +function deployed(over: Partial<DeployedFile> = {}, rec: Partial<DeployRecord> = {}): DeployedFile { + const r: DeployRecord = { + builtStampId: "b1", + builtAt: 3_000, + kind: "production", + url: null, + at: 4_000, + liveCheck: null, + ...rec, + }; + return { v: 1, target: "jer", production: r, previews: {}, ...over }; +} + +test("the table: seven stages, each a publish-<kind> job on the publish queue", () => { + assert.deepEqual(Object.keys(STAGES), [...STAGE_KINDS]); + for (const k of STAGE_KINDS) { + assert.equal(STAGES[k].kind, k); + assert.equal(STAGES[k].jobKind, `publish-${k}`); + assert.equal(STAGES[k].queueKey, "publish"); + } +}); + +// --- update-index ------------------------------------------------------------- + +test("update-index: fresh when a stamp exists and nothing is newer than its scan", () => { + assert.equal(state(needs(input(), req("update-index", "_index"))), "fresh"); + const none = input({ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null } }); + assert.equal(reason(needs(none, req("update-index", "_index"))), "no index stamp yet"); + const data = input({ index: { stamp: indexStamp(), lastIngestDoneAt: 1_001, configChangedAt: null } }); + assert.equal(reason(needs(data, req("update-index", "_index"))), "new data since the last index"); + const older = input({ index: { stamp: indexStamp(), lastIngestDoneAt: 999, configChangedAt: 1_000 } }); + assert.equal(state(needs(older, req("update-index", "_index"))), "fresh", "not NEWER than scannedAt"); + const cfg = input({ index: { stamp: indexStamp(), lastIngestDoneAt: null, configChangedAt: 5_000 } }); + assert.match(reason(needs(cfg, req("update-index", "_index"))), /a config file changed/); + assert.equal(reason(needs(input(), req("update-index", "_index", { force: true }))), "forced"); +}); + +// --- build-site <id> ---------------------------------------------------------- + +test("build-site: blocked 'update the index first' with no stamp — even forced", () => { + const none = input({ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null } }); + assert.deepEqual(needs(none, req("build-site", "jer")), { state: "blocked", reason: "update the index first" }); + assert.equal(state(needs(none, req("build-site", "jer", { force: true }))), "blocked"); +}); + +test("build-site: fresh when the built inputSig is the stamp's, nothing changed and the bundle is sound", () => { + assert.equal(state(needs(input(), req("build-site", "jer"))), "fresh"); +}); + +test("build-site: changed channels make it stale before any index runs, named", () => { + const s = input({ sites: { jer: target({ built: builtStamp(), changedChannels: ["a", "b", "c", "d", "e"] }) } }); + assert.equal(reason(needs(s, req("build-site", "jer"))), "5 channels changed (a, b, c, d, …)"); + const one = input({ sites: { jer: target({ built: builtStamp(), changedChannels: ["a"] }) } }); + assert.equal(reason(needs(one, req("build-site", "jer"))), "1 channel changed (a)"); +}); + +test("build-site: an inputSig mismatch is 'data changed'; a config change, a bad bundle, no build are stale", () => { + const sig = input({ sites: { jer: target({ built: builtStamp({ inputSig: "older" }) }) } }); + assert.equal(reason(needs(sig, req("build-site", "jer"))), "data changed"); + const cfg = input({ sites: { jer: target({ built: builtStamp(), configChangedAt: 3_001 }) } }); + assert.equal(reason(needs(cfg, req("build-site", "jer"))), "config changed"); + const bad = input({ sites: { jer: target({ built: builtStamp(), bundleProblem: "holds a build of x" }) } }); + assert.equal(reason(needs(bad, req("build-site", "jer"))), "holds a build of x"); + const never = input({ sites: { jer: target() } }); + assert.equal(reason(needs(never, req("build-site", "jer"))), "never built"); +}); + +test("build-site: --force builds a fresh site; a code change alone is NOT stale", () => { + assert.equal(reason(needs(input(), req("build-site", "jer", { force: true }))), "forced"); + const code = input({ sites: { jer: target({ built: builtStamp({ commit: "an-older-commit", branch: "x" }) }) } }); + assert.equal(state(needs(code, req("build-site", "jer"))), "fresh"); +}); + +test("build-site: indexAfter waits for this run's index; an unknown site or one the index never saw is blocked", () => { + assert.equal(state(needs(input(), req("build-site", "jer", { indexAfter: 2_000 }))), "fresh"); + assert.match(reason(needs(input(), req("build-site", "jer", { indexAfter: 2_001 }))), /waiting for the index update/); + assert.equal(reason(needs(input(), req("build-site", "nope"))), 'no site "nope"'); + const unseen = input({ sites: { jer: target({ built: builtStamp() }), ani: target() } }); + assert.match(reason(needs(unseen, req("build-site", "ani"))), /the index has not seen site "ani" — update the index first/); +}); + +// --- build-site _all --runner docker ------------------------------------------ + +test("build-site _all: per site, as above", () => { + const s = input({ + index: { + stamp: indexStamp({ sites: { jer: indexStamp().sites.jer, ani: { siteFp: null, statsFp: null, inputSig: "x" } } }), + lastIngestDoneAt: null, + configChangedAt: null, + }, + sites: { jer: target({ built: builtStamp() }), ani: target() }, + }); + assert.equal(reason(needs(s, req("build-site", "_all", { runner: "docker" }))), "1 of 2 sites to build (ani)"); + const allFresh = input(); + assert.equal(state(needs(allFresh, req("build-site", "_all", { runner: "docker" }))), "fresh"); + const none = input({ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null } }); + assert.equal(state(needs(none, req("build-site", "_all"))), "blocked"); +}); + +// --- deploy-site -------------------------------------------------------------- + +test("deploy-site: fresh exactly when the record for that kind/branch names the built stamp", () => { + const prod = input({ sites: { jer: target({ built: builtStamp(), deployed: deployed() }) } }); + assert.equal(state(needs(prod, req("deploy-site", "jer"))), "fresh"); + const newer = input({ sites: { jer: target({ built: builtStamp({ stampId: "b2" }), deployed: deployed() }) } }); + assert.equal(reason(needs(newer, req("deploy-site", "jer"))), "a newer build is not deployed"); + // The production record says nothing about a preview, or the local copy. + assert.equal(reason(needs(prod, req("deploy-site", "jer", { preview: "r18" }))), 'never deployed to preview "r18"'); + assert.equal(reason(needs(prod, req("deploy-site", "jer", { to: "local" }))), "never deployed (local)"); + const pv = input({ + sites: { jer: target({ built: builtStamp(), deployed: deployed({ previews: { r18: deployed().production! } }) }) }, + }); + assert.equal(state(needs(pv, req("deploy-site", "jer", { preview: "r18" }))), "fresh"); + assert.equal(reason(needs(prod, req("deploy-site", "jer", { force: true }))), "forced"); +}); + +test("deploy-site: refused with no build, a private site, no project (pages only), a bad bundle, builtAfter", () => { + const never = input({ sites: { jer: target() } }); + assert.equal(reason(needs(never, req("deploy-site", "jer"))), "no build of jer — archilyzer publish build jer"); + const priv = input({ sites: { jer: target({ built: builtStamp(), deployProblem: "Site jer is private" }) } }); + assert.equal(reason(needs(priv, req("deploy-site", "jer", { to: "local" }))), "Site jer is private"); + const noProject = input({ sites: { jer: target({ built: builtStamp(), pagesProblem: "no project" }) } }); + assert.equal(reason(needs(noProject, req("deploy-site", "jer"))), "no project"); + assert.equal(state(needs(noProject, req("deploy-site", "jer", { to: "local" }))), "stale", "local needs no project"); + const bad = input({ sites: { jer: target({ built: builtStamp(), bundleProblem: "torn" }) } }); + assert.deepEqual(needs(bad, req("deploy-site", "jer")), { state: "blocked", reason: "torn" }); + assert.match(reason(needs(input(), req("deploy-site", "jer", { builtAfter: 3_001 }))), /waiting for the build of jer/); + assert.equal(state(needs(never, req("deploy-site", "jer", { force: true }))), "blocked", "force never deploys nothing"); +}); + +test("deploy-site: production refuses a build made on another branch; a preview of it is fine", () => { + const off = input({ sites: { jer: target({ built: builtStamp({ branch: "r18/stage-core" }) }) } }); + assert.match(reason(needs(off, req("deploy-site", "jer"))), /built from branch "r18\/stage-core"; production ships only a build of main/); + assert.equal(state(needs(off, req("deploy-site", "jer", { preview: "r18" }))), "stale"); + const detached = input({ sites: { jer: target({ built: builtStamp({ branch: null }) }) } }); + assert.equal(state(needs(detached, req("deploy-site", "jer"))), "stale", "no branch (a container): allowed"); +}); + +// --- the hub ------------------------------------------------------------------ + +test("build-hub: fresh when built.inputSig is the stamp's hubSig", () => { + assert.equal(state(needs(input(), req("build-hub", "_hub"))), "fresh"); + const moved = input({ index: { stamp: indexStamp({ hubSig: "hub-2" }), lastIngestDoneAt: null, configChangedAt: null } }); + assert.equal(reason(needs(moved, req("build-hub", "_hub"))), "the index or the pool it lists changed"); + const ch = input({ hub: target({ built: builtStamp({ target: "_hub", kind: "hub", inputSig: "hub-1" }), changedChannels: ["x"] }) }); + assert.equal(reason(needs(ch, req("build-hub", "_hub"))), "1 channel changed (x)"); + const none = input({ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null } }); + assert.equal(reason(needs(none, req("build-hub", "_hub"))), "update the index first"); +}); + +test("deploy-hub: as deploy-site", () => { + const s = input({ hub: target({ built: builtStamp({ target: "_hub", kind: "hub" }), deployed: deployed({ target: "_hub" }) }) }); + assert.equal(state(needs(s, req("deploy-hub", "_hub"))), "fresh"); + assert.equal(reason(needs(input({ hub: target() }), req("deploy-hub", "_hub"))), "no build of the hub — archilyzer publish hub"); + const noProject = input({ hub: target({ built: builtStamp(), pagesProblem: "The hub has no Cloudflare Pages project" }) }); + assert.match(reason(needs(noProject, req("deploy-hub", "_hub"))), /no Cloudflare Pages project/); +}); + +// --- the homepage ------------------------------------------------------------- + +test("build-homepage: fresh when built from the current index stamp and main has not moved", () => { + assert.equal(state(needs(input(), req("build-homepage", "_homepage"))), "fresh"); + const idx = input({ index: { stamp: indexStamp({ stampId: "s2" }), lastIngestDoneAt: null, configChangedAt: null } }); + assert.equal(reason(needs(idx, req("build-homepage", "_homepage"))), "the index was updated"); + const moved = input(); + moved.homepage.mainHead = "main-2"; + assert.equal(reason(needs(moved, req("build-homepage", "_homepage"))), "main has moved since the source was published"); + const noRepo = input(); + noRepo.homepage.mainHead = null; + noRepo.homepage.built = { ...(noRepo.homepage.built as BuiltStamp), sourceCommit: null }; + assert.equal(state(needs(noRepo, req("build-homepage", "_homepage"))), "fresh", "no repository: the index decides"); +}); + +test("deploy-homepage: as deploy-site", () => { + const s = input(); + s.homepage.deployed = deployed({ target: "_homepage" }); + assert.equal(state(needs(s, req("deploy-homepage", "_homepage"))), "fresh"); + s.homepage.built = null; + assert.equal(reason(needs(s, req("deploy-homepage", "_homepage"))), "no build of the homepage — archilyzer publish homepage"); +}); + +test("deployKindOf: --to local, --preview, else production", () => { + assert.equal(deployKindOf({}), "production"); + assert.equal(deployKindOf({ preview: "x" }), "preview"); + assert.equal(deployKindOf({ to: "local" }), "local"); + assert.equal(deployKindOf({ to: "pages" }), "production"); +}); + +// --- argv ------------------------------------------------------------------- + +test("argv: the child's command line, pinned, and parsed back to the same request", () => { + const r: StageRequest = { + kind: "deploy-site", + target: "jer", + runId: "run-9", + preview: "r18", + force: true, + builtAfter: 1_700_000_000_000, + }; + assert.deepEqual(STAGES["deploy-site"].argv(r), [ + "stage", "deploy-site", "jer", "--run-id", "run-9", "--preview", "r18", "--force", "--built-after", "1700000000000", + ]); + const booleans = Object.entries(STAGE_FLAGS).filter(([, k]) => k === "boolean").map(([n]) => n); + for (const sample of [ + r, + { kind: "update-index", target: "_index", runId: "a" }, + { kind: "build-site", target: "_all", runId: "b", runner: "docker", skipArchives: true, indexAfter: 5 }, + { kind: "build-site", target: "ani", runId: "c", allowMissingMedia: true }, + { kind: "deploy-homepage", target: "_homepage", runId: "d", to: "local" }, + ] as StageRequest[]) { + const { positionals, flags } = parseArgv(stageArgv(sample), booleans); + assert.equal(positionals[0], "stage"); + assert.deepEqual(parseStageArgs(positionals.slice(1), flags), sample); + } +}); + +test("parseStageArgs refuses what the child must never guess", () => { + const err = (pos: string[], flags: Record<string, string | boolean> = { "run-id": "r" }) => { + const out = parseStageArgs(pos, flags); + return "error" in out ? out.error : ""; + }; + assert.match(err(["build"]), /which stage\?/); + assert.match(err(["build-site"]), /which target\?/); + assert.match(err(["build-site", "jer"], {}), /--run-id is required/); + assert.match(err(["update-index", "jer"]), /the target is _index/); + assert.match(err(["build-hub", "_index"]), /the target is _hub/); + assert.match(err(["deploy-site", "_all"]), /the target is a site id/); + assert.match(err(["deploy-site", "jer"], { "run-id": "r", to: "ftp" }), /--to is pages or local/); + assert.match(err(["build-site", "_all"], { "run-id": "r", runner: "podman" }), /--runner is local or docker/); + assert.match(err(["build-site", "jer"], { "run-id": "r", "index-after": "soon" }), /--index-after is a time/); + assert.match(err(["deploy-site", "jer"], { "run-id": "r", preview: "x", to: "local" }), /two different deploys/); + assert.match(err(["build-site", "../etc"]), /which target\?/); +}); + +// --- the spawned command ------------------------------------------------------ + +test("stageCommand: common's tsx running bin/archilyzer.ts stage …, cwd common/, the heap cap for update-index only", () => { + const paths = { monorepoRoot: "/repo" } as Paths; + const idx = stageCommand(paths, { kind: "update-index", target: "_index", runId: "r1" }, { PATH: "/bin" }); + assert.deepEqual(idx, { + command: "/repo/common/node_modules/.bin/tsx", + args: ["bin/archilyzer.ts", "stage", "update-index", "_index", "--run-id", "r1"], + cwd: "/repo/common", + env: { PATH: "/bin", NODE_OPTIONS: "--max-old-space-size=8192" }, + }); + const kept = stageCommand(paths, { kind: "update-index", target: "_index", runId: "r1" }, { NODE_OPTIONS: "--trace-warnings" }); + assert.equal(kept.env.NODE_OPTIONS, "--trace-warnings --max-old-space-size=8192"); + const build = stageCommand(paths, { kind: "build-site", target: "jer", runId: "r1" }, { PATH: "/bin" }); + assert.deepEqual(build.env, { PATH: "/bin" }); + assert.deepEqual(build.args, ["bin/archilyzer.ts", "stage", "build-site", "jer", "--run-id", "r1"]); +}); + +test("the exit codes", () => { + assert.deepEqual(STAGE_EXIT, { ok: 0, failed: 1, usage: 2, precondition: 3, cancelled: 130 }); +});