diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index c1c83835a07..9371d766da4 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -105,9 +105,41 @@ 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 ""; @@ -489,7 +521,11 @@ function collectPackedTarballErrors(): string[] { return [ ...collectControlUiPackErrors(packedPaths), ...collectForbiddenPackedPathErrors(packedPaths), +<<<<<<< HEAD ...collectForbiddenPackedContentErrors(packedPaths), +======= + ...collectPackedTestCargoErrors(packedPaths), +>>>>>>> caafdea0bb (Build: prune packaged runtime test cargo) ]; } @@ -544,7 +580,22 @@ 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)); +} + +<<<<<<< HEAD async function main(): Promise { +======= +function main(): number { +>>>>>>> caafdea0bb (Build: prune packaged runtime test cargo) const pkg = loadPackageJson(); const now = new Date(); const skipPackValidation = shouldSkipPackedTarballValidation(); diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index 1431bce8fc6..5c04686b1a2 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, @@ -489,6 +504,40 @@ function pruneDependencyFilesBySuffixes(depRoot, suffixes) { }); } +function pruneDependencyDirectoriesByBasename(depRoot, basenames) { + if (!basenames || basenames.length === 0 || !fs.existsSync(depRoot)) { + return; + } + const basenameSet = new Set(basenames); + const queue = [depRoot]; + while (queue.length > 0) { + const currentDir = queue.shift(); + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const fullPath = path.join(currentDir, entry.name); + if (basenameSet.has(entry.name)) { + removePathIfExists(fullPath); + continue; + } + queue.push(fullPath); + } + } +} + +function pruneDependencyFilesByPatterns(depRoot, patterns) { + if (!patterns || patterns.length === 0 || !fs.existsSync(depRoot)) { + return; + } + walkFiles(depRoot, (fullPath) => { + const relativePath = path.relative(depRoot, fullPath).replace(/\\/g, "/"); + if (patterns.some((pattern) => pattern.test(relativePath))) { + removePathIfExists(fullPath); + } + }); +} + function pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfig) { const depRoot = dependencyNodeModulesPath(nodeModulesDir, depName); if (depRoot === null) { @@ -498,6 +547,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 +835,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..523dd0bc777 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,33 @@ 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([]); + }); +}); + 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..e5634f8a945 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -362,6 +362,59 @@ 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("stages hoisted transitive runtime deps from the root node_modules", () => { const { pluginDir, repoRoot } = createBundledPluginFixture({ packageJson: {