import { test } from "node:test"; import assert from "node:assert/strict"; import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { ENV_AUDIENCES, ENV_VARS, renderEnvironmentMarkdown } from "./envVars"; // Run with: // pnpm --filter yt-dlp-transcript-common test // // envVars.ts is the one declared list of environment variables, and // ENVIRONMENT.md is generated from it. These tests are what keep the list true: // the code is read as text, in both directions. const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); // Where the apps' code lives. umtool is out of scope on purpose (envVars.ts // says why); tests are skipped because a test SETS variables for itself. const CODE_ROOTS = ["common", "editor", "export", "homepage", "mcp/src", "scripts"]; const SKIP_DIRS = new Set(["node_modules", ".next", "out", "public", "test-results", "blob-report", "test-transcripts"]); // The platform's own variables: read here, documented by node, Next, a shell, // Playwright (TEST_WORKER_INDEX, which it sets in each worker process; the // two-origin config reads it to stage only in the runner). const PLATFORM = new Set(["CI", "NODE_ENV", "NEXT_RUNTIME", "LD_LIBRARY_PATH", "PATH", "HOME", "NODE_OPTIONS", "TEST_WORKER_INDEX"]); function codeFiles(): string[] { const out: string[] = []; const walk = (dir: string) => { for (const n of readdirSync(dir)) { if (SKIP_DIRS.has(n) || n.startsWith(".")) continue; const p = path.join(dir, n); if (statSync(p).isDirectory()) walk(p); else if (/\.(ts|tsx|mts|mjs|js)$/.test(n) && !/\.test\.(ts|mjs)$/.test(n)) out.push(p); } }; for (const r of CODE_ROOTS) walk(path.join(REPO, r)); return out; } // Comment lines are dropped first: prose that names `process.env.NAME` as an // example is not a read. function codeText(file: string): string { return readFileSync(file, "utf8") .split("\n") .filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)) .join("\n"); } const READ_PATTERNS = [ /process\.env\??\.([A-Z][A-Z0-9_]+)\b/g, /process\.env\[["']([A-Z][A-Z0-9_]+)["']\]/g, // `env.X` on an env object handed in (a spawn's env, a testable `env = // process.env` parameter). /\b[eE]nv\??\.([A-Z][A-Z0-9_]{2,})\b/g, // audioCheckedDownload.ts reads its overrides through helpers. /env(?:Int|Float|Bool)Override\(\s*["']([A-Z][A-Z0-9_]+)["']\s*\)/g, // run-sharded-e2e.mjs reads a flag's env twin by name. /parseIntArg\(\s*["'][^"']+["'],\s*["']([A-Z][A-Z0-9_]+)["']/g, ]; function reads(): Map> { const byName = new Map>(); for (const file of codeFiles()) { const text = codeText(file); for (const re of READ_PATTERNS) { for (const m of text.matchAll(re)) { const name = m[1]; if (PLATFORM.has(name)) continue; if (!byName.has(name)) byName.set(name, new Set()); byName.get(name)!.add(path.relative(REPO, file)); } } } return byName; } test("ENVIRONMENT.md is what envVars.ts generates", () => { assert.equal( readFileSync(path.join(REPO, "ENVIRONMENT.md"), "utf8"), renderEnvironmentMarkdown(), "ENVIRONMENT.md is stale: run `archilyzer docs env`", ); }); test("every variable the code reads is declared", () => { const declared = new Set(ENV_VARS.map((v) => v.name)); const missing = [...reads()] .filter(([name]) => !declared.has(name)) .map(([name, files]) => `${name} (read by ${[...files].join(", ")})`); assert.deepEqual(missing, [], "declare these in common/lib/envVars.ts"); }); // docker/'s files, the Dockerfiles and the compose files: where the // container's own ARCHILYZER_* set is read. function dockerTexts(): string[] { return [ ...readdirSync(path.join(REPO, "docker")) .filter((n) => statSync(path.join(REPO, "docker", n)).isFile()) .map((n) => readFileSync(path.join(REPO, "docker", n), "utf8")), ...readdirSync(REPO) .filter((n) => /^docker-compose.*\.yml$|^Dockerfile/.test(n)) .map((n) => readFileSync(path.join(REPO, n), "utf8")), ]; } test("every ARCHILYZER_* name in docker/, the Dockerfiles and the compose files is declared", () => { const declared = new Set(ENV_VARS.map((v) => v.name)); const names = new Set(dockerTexts().flatMap((t) => [...t.matchAll(/\bARCHILYZER_[A-Z0-9_]+\b/g)].map((m) => m[0]))); assert.deepEqual([...names].filter((n) => !declared.has(n)).sort(), [], "declare these in common/lib/envVars.ts"); }); test("every declared variable is still named outside the list: the code, docker/, a Dockerfile, a compose file or a script", () => { // envVars.ts itself is left out: every declared name is spelled there, so // with it in the corpus this test could never fail. const corpus = [ ...codeFiles() .filter((f) => !f.endsWith(path.join("common", "lib", "envVars.ts"))) .map((f) => readFileSync(f, "utf8")), ...dockerTexts(), ...["", "editor", "export", "homepage"].map((d) => readFileSync(path.join(REPO, d, "package.json"), "utf8"), ), ].join("\n"); const stale = ENV_VARS.filter((v) => !new RegExp(`\\b${v.name}\\b`).test(corpus)).map((v) => v.name); assert.deepEqual(stale, [], "nothing mentions these any more: delete their entries"); }); test("the paths audience is exactly what getPaths() reads", () => { const pathsTs = codeText(path.join(REPO, "common/lib/paths.ts")); const inPaths = new Set([...pathsTs.matchAll(/process\.env\.([A-Z][A-Z0-9_]+)/g)].map((m) => m[1])); const declared = new Set(ENV_VARS.filter((v) => v.audience === "paths").map((v) => v.name)); assert.deepEqual([...declared].filter((n) => !inPaths.has(n)), [], "declared paths but not read by getPaths()"); assert.deepEqual([...inPaths].filter((n) => !declared.has(n)), [], "read by getPaths() but not declared as paths"); }); test("names are unique and every audience has a section", () => { const names = ENV_VARS.map((v) => v.name); assert.equal(new Set(names).size, names.length); const audiences = new Set(ENV_AUDIENCES.map((a) => a.id)); for (const v of ENV_VARS) assert.ok(audiences.has(v.audience), v.name); const md = renderEnvironmentMarkdown(); for (const v of ENV_VARS) assert.ok(md.includes(`| \`${v.name}\` |`), v.name); }); test("the docker audience is the ARCHILYZER_ set", () => { for (const v of ENV_VARS.filter((x) => x.audience === "docker")) { assert.match(v.name, /^ARCHILYZER_/); } }); // THE TEST AUDIENCE IS E2E_-PREFIXED AND DECLARED IN A PLAYWRIGHT CONFIG // (one-core Phase 4 slice 3). The two exceptions keep names others depend on: // Playwright's own convention, and the machine-global queue's nesting marker // (its protocol is shared with checkouts on older code). // The heavy slot's seams are named for the gate, not for e2e: the gate runs // builds and renders too, and its tests are what set them. const UNPREFIXED_TEST_VARS = new Set([ "PLAYWRIGHT_BASE_URL", "QUEUE_LOCK_HELD", "HEAVY_HELD", "HEAVY_LOCK_FILE", "HEAVY_MEMINFO_FILE", "HEAVY_POLL_MS", ]); test("every test-only variable carries the E2E_ prefix", () => { const bad = ENV_VARS.filter( (v) => v.audience === "test" && !v.name.startsWith("E2E_") && !UNPREFIXED_TEST_VARS.has(v.name), ).map((v) => v.name); assert.deepEqual(bad, []); // And nothing outside the test audience wears the prefix. assert.deepEqual( ENV_VARS.filter((v) => v.audience !== "test" && v.name.startsWith("E2E_")).map((v) => v.name), [], ); }); test("every test-only variable is declared in a playwright config (the queue's own in queue-lock.mjs)", () => { const configs = ["editor", "export", "homepage", "umtool"].flatMap((pkg) => readdirSync(path.join(REPO, pkg)) .filter((n) => /^playwright.*\.config\.ts$/.test(n)) .map((n) => readFileSync(path.join(REPO, pkg, n), "utf8")), ).join("\n"); const queue = readFileSync(path.join(REPO, "scripts", "queue-lock.mjs"), "utf8"); const missing = ENV_VARS.filter((v) => v.audience === "test") .filter((v) => { const home = v.readBy.includes("scripts/queue-lock.mjs") ? queue : configs; return !new RegExp(`\\b${v.name}\\b`).test(home); }) .map((v) => v.name); assert.deepEqual(missing, []); });