mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:51:33 +00:00
459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
// Npm Package Lock Generator tests cover transient npm package-lock behavior.
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyPackageExtensionPeerMetadata,
|
|
collectOverrideViolations,
|
|
collectPnpmLockViolations,
|
|
createNpmPackageLockInstallStrategyArgs,
|
|
createNpmLockExecOptions,
|
|
createNpmLockCommand,
|
|
disableDependencyShrinkwrapOverrideConflictSources,
|
|
exactOverrideRulesFromOverrides,
|
|
exactVersionFromOverrideSpec,
|
|
normalizeNpmVersionDrift,
|
|
normalizeOverrides,
|
|
packageJsonForNpmLock,
|
|
pnpmLockOverrideVersionForVersions,
|
|
parsePnpmPackageKey,
|
|
parseLockPackagePath,
|
|
resolvePnpmLockOverridePlan,
|
|
resolvePackageDirs,
|
|
resolveNpmLockJobs,
|
|
shouldUseLegacyPeerDepsForNpmLock,
|
|
npmLockPackageDirsForChangedPaths,
|
|
} from "../../scripts/generate-npm-package-lock.mjs";
|
|
|
|
describe("generate-npm-package-lock", () => {
|
|
function repoRelativePath(value: string): string {
|
|
return path.relative(process.cwd(), value).replaceAll("\\", "/");
|
|
}
|
|
|
|
it("omits workspace packages that are published beside the package", () => {
|
|
const normalized = packageJsonForNpmLock(
|
|
{
|
|
dependencies: { "@openclaw/ai": "workspace:2026.6.11", chalk: "5.6.2" },
|
|
devDependencies: { local: "workspace:*" },
|
|
peerDependencies: { host: "workspace:^1.2.3" },
|
|
},
|
|
{},
|
|
);
|
|
|
|
expect(normalized).not.toHaveProperty("devDependencies");
|
|
expect(normalized.dependencies).toEqual({ chalk: "5.6.2" });
|
|
expect(normalized.peerDependencies).toEqual({});
|
|
});
|
|
|
|
it("runs npm package-lock generation through cmd.exe for Windows npm shims", () => {
|
|
const execPath = "C:\\nodejs\\node.exe";
|
|
const npmCmdPath = path.win32.resolve(path.win32.dirname(execPath), "npm.cmd");
|
|
|
|
expect(
|
|
createNpmLockCommand(["install", "--package-lock-only"], {
|
|
comSpec: "C:\\Windows\\System32\\cmd.exe",
|
|
env: {},
|
|
execPath,
|
|
existsSync: (candidate: string) => candidate === npmCmdPath,
|
|
platform: "win32",
|
|
}),
|
|
).toEqual({
|
|
args: ["/d", "/s", "/c", `${npmCmdPath} install --package-lock-only`],
|
|
command: "C:\\Windows\\System32\\cmd.exe",
|
|
shell: false,
|
|
windowsVerbatimArguments: true,
|
|
});
|
|
});
|
|
|
|
it("bounds npm-lock command runtime and captured output by default", () => {
|
|
expect(
|
|
createNpmLockExecOptions({ command: "npm", args: ["install"] }, "/tmp/package", {}),
|
|
).toMatchObject({
|
|
cwd: "/tmp/package",
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
timeout: 10 * 60 * 1000,
|
|
});
|
|
});
|
|
|
|
it("adds explicit npm install strategies for package-lock generation", () => {
|
|
expect(createNpmPackageLockInstallStrategyArgs({ installStrategy: "shallow" })).toEqual([
|
|
"--install-strategy=shallow",
|
|
]);
|
|
expect(createNpmPackageLockInstallStrategyArgs({})).toEqual([]);
|
|
expect(() =>
|
|
createNpmPackageLockInstallStrategyArgs({ installStrategy: "global" as never }),
|
|
).toThrow("invalid npm package-lock install strategy: global");
|
|
});
|
|
|
|
it("normalizes pnpm scoped override selectors for npm package locks", () => {
|
|
expect(
|
|
normalizeOverrides({
|
|
"openclaw@2026.5.28>undici": "8.5.0",
|
|
tar: 7.5,
|
|
}),
|
|
).toEqual({
|
|
"openclaw@2026.5.28": {
|
|
undici: "8.5.0",
|
|
},
|
|
tar: "7.5",
|
|
});
|
|
});
|
|
|
|
it("rejects short flag package selectors before resolving npm-lock targets", () => {
|
|
expect(() => resolvePackageDirs(["--package-dir", "-h"])).toThrow(
|
|
"--package-dir requires a package directory.",
|
|
);
|
|
expect(() => resolvePackageDirs(["--changed", "--base", "-h"])).toThrow(
|
|
"--base requires a git ref.",
|
|
);
|
|
expect(() => resolvePackageDirs(["--changed", "--head", "-h"])).toThrow(
|
|
"--head requires a git ref.",
|
|
);
|
|
expect(() => resolvePackageDirs(["--jobs", "-h"])).toThrow(
|
|
"--jobs requires a positive integer.",
|
|
);
|
|
});
|
|
|
|
it("validates npm-lock worker counts from flags and environment", () => {
|
|
expect(resolveNpmLockJobs("3", {})).toBe(3);
|
|
expect(resolveNpmLockJobs(undefined, { OPENCLAW_NPM_LOCK_JOBS: "2" })).toBe(2);
|
|
expect(() => resolveNpmLockJobs("0", {})).toThrow("invalid OPENCLAW_NPM_LOCK_JOBS: 0");
|
|
expect(() => resolveNpmLockJobs("17", {})).toThrow("maximum is 16");
|
|
});
|
|
|
|
it("accepts strict npm-lock command timeout and buffer overrides", () => {
|
|
expect(
|
|
createNpmLockExecOptions({ command: "npm", args: ["install"] }, "/tmp/package", {
|
|
OPENCLAW_NPM_LOCK_COMMAND_MAX_BUFFER_BYTES: "1048576",
|
|
OPENCLAW_NPM_LOCK_COMMAND_TIMEOUT_MS: "30000",
|
|
}),
|
|
).toMatchObject({
|
|
maxBuffer: 1024 * 1024,
|
|
timeout: 30000,
|
|
});
|
|
});
|
|
|
|
it("rejects loose npm-lock command timeout and buffer overrides", () => {
|
|
expect(() =>
|
|
createNpmLockExecOptions({ command: "npm", args: ["install"] }, "/tmp/package", {
|
|
OPENCLAW_NPM_LOCK_COMMAND_TIMEOUT_MS: "30s",
|
|
}),
|
|
).toThrow("invalid OPENCLAW_NPM_LOCK_COMMAND_TIMEOUT_MS: 30s");
|
|
expect(() =>
|
|
createNpmLockExecOptions({ command: "npm", args: ["install"] }, "/tmp/package", {
|
|
OPENCLAW_NPM_LOCK_COMMAND_MAX_BUFFER_BYTES: "64mb",
|
|
}),
|
|
).toThrow("invalid OPENCLAW_NPM_LOCK_COMMAND_MAX_BUFFER_BYTES: 64mb");
|
|
});
|
|
|
|
it("extracts exact versions from npm override specs", () => {
|
|
expect(exactVersionFromOverrideSpec("8.4.0")).toBe("8.4.0");
|
|
expect(exactVersionFromOverrideSpec("npm:@nolyfill/domexception@1.0.28")).toBe("1.0.28");
|
|
expect(exactVersionFromOverrideSpec("^8.4.0")).toBeNull();
|
|
});
|
|
|
|
it("pins same-line pnpm lock versions to the newest locked patch", () => {
|
|
expect(pnpmLockOverrideVersionForVersions(new Set(["3.972.38"]))).toBe("3.972.38");
|
|
expect(pnpmLockOverrideVersionForVersions(new Set(["3.972.38", "3.972.39"]))).toBe("3.972.39");
|
|
expect(pnpmLockOverrideVersionForVersions(new Set(["3.972.39", "3.973.0"]))).toBeNull();
|
|
expect(pnpmLockOverrideVersionForVersions(new Set(["3.972.39", "4.0.0"]))).toBeNull();
|
|
});
|
|
|
|
it("uses scoped forks unless peer contexts conflict under one parent", () => {
|
|
const plan = resolvePnpmLockOverridePlan({
|
|
packages: {
|
|
"@emnapi/core@1.11.1": {},
|
|
"@emnapi/core@1.11.2": {},
|
|
"@types/retry@0.12.0": {},
|
|
"@types/retry@0.12.5": {},
|
|
},
|
|
snapshots: {
|
|
"@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)": {
|
|
dependencies: { "@emnapi/core": "1.11.1" },
|
|
},
|
|
"@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)": {
|
|
dependencies: { "@emnapi/core": "1.11.2" },
|
|
},
|
|
"@slack/web-api@8.0.0": {
|
|
dependencies: { "@types/retry": "0.12.0" },
|
|
},
|
|
"@types/proper-lockfile@4.1.4": {
|
|
dependencies: { "@types/retry": "0.12.5" },
|
|
},
|
|
"p-retry@4.6.2": {
|
|
dependencies: { "@types/retry": "0.12.0" },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(plan).toEqual({
|
|
conflictingPackageNames: ["@emnapi/core"],
|
|
scopedVersionOverrides: {
|
|
"@slack/web-api@8.0.0": { "@types/retry": "0.12.0" },
|
|
"@types/proper-lockfile@4.1.4": { "@types/retry": "0.12.5" },
|
|
"p-retry@4.6.2": { "@types/retry": "0.12.0" },
|
|
},
|
|
versionOverrides: { "@emnapi/core": "1.11.2" },
|
|
});
|
|
});
|
|
|
|
it("parses nested scoped package paths", () => {
|
|
expect(
|
|
parseLockPackagePath("node_modules/@openclaw/codex/node_modules/@anthropic-ai/sdk"),
|
|
).toEqual([
|
|
{
|
|
name: "@openclaw/codex",
|
|
path: "node_modules/@openclaw/codex",
|
|
},
|
|
{
|
|
name: "@anthropic-ai/sdk",
|
|
path: "node_modules/@openclaw/codex/node_modules/@anthropic-ai/sdk",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("parses pnpm lock package keys", () => {
|
|
expect(parsePnpmPackageKey("@aws-sdk/core@3.974.12")).toEqual({
|
|
name: "@aws-sdk/core",
|
|
version: "3.974.12",
|
|
});
|
|
expect(parsePnpmPackageKey("react-dom@19.2.4(react@19.2.4)")).toEqual({
|
|
name: "react-dom",
|
|
version: "19.2.4",
|
|
});
|
|
expect(parsePnpmPackageKey("invalid")).toBeNull();
|
|
});
|
|
|
|
it("disables embedded shrinkwraps that hide workspace overrides under npm 11", () => {
|
|
const lockfile = {
|
|
packages: {
|
|
"": {
|
|
dependencies: {
|
|
"lru-cache": "^11.5.0",
|
|
},
|
|
},
|
|
"node_modules/@openclaw/codex": {
|
|
version: "0.75.4",
|
|
hasShrinkwrap: true,
|
|
},
|
|
"node_modules/@openclaw/codex/node_modules/protobufjs": {
|
|
version: "7.5.9",
|
|
},
|
|
"node_modules/@openclaw/codex/node_modules/fetch-blob": {
|
|
version: "4.0.0",
|
|
},
|
|
"node_modules/@openclaw/codex/node_modules/fetch-blob/node_modules/node-domexception": {
|
|
version: "1.0.0",
|
|
},
|
|
},
|
|
};
|
|
const overrideRules = exactOverrideRulesFromOverrides({
|
|
protobufjs: "8.4.0",
|
|
"node-domexception": "npm:@nolyfill/domexception@1.0.28",
|
|
});
|
|
|
|
expect(collectOverrideViolations(lockfile, overrideRules)).toHaveLength(2);
|
|
expect(disableDependencyShrinkwrapOverrideConflictSources(lockfile, overrideRules)).toEqual([
|
|
"node_modules/@openclaw/codex",
|
|
]);
|
|
expect(lockfile.packages["node_modules/@openclaw/codex"]).not.toHaveProperty("hasShrinkwrap");
|
|
expect(
|
|
lockfile.packages["node_modules/@openclaw/codex/node_modules/protobufjs"],
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("detects npm package-lock entries that bypass the pnpm lock", () => {
|
|
const lockfile = {
|
|
packages: {
|
|
"": {},
|
|
"node_modules/react": {
|
|
version: "19.2.6",
|
|
},
|
|
"node_modules/@nolyfill/domexception": {
|
|
version: "1.0.28",
|
|
},
|
|
},
|
|
};
|
|
const pnpmPackages = new Set(["react@19.2.4", "@nolyfill/domexception@1.0.28"]);
|
|
|
|
expect(collectPnpmLockViolations(lockfile, pnpmPackages, new Map())).toEqual([
|
|
{
|
|
packageKey: "react@19.2.6",
|
|
path: "node_modules/react",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("detects npm package-lock integrity drift from the pnpm lock", () => {
|
|
const packageKey = "react@19.2.4";
|
|
expect(
|
|
collectPnpmLockViolations(
|
|
{
|
|
packages: {
|
|
"node_modules/react": {
|
|
version: "19.2.4",
|
|
integrity: "sha512-unreviewed",
|
|
},
|
|
},
|
|
},
|
|
new Set([packageKey]),
|
|
new Map([[packageKey, new Set(["sha512-reviewed"])]]),
|
|
),
|
|
).toEqual([
|
|
{
|
|
path: "node_modules/react",
|
|
packageKey,
|
|
actualIntegrity: "sha512-unreviewed",
|
|
expectedIntegrities: ["sha512-reviewed"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("normalizes npm patch-version metadata drift", () => {
|
|
expect(
|
|
normalizeNpmVersionDrift({
|
|
packages: {
|
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
|
version: "4.53.5",
|
|
cpu: ["x64"],
|
|
libc: ["glibc"],
|
|
optional: true,
|
|
os: ["linux"],
|
|
},
|
|
"node_modules/zod": {
|
|
version: "4.4.3",
|
|
deprecated: "Use another package",
|
|
peer: true,
|
|
},
|
|
"node_modules/keeps-peer-false": {
|
|
version: "1.0.0",
|
|
peer: false,
|
|
},
|
|
},
|
|
}),
|
|
).toEqual({
|
|
packages: {
|
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
|
version: "4.53.5",
|
|
cpu: ["x64"],
|
|
optional: true,
|
|
os: ["linux"],
|
|
},
|
|
"node_modules/zod": {
|
|
version: "4.4.3",
|
|
},
|
|
"node_modules/keeps-peer-false": {
|
|
version: "1.0.0",
|
|
peer: false,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("uses legacy peer resolution when package extensions mark dependency peers optional", () => {
|
|
expect(
|
|
shouldUseLegacyPeerDepsForNpmLock(
|
|
{ dependencies: { baileys: "7.0.0-rc13" } },
|
|
{ baileys: { peerDependenciesMeta: { sharp: { optional: true } } } },
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
shouldUseLegacyPeerDepsForNpmLock(
|
|
{ dependencies: { "not-baileys": "1.0.0" } },
|
|
{ baileys: { peerDependenciesMeta: { sharp: { optional: true } } } },
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("uses legacy peer resolution when the package has optional peers", () => {
|
|
expect(
|
|
shouldUseLegacyPeerDepsForNpmLock({
|
|
dependencies: { zod: "4.4.3" },
|
|
peerDependencies: { openclaw: ">=2026.5.30" },
|
|
peerDependenciesMeta: { openclaw: { optional: true } },
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("applies package extension peer metadata to generated npm package locks", () => {
|
|
expect(
|
|
applyPackageExtensionPeerMetadata(
|
|
{
|
|
packages: {
|
|
"node_modules/baileys": {
|
|
version: "7.0.0-rc13",
|
|
peerDependencies: {
|
|
"audio-decode": "^2.1.3",
|
|
sharp: "*",
|
|
},
|
|
peerDependenciesMeta: {
|
|
"audio-decode": { optional: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{ baileys: { peerDependenciesMeta: { sharp: { optional: true } } } },
|
|
),
|
|
).toEqual({
|
|
packages: {
|
|
"node_modules/baileys": {
|
|
version: "7.0.0-rc13",
|
|
peerDependencies: {
|
|
"audio-decode": "^2.1.3",
|
|
sharp: "*",
|
|
},
|
|
peerDependenciesMeta: {
|
|
"audio-decode": { optional: true },
|
|
sharp: { optional: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("targets changed publishable plugin manifests", () => {
|
|
expect(
|
|
npmLockPackageDirsForChangedPaths([
|
|
"extensions/acpx/package.json",
|
|
"extensions/acpx/deps/local-runtime/package.json",
|
|
]).map(repoRelativePath),
|
|
).toEqual(["extensions/acpx"]);
|
|
});
|
|
|
|
it("targets the changed publishable gateway protocol manifest", () => {
|
|
expect(
|
|
npmLockPackageDirsForChangedPaths(["packages/gateway-protocol/package.json"]).map(
|
|
repoRelativePath,
|
|
),
|
|
).toEqual(["packages/gateway-protocol"]);
|
|
});
|
|
|
|
it("targets the changed publishable gateway client manifest", () => {
|
|
expect(
|
|
npmLockPackageDirsForChangedPaths(["packages/gateway-client/package.json"]).map(
|
|
repoRelativePath,
|
|
),
|
|
).toEqual(["packages/gateway-client"]);
|
|
});
|
|
|
|
it("falls back to every npm lock when lockfile ownership is ambiguous", () => {
|
|
const packageDirs = npmLockPackageDirsForChangedPaths(["pnpm-lock.yaml"]).map(repoRelativePath);
|
|
|
|
expect(packageDirs).toContain("");
|
|
expect(packageDirs).toContain("packages/gateway-client");
|
|
expect(packageDirs).toContain("packages/gateway-protocol");
|
|
expect(packageDirs).toContain("extensions/acpx");
|
|
});
|
|
|
|
it("falls back to every npm lock when mixed lockfile changes do not map to packages", () => {
|
|
const packageDirs = npmLockPackageDirsForChangedPaths([
|
|
"extensions/acpx/package.json",
|
|
"pnpm-lock.yaml",
|
|
]).map(repoRelativePath);
|
|
|
|
expect(packageDirs).toContain("");
|
|
expect(packageDirs).toContain("extensions/acpx");
|
|
expect(packageDirs.length).toBeGreaterThan(1);
|
|
});
|
|
});
|