diff --git a/CHANGELOG.md b/CHANGELOG.md index f59d770c33f..045d9cce59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792. - Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with `NO_REPLY` so trailing silent sentinels no longer leak summary text to the target channel. (#65004) thanks @neo1027144-creator. - Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so `OPENCLAW_BUNDLED_PLUGINS_DIR` flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras. +- Packaging/plugins: prune common test/spec cargo from bundled plugin runtime dependencies and fail npm release validation if packaged test cargo reappears, keeping published tarballs leaner without plugin-specific special cases. (#67275) thanks @gumadeiras. ## 2026.4.15-beta.1 diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index c1c83835a07..850b04a25f3 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -105,9 +105,40 @@ const FORBIDDEN_PRIVATE_QA_CONTENT_MARKERS = [ "qa-lab/runtime-api.js", ] as const; const FORBIDDEN_PRIVATE_QA_CONTENT_SCAN_PREFIXES = ["dist/"] as const; +const PACKED_TEST_CARGO_DIRECTORY_SEGMENTS = new Set([ + "__snapshots__", + "__tests__", + "test", + "tests", +]); +const PACKED_TEST_CARGO_FILE_RE = /(?:^|\/)[^/]+\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/u; const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024; const skipPackValidationEnv = "OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK"; +function normalizePackedPath(packedPath: string): string { + return packedPath.replace(/\\/g, "/"); +} +function isNodeModulesPackageRoot(segments: string[], index: number): boolean { + const parent = segments[index - 1]; + if (parent === "node_modules") { + return true; + } + return parent?.startsWith("@") && segments[index - 2] === "node_modules"; +} + +function pathContainsPackedTestCargo(packedPath: string): boolean { + const normalizedPath = normalizePackedPath(packedPath); + if (PACKED_TEST_CARGO_FILE_RE.test(normalizedPath)) { + return true; + } + const segments = normalizedPath.split("/").filter(Boolean); + return segments.some( + (segment, index) => + PACKED_TEST_CARGO_DIRECTORY_SEGMENTS.has(segment) && + !isNodeModulesPackageRoot(segments, index), + ); +} + function normalizeRepoUrl(value: unknown): string { if (typeof value !== "string") { return ""; @@ -490,6 +521,7 @@ function collectPackedTarballErrors(): string[] { ...collectControlUiPackErrors(packedPaths), ...collectForbiddenPackedPathErrors(packedPaths), ...collectForbiddenPackedContentErrors(packedPaths), + ...collectPackedTestCargoErrors(packedPaths), ]; } @@ -544,6 +576,17 @@ export function collectForbiddenPackedContentErrors( return errors.toSorted((left, right) => left.localeCompare(right)); } +export function collectPackedTestCargoErrors(paths: Iterable): string[] { + const errors: string[] = []; + for (const packedPath of paths) { + if (!pathContainsPackedTestCargo(packedPath)) { + continue; + } + errors.push(`npm package must not include test cargo "${packedPath}".`); + } + return errors.toSorted((left, right) => left.localeCompare(right)); +} + async function main(): Promise { const pkg = loadPackageJson(); const now = new Date(); diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index 1431bce8fc6..d1ddf4c7635 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -142,6 +142,15 @@ function readInstalledDependencyVersionFromRoot(depRoot) { } const defaultStagedRuntimeDepGlobalPruneSuffixes = [".d.ts", ".map"]; +const defaultStagedRuntimeDepGlobalPruneDirectories = [ + "__snapshots__", + "__tests__", + "test", + "tests", +]; +const defaultStagedRuntimeDepGlobalPruneFilePatterns = [ + /(?:^|\/)[^/]+\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/u, +]; const defaultStagedRuntimeDepPruneRules = new Map([ // Type declarations only; runtime resolves through lib/es entrypoints. ["@larksuiteoapi/node-sdk", { paths: ["types"] }], @@ -182,11 +191,17 @@ const defaultStagedRuntimeDepPruneRules = new Map([ ["@jimp/plugin-quantize", { paths: ["src/__image_snapshots__"] }], ["@jimp/plugin-threshold", { paths: ["src/__image_snapshots__"] }], ]); -const runtimeDepsStagingVersion = 5; +const runtimeDepsStagingVersion = 6; const exactVersionSpecRe = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; function resolveRuntimeDepPruneConfig(params = {}) { return { + globalPruneDirectories: + params.stagedRuntimeDepGlobalPruneDirectories ?? + defaultStagedRuntimeDepGlobalPruneDirectories, + globalPruneFilePatterns: + params.stagedRuntimeDepGlobalPruneFilePatterns ?? + defaultStagedRuntimeDepGlobalPruneFilePatterns, globalPruneSuffixes: params.stagedRuntimeDepGlobalPruneSuffixes ?? defaultStagedRuntimeDepGlobalPruneSuffixes, pruneRules: params.stagedRuntimeDepPruneRules ?? defaultStagedRuntimeDepPruneRules, @@ -463,8 +478,8 @@ function walkFiles(rootDir, visitFile) { return; } const queue = [rootDir]; - while (queue.length > 0) { - const currentDir = queue.shift(); + for (let index = 0; index < queue.length; index += 1) { + const currentDir = queue[index]; for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { @@ -489,6 +504,53 @@ function pruneDependencyFilesBySuffixes(depRoot, suffixes) { }); } +function relativePathSegments(rootDir, fullPath) { + return path.relative(rootDir, fullPath).split(path.sep).filter(Boolean); +} + +function isNodeModulesPackageRoot(segments, index) { + const parent = segments[index - 1]; + if (parent === "node_modules") { + return true; + } + return parent?.startsWith("@") === true && segments[index - 2] === "node_modules"; +} + +function pruneDependencyDirectoriesByBasename(depRoot, basenames) { + if (!basenames || basenames.length === 0 || !fs.existsSync(depRoot)) { + return; + } + const basenameSet = new Set(basenames); + const queue = [depRoot]; + for (let index = 0; index < queue.length; index += 1) { + const currentDir = queue[index]; + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const fullPath = path.join(currentDir, entry.name); + const segments = relativePathSegments(depRoot, fullPath); + if (basenameSet.has(entry.name) && !isNodeModulesPackageRoot(segments, segments.length - 1)) { + removePathIfExists(fullPath); + continue; + } + queue.push(fullPath); + } + } +} + +function pruneDependencyFilesByPatterns(depRoot, patterns) { + if (!patterns || patterns.length === 0 || !fs.existsSync(depRoot)) { + return; + } + walkFiles(depRoot, (fullPath) => { + const relativePath = relativePathSegments(depRoot, fullPath).join("/"); + if (patterns.some((pattern) => pattern.test(relativePath))) { + removePathIfExists(fullPath); + } + }); +} + function pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfig) { const depRoot = dependencyNodeModulesPath(nodeModulesDir, depName); if (depRoot === null) { @@ -498,6 +560,8 @@ function pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfi for (const relativePath of pruneRule?.paths ?? []) { removePathIfExists(path.join(depRoot, relativePath)); } + pruneDependencyDirectoriesByBasename(depRoot, pruneConfig.globalPruneDirectories); + pruneDependencyFilesByPatterns(depRoot, pruneConfig.globalPruneFilePatterns); pruneDependencyFilesBySuffixes(depRoot, pruneConfig.globalPruneSuffixes); pruneDependencyFilesBySuffixes(depRoot, pruneRule?.suffixes ?? []); } @@ -784,6 +848,10 @@ function createRuntimeDepsFingerprint(packageJson, pruneConfig, params = {}) { return createHash("sha256") .update( JSON.stringify({ + globalPruneDirectories: pruneConfig.globalPruneDirectories, + globalPruneFilePatterns: pruneConfig.globalPruneFilePatterns.map((pattern) => + pattern.toString(), + ), globalPruneSuffixes: pruneConfig.globalPruneSuffixes, packageJson, pruneRules: [...pruneConfig.pruneRules.entries()], diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 6eaa26d9ac1..75c5a8748f5 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -8,6 +8,7 @@ import { collectControlUiPackErrors, collectForbiddenPackedContentErrors, collectForbiddenPackedPathErrors, + collectPackedTestCargoErrors, collectReleasePackageMetadataErrors, collectReleaseTagErrors, parseNpmPackJsonOutput, @@ -380,6 +381,55 @@ describe("collectForbiddenPackedPathErrors", () => { }); }); +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", + ]), + ).toEqual([]); + }); + + 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", + ]), + ).toEqual([]); + }); + + 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( diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index ae6d5b6b2bc..ed6190d517b 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -362,6 +362,120 @@ describe("stageBundledPluginRuntimeDeps", () => { expect(fs.existsSync(path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"))).toBe(true); }); + it("prunes staged test cargo from copied runtime dependencies", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { direct: "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const directDir = path.join(repoRoot, "node_modules", "direct"); + fs.mkdirSync(path.join(directDir, "test"), { recursive: true }); + fs.mkdirSync(path.join(directDir, "__snapshots__"), { recursive: true }); + fs.mkdirSync(path.join(directDir, "src"), { recursive: true }); + fs.writeFileSync( + path.join(directDir, "package.json"), + '{ "name": "direct", "version": "1.0.0" }\n', + "utf8", + ); + fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'runtime';\n", "utf8"); + fs.writeFileSync( + path.join(directDir, "test", "index.test.js"), + "module.exports = 'remove';\n", + "utf8", + ); + fs.writeFileSync( + path.join(directDir, "__snapshots__", "index.test.ts.snap"), + "snapshot\n", + "utf8", + ); + fs.writeFileSync( + path.join(directDir, "src", "runtime.spec.js"), + "module.exports = 'remove';\n", + "utf8", + ); + + stageBundledPluginRuntimeDeps({ cwd: repoRoot }); + + expect( + fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"), + ).toBe("module.exports = 'runtime';\n"); + expect( + fs.existsSync(path.join(pluginDir, "node_modules", "direct", "test", "index.test.js")), + ).toBe(false); + expect( + fs.existsSync( + path.join(pluginDir, "node_modules", "direct", "__snapshots__", "index.test.ts.snap"), + ), + ).toBe(false); + expect( + fs.existsSync(path.join(pluginDir, "node_modules", "direct", "src", "runtime.spec.js")), + ).toBe(false); + }); + + it("preserves nested runtime dependencies named test or tests", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + dependencies: { direct: "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const directDir = path.join(repoRoot, "node_modules", "direct"); + const nestedTestDir = path.join(directDir, "node_modules", "test"); + const scopedTestsDir = path.join(directDir, "node_modules", "@scope", "tests"); + fs.mkdirSync(nestedTestDir, { recursive: true }); + fs.mkdirSync(scopedTestsDir, { recursive: true }); + fs.writeFileSync( + path.join(directDir, "package.json"), + '{ "name": "direct", "version": "1.0.0", "dependencies": { "test": "^1.0.0", "@scope/tests": "^1.0.0" } }\n', + "utf8", + ); + fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8"); + fs.writeFileSync( + path.join(nestedTestDir, "package.json"), + '{ "name": "test", "version": "1.0.0" }\n', + "utf8", + ); + fs.writeFileSync(path.join(nestedTestDir, "index.js"), "module.exports = 'test';\n", "utf8"); + fs.writeFileSync( + path.join(scopedTestsDir, "package.json"), + '{ "name": "@scope/tests", "version": "1.0.0" }\n', + "utf8", + ); + fs.writeFileSync( + path.join(scopedTestsDir, "index.js"), + "module.exports = 'scoped-tests';\n", + "utf8", + ); + + stageBundledPluginRuntimeDeps({ cwd: repoRoot }); + + expect( + fs.readFileSync( + path.join(pluginDir, "node_modules", "direct", "node_modules", "test", "index.js"), + "utf8", + ), + ).toBe("module.exports = 'test';\n"); + expect( + fs.readFileSync( + path.join( + pluginDir, + "node_modules", + "direct", + "node_modules", + "@scope", + "tests", + "index.js", + ), + "utf8", + ), + ).toBe("module.exports = 'scoped-tests';\n"); + }); + it("stages hoisted transitive runtime deps from the root node_modules", () => { const { pluginDir, repoRoot } = createBundledPluginFixture({ packageJson: {