// 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.mts"; 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( { bundleDependencies: ["chalk"], bundledDependencies: ["chalk"], dependencies: { "@openclaw/ai": "workspace:2026.6.11", chalk: "5.6.2" }, devDependencies: { local: "workspace:*" }, peerDependencies: { host: "workspace:^1.2.3" }, }, {}, ); expect(normalized).not.toHaveProperty("bundleDependencies"); expect(normalized).not.toHaveProperty("bundledDependencies"); 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); }); });