diff --git a/test/plugin-npm-package-manifest.test.ts b/test/plugin-npm-package-manifest.test.ts index 2b03698219af..e90dc7c7e98f 100644 --- a/test/plugin-npm-package-manifest.test.ts +++ b/test/plugin-npm-package-manifest.test.ts @@ -388,8 +388,25 @@ describe("plugin npm package manifest staging", () => { it("overlays package-local runtime metadata while packing and restores source package json", () => { const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-runtime-"); const packageDir = writePublishablePluginPackage(repoDir); + const sourcePackageJson = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); + sourcePackageJson.openclaw.channel = { + id: "diffs", + configuredState: { + specifier: "./configured-state", + exportName: "hasConfiguredChannelState", + }, + }; + writeJsonFile(join(packageDir, "package.json"), sourcePackageJson); + writeFileText( + join(packageDir, "configured-state.ts"), + "export function hasConfiguredChannelState() {}\n", + ); writeFileText(join(packageDir, "dist", "index.js"), "export {};\n"); writeFileText(join(packageDir, "dist", "setup-entry.js"), "export {};\n"); + writeFileText( + join(packageDir, "dist", "configured-state.js"), + "export function hasConfiguredChannelState() { return true; }\n", + ); const resolved = resolveAugmentedPluginNpmPackageJson({ repoRoot: repoDir, @@ -414,6 +431,13 @@ describe("plugin npm package manifest staging", () => { openclaw: { extensions: ["./index.ts"], setupEntry: "./dist/setup-entry.js", + channel: { + id: "diffs", + configuredState: { + specifier: "./dist/configured-state.js", + exportName: "hasConfiguredChannelState", + }, + }, compat: { pluginApi: ">=2026.4.30", }, @@ -436,6 +460,9 @@ describe("plugin npm package manifest staging", () => { expect(stagedPackageJson.openclaw.runtimeExtensions).toEqual(["./dist/index.js"]); expect(stagedPackageJson.openclaw.setupEntry).toBe("./dist/setup-entry.js"); expect(stagedPackageJson.openclaw.runtimeSetupEntry).toBe("./dist/setup-entry.js"); + expect(stagedPackageJson.openclaw.channel.configuredState.specifier).toBe( + "./dist/configured-state.js", + ); expect(stagedPackageJson.bundledDependencies).toEqual([]); expect(stagedPackageJson.bundleDependencies).toBeUndefined(); expect(stagedPackageJson.files).toContain("dist/**"); @@ -448,159 +475,135 @@ describe("plugin npm package manifest staging", () => { expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe(originalText); }); - it.each( - [ - { - label: "ESM configured-state", - metadataKey: "configuredState", - runtimeFormat: "esm", - sourceName: "configured-state", + it("packs and loads both mapped channel-state probes from one package artifact", () => { + const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-state-runtime-"); + const packageDir = writePublishablePluginPackage(repoDir); + const sourcePackageJson = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); + sourcePackageJson.openclaw.build = { runtimeFormat: "cjs" }; + sourcePackageJson.openclaw.channel = { + id: "diffs", + configuredState: { + specifier: "./configured-state", exportName: "hasConfiguredChannelState", }, - { - label: "CommonJS configured-state", - metadataKey: "configuredState", - runtimeFormat: "cjs", - sourceName: "configured-state", + persistedAuthState: { + specifier: "./dist/auth-presence.cjs", + exportName: "hasPersistedChannelAuth", + }, + }; + writeJsonFile(join(packageDir, "package.json"), sourcePackageJson); + writeFileText( + join(packageDir, "configured-state.ts"), + "export function hasConfiguredChannelState() {}\n", + ); + writeFileText( + join(packageDir, "auth-presence.ts"), + "export function hasPersistedChannelAuth() {}\n", + ); + writeFileText(join(packageDir, "dist", "index.cjs"), "module.exports = {};\n"); + writeFileText(join(packageDir, "dist", "setup-entry.cjs"), "module.exports = {};\n"); + writeFileText( + join(packageDir, "dist", "configured-state.cjs"), + "exports.hasConfiguredChannelState = () => true;\n", + ); + writeFileText( + join(packageDir, "dist", "auth-presence.cjs"), + "exports.hasPersistedChannelAuth = () => true;\n", + ); + + const originalText = readFileSync(join(packageDir, "package.json"), "utf8"); + withAugmentedPluginNpmManifestForPackage({ repoRoot: repoDir, packageDir }, () => { + const stagedPackageJson = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); + expect(stagedPackageJson.openclaw.channel.configuredState).toEqual({ + specifier: "./dist/configured-state.cjs", exportName: "hasConfiguredChannelState", - }, - { - label: "ESM persisted-auth state", - metadataKey: "persistedAuthState", - runtimeFormat: "esm", - sourceName: "auth-presence", - exportName: "hasPersistedChannelAuth", - }, - { - label: "CommonJS persisted-auth state", - metadataKey: "persistedAuthState", - runtimeFormat: "cjs", - sourceName: "auth-presence", - exportName: "hasPersistedChannelAuth", - }, - ].flatMap((testCase) => [ - { ...testCase, label: `${testCase.label} from source`, specifierKind: "source" }, - { ...testCase, label: `${testCase.label} from built runtime`, specifierKind: "runtime" }, - ]), - )( - "packs and loads $label from the actual installed plugin runtime", - ({ metadataKey, runtimeFormat, sourceName, exportName, specifierKind }) => { - const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-state-runtime-"); - const packageDir = writePublishablePluginPackage(repoDir); - const extension = runtimeFormat === "cjs" ? ".cjs" : ".js"; - const sourceSpecifier = `./${sourceName}`; - const runtimeSpecifier = `./dist/${sourceName}${extension}`; - const sourcePackageJson = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); - sourcePackageJson.openclaw.channel = { - id: "diffs", - [metadataKey]: { - specifier: specifierKind === "runtime" ? runtimeSpecifier : sourceSpecifier, - exportName, - }, - }; - if (runtimeFormat === "cjs") { - sourcePackageJson.openclaw.build = { runtimeFormat: "cjs" }; - } - writeJsonFile(join(packageDir, "package.json"), sourcePackageJson); - writeFileText(join(packageDir, `${sourceName}.ts`), `export function ${exportName}() {}\n`); - writeFileText(join(packageDir, "dist", `index${extension}`), "export {};\n"); - writeFileText(join(packageDir, "dist", `setup-entry${extension}`), "export {};\n"); - writeFileText( - join(packageDir, "dist", `${sourceName}${extension}`), - runtimeFormat === "cjs" - ? `exports.${exportName} = () => true;\n` - : `export function ${exportName}() { return true; }\n`, - ); - - const originalText = readFileSync(join(packageDir, "package.json"), "utf8"); - withAugmentedPluginNpmManifestForPackage({ repoRoot: repoDir, packageDir }, () => { - const stagedPackageJson = JSON.parse( - readFileSync(join(packageDir, "package.json"), "utf8"), - ); - expect(stagedPackageJson.openclaw.channel[metadataKey]).toEqual({ - specifier: runtimeSpecifier, - exportName, - }); - - const packedFiles = listNpmPackDryRunFiles(packageDir); - expect(packedFiles).toContain(runtimeSpecifier.slice(2)); - expect(packedFiles).not.toContain(`${sourceName}.ts`); - - const consumerDir = join(repoDir, "external-consumer"); - mkdirSync(consumerDir, { recursive: true }); - writeJsonFile(join(consumerDir, "package.json"), { private: true, type: "module" }); - - const packInvocation = resolvePluginNpmCommand([ - "pack", - "--json", - "--ignore-scripts", - "--pack-destination", - consumerDir, - ]); - const pack = spawnSync(packInvocation.command, packInvocation.args, { - cwd: packageDir, - encoding: "utf8", - ...(packInvocation.env ? { env: packInvocation.env } : {}), - ...(packInvocation.shell !== undefined ? { shell: packInvocation.shell } : {}), - stdio: ["ignore", "pipe", "pipe"], - ...(packInvocation.windowsVerbatimArguments !== undefined - ? { windowsVerbatimArguments: packInvocation.windowsVerbatimArguments } - : {}), - }); - expect(pack.status, pack.stderr).toBe(0); - const [packedPackage] = JSON.parse(pack.stdout) as [{ filename: string }]; - - const installInvocation = resolvePluginNpmCommand([ - "install", - "--ignore-scripts", - "--omit=peer", - "--no-audit", - "--no-fund", - "--package-lock=false", - join(consumerDir, packedPackage.filename), - ]); - const install = spawnSync(installInvocation.command, installInvocation.args, { - cwd: consumerDir, - encoding: "utf8", - ...(installInvocation.env ? { env: installInvocation.env } : {}), - ...(installInvocation.shell !== undefined ? { shell: installInvocation.shell } : {}), - stdio: ["ignore", "pipe", "pipe"], - ...(installInvocation.windowsVerbatimArguments !== undefined - ? { windowsVerbatimArguments: installInvocation.windowsVerbatimArguments } - : {}), - }); - expect(install.status, install.stderr).toBe(0); - - const installedRoot = join(consumerDir, "node_modules", "@openclaw", "diffs"); - const load = spawnSync( - process.execPath, - [ - "--input-type=module", - "--eval", - `import fs from "node:fs";\n` + - `import { pathToFileURL } from "node:url";\n` + - `const root = ${JSON.stringify(installedRoot)};\n` + - `const pkg = JSON.parse(fs.readFileSync(root + "/package.json", "utf8"));\n` + - `const state = pkg.openclaw.channel[${JSON.stringify(metadataKey)}];\n` + - `const loaded = await import(new URL(state.specifier, pathToFileURL(root + "/")));\n` + - `if (loaded[state.exportName]?.() !== true) throw new Error("installed state checker failed");\n` + - `process.stdout.write("INSTALLED_PLUGIN_CHANNEL_STATE_OK\\n");\n`, - ], - { cwd: consumerDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, - ); - expect(load.status, load.stderr).toBe(0); - expect(load.stdout).toBe("INSTALLED_PLUGIN_CHANNEL_STATE_OK\n"); }); - expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe(originalText); - }, - ); + expect(stagedPackageJson.openclaw.channel.persistedAuthState).toEqual({ + specifier: "./dist/auth-presence.cjs", + exportName: "hasPersistedChannelAuth", + }); - it("installs and cleans package-local bundled dependencies while packing", () => { - const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-bundled-deps-"); + const consumerDir = join(repoDir, "external-consumer"); + mkdirSync(consumerDir, { recursive: true }); + writeJsonFile(join(consumerDir, "package.json"), { private: true, type: "module" }); + + const packInvocation = resolvePluginNpmCommand([ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + consumerDir, + ]); + const pack = spawnSync(packInvocation.command, packInvocation.args, { + cwd: packageDir, + encoding: "utf8", + ...(packInvocation.env ? { env: packInvocation.env } : {}), + ...(packInvocation.shell !== undefined ? { shell: packInvocation.shell } : {}), + stdio: ["ignore", "pipe", "pipe"], + ...(packInvocation.windowsVerbatimArguments !== undefined + ? { windowsVerbatimArguments: packInvocation.windowsVerbatimArguments } + : {}), + }); + expect(pack.status, pack.stderr).toBe(0); + const [packedPackage] = JSON.parse(pack.stdout) as [ + { filename: string; files: Array<{ path: string }> }, + ]; + const packedFiles = packedPackage.files.map((file) => file.path); + expect(packedFiles).toContain("dist/configured-state.cjs"); + expect(packedFiles).toContain("dist/auth-presence.cjs"); + expect(packedFiles).not.toContain("configured-state.ts"); + expect(packedFiles).not.toContain("auth-presence.ts"); + + const extract = spawnSync( + "tar", + ["-xzf", join(consumerDir, packedPackage.filename), "-C", consumerDir], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + expect(extract.status, extract.stderr).toBe(0); + + const packageRoot = join(consumerDir, "package"); + const load = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + ` +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; +const root = ${JSON.stringify(packageRoot)}; +const pkg = JSON.parse(fs.readFileSync(root + "/package.json", "utf8")); +for (const key of ["configuredState", "persistedAuthState"]) { + const state = pkg.openclaw.channel[key]; + const loaded = await import(new URL(state.specifier, pathToFileURL(root + "/"))); + if (loaded[state.exportName]?.() !== true) throw new Error("packed state checker failed: " + key); +} +process.stdout.write("PACKED_PLUGIN_CHANNEL_STATE_OK\\n"); +`, + ], + { + cwd: packageRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + expect(load.status, load.stderr).toBe(0); + expect(load.stdout).toBe("PACKED_PLUGIN_CHANNEL_STATE_OK\n"); + }); + expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe(originalText); + }); + + it("stages portable bundled dependencies without polluting pack output", () => { + const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-portable-optional-"); const packageDir = writePublishablePluginPackage(repoDir); writeFileText(join(packageDir, "dist", "index.js"), "export {};\n"); writeFileText(join(packageDir, "dist", "setup-entry.js"), "export {};\n"); - writeLocalDependencyPackage(packageDir); + writeOptionalPlatformDependencyPackage(packageDir); + writeLocalDependencyPackage(packageDir, { + optionalDependencySpec: "file:../../deps/optional-platform-dep", + }); writeJsonFile(join(packageDir, "package.json"), { name: "@openclaw/diffs", version: "2026.5.3", @@ -624,69 +627,19 @@ describe("plugin npm package manifest staging", () => { }); const originalText = readFileSync(join(packageDir, "package.json"), "utf8"); - const nodeModulesPath = join(packageDir, "node_modules"); - expect(existsSync(nodeModulesPath)).toBe(false); - - withAugmentedPluginNpmManifestForPackage( - { repoRoot: repoDir, packageDir, bundleDependencies: true }, - () => { - const stagedPackageJson = JSON.parse( - readFileSync(join(packageDir, "package.json"), "utf8"), - ); - expect(stagedPackageJson.bundledDependencies).toEqual(["local-runtime-dep"]); - expect(stagedPackageJson.bundleDependencies).toBeUndefined(); - expect(stagedPackageJson.devDependencies).toBeUndefined(); - expect(existsSync(join(nodeModulesPath, "local-runtime-dep", "package.json"))).toBe(true); - expect(existsSync(join(packageDir, "package-lock.json"))).toBe(false); - const packedFiles = listNpmPackDryRunFiles(packageDir); - expect(packedFiles).toContain("node_modules/local-runtime-dep/package.json"); - expect(packedFiles).not.toContain("package-lock.json"); - expect(packedFiles).not.toContain("npm-shrinkwrap.json"); - }, - ); - - expect(existsSync(nodeModulesPath)).toBe(false); - expect(existsSync(join(packageDir, "package-lock.json"))).toBe(false); - expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe(originalText); - }); - - it("force-installs missing optional bundled dependencies for portable packs", () => { - const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-portable-optional-"); - const packageDir = writePublishablePluginPackage(repoDir); - writeFileText(join(packageDir, "dist", "index.js"), "export {};\n"); - writeFileText(join(packageDir, "dist", "setup-entry.js"), "export {};\n"); - writeOptionalPlatformDependencyPackage(packageDir); - writeLocalDependencyPackage(packageDir, { - optionalDependencySpec: "file:../../deps/optional-platform-dep", - }); - writeJsonFile(join(packageDir, "package.json"), { - name: "@openclaw/diffs", - version: "2026.5.3", - type: "module", - dependencies: { - "local-runtime-dep": "file:./deps/local-runtime-dep", - }, - openclaw: { - extensions: ["./index.ts"], - setupEntry: "./setup-entry.ts", - compat: { - pluginApi: ">=2026.4.30", - }, - release: { - publishToNpm: true, - }, - }, - }); - const nodeModulesPath = join(packageDir, "node_modules"); const manifestModuleUrl = new URL( "../scripts/lib/plugin-npm-package-manifest.mts", import.meta.url, ).href; const childSource = ` -import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { withAugmentedPluginNpmManifestForPackage } from ${JSON.stringify(manifestModuleUrl)}; +import { + resolvePluginNpmCommand, + withAugmentedPluginNpmManifestForPackage, +} from ${JSON.stringify(manifestModuleUrl)}; const packageDir = ${JSON.stringify(packageDir)}; const nodeModulesPath = ${JSON.stringify(nodeModulesPath)}; @@ -697,12 +650,42 @@ withAugmentedPluginNpmManifestForPackage( bundleDependencies: true, }, () => { + const stagedPackageJson = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); + if (JSON.stringify(stagedPackageJson.bundledDependencies) !== '["local-runtime-dep"]') { + throw new Error("bundled dependencies were not staged"); + } + if (stagedPackageJson.bundleDependencies || stagedPackageJson.devDependencies) { + throw new Error("unpublishable dependency metadata remained staged"); + } if (!existsSync(join(nodeModulesPath, "local-runtime-dep", "package.json"))) { throw new Error("missing bundled runtime dependency"); } if (!existsSync(join(nodeModulesPath, "optional-platform-dep", "package.json"))) { throw new Error("missing portable optional bundled dependency"); } + if (existsSync(join(packageDir, "package-lock.json"))) { + throw new Error("package lock remained staged"); + } + const invocation = resolvePluginNpmCommand(["pack", "--dry-run", "--json", "--ignore-scripts"]); + const pack = spawnSync(invocation.command, invocation.args, { + cwd: packageDir, + encoding: "utf8", + ...(invocation.env ? { env: invocation.env } : {}), + ...(invocation.shell !== undefined ? { shell: invocation.shell } : {}), + stdio: ["ignore", "pipe", "pipe"], + ...(invocation.windowsVerbatimArguments !== undefined + ? { windowsVerbatimArguments: invocation.windowsVerbatimArguments } + : {}), + }); + if (pack.status !== 0) throw new Error(pack.stderr || "npm pack failed"); + const [packedPackage] = JSON.parse(pack.stdout); + const packedFiles = packedPackage.files.map((file) => file.path); + if (!packedFiles.includes("node_modules/local-runtime-dep/package.json")) { + throw new Error("bundled runtime dependency was not packed"); + } + if (packedFiles.includes("package-lock.json") || packedFiles.includes("npm-shrinkwrap.json")) { + throw new Error("package lock was packed"); + } process.stdout.write("pack-json\\n"); }, ); @@ -721,6 +704,8 @@ withAugmentedPluginNpmManifestForPackage( expect(result.stdout).toBe("pack-json\n"); expect(existsSync(nodeModulesPath)).toBe(false); + expect(existsSync(join(packageDir, "package-lock.json"))).toBe(false); + expect(readFileSync(join(packageDir, "package.json"), "utf8")).toBe(originalText); }); it("honors plugin package opt-out for bundled runtime dependencies", () => {