openclaw/test/scripts/worktree-setup.test.ts
Peter Steinberger 4157ed60ff
fix(ui): stop serving a failed Control UI build on the next Gateway start (#161438)
* fix(ui): stop serving a failed Control UI build on the next Gateway start

Rolldown writes the complete bundle, runs the writeBundle hook, and only
then reports aggregated resolve errors and exits non-zero. scripts/ui.mts
built straight into the served dist/control-ui and reused the runtime build
identity, so a failed build left a correctly stamped tree that the Gateway's
asset health check accepted as ready on the next start (observed: a bare
markdown-it-emoji import broke the Control UI after a gateway-profile worktree
install skipped ui/ dependencies).

scripts/ui.mts now builds into a same-depth dist/control-ui.build-<pid>-*
sibling, runs both validators against it, and renames it into place only on
success, restoring the previous output if publication fails. Dead-process
leftovers are reclaimed on the next build and tsdown's dist cleaning skips
in-flight staging trees. The gateway worktree-setup workload now installs
./ui... because the Gateway builds the Control UI from source on first start.

* fix(ui): restore the previous Control UI bundle after an interrupted swap

A builder killed between moving dist/control-ui aside and renaming its staged build into place left the previous complete bundle only in a dead-process .retired sibling, which the next build's leftover cleanup deleted. The next build now restores the newest dead-process retired bundle when the served path is missing, before reclaiming leftovers, so a failed retry still serves it.

* fix(ui): keep interrupted Control UI builds out of packages

A killed builder cannot reach its cleanup, so a dist/control-ui.build-* staging or retired sibling can survive until the next UI build. Exclude those siblings from the package files list, which also drives the dist inventory, and type the new UI test fixtures for the scripts test lane.

* fix(ui): retry transient Windows denials when publishing the Control UI build

Windows scanners and indexers can briefly deny renaming a freshly written or served directory with EPERM, which failed an otherwise valid rebuild on its first attempt. Every publication rename now retries EPERM, EACCES, and EBUSY on a bounded 100-1600 ms schedule (about 3 s) and keeps restoring the previous bundle when the denial is permanent.
2026-09-30 03:28:17 +00:00

656 lines
27 KiB
TypeScript

import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { readBundledPluginAssetHooks } from "../../scripts/bundled-plugin-assets.mts";
import { collectSourceCheckoutPluginBuildEntries } from "../../scripts/lib/bundled-plugin-build-entries.mjs";
import { collectRuntimeImportClosure } from "../../scripts/lib/runtime-import-closure.mts";
import { createWorktreeSetupPlan, parseWorktreeSetupArgs } from "../../scripts/worktree-setup.mjs";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
const scriptPath = path.join(repoRoot, "scripts/worktree-setup.mjs");
// Seed the entrypoint and its lazy runtime helpers; copy their eager source closure.
// Cold subprocesses still run original bytes without a loader or node_modules.
const COLD_SCRIPT_INPUTS = collectRuntimeImportClosure(repoRoot, [
"scripts/worktree-setup.mjs",
"scripts/bundled-plugin-assets.mts",
"scripts/pnpm-runner.mts",
"scripts/lib/bundled-plugin-build-entries.mjs",
"scripts/lib/managed-child-process.mts",
"scripts/lib/output-root-guard.mjs",
]);
type CommandCall = { kind: "pnpm" | "gateway-build"; args: string[]; cwd: string };
function makePreparationFixture() {
const directory = tempDirs.make("openclaw-preparation-");
const rootDir = path.join(directory, "repo");
const controls = path.join(directory, "controls");
fs.mkdirSync(rootDir);
fs.mkdirSync(controls);
const write = (relative: string, value: string) => {
const target = path.join(rootDir, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, value);
};
const json = (relative: string, value: unknown) => write(relative, JSON.stringify(value));
const env: NodeJS.ProcessEnv = { ...process.env, GIT_NO_LAZY_FETCH: "1" };
// Cold execution must not inherit a loader or package path from the test runner.
for (const key of [
"NODE_OPTIONS",
"NODE_PATH",
"npm_execpath",
"OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS",
"OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS",
"OPENCLAW_INCLUDE_OPTIONAL_BUNDLED",
"OPENCLAW_BUILD_PRIVATE_QA",
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
]) {
delete env[key];
}
const git = (...args: string[]) =>
execFileSync("git", args, {
cwd: rootDir,
env,
encoding: "utf8",
});
for (const input of COLD_SCRIPT_INPUTS) {
write(input, fs.readFileSync(path.join(repoRoot, input), "utf8"));
}
const pin: string = JSON.parse(
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"),
).packageManager;
json("package.json", { name: "openclaw", type: "module", packageManager: pin });
write("pnpm-workspace.yaml", "packages:\n - .\n - packages/*\n - extensions/*\n - ui\n");
write("pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
json("ui/package.json", { name: "openclaw-control-ui", version: "0.0.0" });
json("packages/support/package.json", { name: "@openclaw/support", version: "0.0.0" });
json("extensions/isolated/package.json", {
name: "@openclaw/isolated",
dependencies: { "@openclaw/support": "workspace:*" },
openclaw: {
extensions: ["./index.ts"],
build: { bundledDist: false },
release: { publishToNpm: true },
},
});
json("extensions/isolated/openclaw.plugin.json", { id: "isolated" });
write("extensions/isolated/index.ts", "export {};\n");
// No manifest or entrypoint: these owners exist only in the asset inventory.
for (const phase of ["build", "copy"]) {
json("extensions/asset-" + phase + "/package.json", {
name: "@example/asset-" + phase,
openclaw: { assetScripts: { [phase]: "node asset.mjs" } },
});
}
write(".openclaw/worktree-profiles/gateway", "scripts\npackages\nextensions\nsrc\n");
write("src/gateway/input.ts", "export {};\n");
write("apps/android/build.gradle.kts", "// Omitted by the synthetic gateway cone.\n");
// These two files are command recorders, NOT a gateway build implementation.
write("scripts/tsx.mjs", "export {};\n");
write(
"scripts/build-all.mts",
[
'import fs from "node:fs";',
"fs.appendFileSync(process.env.SETUP_TEST_CALLS, JSON.stringify({",
' kind: "gateway-build", args: process.argv.slice(2), cwd: process.cwd(),',
'}) + "\\n");',
'process.exitCode = Number(process.env.SETUP_TEST_BUILD_EXIT ?? "0");',
].join("\n"),
);
git("init", "--quiet");
git("add", ".");
git(
"-c",
"user.name=Setup Test",
"-c",
"user.email=setup@example.invalid",
"-c",
"commit.gpgsign=false",
"-c",
"core.hooksPath=" + controls,
"commit",
"--quiet",
"-m",
"fixture",
);
const callsPath = path.join(controls, "calls.jsonl");
const pnpmPath = path.join(controls, "pnpm.cjs");
const recorder = [
'const fs = require("node:fs");',
"const args = process.argv.slice(2);",
"fs.appendFileSync(process.env.SETUP_TEST_CALLS, JSON.stringify({",
' kind: "pnpm", args, cwd: process.cwd(),',
'}) + "\\n");',
'if (args.length === 1 && args[0] === "--version") {',
" console.log(process.env.SETUP_TEST_VERSION);",
'} else if (args.join(" ") === "store path") {',
" if (process.env.SETUP_TEST_STORE_HOOK) { require(process.env.SETUP_TEST_STORE_HOOK); }",
" console.log(process.env.SETUP_TEST_STORE);",
'} else if (args.includes("install")) {',
' process.exitCode = Number(process.env.SETUP_TEST_INSTALL_EXIT ?? "0");',
'} else if (args.join(" ") === "build") {',
' process.exitCode = Number(process.env.SETUP_TEST_BUILD_EXIT ?? "0");',
"} else {",
" process.exitCode = 97;",
"}",
].join("\n");
fs.writeFileSync(pnpmPath, recorder);
// Trap fallback resolution as well; no test may invoke the host's real pnpm.
fs.writeFileSync(path.join(controls, "pnpm"), "#!/usr/bin/env node\n" + recorder, {
mode: 0o755,
});
fs.writeFileSync(
path.join(controls, "pnpm.cmd"),
'@"' + process.execPath + '" "' + pnpmPath + '" %*\r\n',
);
Object.assign(env, {
npm_execpath: pnpmPath,
PATH:
controls +
path.delimiter +
path.dirname(process.execPath) +
path.delimiter +
(env.PATH ?? ""),
SETUP_TEST_CALLS: callsPath,
SETUP_TEST_VERSION: pin.slice("pnpm@".length).split("+")[0],
SETUP_TEST_STORE: path.join(controls, "store", "not-created"),
});
const run = (args: string[], extraEnv: NodeJS.ProcessEnv = {}, nodeArgs: string[] = []) => {
const result = spawnSync(
process.execPath,
[...nodeArgs, path.join(rootDir, "scripts/worktree-setup.mjs"), ...args],
{ cwd: rootDir, env: { ...env, ...extraEnv }, encoding: "utf8" },
);
expect(result.error).toBeUndefined();
return result;
};
const calls = (): CommandCall[] =>
fs.existsSync(callsPath)
? fs
.readFileSync(callsPath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
: [];
const assertNoOutputs = () => {
for (const relative of [
"node_modules",
"dist",
"packages/support/node_modules",
"extensions/isolated/node_modules",
"ui/node_modules",
"apps/android/build",
]) {
expect(fs.existsSync(path.join(rootDir, relative)), relative).toBe(false);
}
};
return { rootDir, controls, env, run, git, write, calls, assertNoOutputs };
}
describe("source-only worktree preparation", () => {
it("runs source plans without Git, pnpm, repository imports, or generated output", () => {
const rootDir = tempDirs.make("openclaw-source-preparation-");
const script = path.join(rootDir, "worktree-setup.mjs");
fs.copyFileSync(scriptPath, script);
for (const args of [[], ["source"], ["--plan"]]) {
const output = execFileSync(process.execPath, [script, ...args], {
cwd: rootDir,
env: { ...process.env, PATH: "", NODE_OPTIONS: "", NODE_PATH: "" },
encoding: "utf8",
});
if (args.includes("--plan")) {
expect(JSON.parse(output)).toMatchObject({
workload: "source",
install: null,
build: null,
});
} else {
expect(output).toContain("Source-only");
}
expect(fs.readdirSync(rootDir)).toEqual(["worktree-setup.mjs"]);
}
});
it("rejects invalid selectors before reading repository inputs", async () => {
expect(() => parseWorktreeSetupArgs(["gateway", "full"])).toThrow("Unexpected");
expect(() => parseWorktreeSetupArgs(["--profile", "gateway"])).toThrow("Unexpected");
expect(() => parseWorktreeSetupArgs(["--source-profile", "gateway"])).toThrow("Unexpected");
await expect(
createWorktreeSetupPlan({ rootDir: "/missing-worktree", workload: "unknown" }),
).rejects.toThrow("Unknown preparation workload");
});
it.skipIf(process.platform === "win32")(
"keeps the actual Claude startup hook source-only",
() => {
const fixture = makePreparationFixture();
const settings = JSON.parse(
fs.readFileSync(path.join(repoRoot, ".claude/settings.json"), "utf8"),
) as {
hooks: { SessionStart: { matcher: string; hooks: { type: string; command: string }[] }[] };
};
const hooks = settings.hooks.SessionStart.filter((entry) => entry.matcher === "startup")
.flatMap((entry) => entry.hooks)
.filter((hook) => hook.type === "command");
expect(hooks.length).toBeGreaterThan(0);
// Source startup must work even when the cone cannot prepare the gateway.
// Keep only scripts: the missing tracked plugin would fail input validation
// if startup accidentally inferred gateway preparation from sparse state.
for (const state of ["full", "sparse"]) {
if (state === "sparse") {
fixture.git("sparse-checkout", "set", "--cone", "scripts");
expect(fs.existsSync(path.join(fixture.rootDir, "extensions/isolated/index.ts"))).toBe(
false,
);
}
const before = fixture.git("status", "--porcelain", "--untracked-files=all");
for (const hook of hooks) {
const output = execFileSync("/bin/sh", ["-c", hook.command], {
cwd: path.join(fixture.rootDir, "scripts"),
env: fixture.env,
encoding: "utf8",
});
expect(output).toContain("Source-only");
}
expect(fixture.git("status", "--porcelain", "--untracked-files=all")).toBe(before);
if (state === "sparse") {
expect(fixture.git("sparse-checkout", "list").trim()).toBe("scripts");
}
expect(fixture.calls()).toEqual([]);
fixture.assertNoOutputs();
}
},
);
});
describe("explicit preparation input closure", () => {
it("plans the actual checkout from canonical source and both asset inventories", async () => {
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const entries = collectSourceCheckoutPluginBuildEntries({ cwd: repoRoot, env: {} });
const buildHooks = await readBundledPluginAssetHooks({ rootDir: repoRoot, phase: "build" });
const copyHooks = await readBundledPluginAssetHooks({ rootDir: repoRoot, phase: "copy" });
expect(entries.length).toBeGreaterThan(0);
expect(buildHooks.length).toBeGreaterThan(0);
expect(copyHooks.length).toBeGreaterThan(0);
const expected = new Set([pkg.name + "...", "./packages/*...", "./ui..."]);
for (const entry of entries) {
if (entry.hasPackageJson) {
expected.add("./extensions/" + entry.id + "...");
}
}
for (const hook of [...buildHooks, ...copyHooks]) {
expected.add(
"./" + path.relative(repoRoot, hook.pluginDir).split(path.sep).join("/") + "...",
);
}
const plan = await createWorktreeSetupPlan({ rootDir: repoRoot, workload: "gateway", env: {} });
expect(plan.install).toEqual({
command: "pnpm",
args: [...expected]
.toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0))
.flatMap((filter) => ["--filter", filter])
.concat(["install", "--frozen-lockfile"]),
});
expect(plan.build).toEqual({
command: "node",
args: ["--import", "./scripts/tsx.mjs", "scripts/build-all.mts", "qaRuntime"],
});
});
it("runs cold plain-Node gateway and full plans without resolving pnpm or producing outputs", () => {
const fixture = makePreparationFixture();
const before = fixture.git("status", "--porcelain", "--untracked-files=all");
const gateway = fixture.run(["gateway", "--plan"], { SETUP_TEST_VERSION: "wrong" });
expect(gateway.status, gateway.stderr).toBe(0);
expect(JSON.parse(gateway.stdout).install).toEqual({
command: "pnpm",
args: [
"--filter",
"./extensions/asset-build...",
"--filter",
"./extensions/asset-copy...",
"--filter",
"./extensions/isolated...",
"--filter",
"./packages/*...",
"--filter",
"./ui...",
"--filter",
"openclaw...",
"install",
"--frozen-lockfile",
],
});
const full = fixture.run(["full", "--plan"], { SETUP_TEST_VERSION: "wrong" });
expect(full.status, full.stderr).toBe(0);
expect(JSON.parse(full.stdout)).toMatchObject({
install: { command: "pnpm", args: ["install", "--frozen-lockfile"] },
build: { command: "pnpm", args: ["build"] },
});
expect(fixture.calls()).toEqual([]);
expect(fixture.git("status", "--porcelain", "--untracked-files=all")).toBe(before);
fixture.assertNoOutputs();
});
it("accepts gateway cones without ui, rejects sparse full, and preserves explicit native expansion", () => {
const fixture = makePreparationFixture();
fixture.git(
"sparse-checkout",
"set",
"--cone",
"scripts",
"packages",
"extensions",
"src",
".openclaw/worktree-profiles",
);
expect(fs.existsSync(path.join(fixture.rootDir, "apps/android/build.gradle.kts"))).toBe(false);
expect(fs.existsSync(path.join(fixture.rootDir, "ui/package.json"))).toBe(false);
const gateway = fixture.run(["gateway", "--plan"]);
expect(gateway.status, gateway.stderr).toBe(0);
expect(JSON.parse(gateway.stdout).requiredInputs.sparse).toBe(true);
expect(JSON.parse(gateway.stdout).install.args).not.toContain("./ui...");
const patternsBefore = fixture.git("sparse-checkout", "list");
for (const args of [["full"], ["full", "--plan"]]) {
const result = fixture.run(args);
expect(result.status).toBe(1);
expect(result.stderr).toContain("git sparse-checkout disable");
}
expect(fixture.git("sparse-checkout", "list")).toBe(patternsBefore);
expect(fixture.run(["source"]).status).toBe(0);
expect(fixture.calls()).toEqual([]);
fixture.git("sparse-checkout", "disable");
expect(fs.existsSync(path.join(fixture.rootDir, "apps/android/build.gradle.kts"))).toBe(true);
const full = fixture.run(["full", "--plan"]);
expect(full.status, full.stderr).toBe(0);
expect(JSON.parse(full.stdout)).toMatchObject({
install: { command: "pnpm", args: ["install", "--frozen-lockfile"] },
build: { command: "pnpm", args: ["build"] },
requiredInputs: { sparse: false },
});
expect(fixture.calls()).toEqual([]);
fixture.assertNoOutputs();
});
it("rejects a gateway selection changed during toolchain checks before install", () => {
const fixture = makePreparationFixture();
const hook = path.join(fixture.controls, "change-owner.cjs");
fs.writeFileSync(
hook,
[
'const fs = require("node:fs");',
'const file = "extensions/asset-copy/package.json";',
'const pkg = JSON.parse(fs.readFileSync(file, "utf8"));',
"delete pkg.openclaw.assetScripts.copy;",
"fs.writeFileSync(file, JSON.stringify(pkg));",
].join("\n"),
);
const result = fixture.run(["gateway"], { SETUP_TEST_STORE_HOOK: hook });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Preparation inputs changed");
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]);
const pkg = JSON.parse(
fs.readFileSync(path.join(fixture.rootDir, "extensions/asset-copy/package.json"), "utf8"),
);
expect(pkg.openclaw.assetScripts.copy).toBeUndefined();
fixture.assertNoOutputs();
});
it("rejects excluded tracked asset owners before pnpm, even when collectors would omit them", () => {
const fixture = makePreparationFixture();
fixture.git(
"sparse-checkout",
"set",
"--cone",
"scripts",
"packages",
"extensions/isolated",
"src",
".openclaw/worktree-profiles",
);
expect(fs.existsSync(path.join(fixture.rootDir, "extensions/asset-build/package.json"))).toBe(
false,
);
for (const args of [["gateway"], ["gateway", "--plan"]]) {
const result = fixture.run(args);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Gateway inputs are excluded");
expect(result.stderr).toContain("extensions/asset-build/package.json");
expect(result.stderr).toContain("git sparse-checkout disable");
}
expect(fixture.calls()).toEqual([]);
fixture.assertNoOutputs();
});
it("resolves linked plugin inputs before inventory discovery", () => {
const fixture = makePreparationFixture();
const manifest = path.join(fixture.rootDir, "extensions/isolated/package.json");
const target = path.join(fixture.controls, "linked-package.json");
fs.renameSync(manifest, target);
fs.symlinkSync(target, manifest, "file");
const valid = fixture.run(["gateway", "--plan"]);
expect(valid.status).toBe(0);
expect(JSON.parse(valid.stdout).install.args).toContain("./extensions/isolated...");
expect(fixture.calls()).toEqual([]);
fs.unlinkSync(target);
for (const args of [["gateway", "--plan"], ["gateway"]]) {
const result = fixture.run(args);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Tracked preparation inputs are missing");
expect(result.stderr).toContain("extensions/isolated/package.json");
expect(fs.readlinkSync(manifest)).toBe(target);
expect(fs.existsSync(target)).toBe(false);
}
expect(fixture.calls()).toEqual([]);
fixture.assertNoOutputs();
});
it.each([
{ workload: "gateway", missing: "extensions/isolated/index.ts" },
{ workload: "gateway", missing: "extensions/isolated" },
{ workload: "full", missing: "extensions/isolated/index.ts" },
{ workload: "full", missing: "extensions/isolated" },
])(
"rejects missing tracked $missing before inventory discovery or pnpm for $workload",
({ workload, missing }) => {
const fixture = makePreparationFixture();
// This deletes only a future test-owned fixture input, never retained source.
fs.rmSync(path.join(fixture.rootDir, missing), { recursive: true });
for (const args of [[workload], [workload, "--plan"]]) {
const result = fixture.run(args);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Tracked preparation inputs are missing");
expect(result.stderr).toContain("extensions/isolated/index.ts");
if (missing === "extensions/isolated") {
expect(result.stderr).toContain("extensions/isolated/package.json");
expect(result.stderr).toContain("extensions/isolated/openclaw.plugin.json");
}
}
expect(fixture.calls()).toEqual([]);
fixture.assertNoOutputs();
},
);
});
// These are subprocess/ordering regressions. Recorders never install packages,
// prove pnpm closure, build the Gateway, or establish graph/volume isolation.
describe.each(["gateway", "full"])("%s preparation execution boundaries", (workload) => {
it("rejects the wrong target pnpm before querying its store or installing", () => {
const fixture = makePreparationFixture();
const result = fixture.run([workload], { SETUP_TEST_VERSION: "0.0.0" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Expected repository pnpm");
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"]]);
fixture.assertNoOutputs();
});
it("rejects a nonabsolute store before install", () => {
const fixture = makePreparationFixture();
const result = fixture.run([workload], { SETUP_TEST_STORE: "relative/store" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("same volume");
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]);
fixture.assertNoOutputs();
});
it("rejects a simulated foreign-volume store before install", () => {
const fixture = makePreparationFixture();
const store = path.join(fixture.controls, "foreign-store");
fs.mkdirSync(store);
const preload = path.join(fixture.controls, "foreign-device.mjs");
// Simulate only st_dev for this sentinel path; no mount or shared fs mutation.
fs.writeFileSync(
preload,
[
'import fs from "node:fs";',
"const original = fs.statSync;",
"fs.statSync = function (target, ...args) {",
" const stat = original.call(this, target, ...args);",
" if (target === " + JSON.stringify(store) + ") { stat.dev += 1; }",
" return stat;",
"};",
].join("\n"),
);
const result = fixture.run([workload], { SETUP_TEST_STORE: store }, [
"--import",
pathToFileURL(preload).href,
]);
expect(result.status).toBe(1);
expect(result.stderr).toContain("same volume");
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]);
fixture.assertNoOutputs();
});
it.each(["node_modules", "node_modules/.pnpm", "packages/support/node_modules", "dist"])(
"rejects linked %s before invoking pnpm and preserves its target",
(relative) => {
const fixture = makePreparationFixture();
const target = path.join(fixture.controls, "retained");
fs.mkdirSync(target);
fs.writeFileSync(path.join(target, "sentinel"), "unchanged");
const link = path.join(fixture.rootDir, relative);
fs.mkdirSync(path.dirname(link), { recursive: true });
fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
const result = fixture.run([workload]);
expect(result.status).toBe(1);
expect(result.stderr).toContain("symbolic link");
expect(fixture.calls()).toEqual([]);
expect(fs.readFileSync(path.join(target, "sentinel"), "utf8")).toBe("unchanged");
expect(fs.readdirSync(target)).toEqual(["sentinel"]);
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
},
);
it("revalidates missing inputs after store resolution and before install", () => {
const fixture = makePreparationFixture();
const hook = path.join(fixture.controls, "remove-input.cjs");
fs.writeFileSync(hook, 'require("node:fs").unlinkSync("extensions/isolated/index.ts");\n');
const result = fixture.run([workload], { SETUP_TEST_STORE_HOOK: hook });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Tracked preparation inputs are missing");
expect(result.stderr).toContain("extensions/isolated/index.ts");
expect(fs.existsSync(path.join(fixture.rootDir, "extensions/isolated/index.ts"))).toBe(false);
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]);
fixture.assertNoOutputs();
});
it("revalidates output roots after store resolution and before install", () => {
const fixture = makePreparationFixture();
const target = path.join(fixture.controls, "retained");
fs.mkdirSync(target);
fs.writeFileSync(path.join(target, "sentinel"), "unchanged");
const hook = path.join(fixture.controls, "link-output.cjs");
fs.writeFileSync(
hook,
[
'const fs = require("node:fs");',
"fs.symlinkSync(" +
JSON.stringify(target) +
', "dist", ' +
JSON.stringify(process.platform === "win32" ? "junction" : "dir") +
");",
].join("\n"),
);
const result = fixture.run([workload], { SETUP_TEST_STORE_HOOK: hook });
expect(result.status).toBe(1);
expect(result.stderr).toContain("symbolic link");
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]);
expect(fs.lstatSync(path.join(fixture.rootDir, "dist")).isSymbolicLink()).toBe(true);
expect(fs.readdirSync(target)).toEqual(["sentinel"]);
expect(fs.readFileSync(path.join(target, "sentinel"), "utf8")).toBe("unchanged");
expect(fs.existsSync(path.join(fixture.rootDir, "node_modules"))).toBe(false);
});
it("propagates install failure without starting a build", () => {
const fixture = makePreparationFixture();
const result = fixture.run([workload], { SETUP_TEST_INSTALL_EXIT: "23" });
expect(result.status, result.stderr).toBe(23);
const calls = fixture.calls();
expect(calls).toHaveLength(3);
expect(calls.map((call) => call.kind)).toEqual(["pnpm", "pnpm", "pnpm"]);
expect(calls.at(-1)?.args).toContain("install");
expect(calls.at(-1)?.args).toContain("--frozen-lockfile");
fixture.assertNoOutputs();
});
it("propagates selected-build failure after a successful recorder install", () => {
const fixture = makePreparationFixture();
const result = fixture.run([workload], { SETUP_TEST_BUILD_EXIT: "29" });
expect(result.status, result.stderr).toBe(29);
const calls = fixture.calls();
expect(calls).toHaveLength(4);
assert.ok(calls[2]);
expect(calls[2].args).toContain("install");
expect(calls[3]).toMatchObject(
workload === "gateway"
? { kind: "gateway-build", args: ["qaRuntime"] }
: { kind: "pnpm", args: ["build"] },
);
fixture.assertNoOutputs();
});
it("reaches only the selected build after successful recorder install (positive control)", () => {
const fixture = makePreparationFixture();
const result = fixture.run([workload]);
expect(result.status, result.stderr).toBe(0);
const calls = fixture.calls();
expect(calls).toHaveLength(4);
assert.ok(calls[0]);
assert.ok(calls[1]);
assert.ok(calls[2]);
expect(calls[0].args).toEqual(["--version"]);
expect(calls[1].args).toEqual(["store", "path"]);
expect(calls[2].kind).toBe("pnpm");
expect(calls[2].args).toContain("install");
expect(calls[2].args).toContain("--frozen-lockfile");
expect(calls[2].args).not.toContain("--ignore-scripts");
expect(calls[3]).toMatchObject(
workload === "gateway"
? { kind: "gateway-build", args: ["qaRuntime"] }
: { kind: "pnpm", args: ["build"] },
);
if (workload === "full") {
expect(calls[2].args).toEqual(["install", "--frozen-lockfile"]);
}
for (const call of calls) {
expect(fs.realpathSync(call.cwd)).toBe(fs.realpathSync(fixture.rootDir));
}
fixture.assertNoOutputs();
});
});