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 (#149587)
* fix: keep source-only sessions free of dependency installs * fix: satisfy setup typing and ordering checks * test(plugins): cover bundled but publishable packages
This commit is contained in:
parent
20230c35f0
commit
f5c3d16561
7 changed files with 966 additions and 13 deletions
|
|
@ -6,8 +6,8 @@
|
|||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'r=\"$(git rev-parse --show-toplevel 2>/dev/null)\"; [ -n \"$r\" ] && cd \"$r\" || exit 0; command -v pnpm >/dev/null 2>&1 && [ -f pnpm-lock.yaml ] || exit 0; CI=true pnpm install --frozen-lockfile --ignore-scripts || echo \"[worktree-setup] pnpm install failed; run pnpm install manually in this worktree\"'",
|
||||
"timeout": 600
|
||||
"command": "bash -c 'r=\"$(git rev-parse --show-toplevel 2>/dev/null)\"; [ -n \"$r\" ] && cd \"$r\" || exit 0; node scripts/worktree-setup.mjs source'",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import fs from "node:fs/promises";
|
|||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
// Discovers and runs bundled plugin package asset hooks.
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { runManagedCommand } from "./lib/managed-child-process.mts";
|
||||
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
|
||||
import { isRecord } from "./lib/record-shared.mjs";
|
||||
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
||||
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mts";
|
||||
const rootDir = resolveRepoRoot(import.meta.url);
|
||||
|
|
|
|||
288
scripts/worktree-setup.mjs
Normal file
288
scripts/worktree-setup.mjs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
#!/usr/bin/env node
|
||||
// Explicit repository preparation; source composition never selects a workload.
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const WORKLOADS = new Set(["source", "gateway", "full"]);
|
||||
const SOURCE_GUIDANCE =
|
||||
"[worktree-setup] Source-only: no dependencies installed or artifacts built. " +
|
||||
"For gateway work run node scripts/worktree-setup.mjs gateway; " +
|
||||
"for full preparation run node scripts/worktree-setup.mjs full.";
|
||||
const FULL_TRANSITION = "Run git sparse-checkout disable, then retry preparation.";
|
||||
|
||||
function git(rootDir, args, input) {
|
||||
return execFileSync("git", args, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
input,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function readSparseState(rootDir) {
|
||||
const result = spawnSync("git", ["config", "--get", "--bool", "core.sparseCheckout"], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status === 1) {
|
||||
return false;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr.trim() || "Cannot read Git sparse-checkout state.");
|
||||
}
|
||||
return result.stdout.trim() === "true";
|
||||
}
|
||||
|
||||
function validateInputs(rootDir, workload) {
|
||||
const topLevel = git(rootDir, ["rev-parse", "--show-toplevel"]).trim();
|
||||
if (fs.realpathSync(topLevel) !== fs.realpathSync(rootDir)) {
|
||||
throw new Error("Run preparation from the target repository root.");
|
||||
}
|
||||
const sparse = readSparseState(rootDir);
|
||||
if (workload === "full" && sparse) {
|
||||
throw new Error("Full preparation requires full source. " + FULL_TRANSITION);
|
||||
}
|
||||
const tracked = git(rootDir, ["ls-files", "-z"]).split("\0").filter(Boolean);
|
||||
let required = tracked;
|
||||
if (sparse) {
|
||||
// Git interprets the repository-owned cone list. This helper has no profile
|
||||
// parser and never changes checkout state.
|
||||
const definition = path.join(rootDir, ".openclaw/worktree-profiles/gateway");
|
||||
if (!fs.existsSync(definition)) {
|
||||
throw new Error(
|
||||
"Sparse gateway preparation needs the repository gateway definition. " + FULL_TRANSITION,
|
||||
);
|
||||
}
|
||||
required = git(
|
||||
rootDir,
|
||||
["sparse-checkout", "check-rules", "--cone", "--rules-file", definition, "-z"],
|
||||
tracked.join("\0") + "\0",
|
||||
)
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const included = new Set(
|
||||
git(rootDir, ["sparse-checkout", "check-rules", "-z"], required.join("\0") + "\0")
|
||||
.split("\0")
|
||||
.filter(Boolean),
|
||||
);
|
||||
const excluded = required.filter((file) => !included.has(file));
|
||||
if (excluded.length > 0) {
|
||||
throw new Error(
|
||||
"Gateway inputs are excluded: " + excluded.slice(0, 10).join(", ") + ". " + FULL_TRANSITION,
|
||||
);
|
||||
}
|
||||
}
|
||||
const missing = required.filter((file) => !fs.existsSync(path.join(rootDir, file)));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
"Tracked preparation inputs are missing: " +
|
||||
missing.slice(0, 10).join(", ") +
|
||||
". " +
|
||||
FULL_TRANSITION,
|
||||
);
|
||||
}
|
||||
// The package/build inventories below use the filesystem. Validate first so
|
||||
// a missing tracked plugin cannot silently narrow the selected install.
|
||||
for (const file of [
|
||||
"package.json",
|
||||
"pnpm-workspace.yaml",
|
||||
"pnpm-lock.yaml",
|
||||
"scripts/build-all.mts",
|
||||
]) {
|
||||
if (!fs.existsSync(path.join(rootDir, file))) {
|
||||
throw new Error("Missing preparation input: " + file);
|
||||
}
|
||||
}
|
||||
return { sparse, tracked, requiredCount: required.length };
|
||||
}
|
||||
|
||||
export async function createWorktreeSetupPlan({ rootDir, workload = "source", env = process.env }) {
|
||||
if (!WORKLOADS.has(workload)) {
|
||||
throw new Error("Unknown preparation workload: " + workload);
|
||||
}
|
||||
if (workload === "source") {
|
||||
return { workload, packageManager: null, install: null, build: null, requiredInputs: null };
|
||||
}
|
||||
const inputs = validateInputs(rootDir, workload);
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
||||
if (
|
||||
typeof pkg.packageManager !== "string" ||
|
||||
!/^pnpm@\d+\.\d+\.\d+(?:[-+].*)?$/u.test(pkg.packageManager)
|
||||
) {
|
||||
throw new Error("Target package.json must pin pnpm through packageManager.");
|
||||
}
|
||||
const filters = new Set();
|
||||
if (workload === "gateway") {
|
||||
const { collectSourceCheckoutPluginBuildEntries } =
|
||||
await import("./lib/bundled-plugin-build-entries.mjs");
|
||||
const { readBundledPluginAssetHooks } = await import("./bundled-plugin-assets.mts");
|
||||
if (typeof pkg.name !== "string" || !pkg.name) {
|
||||
throw new Error("Target package.json is missing its package name.");
|
||||
}
|
||||
filters.add(pkg.name + "...");
|
||||
filters.add("./packages/*...");
|
||||
for (const entry of collectSourceCheckoutPluginBuildEntries({ cwd: rootDir, env })) {
|
||||
if (entry.hasPackageJson) {
|
||||
filters.add("./extensions/" + entry.id + "...");
|
||||
}
|
||||
}
|
||||
for (const phase of ["build", "copy"]) {
|
||||
for (const hook of await readBundledPluginAssetHooks({ rootDir, phase })) {
|
||||
filters.add(
|
||||
"./" + path.relative(rootDir, hook.pluginDir).split(path.sep).join("/") + "...",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const filterArgs = [...filters]
|
||||
.toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.flatMap((filter) => ["--filter", filter]);
|
||||
return {
|
||||
workload,
|
||||
packageManager: pkg.packageManager,
|
||||
install: { command: "pnpm", args: [...filterArgs, "install", "--frozen-lockfile"] },
|
||||
build:
|
||||
workload === "gateway"
|
||||
? {
|
||||
command: "node",
|
||||
args: ["--import", "./scripts/tsx.mjs", "scripts/build-all.mts", "qaRuntime"],
|
||||
}
|
||||
: { command: "pnpm", args: ["build"] },
|
||||
requiredInputs: { sparse: inputs.sparse, trackedFiles: inputs.requiredCount },
|
||||
};
|
||||
}
|
||||
|
||||
function existingAncestor(target) {
|
||||
let current = target;
|
||||
while (!fs.existsSync(current)) {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
throw new Error("Cannot inspect package store volume: " + target);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
async function executePreparation(rootDir, workload) {
|
||||
const plan = await createWorktreeSetupPlan({ rootDir, workload });
|
||||
const { assertRealOutputRoot } = await import("./lib/output-root-guard.mjs");
|
||||
const { createPnpmRunnerSpawnSpec } = await import("./pnpm-runner.mts");
|
||||
const { runManagedCommand } = await import("./lib/managed-child-process.mts");
|
||||
const validateOutputRoots = () => {
|
||||
const manifests = git(rootDir, ["ls-files", "-z"])
|
||||
.split("\0")
|
||||
.filter((file) => file === "package.json" || file.endsWith("/package.json"));
|
||||
for (const manifest of manifests) {
|
||||
const packageRoot = path.join(rootDir, path.dirname(manifest));
|
||||
assertRealOutputRoot(path.join(packageRoot, "node_modules"));
|
||||
}
|
||||
assertRealOutputRoot(path.join(rootDir, "node_modules/.pnpm"));
|
||||
assertRealOutputRoot(path.join(rootDir, "dist"));
|
||||
};
|
||||
validateOutputRoots();
|
||||
const pnpm = (args) => createPnpmRunnerSpawnSpec({ cwd: rootDir, pnpmArgs: args });
|
||||
const capture = (args) => {
|
||||
const spec = pnpm(args);
|
||||
return execFileSync(spec.command, spec.args, {
|
||||
...spec.options,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
};
|
||||
const version = capture(["--version"]);
|
||||
const pinnedVersion = plan.packageManager.slice("pnpm@".length).split("+")[0];
|
||||
if (version !== pinnedVersion) {
|
||||
throw new Error(
|
||||
"Expected repository pnpm " +
|
||||
pinnedVersion +
|
||||
", got " +
|
||||
version +
|
||||
". Run through the repository-pinned toolchain.",
|
||||
);
|
||||
}
|
||||
const storePath = capture(["store", "path"]);
|
||||
if (
|
||||
!path.isAbsolute(storePath) ||
|
||||
fs.statSync(existingAncestor(storePath)).dev !== fs.statSync(rootDir).dev
|
||||
) {
|
||||
throw new Error("Preparation requires a pnpm store on the same volume as the worktree.");
|
||||
}
|
||||
// Do not trust a previously displayed plan as permission to prepare changed inputs.
|
||||
const current = await createWorktreeSetupPlan({ rootDir, workload });
|
||||
if (JSON.stringify(current) !== JSON.stringify(plan)) {
|
||||
throw new Error("Preparation inputs changed while checking the toolchain; retry.");
|
||||
}
|
||||
// Toolchain probes can take time; reject output roots linked since the first check.
|
||||
validateOutputRoots();
|
||||
for (const step of [current.install, current.build]) {
|
||||
const spec =
|
||||
step.command === "pnpm"
|
||||
? pnpm(step.args)
|
||||
: {
|
||||
command: process.execPath,
|
||||
args: step.args,
|
||||
options: { cwd: rootDir, shell: false, stdio: "inherit" },
|
||||
};
|
||||
const status = await runManagedCommand({
|
||||
bin: spec.command,
|
||||
args: spec.args,
|
||||
...spec.options,
|
||||
requireProcessTreeExit: process.platform !== "win32",
|
||||
});
|
||||
if (status !== 0) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function parseWorktreeSetupArgs(argv) {
|
||||
let workload;
|
||||
let plan = false;
|
||||
let help = false;
|
||||
for (const arg of argv) {
|
||||
if (arg === "--plan") {
|
||||
plan = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
help = true;
|
||||
} else if (WORKLOADS.has(arg) && workload === undefined) {
|
||||
workload = arg;
|
||||
} else {
|
||||
throw new Error("Unexpected preparation argument: " + arg);
|
||||
}
|
||||
}
|
||||
return { workload: workload ?? "source", plan, help };
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
try {
|
||||
const args = parseWorktreeSetupArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log(
|
||||
"Usage: node scripts/worktree-setup.mjs [source|gateway|full] [--plan]\n" + SOURCE_GUIDANCE,
|
||||
);
|
||||
} else if (args.plan) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
await createWorktreeSetupPlan({ rootDir: process.cwd(), workload: args.workload }),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else if (args.workload === "source") {
|
||||
console.log(SOURCE_GUIDANCE);
|
||||
} else {
|
||||
process.exitCode = await executePreparation(process.cwd(), args.workload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -407,19 +407,18 @@ describe("official external plugin catalog", () => {
|
|||
expect(gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it("declares each published ClawHub counterpart in its package and discovery catalog", () => {
|
||||
it("declares each ClawHub publication target in its package", () => {
|
||||
const gaps = listPublishedPluginOwners().flatMap(
|
||||
({ id, packageName, install, publishToClawHub }) => {
|
||||
if (!publishToClawHub) {
|
||||
return [];
|
||||
}
|
||||
const expected = `clawhub:${packageName}`;
|
||||
const catalogSpec = resolveOfficialExternalPluginInstall(
|
||||
expectCatalogEntry(id),
|
||||
)?.clawhubSpec;
|
||||
return install.clawhubSpec === expected && catalogSpec === expected
|
||||
// Bundled packages may be publishable before external catalog publication.
|
||||
// The preceding test checks catalog/install parity for external owners.
|
||||
return install.clawhubSpec === expected
|
||||
? []
|
||||
: [{ id, packageName, expected, packageSpec: install.clawhubSpec, catalogSpec }];
|
||||
: [{ id, packageName, expected, packageSpec: install.clawhubSpec }];
|
||||
},
|
||||
);
|
||||
expect(gaps).toEqual([]);
|
||||
|
|
|
|||
|
|
@ -685,7 +685,14 @@ describe("collectPublishablePluginPackages", () => {
|
|||
});
|
||||
|
||||
it("keeps publishable plugin dist trees out of the core npm package unless bundled", () => {
|
||||
const corePackageRuntimePluginIds = new Set(["discord"]);
|
||||
// These publication targets intentionally remain in the core distribution.
|
||||
// bundledDist: true would defer their separate npm/ClawHub publication.
|
||||
const corePackageRuntimePluginIds = new Set([
|
||||
"discord",
|
||||
"logbook",
|
||||
"memory-wiki",
|
||||
"onepassword",
|
||||
]);
|
||||
const rootPackage = JSON.parse(readFileSync("package.json", "utf8")) as {
|
||||
files?: unknown;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1772,7 +1772,7 @@ mutateModule.syncBuiltinESMExports();
|
|||
expect(existsSync(sentinel)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches the exact current publisher inventory: 95 npm and 91 ClawHub packages", () => {
|
||||
it("matches the exact current publisher inventory: 98 npm and 94 ClawHub packages", () => {
|
||||
const root = tempDirs.make("openclaw-release-plan-current-");
|
||||
const candidateSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: resolve("."),
|
||||
|
|
@ -1825,8 +1825,8 @@ mutateModule.syncBuiltinESMExports();
|
|||
const clawHubPackages = plan.inventory.packages.filter((entry) =>
|
||||
entry.targets.includes("clawhub"),
|
||||
);
|
||||
expect(npmPackages).toHaveLength(95);
|
||||
expect(clawHubPackages).toHaveLength(91);
|
||||
expect(npmPackages).toHaveLength(98);
|
||||
expect(clawHubPackages).toHaveLength(94);
|
||||
const coreNpmPackages = new Set([
|
||||
"@openclaw/ai",
|
||||
"@openclaw/gateway-client",
|
||||
|
|
|
|||
659
test/scripts/worktree-setup.test.ts
Normal file
659
test/scripts/worktree-setup.test.ts
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue