import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { collectEnvVarNames, isCountedSourcePath, main, parseBudget, } from "../../scripts/check-env-var-count.mts"; const tempDirs: string[] = []; afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { force: true, recursive: true }); } }); describe("check-env-var-count", () => { it("counts production source and excludes tests and QA Lab", () => { expect(isCountedSourcePath("src/config/paths.ts")).toBe(true); expect(isCountedSourcePath("packages/api/src/index.ts")).toBe(true); expect(isCountedSourcePath("extensions/demo/src/index.ts")).toBe(true); expect(isCountedSourcePath("src/config/paths.test.ts")).toBe(false); expect(isCountedSourcePath("extensions/qa-lab/src/index.ts")).toBe(false); }); it("collects each distinct name once", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-")); tempDirs.push(root); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync( path.join(root, "src/runtime.ts"), 'const a = process.env.OPENCLAW_ALPHA; const b = "OPENCLAW_ALPHA OPENCLAW_BETA";\n', ); fs.writeFileSync(path.join(root, "src/runtime.test.ts"), "OPENCLAW_TEST_ONLY\n"); execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); expect(collectEnvVarNames(root)).toEqual(["OPENCLAW_ALPHA", "OPENCLAW_BETA"]); fs.rmSync(path.join(root, "src/runtime.ts")); expect(collectEnvVarNames(root)).toEqual([]); }); it("parses exactly one integer budget", () => { expect(parseBudget("# count\n42\n")).toBe(42); expect(() => parseBudget("42\n43\n")).toThrow(); expect(() => parseBudget("many\n")).toThrow(); }); it("reads staged source from the index", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-staged-")); tempDirs.push(root); fs.mkdirSync(path.join(root, "src"), { recursive: true }); const sourcePath = path.join(root, "src/runtime.ts"); fs.writeFileSync(sourcePath, "process.env.OPENCLAW_STAGED;\n"); execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); execFileSync("git", ["add", "src/runtime.ts"], { cwd: root, stdio: "ignore" }); fs.writeFileSync(sourcePath, "process.env.OPENCLAW_WORKTREE;\n"); expect(collectEnvVarNames(root, { staged: true })).toEqual(["OPENCLAW_STAGED"]); expect(collectEnvVarNames(root)).toEqual(["OPENCLAW_WORKTREE"]); }); it("fails closed when the base ref cannot be resolved", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-base-")); tempDirs.push(root); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "0\n"); execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); expect(() => main(["--base", "missing"], root)).toThrow(/Could not resolve/u); }); it("still checks the budget when the base shares no reachable ancestor", () => { // Shallow clones and grafted agent checkouts resolve origin/main but truncate the // history behind it, which used to fail the whole changed-file gate. const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-shallow-")); tempDirs.push(root); const git = (...args: string[]) => execFileSync( "git", ["-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", ...args], { cwd: root, stdio: "ignore" }, ); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "1\n"); fs.writeFileSync(path.join(root, "src/runtime.ts"), "process.env.OPENCLAW_ONLY;\n"); git("init"); git("add", "."); git("commit", "-m", "detached base"); // Name the base explicitly; init.defaultBranch varies by environment. git("branch", "-M", "severed-base"); git("checkout", "--orphan", "severed"); git("add", "."); git("commit", "-m", "severed history"); expect(() => main(["--base", "severed-base"], root)).not.toThrow(); // The absolute budget check must still run without a baseline. fs.writeFileSync( path.join(root, "src/runtime.ts"), "process.env.OPENCLAW_ONE; process.env.OPENCLAW_TWO;\n", ); expect(() => main(["--base", "severed-base"], root)).toThrow(/exceeds budget/u); }); it("compares against the fork budget when the base branch later shrinks", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-fork-")); tempDirs.push(root); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "2\n"); fs.writeFileSync( path.join(root, "src/runtime.ts"), "process.env.OPENCLAW_ONE; process.env.OPENCLAW_TWO;\n", ); execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" }); execFileSync( "git", ["-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", "commit", "-m", "base"], { cwd: root, stdio: "ignore" }, ); execFileSync("git", ["branch", "release"], { cwd: root, stdio: "ignore" }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "1\n"); fs.writeFileSync(path.join(root, "src/runtime.ts"), "process.env.OPENCLAW_ONE;\n"); execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" }); execFileSync( "git", [ "-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", "commit", "-m", "shrink main", ], { cwd: root, stdio: "ignore" }, ); execFileSync("git", ["branch", "moving-main"], { cwd: root, stdio: "ignore" }); execFileSync("git", ["checkout", "release"], { cwd: root, stdio: "ignore" }); expect(() => main(["--base", "moving-main"], root)).not.toThrow(); }); it("rejects growth above the budget", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-grow-")); tempDirs.push(root); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "1\n"); fs.writeFileSync( path.join(root, "src/runtime.ts"), "process.env.OPENCLAW_ONE; process.env.OPENCLAW_TWO;\n", ); execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" }); execFileSync( "git", ["-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", "commit", "-m", "base"], { cwd: root, stdio: "ignore" }, ); expect(() => main(["--base", "HEAD"], root)).toThrow(/exceeds budget|over budget/u); }); it("passes when the count exactly matches the budget", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-exact-")); tempDirs.push(root); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "1\n"); fs.writeFileSync(path.join(root, "src/runtime.ts"), "process.env.OPENCLAW_ONLY;\n"); execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" }); execFileSync( "git", ["-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", "commit", "-m", "base"], { cwd: root, stdio: "ignore" }, ); expect(() => main(["--base", "HEAD"], root)).not.toThrow(); }); it("rejects stale headroom after the count shrinks", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-tight-")); tempDirs.push(root); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "2\n"); fs.writeFileSync(path.join(root, "src/runtime.ts"), "process.env.OPENCLAW_ONLY;\n"); execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" }); execFileSync( "git", ["-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", "commit", "-m", "base"], { cwd: root, stdio: "ignore" }, ); expect(() => main(["--base", "HEAD"], root)).toThrow(/is below budget/u); }); });