diff --git a/docs/help/testing-updates-plugins.md b/docs/help/testing-updates-plugins.md index 33fff82108b..f9259c2dc04 100644 --- a/docs/help/testing-updates-plugins.md +++ b/docs/help/testing-updates-plugins.md @@ -33,8 +33,8 @@ Update and plugin tests protect these contracts: - Plugin npm dependencies are installed in the managed npm root, scanned before trust, and removed through npm during uninstall so hoisted dependencies do not linger. -- Plugin update is stable when nothing changed: install records, resolved source, - and enabled state stay intact. +- Plugin update is stable when nothing changed: install records, resolved + source, installed dependency layout, and enabled state stay intact. ## Local proof during development @@ -83,9 +83,11 @@ pnpm test:docker:update-migration Important lanes: - `test:docker:plugins` validates plugin install smoke, local folder installs, - local folders with preinstalled dependencies, git installs with package - dependencies, npm package dependency installs, local ClawHub fixture installs, - marketplace update behavior, and Claude-bundle enable/inspect. Set + local folder update skip behavior, local folders with preinstalled + dependencies, `file:` package installs, git installs with CLI execution, git + moving-ref updates, npm registry installs with hoisted transitive + dependencies, npm update no-ops, local ClawHub fixture installs and update + no-ops, marketplace update behavior, and Claude-bundle enable/inspect. Set `OPENCLAW_PLUGINS_E2E_CLAWHUB=0` to keep the ClawHub block hermetic/offline. - `test:docker:plugin-update` validates that an unchanged installed plugin does not reinstall or lose install metadata during `openclaw plugins update`. @@ -234,6 +236,10 @@ can fail for the right reason: - Published-release migration behavior: `published-upgrade-survivor` scenario. - Registry/package source behavior: `test:docker:plugins` fixture or ClawHub fixture server. +- Dependency layout or cleanup behavior: assert both runtime execution and the + filesystem boundary. npm dependencies may be hoisted under the managed npm + root, so tests should prove the root is scanned/cleaned instead of assuming a + package-local `node_modules` tree. Keep new Docker fixtures hermetic by default. Use local fixture registries and fake packages unless the point of the test is live registry behavior. diff --git a/docs/help/testing.md b/docs/help/testing.md index a5408537ab2..eb8d0642c88 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -632,11 +632,11 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - MCP channel bridge (seeded Gateway + stdio bridge + raw Claude notification-frame smoke): `pnpm test:docker:mcp-channels` (script: `scripts/e2e/mcp-channels-docker.sh`) - Pi bundle MCP tools (real stdio MCP server + embedded Pi profile allow/deny smoke): `pnpm test:docker:pi-bundle-mcp-tools` (script: `scripts/e2e/pi-bundle-mcp-tools-docker.sh`) - Cron/subagent MCP cleanup (real Gateway + stdio MCP child teardown after isolated cron and one-shot subagent runs): `pnpm test:docker:cron-mcp-cleanup` (script: `scripts/e2e/cron-mcp-cleanup-docker.sh`) -- Plugins (install smoke, ClawHub kitchen-sink install/uninstall, marketplace updates, and Claude-bundle enable/inspect): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`) +- Plugins (install/update smoke for local path, `file:`, npm registry with hoisted dependencies, git moving refs, ClawHub kitchen-sink, marketplace updates, and Claude-bundle enable/inspect): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`) Set `OPENCLAW_PLUGINS_E2E_CLAWHUB=0` to skip the ClawHub block, or override the default kitchen-sink package/runtime pair with `OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC` and `OPENCLAW_PLUGINS_E2E_CLAWHUB_ID`. Without `OPENCLAW_CLAWHUB_URL`/`CLAWHUB_URL`, the test uses a hermetic local ClawHub fixture server. - Plugin update unchanged smoke: `pnpm test:docker:plugin-update` (script: `scripts/e2e/plugin-update-unchanged-docker.sh`) - Config reload metadata smoke: `pnpm test:docker:config-reload` (script: `scripts/e2e/config-reload-source-docker.sh`) -- Plugins: `pnpm test:docker:plugins` covers install smoke, local ClawHub fixture installs, marketplace updates, npm package dependency installs, and Claude-bundle enable/inspect. `pnpm test:docker:plugin-update` covers unchanged update behavior for installed plugins. +- Plugins: `pnpm test:docker:plugins` covers install/update smoke for local path, `file:`, npm registry with hoisted dependencies, git moving refs, ClawHub fixtures, marketplace updates, and Claude-bundle enable/inspect. `pnpm test:docker:plugin-update` covers unchanged update behavior for installed plugins. To prebuild and reuse the shared functional image manually: diff --git a/docs/plugins/dependency-resolution.md b/docs/plugins/dependency-resolution.md index e08d272c5ca..e52cb5720f2 100644 --- a/docs/plugins/dependency-resolution.md +++ b/docs/plugins/dependency-resolution.md @@ -46,6 +46,11 @@ npm installs run in the npm root with: npm install --prefix ~/.openclaw/npm --omit=dev --ignore-scripts --no-audit --no-fund ``` +npm may hoist transitive dependencies to `~/.openclaw/npm/node_modules` beside +the plugin package. OpenClaw scans the managed npm root before trusting the +install and uses npm to remove npm-managed packages during uninstall, so hoisted +runtime dependencies stay inside the managed cleanup boundary. + git installs clone or refresh the repository, then run: ```bash @@ -53,7 +58,8 @@ npm install --omit=dev --ignore-scripts --no-audit --no-fund ``` The installed plugin then loads from that package directory, so package-local -`node_modules` resolution works the same way it does for a normal Node package. +and parent `node_modules` resolution works the same way it does for a normal +Node package. ## Local plugins diff --git a/docs/reference/test.md b/docs/reference/test.md index 08887989b85..00fce1a4af9 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -46,6 +46,7 @@ title: "Tests" - `pnpm test:docker:upgrade-survivor`: Installs the packed OpenClaw tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks that agents, channel config, plugin allowlists, workspace/session files, stale legacy plugin dependency state, startup, and RPC status survive. - `pnpm test:docker:published-upgrade-survivor`: Installs `openclaw@latest` by default, seeds realistic existing-user files without live provider or channel keys, configures that baseline with a baked `openclaw config set` command recipe, updates that published install to the packed OpenClaw tarball, runs non-interactive doctor, writes `.artifacts/upgrade-survivor/summary.json`, then starts a loopback Gateway and checks that configured intents, workspace/session files, stale plugin config and legacy dependency state, startup, `/healthz`, `/readyz`, and RPC status survive or repair cleanly. Override one baseline with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC`, expand an exact matrix with `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS`, or add scenario fixtures with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS=reported-issues`; Package Acceptance exposes those as `published_upgrade_survivor_baseline`, `published_upgrade_survivor_baselines`, and `published_upgrade_survivor_scenarios`. - `pnpm test:docker:update-migration`: Runs the published-upgrade survivor harness in the cleanup-heavy `plugin-deps-cleanup` scenario, starting at `openclaw@2026.4.23` by default. The separate `Update Migration` workflow expands this lane with `baselines=all-since-2026.4.23` so every stable published package from `.23` onward updates to the candidate and proves configured-plugin dependency cleanup outside Full Release CI. +- `pnpm test:docker:plugins`: Runs install/update smoke for local path, `file:`, npm registry packages with hoisted dependencies, git moving refs, ClawHub fixtures, marketplace updates, and Claude-bundle enable/inspect. ## Local PR gate diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 6e4d3782c40..2f04c927ca7 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -100,9 +100,10 @@ Plugin dependency installation happens only during explicit install/update or doctor repair flows. Gateway startup, config reload, and runtime inspection do not run package managers or repair dependency trees. Local plugins must already have their dependencies installed, while npm, git, and ClawHub plugins are -installed under OpenClaw's managed plugin roots with package-local -dependencies. External plugins and custom load paths must still be installed -through `openclaw plugins install`. +installed under OpenClaw's managed plugin roots. npm dependencies may be hoisted +within OpenClaw's managed npm root; install/update scans that managed root before +trust and uninstall removes npm-managed packages through npm. External plugins +and custom load paths must still be installed through `openclaw plugins install`. See [Plugin dependency resolution](/plugins/dependency-resolution) for the install-time lifecycle. diff --git a/scripts/e2e/lib/fixtures/plugins.mjs b/scripts/e2e/lib/fixtures/plugins.mjs index 129bf1e9b2e..f54650d0fee 100644 --- a/scripts/e2e/lib/fixtures/plugins.mjs +++ b/scripts/e2e/lib/fixtures/plugins.mjs @@ -86,6 +86,39 @@ function writePluginWithCli([dir, id, version, method, name, cliRoot, cliOutput] writePluginManifest(path.join(dir, "openclaw.plugin.json"), id); } +function writePluginWithCliRegistryDependency([ + dir, + id, + version, + method, + name, + cliRoot, + cliOutput, +]) { + for (const [value, label] of [ + [dir, "dir"], + [id, "id"], + [version, "version"], + [method, "method"], + [name, "name"], + [cliRoot, "cliRoot"], + [cliOutput, "cliOutput"], + ]) { + requireArg(value, label); + } + writeJson(path.join(dir, "package.json"), { + name: `@openclaw/${id}`, + version, + dependencies: { "is-number": "7.0.0" }, + openclaw: { extensions: ["./index.js"] }, + }); + write( + path.join(dir, "index.js"), + `const isNumber = require("is-number");\nmodule.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: isNumber(42) })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`, + ); + writePluginManifest(path.join(dir, "openclaw.plugin.json"), id); +} + function writeClaudeBundle([root]) { root = requireArg(root, "root"); writeJson(path.join(root, ".claude-plugin", "plugin.json"), { name: "claude-bundle-e2e" }); @@ -128,6 +161,8 @@ export const pluginCommands = { plugin: writePlugin, "plugin-vendored-dep": writePluginWithVendoredDependency, "plugin-cli": writePluginWithCli, + "plugin-cli-registry-dep": writePluginWithCliRegistryDependency, + "fake-is-number-package": ([dir]) => writeFakeIsNumberPackage(requireArg(dir, "dir")), "plugin-manifest": ([file, id]) => writePluginManifest(requireArg(file, "file"), requireArg(id, "id")), "claude-bundle": writeClaudeBundle, diff --git a/scripts/e2e/lib/plugins/assertions.mjs b/scripts/e2e/lib/plugins/assertions.mjs index 69b5699d785..4a2b4a11575 100644 --- a/scripts/e2e/lib/plugins/assertions.mjs +++ b/scripts/e2e/lib/plugins/assertions.mjs @@ -101,6 +101,15 @@ function assertSimplePlugin(jsonFile, inspectFile, pluginId, method) { } } +function assertUpdateOutput(logFile, expectedSnippet) { + const output = fs.readFileSync(logFile, "utf8"); + if (!output.includes(expectedSnippet)) { + throw new Error( + `expected update output to include ${JSON.stringify(expectedSnippet)}:\n${output}`, + ); + } +} + function assertClaudeBundleDisabled() { const data = readJson("/tmp/plugins-bundle-disabled.json"); const plugin = (data.plugins || []).find((entry) => entry.id === "claude-bundle-e2e"); @@ -326,6 +335,62 @@ function assertPluginDirDeps() { assertRealPathInside(installPath, dependencyPackagePath, "local plugin copied dependency"); } +function assertLocalPathUpdateSkipped() { + assertUpdateOutput("/tmp/plugins-dir-update.log", 'Skipping "demo-plugin-dir" (source: path).'); +} + +function assertNpmPlugin() { + assertSimplePlugin( + "/tmp/plugins-npm.json", + "/tmp/plugins-npm-inspect.json", + "demo-plugin-npm", + "demo.npm", + ); + + const inspect = readJson("/tmp/plugins-npm-inspect.json"); + if (!Array.isArray(inspect.cliCommands) || !inspect.cliCommands.includes("demo-npm")) { + throw new Error(`expected demo-npm cli command, got ${inspect.cliCommands?.join(", ")}`); + } + + const cliOutput = fs.readFileSync("/tmp/plugins-npm-cli.txt", "utf8"); + if (!cliOutput.includes("demo-plugin-npm:pong")) { + throw new Error(`unexpected npm plugin cli output: ${cliOutput.trim()}`); + } + + const record = getInstallRecords()["demo-plugin-npm"]; + if (!record) { + throw new Error("missing npm install record for demo-plugin-npm"); + } + if (record.source !== "npm") { + throw new Error(`unexpected npm install source: ${record.source}`); + } + if (record.spec !== "@openclaw/demo-plugin-npm@0.0.1") { + throw new Error(`unexpected npm spec: ${record.spec}`); + } + if (record.resolvedName !== "@openclaw/demo-plugin-npm") { + throw new Error(`unexpected npm resolved name: ${record.resolvedName}`); + } + if (record.resolvedVersion !== "0.0.1") { + throw new Error(`unexpected npm resolved version: ${record.resolvedVersion}`); + } + const installPath = record.installPath?.replace(/^~(?=$|\/)/u, process.env.HOME); + if (!installPath || !fs.existsSync(installPath)) { + throw new Error(`npm install path missing on disk: ${installPath}`); + } + const nodeModulesRoot = path.dirname(path.dirname(installPath)); + const npmRoot = path.dirname(nodeModulesRoot); + const dependencyPackagePath = path.join(nodeModulesRoot, "is-number", "package.json"); + if (!fs.existsSync(dependencyPackagePath)) { + throw new Error(`missing npm plugin installed dependency: ${dependencyPackagePath}`); + } + assertRealPathInside(npmRoot, dependencyPackagePath, "npm plugin installed dependency"); +} + +function assertNpmPluginUpdateUnchanged() { + assertUpdateOutput("/tmp/plugins-npm-update.log", "demo-plugin-npm is up to date (0.0.1)."); + assertNpmPlugin(); +} + function assertMarketplaceUpdated() { const data = readJson("/tmp/plugins-marketplace-updated.json"); const inspect = readJson("/tmp/plugins-marketplace-updated-inspect.json"); @@ -341,6 +406,49 @@ function assertMarketplaceUpdated() { } } +function assertGitPluginUpdated() { + const beforeCommit = process.argv[3]; + assertSimplePlugin( + "/tmp/plugins-git-update.json", + "/tmp/plugins-git-update-inspect.json", + "demo-plugin-git-update", + "demo.git.update.v2", + ); + + const inspect = readJson("/tmp/plugins-git-update-inspect.json"); + if (!Array.isArray(inspect.cliCommands) || !inspect.cliCommands.includes("demo-git-update")) { + throw new Error(`expected demo-git-update cli command, got ${inspect.cliCommands?.join(", ")}`); + } + + const cliOutput = fs.readFileSync("/tmp/plugins-git-update-cli.txt", "utf8"); + if (!cliOutput.includes("demo-plugin-git-update:pong-v2")) { + throw new Error(`unexpected updated git plugin cli output: ${cliOutput.trim()}`); + } + + const record = getInstallRecords()["demo-plugin-git-update"]; + if (!record) { + throw new Error("missing git update install record for demo-plugin-git-update"); + } + if (record.source !== "git") { + throw new Error(`unexpected git update source: ${record.source}`); + } + if (record.gitRef !== "main") { + throw new Error(`unexpected git update ref: ${record.gitRef}`); + } + if (!record.gitCommit || record.gitCommit === beforeCommit) { + throw new Error( + `expected git update commit to advance from ${beforeCommit}, got ${record.gitCommit}`, + ); + } + if (record.version !== "0.0.2") { + throw new Error(`unexpected git update version: ${record.version}`); + } + assertUpdateOutput( + "/tmp/plugins-git-update.log", + "Updated demo-plugin-git-update: 0.0.1 -> 0.0.2.", + ); +} + async function assertClawHubPreflight() { const spec = process.env.CLAWHUB_PLUGIN_SPEC; if (!spec?.startsWith("clawhub:")) { @@ -476,6 +584,14 @@ function assertClawHubRemoved() { } } +function assertClawHubUpdated() { + assertUpdateOutput( + "/tmp/plugins-clawhub-update.log", + `${process.env.CLAWHUB_PLUGIN_ID} already at 0.1.0.`, + ); + assertClawHubInstalled(); +} + const commands = { "record-fixture-plugin-trust": recordFixturePluginTrust, "demo-plugin": assertDemoPlugin, @@ -493,6 +609,7 @@ const commands = { "demo-plugin-dir", "demo.dir", ), + "plugin-dir-update-skipped": assertLocalPathUpdateSkipped, "plugin-dir-deps": assertPluginDirDeps, "plugin-file": () => assertSimplePlugin( @@ -501,16 +618,20 @@ const commands = { "demo-plugin-file", "demo.file", ), + "plugin-npm": assertNpmPlugin, + "plugin-npm-update": assertNpmPluginUpdateUnchanged, "bundle-disabled": assertClaudeBundleDisabled, "bundle-inspect": assertClaudeBundleInspect, "slash-install": assertSlashInstall, "plugin-git": assertGitPlugin, + "plugin-git-updated": assertGitPluginUpdated, "marketplace-list": assertMarketplaceList, "marketplace-installed": assertMarketplaceInstalled, "marketplace-records": assertMarketplaceRecords, "marketplace-updated": assertMarketplaceUpdated, "clawhub-preflight": assertClawHubPreflight, "clawhub-installed": assertClawHubInstalled, + "clawhub-updated": assertClawHubUpdated, "clawhub-removed": assertClawHubRemoved, }; diff --git a/scripts/e2e/lib/plugins/clawhub.sh b/scripts/e2e/lib/plugins/clawhub.sh index 4ae7bfe3c90..c77c8688076 100644 --- a/scripts/e2e/lib/plugins/clawhub.sh +++ b/scripts/e2e/lib/plugins/clawhub.sh @@ -49,6 +49,12 @@ run_plugins_clawhub_scenario() { node scripts/e2e/lib/plugins/assertions.mjs clawhub-installed + node "$OPENCLAW_ENTRY" plugins update "$CLAWHUB_PLUGIN_ID" >/tmp/plugins-clawhub-update.log 2>&1 + node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-clawhub-updated.json + node "$OPENCLAW_ENTRY" plugins inspect "$CLAWHUB_PLUGIN_ID" --json >/tmp/plugins-clawhub-updated-inspect.json + + node scripts/e2e/lib/plugins/assertions.mjs clawhub-updated + run_logged uninstall-clawhub node "$OPENCLAW_ENTRY" plugins uninstall "$CLAWHUB_PLUGIN_SPEC" --force node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-clawhub-uninstalled.json diff --git a/scripts/e2e/lib/plugins/fixtures.sh b/scripts/e2e/lib/plugins/fixtures.sh index ba9affe298a..5430af4b6dc 100644 --- a/scripts/e2e/lib/plugins/fixtures.sh +++ b/scripts/e2e/lib/plugins/fixtures.sh @@ -32,6 +32,30 @@ write_fixture_plugin_with_cli() { node scripts/e2e/lib/fixture.mjs plugin-cli "$dir" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output" } +pack_fixture_plugin_with_cli_registry_dependency() { + local pack_dir="$1" + local output_tgz="$2" + local id="$3" + local version="$4" + local method="$5" + local name="$6" + local cli_root="$7" + local cli_output="$8" + + mkdir -p "$pack_dir/package" + node scripts/e2e/lib/fixture.mjs plugin-cli-registry-dep "$pack_dir/package" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output" + tar -czf "$output_tgz" -C "$pack_dir" package +} + +pack_fake_is_number_package() { + local pack_dir="$1" + local output_tgz="$2" + + mkdir -p "$pack_dir/package" + node scripts/e2e/lib/fixture.mjs fake-is-number-package "$pack_dir/package" + tar -czf "$output_tgz" -C "$pack_dir" package +} + write_fixture_plugin_with_vendored_dependency() { local dir="$1" local id="$2" @@ -62,6 +86,39 @@ pack_fixture_plugin() { tar -czf "$output_tgz" -C "$pack_dir" package } +start_npm_fixture_registry() { + local package_name="$1" + local version="$2" + local tarball="$3" + local fixture_dir="$4" + local server_log="$fixture_dir/npm-registry.log" + local server_port_file="$fixture_dir/npm-registry-port" + local server_pid_file="$fixture_dir/npm-registry-pid" + + shift 4 + + node scripts/e2e/lib/plugins/npm-registry-server.mjs "$server_port_file" "$package_name" "$version" "$tarball" "$@" >"$server_log" 2>&1 & + local server_pid="$!" + echo "$server_pid" >"$server_pid_file" + + for _ in $(seq 1 100); do + if [[ -s "$server_port_file" ]]; then + export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$server_port_file")" + trap 'if [[ -f "'"$server_pid_file"'" ]]; then kill "$(cat "'"$server_pid_file"'")" 2>/dev/null || true; fi' EXIT + return 0 + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + cat "$server_log" + return 1 + fi + sleep 0.1 + done + + cat "$server_log" + echo "Timed out waiting for npm fixture registry." >&2 + return 1 +} + write_claude_bundle_fixture() { local bundle_root="$1" diff --git a/scripts/e2e/lib/plugins/npm-registry-server.mjs b/scripts/e2e/lib/plugins/npm-registry-server.mjs new file mode 100644 index 00000000000..e3f83dc4f13 --- /dev/null +++ b/scripts/e2e/lib/plugins/npm-registry-server.mjs @@ -0,0 +1,99 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; + +const [portFile, ...packageArgs] = process.argv.slice(2); + +if (!portFile || packageArgs.length === 0 || packageArgs.length % 3 !== 0) { + console.error( + "usage: npm-registry-server.mjs [...]", + ); + process.exit(1); +} + +const packages = new Map(); +for (let index = 0; index < packageArgs.length; index += 3) { + const packageName = packageArgs[index]; + const version = packageArgs[index + 1]; + const tarballPath = packageArgs[index + 2]; + const archive = fs.readFileSync(tarballPath); + packages.set(packageName, { + archive, + dependencies: packageName === "@openclaw/demo-plugin-npm" ? { "is-number": "7.0.0" } : {}, + encodedPackageName: encodeURIComponent(packageName).replace("%40", "@"), + integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`, + packageName, + shasum: crypto.createHash("sha1").update(archive).digest("hex"), + tarballName: path.basename(tarballPath), + version, + }); +} + +const metadataFor = (entry, baseUrl) => ({ + name: entry.packageName, + "dist-tags": { latest: entry.version }, + versions: { + [entry.version]: { + dependencies: entry.dependencies, + name: entry.packageName, + version: entry.version, + dist: { + integrity: entry.integrity, + shasum: entry.shasum, + tarball: `${baseUrl}/${entry.encodedPackageName}/-/${entry.tarballName}`, + }, + }, + }, +}); + +function findPackageForPath(pathname) { + return packages.get(decodeURIComponent(pathname.slice(1))); +} + +function findTarballForPath(pathname) { + for (const entry of packages.values()) { + const prefix = `/${entry.encodedPackageName}/-/`; + if ( + pathname.toLowerCase().startsWith(prefix.toLowerCase()) && + pathname.endsWith(`/${entry.tarballName}`) + ) { + return entry; + } + } + return undefined; +} + +const server = http.createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + if (request.method !== "GET") { + response.writeHead(405, { "content-type": "text/plain" }); + response.end("method not allowed"); + return; + } + + const packageEntry = findPackageForPath(url.pathname); + if (packageEntry) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify(metadataFor(packageEntry, baseUrl))}\n`); + return; + } + + const tarballEntry = findTarballForPath(url.pathname); + if (tarballEntry) { + response.writeHead(200, { + "content-type": "application/octet-stream", + "content-length": String(tarballEntry.archive.length), + }); + response.end(tarballEntry.archive); + return; + } + + response.writeHead(404, { "content-type": "text/plain" }); + response.end(`not found: ${url.pathname}`); +}); + +server.listen(0, "127.0.0.1", () => { + fs.writeFileSync(portFile, String(server.address().port)); +}); diff --git a/scripts/e2e/lib/plugins/sweep.sh b/scripts/e2e/lib/plugins/sweep.sh index efa60448048..3dab93defc7 100644 --- a/scripts/e2e/lib/plugins/sweep.sh +++ b/scripts/e2e/lib/plugins/sweep.sh @@ -46,6 +46,9 @@ node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-dir --runtime --json >/tmp/pl node scripts/e2e/lib/plugins/assertions.mjs plugin-dir +node "$OPENCLAW_ENTRY" plugins update demo-plugin-dir >/tmp/plugins-dir-update.log 2>&1 +node scripts/e2e/lib/plugins/assertions.mjs plugin-dir-update-skipped + echo "Testing install from local folder with preinstalled dependencies..." dir_deps_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir-deps.XXXXXX")" write_fixture_plugin_with_vendored_dependency "$dir_deps_plugin" demo-plugin-dir-deps 0.0.1 demo.dir.deps "Demo Plugin DIR Deps" @@ -66,6 +69,24 @@ node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-file --runtime --json >/tmp/p node scripts/e2e/lib/plugins/assertions.mjs plugin-file +echo "Testing install and update from npm registry..." +npm_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-npm-pack.XXXXXX")" +npm_dep_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-npm-dep-pack.XXXXXX")" +npm_registry_dir="$(mktemp -d "/tmp/openclaw-plugin-npm-registry.XXXXXX")" +pack_fixture_plugin_with_cli_registry_dependency "$npm_pack_dir" /tmp/demo-plugin-npm.tgz demo-plugin-npm 0.0.1 demo.npm "Demo Plugin NPM" demo-npm "demo-plugin-npm:pong" +pack_fake_is_number_package "$npm_dep_pack_dir" /tmp/is-number-7.0.0.tgz +start_npm_fixture_registry "@openclaw/demo-plugin-npm" "0.0.1" /tmp/demo-plugin-npm.tgz "$npm_registry_dir" "is-number" "7.0.0" /tmp/is-number-7.0.0.tgz + +run_logged install-npm node "$OPENCLAW_ENTRY" plugins install "npm:@openclaw/demo-plugin-npm@0.0.1" +node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-npm.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-npm --runtime --json >/tmp/plugins-npm-inspect.json +run_logged exec-npm-plugin-cli bash -c 'node "$OPENCLAW_ENTRY" demo-npm ping >/tmp/plugins-npm-cli.txt' + +node scripts/e2e/lib/plugins/assertions.mjs plugin-npm + +node "$OPENCLAW_ENTRY" plugins update demo-plugin-npm >/tmp/plugins-npm-update.log 2>&1 +node scripts/e2e/lib/plugins/assertions.mjs plugin-npm-update + echo "Testing install from git repo and plugin CLI execution..." git_fixture_root="$(mktemp -d "/tmp/openclaw-plugin-git.XXXXXX")" git_repo="$git_fixture_root/repo" @@ -85,6 +106,31 @@ run_logged exec-git-plugin-cli bash -c 'node "$OPENCLAW_ENTRY" demo-git ping >/t node scripts/e2e/lib/plugins/assertions.mjs plugin-git "$git_repo_url" "$git_ref" +echo "Testing git plugin update from moving ref..." +git_update_fixture_root="$(mktemp -d "/tmp/openclaw-plugin-git-update.XXXXXX")" +git_update_repo="$git_update_fixture_root/repo" +git_update_repo_url="file://$git_update_repo" +write_fixture_plugin_with_cli "$git_update_repo" demo-plugin-git-update 0.0.1 demo.git.update.v1 "Demo Plugin Git Update" demo-git-update "demo-plugin-git-update:pong-v1" +git -C "$git_update_repo" init -q +git -C "$git_update_repo" config user.email "docker-e2e@openclaw.local" +git -C "$git_update_repo" config user.name "OpenClaw Docker E2E" +git -C "$git_update_repo" checkout -qb main +git -C "$git_update_repo" add -A +git -C "$git_update_repo" commit -qm "test fixture v1" +git_update_ref_v1="$(git -C "$git_update_repo" rev-parse HEAD)" + +run_logged install-git-update node "$OPENCLAW_ENTRY" plugins install "git:$git_update_repo_url@main" +write_fixture_plugin_with_cli "$git_update_repo" demo-plugin-git-update 0.0.2 demo.git.update.v2 "Demo Plugin Git Update" demo-git-update "demo-plugin-git-update:pong-v2" +git -C "$git_update_repo" add -A +git -C "$git_update_repo" commit -qm "test fixture v2" + +node "$OPENCLAW_ENTRY" plugins update demo-plugin-git-update >/tmp/plugins-git-update.log 2>&1 +node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-git-update.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-git-update --runtime --json >/tmp/plugins-git-update-inspect.json +run_logged exec-updated-git-plugin-cli bash -c 'node "$OPENCLAW_ENTRY" demo-git-update ping >/tmp/plugins-git-update-cli.txt' + +node scripts/e2e/lib/plugins/assertions.mjs plugin-git-updated "$git_update_ref_v1" + echo "Testing Claude bundle enable and inspect flow..." bundle_plugin_id="claude-bundle-e2e" bundle_root="$OPENCLAW_PLUGIN_HOME/$bundle_plugin_id" diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 3edfd4051f9..f2eb758d403 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -24,6 +24,7 @@ const PLUGINS_DOCKER_SWEEP_PATH = "scripts/e2e/lib/plugins/sweep.sh"; const PLUGINS_DOCKER_MARKETPLACE_PATH = "scripts/e2e/lib/plugins/marketplace.sh"; const PLUGINS_DOCKER_CLAWHUB_PATH = "scripts/e2e/lib/plugins/clawhub.sh"; const PLUGINS_DOCKER_ASSERTIONS_PATH = "scripts/e2e/lib/plugins/assertions.mjs"; +const PLUGINS_DOCKER_NPM_REGISTRY_PATH = "scripts/e2e/lib/plugins/npm-registry-server.mjs"; const PLUGIN_UPDATE_DOCKER_E2E_PATH = "scripts/e2e/plugin-update-unchanged-docker.sh"; const PLUGIN_UPDATE_SCENARIO_PATH = "scripts/e2e/lib/plugin-update/unchanged-scenario.sh"; const PLUGIN_UPDATE_PROBE_PATH = "scripts/e2e/lib/plugin-update/probe.mjs"; @@ -274,4 +275,30 @@ describe("docker build helper", () => { expect(clawhub).toContain("live ClawHub can rate-limit CI"); expect(clawhub).toContain('[[ -z "${OPENCLAW_CLAWHUB_URL:-}" && -z "${CLAWHUB_URL:-}" ]]'); }); + + it("covers plugin install/update sources in the Docker plugin sweep", () => { + const sweep = readFileSync(PLUGINS_DOCKER_SWEEP_PATH, "utf8"); + const clawhub = readFileSync(PLUGINS_DOCKER_CLAWHUB_PATH, "utf8"); + const assertions = readFileSync(PLUGINS_DOCKER_ASSERTIONS_PATH, "utf8"); + const npmRegistry = readFileSync(PLUGINS_DOCKER_NPM_REGISTRY_PATH, "utf8"); + + expect(sweep).toContain('plugins install "$dir_plugin"'); + expect(sweep).toContain("plugins update demo-plugin-dir"); + expect(assertions).toContain('Skipping "demo-plugin-dir" (source: path).'); + + expect(sweep).toContain("start_npm_fixture_registry"); + expect(sweep).toContain('plugins install "npm:@openclaw/demo-plugin-npm@0.0.1"'); + expect(sweep).toContain("plugins update demo-plugin-npm"); + expect(assertions).toContain("demo-plugin-npm is up to date (0.0.1)."); + expect(npmRegistry).toContain('"dist-tags": { latest: entry.version }'); + expect(npmRegistry).toContain("packageArgs.length % 3"); + + expect(sweep).toContain('plugins install "git:$git_update_repo_url@main"'); + expect(sweep).toContain("plugins update demo-plugin-git-update"); + expect(assertions).toContain("demo.git.update.v2"); + + expect(clawhub).toContain('plugins install "$CLAWHUB_PLUGIN_SPEC"'); + expect(clawhub).toContain('plugins update "$CLAWHUB_PLUGIN_ID"'); + expect(assertions).toContain("clawhub-updated"); + }); });