mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
* fix: keep source-only sessions free of dependency installs * fix: satisfy setup typing and ordering checks * test(plugins): cover bundled but publishable packages
659 lines
27 KiB
TypeScript
659 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 { 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");
|
|
|
|
// Copy real script owners, not replacement inventories or dependency symlinks.
|
|
// This bounded list deliberately fails if the plain-Node import closure grows.
|
|
const COLD_SCRIPT_INPUTS = [
|
|
"scripts/worktree-setup.mjs",
|
|
"scripts/bundled-plugin-assets.mts",
|
|
"scripts/pnpm-runner.mts",
|
|
"scripts/windows-cmd-helpers.mjs",
|
|
"scripts/lib/bundled-plugin-build-entries.mjs",
|
|
"scripts/lib/bundled-plugin-paths.mjs",
|
|
"scripts/lib/managed-child-process.mts",
|
|
"scripts/lib/optional-bundled-clusters.mjs",
|
|
"scripts/lib/output-root-guard.mjs",
|
|
"scripts/lib/record-shared.mjs",
|
|
"scripts/lib/repo-root.mjs",
|
|
"scripts/lib/root-package-bundled-plugin-excludes.mjs",
|
|
"scripts/lib/static-extension-assets.mts",
|
|
"scripts/lib/vitest-resource-ownership.mts",
|
|
"scripts/lib/windows-taskkill.mjs",
|
|
"src/shared/non-packaged-plugin-dirs.ts",
|
|
];
|
|
|
|
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");
|
|
write("pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
|
|
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",
|
|
"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/*..."]);
|
|
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",
|
|
"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 complete gateway cones, 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);
|
|
const gateway = fixture.run(["gateway", "--plan"]);
|
|
expect(gateway.status, gateway.stderr).toBe(0);
|
|
expect(JSON.parse(gateway.stdout).requiredInputs.sparse).toBe(true);
|
|
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();
|
|
});
|
|
});
|