import { test } from "node:test"; import assert from "node:assert/strict"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, } from "node:fs"; import os from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; import { HOMEPAGE_PAGES_PROJECT, buildHomepage, buildHubSteps, buildSiteSteps, hubProjectProblem, homepageOutDir, dockerSiteOutDir, dockerSiteStagingDir, heavyGated, resolveOutDir, } from "./build"; // Run with: // pnpm --filter yt-dlp-transcript-common test // // The three pure path helpers of the publish layer. Their outputs are the // on-disk contract the build and deploy phases share (and that // `docker/build-site.sh` mounts), so they are pinned here exactly. const paths = { exportDir: "/repo/export", exportBuildsDir: "/repo/export/.export-builds", exportPublicDir: "/repo/export/public", transcriptsDir: "/data/transcripts", } as Paths; test("resolveOutDir is export/out whatever the site", () => { assert.equal(resolveOutDir("jeralyzer", paths), "/repo/export/out"); assert.equal(resolveOutDir("anilyzer", paths), "/repo/export/out"); }); test("dockerSiteOutDir is a per-site out/ under exportBuildsDir", () => { assert.equal( dockerSiteOutDir(paths, "jeralyzer"), "/repo/export/.export-builds/jeralyzer/out", ); assert.notEqual( dockerSiteOutDir(paths, "jeralyzer"), dockerSiteOutDir(paths, "anilyzer"), ); }); test("dockerSiteStagingDir nests .r2-staging//archives under the site's build dir", () => { assert.equal( dockerSiteStagingDir(paths, "jeralyzer"), "/repo/export/.export-builds/jeralyzer/.r2-staging/jeralyzer/archives", ); }); // The basic build of one site, as the children it runs. Pinned because the // data phase used to be npm's `prebuild` hook on a script called `build`, and // `build:nodata` (the same body under another name) was how it was skipped — // both scripts are gone, so this list is now the whole contract. test("buildSiteSteps: data phase, compose, next build — all in export/, one env", () => { const steps = buildSiteSteps({ siteId: "jeralyzer", paths, baseEnv: { PATH: "/bin" }, }); const env = { PATH: "/bin", NODE_ENV: "production", TRANSCRIPTS_DIR: "/data/transcripts", EXPORT_PUBLIC_DIR: "/repo/export/public", SITE_ID: "jeralyzer", }; assert.deepEqual(steps, [ { command: "pnpm", args: ["run", "build:data"], cwd: "/repo/export", env }, { command: "pnpm", args: ["run", "compose:site"], cwd: "/repo/export", env }, { command: "pnpm", args: ["exec", "next", "build"], cwd: "/repo/export", env }, ]); }); test("buildSiteSteps: skipData drops the data phase; skipArchives sets BUILD_ARCHIVES=0", () => { const steps = buildSiteSteps({ siteId: "anilyzer", paths, skipData: true, skipArchives: true, baseEnv: {}, }); assert.deepEqual( steps.map((s) => s.args.join(" ")), ["run compose:site", "exec next build"], ); for (const s of steps) { assert.equal(s.env.BUILD_ARCHIVES, "0"); assert.equal(s.env.SITE_ID, "anilyzer"); } assert.equal( buildSiteSteps({ siteId: "a", paths, baseEnv: {} })[0].env.BUILD_ARCHIVES, undefined, ); }); test("buildSiteSteps: allowMissingMedia lets compose through a report citation with no prepared media", () => { const [compose] = buildSiteSteps({ siteId: "a", paths, skipData: true, allowMissingMedia: true, baseEnv: {} }); assert.equal(compose.args.join(" "), "run compose:site"); assert.equal(compose.env.REPORTS_ALLOW_MISSING_MEDIA, "1"); assert.equal( buildSiteSteps({ siteId: "a", paths, baseEnv: {} })[0].env.REPORTS_ALLOW_MISSING_MEDIA, undefined, ); }); test("EXPORT_NEXT_BIN replaces `pnpm exec next build` in a site's and the hub's build (the e2e fake)", () => { const p = { exportDir: "/x/export", transcriptsDir: "/x/t", exportPublicDir: "/x/t/.p" } as Paths; const site = buildSiteSteps({ siteId: "jer", paths: p, skipData: true, baseEnv: { EXPORT_NEXT_BIN: "/bin/fake-next" } }); assert.deepEqual(site.map((s) => [s.command, ...s.args]), [ ["pnpm", "run", "compose:site"], ["/bin/fake-next", "build"], ]); const hub = buildHubSteps({ paths: p, baseEnv: { EXPORT_NEXT_BIN: "/bin/fake-next" } }); assert.deepEqual(hub[1].command, "/bin/fake-next"); assert.equal(hub[1].env?.INSTANCE_MODE, "hub"); const plain = buildSiteSteps({ siteId: "jer", paths: p, skipData: true, baseEnv: {} }); assert.deepEqual(plain[1].args, ["exec", "next", "build"]); }); // A real `next build` runs through the heavy slot (scripts/queue-lock.mjs // --heavy): one heavy job machine-wide, above the memory floor. The e2e fake is // never gated, and a root without the gate script (every pure test above, whose // /repo does not exist) runs the step unchanged. test("a real next build goes through the heavy slot; the e2e fake and a root without the gate do not", () => { const root = mkdtempSync(path.join(os.tmpdir(), "build-heavy-")); try { mkdirSync(path.join(root, "scripts")); const gate = path.join(root, "scripts", "queue-lock.mjs"); writeFileSync(gate, ""); const p = { ...paths, monorepoRoot: root, exportDir: path.join(root, "export") } as Paths; const [, next] = buildSiteSteps({ siteId: "jer", paths: p, skipData: true, baseEnv: {} }); assert.equal(next.command, process.execPath); assert.deepEqual(next.args, [gate, "--heavy", "--", "pnpm", "exec", "next", "build"]); assert.equal(next.cwd, path.join(root, "export")); const hub = buildHubSteps({ paths: p, baseEnv: {} }); assert.deepEqual(hub[1].args.slice(0, 3), [gate, "--heavy", "--"]); assert.equal(hub[1].env.INSTANCE_MODE, "hub"); const fake = buildSiteSteps({ siteId: "jer", paths: p, skipData: true, baseEnv: { EXPORT_NEXT_BIN: "/bin/fake-next" } }); assert.deepEqual([fake[1].command, ...fake[1].args], ["/bin/fake-next", "build"]); const step = { command: "pnpm", args: ["x"], cwd: "/", env: {} }; assert.equal(heavyGated({ monorepoRoot: path.join(root, "nope") }, step), step); } finally { rmSync(root, { recursive: true, force: true }); } }); test("buildHubSteps: compose:hub, then next build with INSTANCE_MODE=hub, in export/", () => { const steps = buildHubSteps({ paths, baseEnv: { PATH: "/bin" } }); const env = { PATH: "/bin", NODE_ENV: "production", TRANSCRIPTS_DIR: "/data/transcripts", EXPORT_PUBLIC_DIR: "/repo/export/public", }; assert.deepEqual(steps, [ { command: "pnpm", args: ["run", "compose:hub"], cwd: "/repo/export", env }, { command: "pnpm", args: ["exec", "next", "build"], cwd: "/repo/export", env: { ...env, INSTANCE_MODE: "hub" }, }, ]); }); // The hub and the homepage are two Pages projects. homepage.json's project was // "archilyzer" — the homepage's — before the hub could deploy, so that value // is refused by name rather than trusted. test("hubProjectProblem refuses a missing project and the homepage's", () => { assert.equal(HOMEPAGE_PAGES_PROJECT, "archilyzer"); assert.equal( hubProjectProblem(undefined), "The hub has no Cloudflare Pages project configured — set it on /sites under Hub.", ); assert.equal(hubProjectProblem(" "), hubProjectProblem(undefined)); assert.match(hubProjectProblem("archilyzer")!, /homepage's — set the hub's own project/); assert.equal(hubProjectProblem("archilyzer-hub"), null); }); // The build-homepage stage writes and the deploy stage ships this path, and // both judge "is there a build?" on it. test("homepageOutDir is homepage/out of the checkout", () => { assert.equal(homepageOutDir({ monorepoRoot: "/repo" } as Paths), "/repo/homepage/out"); }); // buildHomepage's order — compose, the source step, `next build` — over a temp // homepage/ whose `compose` is `true` and whose `next` only says it ran. test("buildHomepage: the source step sits between compose and next build; a refusal stops the build; --no-source clears instead", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "build-homepage-")); try { const home = path.join(root, "homepage"); mkdirSync(path.join(home, "node_modules", ".bin"), { recursive: true }); writeFileSync( path.join(home, "package.json"), JSON.stringify({ name: "fake-homepage", private: true, scripts: { compose: "echo COMPOSED" } }), ); const next = path.join(home, "node_modules", ".bin", "next"); writeFileSync(next, "#!/bin/sh\necho NEXT RAN\n"); chmodSync(next, 0o755); const fakePaths = { monorepoRoot: root } as Paths; // The last good build's source, in out/ (M1: a refusal must take it out). const out = path.join(home, "out"); const plantOut = () => { mkdirSync(path.join(out, "source", "archilyzer.git"), { recursive: true }); mkdirSync(path.join(out, "downloads"), { recursive: true }); writeFileSync(path.join(out, "source", "manifest.json"), "{}"); writeFileSync(path.join(out, "downloads", "archilyzer-source.tar.gz"), "old"); writeFileSync(path.join(out, "downloads", "snapshot.json"), "{}"); writeFileSync(path.join(out, "downloads", "index.html"), "

the page

"); }; const run = async (o: Parameters[0]) => { const logs: string[] = []; const code = await buildHomepage({ paths: fakePaths, onLog: (l) => logs.push(l), ...o }); return { code, logs: logs.join("\n") }; }; const calls: string[] = []; // A refusal (1) fails the build before next build runs, and takes the // last build's source out of homepage/out, so a deploy-only ships none. plantOut(); let r = await run({ publishSource: async (p) => { calls.push(`publish:${p.paths === fakePaths}:${p.noRepository}`); return 1; }, }); assert.equal(r.code, 1); assert.match(r.logs, /COMPOSED/); assert.doesNotMatch(r.logs, /NEXT RAN/); assert.deepEqual(calls, ["publish:true:empty"], "a checkout with no git builds with the empty state"); assert.ok(!existsSync(path.join(out, "source")), "out/source is withdrawn"); assert.ok(!existsSync(path.join(out, "downloads", "archilyzer-source.tar.gz"))); assert.ok(!existsSync(path.join(out, "downloads", "snapshot.json"))); assert.ok(existsSync(path.join(out, "downloads", "index.html")), "the page itself stays"); assert.match(r.logs, /removed from homepage\/out too/); // Published: next build follows. r = await run({ publishSource: async () => (calls.push("publish"), 0) }); assert.equal(r.code, 0, r.logs); assert.ok(r.logs.indexOf("COMPOSED") < r.logs.indexOf("NEXT RAN")); // --no-source: the publish is never called; the clear is. calls.length = 0; r = await run({ skipSource: true, publishSource: async () => { throw new Error("--no-source must not publish"); }, clearSource: async () => { calls.push("clear"); }, }); assert.equal(r.code, 0, r.logs); assert.deepEqual(calls, ["clear"]); assert.match(r.logs, /NEXT RAN/); } finally { rmSync(root, { recursive: true, force: true }); } });