mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
* fix(update): validate lifecycle runtimes * fix(update): verify Bun lifecycle path * fix(update): reject drive-relative Node paths * test(update): align lifecycle argv expectations
527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
// Preinstall Package Manager Warning tests cover preinstall package manager warning script behavior.
|
|
import { spawnSync } from "node:child_process";
|
|
import { copyFileSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { PACKAGE_INSTALL_GUARD_RELATIVE_PATH } from "../../scripts/lib/package-dist-inventory.ts";
|
|
import {
|
|
completePackageInstallGuard,
|
|
createPackageManagerWarningMessage,
|
|
detectLifecyclePackageManager,
|
|
enforceSupportedNodeRuntime,
|
|
nodeVersionSatisfiesPackageEngine,
|
|
PACKAGE_INSTALL_GUARD_RELATIVE_PATH as PREINSTALL_GUARD_RELATIVE_PATH,
|
|
probePackageCliNodeRuntime,
|
|
readPackageNodeEngine,
|
|
warnIfNonPnpmLifecycle,
|
|
} from "../../scripts/preinstall-package-manager-warning.mjs";
|
|
import { isSupportedNodeVersion } from "../../src/infra/runtime-guard.js";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
|
|
const EXPECTED_NODE_ENGINE_RANGE = ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0";
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
|
|
function requireFirstWarning(warn: ReturnType<typeof vi.fn>): unknown {
|
|
const [call] = warn.mock.calls;
|
|
if (!call) {
|
|
throw new Error("expected package manager warning");
|
|
}
|
|
const [message] = call;
|
|
if (message === undefined) {
|
|
throw new Error("expected package manager warning");
|
|
}
|
|
return message;
|
|
}
|
|
|
|
describe("install runtime enforcement", () => {
|
|
it("shares the packaged install guard path", () => {
|
|
expect(PREINSTALL_GUARD_RELATIVE_PATH).toBe(PACKAGE_INSTALL_GUARD_RELATIVE_PATH);
|
|
});
|
|
|
|
it("reads the canonical package engine range", () => {
|
|
expect(readPackageNodeEngine()).toBe(EXPECTED_NODE_ENGINE_RANGE);
|
|
});
|
|
|
|
it.each(["22.22.2", "22.22.3", "23.11.0", "24.14.1", "24.15.0", "25.8.1", "25.9.0", "26.0.0"])(
|
|
"matches the CLI runtime guard for Node %s",
|
|
(version) => {
|
|
expect(nodeVersionSatisfiesPackageEngine(version, EXPECTED_NODE_ENGINE_RANGE)).toBe(
|
|
isSupportedNodeVersion(version),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(["24.15.0-rc.1", "25.9.1-nightly.20260714", "24.15", "24.15.0+", "24.15.0+local..1"])(
|
|
"rejects non-release Node version %s",
|
|
(version) => {
|
|
expect(nodeVersionSatisfiesPackageEngine(version, EXPECTED_NODE_ENGINE_RANGE)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("accepts SemVer build metadata on a supported Node release", () => {
|
|
expect(nodeVersionSatisfiesPackageEngine("24.15.0+local.1", EXPECTED_NODE_ENGINE_RANGE)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("blocks unsupported Node before package replacement", () => {
|
|
const reportError = vi.fn();
|
|
expect(
|
|
enforceSupportedNodeRuntime(
|
|
{
|
|
version: "24.14.1",
|
|
engine: EXPECTED_NODE_ENGINE_RANGE,
|
|
execPath: "/opt/node/bin/node",
|
|
},
|
|
reportError,
|
|
),
|
|
).toBe(false);
|
|
expect(reportError).toHaveBeenCalledWith(
|
|
expect.stringContaining("this OpenClaw release requires Node"),
|
|
);
|
|
expect(reportError).toHaveBeenCalledWith(expect.stringContaining("detected Node 24.14.1"));
|
|
});
|
|
|
|
it("allows supported Node without an error", () => {
|
|
const reportError = vi.fn();
|
|
expect(
|
|
enforceSupportedNodeRuntime(
|
|
{
|
|
version: "24.15.0",
|
|
engine: EXPECTED_NODE_ENGINE_RANGE,
|
|
execPath: "/opt/node/bin/node",
|
|
},
|
|
reportError,
|
|
),
|
|
).toBe(true);
|
|
expect(reportError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exits nonzero when the packed entrypoint sees an unsupported runtime", () => {
|
|
const root = tempDirs.make("openclaw-preinstall-", realpathSync(tmpdir()));
|
|
const scriptsDir = join(root, "scripts");
|
|
mkdirSync(scriptsDir);
|
|
const scriptPath = join(scriptsDir, "preinstall-package-manager-warning.mjs");
|
|
copyFileSync(
|
|
new URL("../../scripts/preinstall-package-manager-warning.mjs", import.meta.url),
|
|
scriptPath,
|
|
);
|
|
writeFileSync(join(root, "package.json"), JSON.stringify({ engines: { node: ">=999.0.0" } }));
|
|
|
|
const result = spawnSync(process.execPath, [scriptPath], { encoding: "utf8" });
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("requires Node >=999.0.0");
|
|
expect(result.stderr).toContain(`detected Node ${process.versions.node}`);
|
|
});
|
|
|
|
it("allows Bun package lifecycle scripts when the installed CLI will use supported Node", () => {
|
|
const reportError = vi.fn();
|
|
expect(
|
|
enforceSupportedNodeRuntime(
|
|
{
|
|
version: "24.14.1",
|
|
bunVersion: "1.3.0",
|
|
engine: EXPECTED_NODE_ENGINE_RANGE,
|
|
execPath: "/opt/bun/bin/bun",
|
|
probeNodeRuntime: () => ({
|
|
version: "24.15.0",
|
|
bunVersion: null,
|
|
execPath: "/opt/node/bin/node",
|
|
}),
|
|
},
|
|
reportError,
|
|
),
|
|
).toBe(true);
|
|
expect(reportError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks Bun package lifecycle scripts when the installed CLI will use old Node", () => {
|
|
const reportError = vi.fn();
|
|
expect(
|
|
enforceSupportedNodeRuntime(
|
|
{
|
|
bunVersion: "1.3.0",
|
|
engine: EXPECTED_NODE_ENGINE_RANGE,
|
|
probeNodeRuntime: () => ({
|
|
version: "24.14.1",
|
|
bunVersion: null,
|
|
execPath: "/opt/node/bin/node",
|
|
}),
|
|
},
|
|
reportError,
|
|
),
|
|
).toBe(false);
|
|
expect(reportError).toHaveBeenCalledWith(expect.stringContaining("detected Node 24.14.1"));
|
|
});
|
|
|
|
it("blocks Bun package lifecycle scripts when no real Node follows its shim", () => {
|
|
const reportError = vi.fn();
|
|
expect(
|
|
enforceSupportedNodeRuntime(
|
|
{
|
|
bunVersion: "1.3.0",
|
|
engine: EXPECTED_NODE_ENGINE_RANGE,
|
|
probeNodeRuntime: () => null,
|
|
},
|
|
reportError,
|
|
),
|
|
).toBe(false);
|
|
expect(reportError).toHaveBeenCalledWith(expect.stringContaining("detected Node missing"));
|
|
});
|
|
|
|
it("strips only Bun's cwd-to-root lifecycle PATH prefix", () => {
|
|
const candidates: string[] = [];
|
|
const runtime = probePackageCliNodeRuntime({
|
|
cwd: "/work/openclaw",
|
|
pathEnv: [
|
|
"/work/openclaw/node_modules/.bin",
|
|
"/work/node_modules/.bin",
|
|
"/node_modules/.bin",
|
|
"/opt/node/bin",
|
|
].join(":"),
|
|
platform: "linux",
|
|
run: (command) => {
|
|
candidates.push(command);
|
|
return {
|
|
status: 0,
|
|
stdout: JSON.stringify({
|
|
version: "24.15.0",
|
|
bunVersion: null,
|
|
execPath: "/opt/node/bin/node",
|
|
}),
|
|
};
|
|
},
|
|
});
|
|
|
|
expect(candidates).toEqual(["/opt/node/bin/node"]);
|
|
expect(runtime).toEqual({
|
|
version: "24.15.0",
|
|
bunVersion: null,
|
|
execPath: "/opt/node/bin/node",
|
|
});
|
|
});
|
|
|
|
it("checks an inherited node_modules/.bin entry after Bun's prefix", () => {
|
|
const candidates: string[] = [];
|
|
const runtime = probePackageCliNodeRuntime({
|
|
cwd: "/work/openclaw",
|
|
pathEnv: [
|
|
"/work/openclaw/node_modules/.bin",
|
|
"/work/node_modules/.bin",
|
|
"/node_modules/.bin",
|
|
"/opt/tools/node_modules/.bin",
|
|
"/opt/node/bin",
|
|
].join(":"),
|
|
platform: "linux",
|
|
run: (command) => {
|
|
candidates.push(command);
|
|
return {
|
|
status: 0,
|
|
stdout: JSON.stringify({
|
|
version: "24.14.1",
|
|
bunVersion: null,
|
|
execPath: command,
|
|
}),
|
|
};
|
|
},
|
|
});
|
|
|
|
expect(candidates).toEqual(["/opt/tools/node_modules/.bin/node"]);
|
|
expect(runtime?.version).toBe("24.14.1");
|
|
});
|
|
|
|
it("checks a duplicate lifecycle-looking entry inherited in the original PATH", () => {
|
|
const candidates: string[] = [];
|
|
const runtime = probePackageCliNodeRuntime({
|
|
cwd: "/work/openclaw",
|
|
pathEnv: [
|
|
"/work/openclaw/node_modules/.bin",
|
|
"/work/node_modules/.bin",
|
|
"/node_modules/.bin",
|
|
"/work/openclaw/node_modules/.bin",
|
|
"/opt/node/bin",
|
|
].join(":"),
|
|
platform: "linux",
|
|
run: (command) => {
|
|
candidates.push(command);
|
|
return {
|
|
status: 0,
|
|
stdout: JSON.stringify({
|
|
version: "24.14.1",
|
|
bunVersion: null,
|
|
execPath: command,
|
|
}),
|
|
};
|
|
},
|
|
});
|
|
|
|
expect(candidates).toEqual(["/work/openclaw/node_modules/.bin/node"]);
|
|
expect(runtime?.version).toBe("24.14.1");
|
|
});
|
|
|
|
it("fails closed when Bun's lifecycle PATH prefix cannot be proven", () => {
|
|
const run = vi.fn();
|
|
expect(
|
|
probePackageCliNodeRuntime({
|
|
cwd: "/work/openclaw",
|
|
pathEnv: ["/unproven/node_modules/.bin", "/opt/node/bin"].join(":"),
|
|
platform: "linux",
|
|
run,
|
|
}),
|
|
).toBeNull();
|
|
expect(run).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed on a Bun-backed candidate from the original PATH", () => {
|
|
const candidates: string[] = [];
|
|
expect(
|
|
probePackageCliNodeRuntime({
|
|
cwd: "/work/openclaw",
|
|
pathEnv: [
|
|
"/work/openclaw/node_modules/.bin",
|
|
"/work/node_modules/.bin",
|
|
"/node_modules/.bin",
|
|
"/opt/bun-wrapper",
|
|
"/opt/node/bin",
|
|
].join(":"),
|
|
platform: "linux",
|
|
run: (command) => {
|
|
candidates.push(command);
|
|
return {
|
|
status: 0,
|
|
stdout: JSON.stringify({
|
|
version: "24.15.0",
|
|
bunVersion: "1.3.14",
|
|
execPath: "/opt/bun/bin/bun",
|
|
}),
|
|
};
|
|
},
|
|
}),
|
|
).toBeNull();
|
|
expect(candidates).toEqual(["/opt/bun-wrapper/node"]);
|
|
});
|
|
|
|
it.each(["", ".", "relative/bin"])(
|
|
"fails closed before a relative PATH component %j",
|
|
(relativeEntry) => {
|
|
const run = vi.fn();
|
|
expect(
|
|
probePackageCliNodeRuntime({
|
|
cwd: "/work/openclaw",
|
|
pathEnv: [
|
|
"/work/openclaw/node_modules/.bin",
|
|
"/work/node_modules/.bin",
|
|
"/node_modules/.bin",
|
|
relativeEntry,
|
|
"/opt/node/bin",
|
|
].join(":"),
|
|
platform: "linux",
|
|
run,
|
|
}),
|
|
).toBeNull();
|
|
expect(run).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each(["\\tools", "/tools"])(
|
|
"fails closed before a Windows root-relative PATH component %j",
|
|
(relativeEntry) => {
|
|
const run = vi.fn();
|
|
expect(
|
|
probePackageCliNodeRuntime({
|
|
cwd: "C:\\work\\openclaw",
|
|
pathEnv: [
|
|
"C:\\work\\openclaw\\node_modules\\.bin",
|
|
"C:\\work\\node_modules\\.bin",
|
|
"C:\\node_modules\\.bin",
|
|
relativeEntry,
|
|
"C:\\node",
|
|
].join(";"),
|
|
platform: "win32",
|
|
run,
|
|
}),
|
|
).toBeNull();
|
|
expect(run).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("removes NODE_OPTIONS case-insensitively from a Windows probe", () => {
|
|
let childEnv: NodeJS.ProcessEnv | undefined;
|
|
expect(
|
|
probePackageCliNodeRuntime({
|
|
cwd: "C:\\work\\openclaw",
|
|
env: {
|
|
PATH: [
|
|
"C:\\work\\openclaw\\node_modules\\.bin",
|
|
"C:\\work\\node_modules\\.bin",
|
|
"C:\\node_modules\\.bin",
|
|
"C:\\node",
|
|
].join(";"),
|
|
NODE_OPTIONS: "--require=first.cjs",
|
|
Node_Options: "--require=second.cjs",
|
|
OPENCLAW_PROBE_SENTINEL: "preserved",
|
|
},
|
|
platform: "win32",
|
|
run: (_command, _args, options) => {
|
|
childEnv = options.env;
|
|
return {
|
|
status: 0,
|
|
stdout: JSON.stringify({
|
|
version: "24.15.0",
|
|
bunVersion: null,
|
|
execPath: "C:\\node\\node.exe",
|
|
}),
|
|
};
|
|
},
|
|
}),
|
|
).toEqual({
|
|
version: "24.15.0",
|
|
bunVersion: null,
|
|
execPath: "C:\\node\\node.exe",
|
|
});
|
|
expect(childEnv).toEqual({
|
|
PATH: [
|
|
"C:\\work\\openclaw\\node_modules\\.bin",
|
|
"C:\\work\\node_modules\\.bin",
|
|
"C:\\node_modules\\.bin",
|
|
"C:\\node",
|
|
].join(";"),
|
|
OPENCLAW_PROBE_SENTINEL: "preserved",
|
|
});
|
|
});
|
|
|
|
it("removes the install guard after runtime validation", () => {
|
|
const markerUrl = new URL("file:///tmp/openclaw-install-guard");
|
|
const remove = vi.fn();
|
|
const reportError = vi.fn();
|
|
|
|
expect(completePackageInstallGuard({ markerUrl, remove }, reportError)).toBe(true);
|
|
expect(remove).toHaveBeenCalledWith(markerUrl, { force: true });
|
|
expect(reportError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails installation when the install guard cannot be removed", () => {
|
|
const reportError = vi.fn();
|
|
expect(
|
|
completePackageInstallGuard(
|
|
{
|
|
remove: () => {
|
|
throw new Error("read-only package");
|
|
},
|
|
},
|
|
reportError,
|
|
),
|
|
).toBe(false);
|
|
expect(reportError).toHaveBeenCalledWith(
|
|
expect.stringContaining("could not complete package preinstall: read-only package"),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("detectLifecyclePackageManager", () => {
|
|
it("prefers npm_config_user_agent when present", () => {
|
|
expect(
|
|
detectLifecyclePackageManager({
|
|
npm_config_user_agent: "npm/11.4.1 node/v22.20.0 darwin arm64",
|
|
}),
|
|
).toBe("npm");
|
|
});
|
|
|
|
it("falls back to npm_execpath when user agent is missing", () => {
|
|
expect(
|
|
detectLifecyclePackageManager({
|
|
npm_execpath: "/Users/test/.cache/node/corepack/v1/pnpm/10.32.1/bin/pnpm.cjs",
|
|
}),
|
|
).toBe("pnpm");
|
|
});
|
|
|
|
it("detects npm cli launchers from npm_execpath", () => {
|
|
expect(
|
|
detectLifecyclePackageManager({
|
|
npm_execpath: "C:\\Tools\\nodejs\\node_modules\\npm\\bin\\npm-cli.js",
|
|
}),
|
|
).toBe("npm");
|
|
});
|
|
|
|
it("detects yarnpkg launchers from npm_execpath", () => {
|
|
expect(
|
|
detectLifecyclePackageManager({
|
|
npm_execpath: "C:\\Tools\\corepack\\yarnpkg.cmd",
|
|
}),
|
|
).toBe("yarn");
|
|
});
|
|
|
|
it("detects versioned Yarn release launchers from npm_execpath", () => {
|
|
expect(
|
|
detectLifecyclePackageManager({
|
|
npm_execpath: "/work/project/.yarn/releases/yarn-4.5.0.cjs",
|
|
}),
|
|
).toBe("yarn");
|
|
});
|
|
|
|
it("detects Yarn Berry release launchers from npm_execpath", () => {
|
|
expect(
|
|
detectLifecyclePackageManager({
|
|
npm_execpath: "/work/project/.yarn/releases/yarn-berry.cjs",
|
|
}),
|
|
).toBe("yarn");
|
|
});
|
|
|
|
it("ignores package manager names in npm_execpath parent directories", () => {
|
|
expect(
|
|
detectLifecyclePackageManager({
|
|
npm_execpath: "/tmp/npm-cache/bin/yarn.js",
|
|
}),
|
|
).toBe("yarn");
|
|
});
|
|
|
|
it("ignores untrusted user-agent tokens with control characters", () => {
|
|
expect(
|
|
detectLifecyclePackageManager({
|
|
npm_config_user_agent: "\u001bnpm/11.4.1 node/v22.20.0 darwin arm64",
|
|
npm_execpath: "/Users/test/.cache/node/corepack/v1/pnpm/10.32.1/bin/pnpm.cjs",
|
|
}),
|
|
).toBe("pnpm");
|
|
});
|
|
});
|
|
|
|
describe("createPackageManagerWarningMessage", () => {
|
|
it("returns null for pnpm", () => {
|
|
expect(createPackageManagerWarningMessage("pnpm")).toBeNull();
|
|
});
|
|
|
|
it("warns for npm installs", () => {
|
|
expect(createPackageManagerWarningMessage("npm")).toContain("prefer: corepack pnpm install");
|
|
});
|
|
});
|
|
|
|
describe("warnIfNonPnpmLifecycle", () => {
|
|
it("warns once for npm lifecycle runs", () => {
|
|
const warn = vi.fn();
|
|
expect(
|
|
warnIfNonPnpmLifecycle(
|
|
{
|
|
npm_config_user_agent: "npm/11.4.1 node/v22.20.0 darwin arm64",
|
|
},
|
|
warn,
|
|
),
|
|
).toBe(true);
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(requireFirstWarning(warn)).toContain("detected npm");
|
|
});
|
|
|
|
it("stays quiet for pnpm", () => {
|
|
const warn = vi.fn();
|
|
expect(
|
|
warnIfNonPnpmLifecycle(
|
|
{
|
|
npm_config_user_agent: "pnpm/10.32.1 npm/? node/v22.20.0 darwin arm64",
|
|
},
|
|
warn,
|
|
),
|
|
).toBe(false);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
});
|
|
});
|