Files
openclaw/test/scripts/preinstall-package-manager-warning.test.ts
Peter Steinberger 23c0a7b612 fix: preserve working installs on unsupported Node (#106994)
* fix(update): preserve installs on unsupported Node

* fix(update): verify skipped install scripts

* refactor(update): share global install preflight

* fix(update): validate Bun-installed Node runtime

* fix(update): classify install preflight failures

* refactor(update): normalize install failure reasons

* style(update): format updater policy test

* fix(update): preserve pnpm script policy safety

* fix(update): classify hosted git sources

* chore: leave release notes to release flow

* fix(release): defer package inventory helper loading

* style(release): keep inventory loader LOC-neutral

* fix(update): validate staged package lifecycle

* fix(update): match packed lifecycle contract

* fix(update): harden Bun package activation

* test(update): model packed Bun lifecycle

* style(update): avoid lifecycle name shadowing

* fix(update): remove unsafe Bun staging experiment

* fix(update): satisfy updater type checks

* fix(update): preserve packed npm package name

* fix(update): preserve checkout failure status

* fix(update): disable scripts while packing candidates

* fix(update): preflight source package engines safely

* fix(update): preserve legacy npm downgrades

* fix(update): pin npm packing to selected Node

* fix(update): stage npm activation before replacing live package

* fix(update): guard non-npm source activation

* refactor(update): isolate npm staging helpers

* fix(update): harden staged package lifecycle

* test(update): satisfy merged type gates

* fix(update): privatize runtime npm helper

* fix(ci): satisfy merged lint and type gates

* docs(changelog): defer updater note to release

* fix(update): guard package activation runtime

* fix(update): preserve installs on unsupported Node

* test(update): reject prerelease Node runtimes

* test(update): use shared temp cleanup

* fix(update): fail closed when install scripts are skipped

* fix(update): pack install guard in docker artifacts

* fix(ci): keep install guard export tooling-local
2026-07-14 23:19:48 -07:00

260 lines
8.1 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,
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"])(
"rejects non-release Node version %s",
(version) => {
expect(nodeVersionSatisfiesPackageEngine(version, EXPECTED_NODE_ENGINE_RANGE)).toBe(false);
},
);
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", () => {
const reportError = vi.fn();
expect(
enforceSupportedNodeRuntime(
{
version: "24.14.1",
bunVersion: "1.3.0",
engine: EXPECTED_NODE_ENGINE_RANGE,
execPath: "/opt/bun/bin/bun",
},
reportError,
),
).toBe(true);
expect(reportError).not.toHaveBeenCalled();
});
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();
});
});