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 = { 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 = {}): 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 = {}, 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"), ""); 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 no-op build records that it checked (checkedAt), and nothing else. const checked = await stamps.readBuiltStamp(paths, "jer"); assert.equal(checked?.stampId, "b-jer"); assert.equal(typeof checked?.checkedAt, "number"); assert.equal(checked?.inputSig, stamp.sites.jer.inputSig); // 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 }); // What an earlier local deploy left (a bundle: it has an index.html — a // destination without one is refused, deployStage.ts localDestProblem). writeFileSync(path.join(siteOut, "index.html"), "old"); 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"); // The copy happened: the bundle's own files arrived (the destination had // neither), its index.html replaced the seeded one, and the stale file went. assert.ok(existsSync(path.join(siteOut, "corpus.json"))); assert.ok(existsSync(path.join(siteOut, "site.json"))); assert.notEqual(readFileSync(path.join(siteOut, "index.html"), "utf8"), "old"); 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: Date.now() }), ); try { const ac = new AbortController(); const logs: string[] = []; const running = runStage( { kind: "build-site", target: "jer", runId: "t" }, { paths, signal: ac.signal, // Cancel once the wait is announced — a fixed delay raced the announcement under load. onLog: (l) => { logs.push(l); if (/waiting for the publish lock/.test(l)) ac.abort(); }, lockEnv: { pollMs: 5 }, }, ); 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"), ""); }); test("stamps' commit/branch: ARCHILYZER_COMMIT / ARCHILYZER_BRANCH win over git, each on its own; no repository is null", async () => { const { checkoutInfo } = await import("./stageBodies"); const repo = { monorepoRoot: path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..") }; const fromGit = await checkoutInfo(repo, {}); assert.match(fromGit.commit ?? "", /^[0-9a-f]{40}$/); assert.deepEqual(await checkoutInfo(repo, { ARCHILYZER_BRANCH: "main", ARCHILYZER_COMMIT: "abc" }), { commit: "abc", branch: "main", }); const branchOnly = await checkoutInfo(repo, { ARCHILYZER_BRANCH: "main" }); assert.equal(branchOnly.branch, "main"); assert.equal(branchOnly.commit, fromGit.commit); const noRepo = { monorepoRoot: ROOT }; assert.deepEqual(await checkoutInfo(noRepo, {}), { commit: null, branch: null }); assert.deepEqual(await checkoutInfo(noRepo, { ARCHILYZER_COMMIT: "c1", ARCHILYZER_BRANCH: "main" }), { commit: "c1", branch: "main", }); }); test("publish deploy all: only a private site and a site with no Pages project are skipped; any other refusal fails the run (exit 1) after the rest are tried", async () => { const { publishDeploy } = await import("../bin/publish"); writeSite("mine", { audience: "private", cloudflareProject: "w3c-never-real" }); writeSite("noproj"); writeSite("unbuilt", { cloudflareProject: "w3c-never-real" }); const siteOut = path.join(ROOT, "builds", "site-all"); process.env.ARCHILYZER_SITE_OUT = siteOut; const built = (await stamps.readBuiltStamp(paths, "jer"))!; await stamps.writeBuiltStamp(paths, { ...built, branch: "main" }); try { const lines: string[] = []; const out = { log: (l: string) => lines.push(l), error: (l: string) => lines.push(l) }; // Pages: private and no-project are skipped quietly; jer and unbuilt are // TRIED (jer: never deployed there, w3c-never-real is no real project — // its deploy is refused at wrangler or before; unbuilt: exit 3). const local = await publishDeploy({ target: "all", to: "local", force: true, paths, signal: new AbortController().signal }, out); assert.equal(local, 1, lines.join("\n")); assert.ok(lines.includes("[publish] mine: skipped — " + 'Site "mine" is private (audience: private): it is built for reading on this machine and is never deployed. Build it without deploying, or set its audience to public on its Settings tab')); assert.ok(!lines.some((l) => l.startsWith("[publish] noproj: skipped")), "--to local needs no project"); assert.ok(existsSync(path.join(siteOut, "index.html")), "jer was still deployed"); const local2 = stamps.deployRecordFor(await stamps.readDeployedFile(paths, "unbuilt"), "local"); assert.equal(local2, null, "unbuilt was refused"); lines.length = 0; // Only never-deployable sites skipped: an all of nothing but those is exit 0. rmSync(path.join(PINNED.SITES_DIR, "unbuilt"), { recursive: true }); rmSync(path.join(PINNED.SITES_DIR, "noproj"), { recursive: true }); const onlyOk = await publishDeploy({ target: "all", to: "local", paths, signal: new AbortController().signal }, out); assert.equal(onlyOk, 0, lines.join("\n")); // To Pages: a site with no project is skipped too. (jer's build is taken // away first, so its refusal comes before any wrangler.) writeSite("noproj"); rmSync(stamps.builtStampPath(paths, "jer")); lines.length = 0; const pages = await publishDeploy({ target: "all", preview: "r18", paths, signal: new AbortController().signal }, out); assert.equal(pages, 1, "jer, never built, is a failure"); assert.ok(lines.includes("[publish] noproj: skipped — no Cloudflare Pages project"), lines.join("\n")); } finally { delete process.env.ARCHILYZER_SITE_OUT; for (const id of ["mine", "noproj", "unbuilt"]) rmSync(path.join(PINNED.SITES_DIR, id), { recursive: true, force: true }); } });