import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { WORKSPACE_TEMPLATE_PACK_PATHS } from "../scripts/lib/workspace-bootstrap-smoke.mjs"; import { compareReleaseVersions, collectControlUiPackErrors, collectForbiddenPackedContentErrors, collectForbiddenPackedPathErrors, collectPackedTestCargoErrors, collectReleasePackageMetadataErrors, collectReleaseTagErrors, parseNpmPackJsonOutput, parseReleaseTagVersion, parseReleaseVersion, resolveNpmDistTagMirrorAuth, resolveNpmPublishPlan, resolveNpmCommandInvocation, shouldSkipPackedTarballValidation, utcCalendarDayDistance, } from "../scripts/openclaw-npm-release-check.ts"; import { LOCAL_BUILD_METADATA_DIST_PATHS, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, } from "../src/infra/package-dist-inventory.ts"; const REQUIRED_PACKED_PATHS = [ PACKAGE_DIST_INVENTORY_RELATIVE_PATH, ...WORKSPACE_TEMPLATE_PACK_PATHS, ] as const; describe("parseReleaseVersion", () => { it("parses stable CalVer releases", () => { expect(parseReleaseVersion("2026.3.10")).toStrictEqual({ version: "2026.3.10", baseVersion: "2026.3.10", channel: "stable", year: 2026, month: 3, day: 10, alphaNumber: undefined, betaNumber: undefined, date: new Date(Date.UTC(2026, 2, 10)), }); }); it("parses beta CalVer releases", () => { expect(parseReleaseVersion("2026.3.10-beta.2")).toStrictEqual({ version: "2026.3.10-beta.2", baseVersion: "2026.3.10", channel: "beta", year: 2026, month: 3, day: 10, alphaNumber: undefined, betaNumber: 2, date: new Date(Date.UTC(2026, 2, 10)), }); }); it("parses alpha CalVer releases", () => { expect(parseReleaseVersion("2026.3.10-alpha.2")).toStrictEqual({ version: "2026.3.10-alpha.2", baseVersion: "2026.3.10", channel: "alpha", year: 2026, month: 3, day: 10, alphaNumber: 2, betaNumber: undefined, date: new Date(Date.UTC(2026, 2, 10)), }); }); it("parses stable correction releases", () => { expect(parseReleaseVersion("2026.3.10-1")).toStrictEqual({ version: "2026.3.10-1", baseVersion: "2026.3.10", channel: "stable", year: 2026, month: 3, day: 10, alphaNumber: undefined, betaNumber: undefined, date: new Date(Date.UTC(2026, 2, 10)), correctionNumber: 1, }); }); it("rejects legacy and malformed release formats", () => { expect(parseReleaseVersion("2026.03.09")).toBeNull(); expect(parseReleaseVersion("v2026.3.10")).toBeNull(); expect(parseReleaseVersion("2026.2.30")).toBeNull(); expect(parseReleaseVersion("2026.3.10-0")).toBeNull(); expect(parseReleaseVersion("2.0.0-beta2")).toBeNull(); }); }); describe("parseReleaseTagVersion", () => { it("accepts correction release tags", () => { expect(parseReleaseTagVersion("2026.3.10-2")).toStrictEqual({ version: "2026.3.10-2", packageVersion: "2026.3.10-2", baseVersion: "2026.3.10", channel: "stable", date: new Date(Date.UTC(2026, 2, 10)), correctionNumber: 2, }); }); it("rejects beta correction tags and malformed correction tags", () => { expect(parseReleaseTagVersion("2026.3.10-beta.1-1")).toBeNull(); expect(parseReleaseTagVersion("2026.3.10-0")).toBeNull(); }); }); describe("resolveNpmPublishPlan", () => { it("publishes beta prereleases to beta only", () => { expect(resolveNpmPublishPlan("2026.3.29-beta.2")).toEqual({ channel: "beta", publishTag: "beta", mirrorDistTags: [], }); }); it("publishes alpha prereleases to alpha only", () => { expect(resolveNpmPublishPlan("2026.3.29-alpha.2", undefined, "alpha")).toEqual({ channel: "alpha", publishTag: "alpha", mirrorDistTags: [], }); }); it("publishes stable releases to beta first", () => { expect(resolveNpmPublishPlan("2026.3.29")).toEqual({ channel: "stable", publishTag: "beta", mirrorDistTags: [], }); }); it("publishes stable correction releases to beta first too", () => { expect(resolveNpmPublishPlan("2026.3.29-2")).toEqual({ channel: "stable", publishTag: "beta", mirrorDistTags: [], }); }); it("can publish stable releases directly to latest when requested", () => { expect(resolveNpmPublishPlan("2026.3.29", undefined, "latest")).toEqual({ channel: "stable", publishTag: "latest", mirrorDistTags: [], }); }); it("can publish stable correction releases directly to latest when requested", () => { expect(resolveNpmPublishPlan("2026.3.29-1", undefined, "latest")).toEqual({ channel: "stable", publishTag: "latest", mirrorDistTags: [], }); }); it("ignores current beta dist-tag state for stable publishes", () => { expect(resolveNpmPublishPlan("2026.3.29", "2026.4.1-beta.1")).toEqual({ channel: "stable", publishTag: "beta", mirrorDistTags: [], }); }); it("rejects publishing beta prereleases to latest", () => { expect(() => resolveNpmPublishPlan("2026.3.29-beta.2", undefined, "latest")).toThrow( "Beta prereleases must publish to the beta dist-tag.", ); }); it("rejects publishing alpha prereleases to beta or latest", () => { expect(() => resolveNpmPublishPlan("2026.3.29-alpha.2")).toThrow( "Alpha prereleases must publish to the alpha dist-tag.", ); expect(() => resolveNpmPublishPlan("2026.3.29-alpha.2", undefined, "latest")).toThrow( "Alpha prereleases must publish to the alpha dist-tag.", ); }); }); describe("resolveNpmDistTagMirrorAuth", () => { it("prefers NODE_AUTH_TOKEN when both auth env vars exist", () => { expect( resolveNpmDistTagMirrorAuth({ nodeAuthToken: "node-token", npmToken: "npm-token", }), ).toEqual({ hasAuth: true, source: "node-auth-token", }); }); it("falls back to NPM_TOKEN when NODE_AUTH_TOKEN is missing", () => { expect( resolveNpmDistTagMirrorAuth({ nodeAuthToken: " ", npmToken: "npm-token", }), ).toEqual({ hasAuth: true, source: "npm-token", }); }); it("reports missing auth when neither token exists", () => { expect( resolveNpmDistTagMirrorAuth({ nodeAuthToken: "", npmToken: undefined, }), ).toEqual({ hasAuth: false, source: "none", }); }); }); describe("shouldSkipPackedTarballValidation", () => { it("defaults to full pack validation", () => { expect(shouldSkipPackedTarballValidation({})).toBe(false); }); it("accepts truthy values for metadata-only validation", () => { expect( shouldSkipPackedTarballValidation({ OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "1", }), ).toBe(true); }); it("treats false-like values as disabled", () => { expect( shouldSkipPackedTarballValidation({ OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "false", }), ).toBe(false); }); }); describe("compareReleaseVersions", () => { it("treats stable as newer than same-day beta", () => { expect(compareReleaseVersions("2026.3.29", "2026.3.29-beta.2")).toBe(1); }); it("orders alpha before beta on the same day", () => { expect(compareReleaseVersions("2026.3.29-alpha.2", "2026.3.29-beta.1")).toBe(-1); }); it("treats a newer beta day as newer than an older stable day", () => { expect(compareReleaseVersions("2026.4.1-beta.1", "2026.3.29")).toBe(1); }); it("orders stable correction releases after the base stable release", () => { expect(compareReleaseVersions("2026.3.29-2", "2026.3.29")).toBe(1); }); it("returns null when either version is not release-shaped", () => { expect(compareReleaseVersions("latest", "2026.3.29")).toBeNull(); }); }); describe("utcCalendarDayDistance", () => { it("compares UTC calendar days rather than wall-clock hours", () => { const left = new Date("2026-03-09T23:59:59Z"); const right = new Date("2026-03-11T00:00:01Z"); expect(utcCalendarDayDistance(left, right)).toBe(2); }); }); describe("resolveNpmCommandInvocation", () => { it("uses npm_execpath when it points to npm", () => { expect( resolveNpmCommandInvocation({ npmExecPath: "/usr/local/lib/node_modules/npm/bin/npm-cli.js", nodeExecPath: "/usr/local/bin/node", platform: "linux", }), ).toEqual({ command: "/usr/local/bin/node", args: ["/usr/local/lib/node_modules/npm/bin/npm-cli.js"], }); }); it("falls back to the npm command when npm_execpath points to pnpm", () => { expect( resolveNpmCommandInvocation({ npmExecPath: "/home/test/.cache/node/corepack/v1/pnpm/10.23.0/bin/pnpm.cjs", nodeExecPath: "/usr/local/bin/node", platform: "linux", }), ).toEqual({ command: "npm", args: [], }); }); it("uses the platform npm command when npm_execpath is missing", () => { expect(resolveNpmCommandInvocation({ platform: "win32" })).toEqual({ command: "npm.cmd", args: [], }); }); }); describe("parseNpmPackJsonOutput", () => { it("parses a plain npm pack JSON array", () => { expect(parseNpmPackJsonOutput('[{"filename":"openclaw.tgz","files":[]}]')).toEqual([ { filename: "openclaw.tgz", files: [] }, ]); }); it("parses the trailing JSON payload after npm lifecycle logs", () => { const stdout = [ 'npm warn Unknown project config "node-linker".', "", "> openclaw@2026.3.23 prepack", "> pnpm build && pnpm ui:build", "", "[copy-hook-metadata] Copied 4 hook metadata files.", '[{"filename":"openclaw.tgz","files":[{"path":"dist/control-ui/index.html"}]}]', ].join("\n"); expect(parseNpmPackJsonOutput(stdout)).toEqual([ { filename: "openclaw.tgz", files: [{ path: "dist/control-ui/index.html" }], }, ]); }); it("returns null when no JSON payload is present", () => { expect(parseNpmPackJsonOutput("> openclaw@2026.3.23 prepack")).toBeNull(); }); }); describe("collectControlUiPackErrors", () => { it("rejects packs that ship the dashboard HTML without the asset payload", () => { expect(collectControlUiPackErrors(["dist/control-ui/index.html"])).toEqual([ ...REQUIRED_PACKED_PATHS.map( (requiredPath) => `npm package is missing required path "${requiredPath}". Ensure UI assets are built and included before publish.`, ), 'npm package is missing Control UI asset payload under "dist/control-ui/assets/". Refuse release when the dashboard tarball would be empty.', ]); }); it("accepts packs that ship dashboard HTML and bundled assets", () => { expect( collectControlUiPackErrors([ "dist/control-ui/index.html", ...REQUIRED_PACKED_PATHS, "dist/control-ui/assets/index-Bu8rSoJV.js", "dist/control-ui/assets/index-BK0yXA_h.css", ]), ).toStrictEqual([]); }); }); describe("collectForbiddenPackedPathErrors", () => { it("rejects generated docs artifacts in npm pack output", () => { expect( collectForbiddenPackedPathErrors([ "dist/index.js", "docs/.generated/config-baseline.json", "docs/.generated/config-baseline.plugin.json", ]), ).toEqual([ 'npm package must not include generated docs artifact "docs/.generated/config-baseline.json".', 'npm package must not include generated docs artifact "docs/.generated/config-baseline.plugin.json".', ]); }); it("rejects local build metadata in npm pack output", () => { expect( collectForbiddenPackedPathErrors(["dist/index.js", ...LOCAL_BUILD_METADATA_DIST_PATHS]), ).toEqual([ 'npm package must not include local build metadata "dist/.buildstamp".', 'npm package must not include local build metadata "dist/.runtime-postbuildstamp".', ]); }); it("rejects private qa artifacts in npm pack output", () => { expect( collectForbiddenPackedPathErrors([ "dist/extensions/qa-channel/runtime-api.js", "dist/extensions/qa-channel/package.json", "dist/extensions/qa-lab/runtime-api.js", "dist/extensions/qa-lab/src/cli.js", "dist/plugin-sdk/extensions/qa-channel/api.d.ts", "dist/plugin-sdk/extensions/qa-lab/cli.d.ts", "dist/plugin-sdk/qa-channel.js", "dist/plugin-sdk/qa-channel-protocol.d.ts", "dist/qa-runtime-B9LDtssJ.js", "docs/channels/qa-channel.md", "qa/scenarios/index.md", ]), ).toEqual([ 'npm package must not include private QA channel artifact "dist/extensions/qa-channel/package.json".', 'npm package must not include private QA channel artifact "dist/extensions/qa-channel/runtime-api.js".', 'npm package must not include private QA channel docs "docs/channels/qa-channel.md".', 'npm package must not include private QA channel SDK artifact "dist/plugin-sdk/qa-channel-protocol.d.ts".', 'npm package must not include private QA channel SDK artifact "dist/plugin-sdk/qa-channel.js".', 'npm package must not include private QA channel type artifact "dist/plugin-sdk/extensions/qa-channel/api.d.ts".', 'npm package must not include private QA lab artifact "dist/extensions/qa-lab/runtime-api.js".', 'npm package must not include private QA lab artifact "dist/extensions/qa-lab/src/cli.js".', 'npm package must not include private QA lab type artifact "dist/plugin-sdk/extensions/qa-lab/cli.d.ts".', 'npm package must not include private QA runtime chunk "dist/qa-runtime-B9LDtssJ.js".', 'npm package must not include private QA suite artifact "qa/scenarios/index.md".', ]); }); it("rejects legacy update verifier QA runtime sidecars", () => { expect( collectForbiddenPackedPathErrors([ "dist/extensions/qa-channel/runtime-api.js", "dist/extensions/qa-lab/runtime-api.js", ]), ).toEqual([ 'npm package must not include private QA channel artifact "dist/extensions/qa-channel/runtime-api.js".', 'npm package must not include private QA lab artifact "dist/extensions/qa-lab/runtime-api.js".', ]); }); it("rejects root dist chunks that still reference the private qa lab", () => { const rootDir = mkdtempSync(join(tmpdir(), "openclaw-pack-private-qa-")); try { mkdirSync(join(rootDir, "dist"), { recursive: true }); writeFileSync( join(rootDir, "dist", "entry.js"), "//#region extensions/qa-lab/src/cli.ts\n", "utf8", ); writeFileSync(join(rootDir, "README.md"), "developer docs mention extensions/qa-lab/\n"); expect(collectForbiddenPackedContentErrors(["dist/entry.js", "README.md"], rootDir)).toEqual([ 'npm package must not include private QA lab marker "//#region extensions/qa-lab/" in "dist/entry.js".', ]); } finally { rmSync(rootDir, { recursive: true, force: true }); } }); it("rejects private QA paths in the generated dist inventory", () => { const rootDir = mkdtempSync(join(tmpdir(), "openclaw-pack-inventory-")); try { mkdirSync(join(rootDir, "dist"), { recursive: true }); writeFileSync( join(rootDir, PACKAGE_DIST_INVENTORY_RELATIVE_PATH), JSON.stringify(["dist/extensions/qa-lab/runtime-api.js"]), "utf8", ); expect( collectForbiddenPackedContentErrors([PACKAGE_DIST_INVENTORY_RELATIVE_PATH], rootDir), ).toEqual([ 'npm package must not include private QA lab marker "qa-lab/runtime-api.js" in "dist/postinstall-inventory.json".', ]); } finally { rmSync(rootDir, { recursive: true, force: true }); } }); }); describe("collectPackedTestCargoErrors", () => { it("rejects packed test files and test directories", () => { expect( collectPackedTestCargoErrors([ "dist/extensions/webhooks/node_modules/zod/src/v3/tests/all-errors.test.ts", "dist/extensions/whatsapp/node_modules/pino/test/basic.test.js", "dist/extensions/whatsapp/node_modules/@jimp/plugin-crop/src/__snapshots__/crop.test.ts.snap", "dist/index.js", ]), ).toEqual([ 'npm package must not include test cargo "dist/extensions/webhooks/node_modules/zod/src/v3/tests/all-errors.test.ts".', 'npm package must not include test cargo "dist/extensions/whatsapp/node_modules/@jimp/plugin-crop/src/__snapshots__/crop.test.ts.snap".', 'npm package must not include test cargo "dist/extensions/whatsapp/node_modules/pino/test/basic.test.js".', ]); }); it("allows normal runtime files", () => { expect( collectPackedTestCargoErrors([ "dist/index.js", "dist/extensions/whatsapp/node_modules/pino/lib/proto.js", "dist/extensions/webhooks/node_modules/zod/v4/core/api.js", ]), ).toStrictEqual([]); }); it("allows legitimate package roots named test under node_modules", () => { expect( collectPackedTestCargoErrors([ "dist/extensions/fixture-plugin/node_modules/direct/node_modules/test/index.js", "dist/extensions/fixture-plugin/node_modules/direct/node_modules/@scope/tests/index.js", ]), ).toStrictEqual([]); }); it("allows leaf runtime filenames named test or tests", () => { expect( collectPackedTestCargoErrors([ "dist/extensions/fixture-plugin/node_modules/direct/bin/test", "dist/extensions/fixture-plugin/node_modules/direct/bin/tests", ]), ).toStrictEqual([]); }); it("normalizes Windows or mixed separators before classifying test cargo", () => { expect( collectPackedTestCargoErrors([ String.raw`dist\extensions\fixture-plugin\node_modules\direct\__tests__\index.js`, String.raw`dist/extensions/fixture-plugin\node_modules/direct/src/runtime.spec.ts`, String.raw`dist\extensions\fixture-plugin\node_modules\direct\node_modules\test\index.js`, ]), ).toEqual([ `npm package must not include test cargo "${String.raw`dist/extensions/fixture-plugin\node_modules/direct/src/runtime.spec.ts`}".`, `npm package must not include test cargo "${String.raw`dist\extensions\fixture-plugin\node_modules\direct\__tests__\index.js`}".`, ]); }); }); describe("collectReleaseTagErrors", () => { it("accepts versions within the two-day CalVer window", () => { expect( collectReleaseTagErrors({ packageVersion: "2026.3.10", releaseTag: "v2026.3.10", now: new Date("2026-03-11T12:00:00Z"), }), ).toStrictEqual([]); }); it("rejects versions outside the two-day CalVer window", () => { expect( collectReleaseTagErrors({ packageVersion: "2026.3.10", releaseTag: "v2026.3.10", now: new Date("2026-03-13T00:00:00Z"), }), ).toStrictEqual([ "Release version 2026.3.10 is 3 days away from current UTC date 2026-03-13; release CalVer date 2026-03-10 must be within 2 days.", ]); }); it("accepts fallback correction tags for stable package versions", () => { expect( collectReleaseTagErrors({ packageVersion: "2026.3.10", releaseTag: "v2026.3.10-1", now: new Date("2026-03-10T00:00:00Z"), }), ).toStrictEqual([]); }); it("accepts correction package versions paired with matching correction tags", () => { expect( collectReleaseTagErrors({ packageVersion: "2026.3.10-1", releaseTag: "v2026.3.10-1", now: new Date("2026-03-10T00:00:00Z"), }), ).toStrictEqual([]); }); it("rejects beta package versions paired with fallback correction tags", () => { expect( collectReleaseTagErrors({ packageVersion: "2026.3.10-beta.1", releaseTag: "v2026.3.10-1", now: new Date("2026-03-10T00:00:00Z"), }), ).toStrictEqual([ "Release tag v2026.3.10-1 does not match package.json version 2026.3.10-beta.1; expected v2026.3.10-beta.1.", ]); }); }); describe("collectReleasePackageMetadataErrors", () => { it("validates the expected npm package metadata", () => { expect( collectReleasePackageMetadataErrors({ name: "openclaw", description: "Multi-channel AI gateway with extensible messaging integrations", license: "MIT", repository: { url: "git+https://github.com/openclaw/openclaw.git" }, bin: { openclaw: "openclaw.mjs" }, }), ).toStrictEqual([]); }); it("rejects node-llama-cpp as a peer dependency", () => { expect( collectReleasePackageMetadataErrors({ name: "openclaw", description: "Multi-channel AI gateway with extensible messaging integrations", license: "MIT", repository: { url: "git+https://github.com/openclaw/openclaw.git" }, bin: { openclaw: "openclaw.mjs" }, peerDependencies: { "node-llama-cpp": "3.18.1" }, peerDependenciesMeta: { "node-llama-cpp": { optional: true } }, }), ).toEqual([ 'package.json peerDependencies["node-llama-cpp"] must be omitted; keep it optional.', 'package.json peerDependenciesMeta["node-llama-cpp"] must be omitted; keep it optional.', ]); }); it("rejects node-llama-cpp as a direct runtime dependency", () => { expect( collectReleasePackageMetadataErrors({ name: "openclaw", description: "Multi-channel AI gateway with extensible messaging integrations", license: "MIT", repository: { url: "git+https://github.com/openclaw/openclaw.git" }, bin: { openclaw: "openclaw.mjs" }, dependencies: { "node-llama-cpp": "3.18.1" }, }), ).toContain('package.json dependencies["node-llama-cpp"] must be omitted; keep it optional.'); }); it("rejects local fs-safe dependency specs for npm release", () => { expect( collectReleasePackageMetadataErrors({ name: "openclaw", description: "Multi-channel AI gateway with extensible messaging integrations", license: "MIT", repository: { url: "git+https://github.com/openclaw/openclaw.git" }, bin: { openclaw: "openclaw.mjs" }, dependencies: { "@openclaw/fs-safe": "link:../fs-safe" }, }), ).toContain( 'package.json dependencies["@openclaw/fs-safe"] must use a published semver range before npm release; found "link:../fs-safe".', ); }); it("rejects node-llama-cpp as an optional dependency", () => { expect( collectReleasePackageMetadataErrors({ name: "openclaw", description: "Multi-channel AI gateway with extensible messaging integrations", license: "MIT", repository: { url: "git+https://github.com/openclaw/openclaw.git" }, bin: { openclaw: "openclaw.mjs" }, optionalDependencies: { "node-llama-cpp": "3.18.1" }, }), ).toContain( 'package.json optionalDependencies["node-llama-cpp"] must be omitted; keep it operator-installed.', ); }); });