From 018f77cdc25056d1acf1cbc54fffeecebdd5b319 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 11:13:08 +0100 Subject: [PATCH] test: cover legacy runtime deps update recovery (#75288) --- scripts/e2e/lib/upgrade-survivor/run.sh | 76 ++++++++++++++++ scripts/e2e/upgrade-survivor-docker.sh | 1 + .../lib/bundled-runtime-deps-stage-state.mjs | 23 +++-- .../stage-bundled-plugin-runtime-deps.test.ts | 91 ++++++++++++++++--- 4 files changed, 172 insertions(+), 19 deletions(-) diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index a106b1c8783..9546cda575a 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -234,6 +234,80 @@ package_root() { printf '%s/lib/node_modules/openclaw\n' "$npm_config_prefix" } +legacy_runtime_deps_symlink_plugin() { + local plugin="${OPENCLAW_UPGRADE_SURVIVOR_LEGACY_RUNTIME_DEPS_SYMLINK:-}" + if [ -z "$plugin" ]; then + return 1 + fi + case "$plugin" in + *[!A-Za-z0-9._-]*) + echo "OPENCLAW_UPGRADE_SURVIVOR_LEGACY_RUNTIME_DEPS_SYMLINK must be a plugin id, got: $plugin" >&2 + return 2 + ;; + esac + printf '%s\n' "$plugin" +} + +legacy_runtime_deps_symlink_target() { + local plugin="$1" + printf '%s/dist/extensions/%s/node_modules\n' "$(package_root)" "$plugin" +} + +legacy_runtime_deps_symlink_source() { + local plugin="$1" + printf '%s/.local/bundled-plugin-runtime-deps/%s-upgrade-survivor/node_modules\n' \ + "$(package_root)" \ + "$plugin" +} + +seed_legacy_runtime_deps_symlink() { + local plugin + plugin="$(legacy_runtime_deps_symlink_plugin)" || { + local status=$? + [ "$status" -eq 1 ] && return 0 + return "$status" + } + + local plugin_dir + plugin_dir="$(package_root)/dist/extensions/$plugin" + if [ ! -d "$plugin_dir" ]; then + echo "cannot seed legacy runtime deps symlink; packaged plugin is missing: $plugin_dir" >&2 + return 1 + fi + + local source_dir + local target_dir + source_dir="$(legacy_runtime_deps_symlink_source "$plugin")" + target_dir="$(legacy_runtime_deps_symlink_target "$plugin")" + mkdir -p "$source_dir" + printf '{"name":"openclaw-upgrade-survivor-legacy-runtime-deps","version":"0.0.0"}\n' \ + >"$source_dir/package.json" + rm -rf "$target_dir" + ln -s "$source_dir" "$target_dir" + if [ ! -L "$target_dir" ]; then + echo "failed to create legacy runtime deps symlink: $target_dir" >&2 + return 1 + fi + echo "Seeded legacy runtime deps symlink for $plugin: $target_dir -> $source_dir" +} + +assert_legacy_runtime_deps_symlink_repaired() { + local plugin + plugin="$(legacy_runtime_deps_symlink_plugin)" || { + local status=$? + [ "$status" -eq 1 ] && return 0 + return "$status" + } + + local target_dir + target_dir="$(legacy_runtime_deps_symlink_target "$plugin")" + if [ -L "$target_dir" ]; then + echo "legacy runtime deps symlink survived package update: $target_dir -> $(readlink "$target_dir")" >&2 + return 1 + fi + echo "Legacy runtime deps symlink repaired for $plugin." +} + read_installed_version() { node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1] + "/package.json", "utf8")).version' "$(package_root)" } @@ -450,8 +524,10 @@ phase seed-state seed_state phase apply-baseline-config-recipe apply_baseline_config_recipe phase validate-baseline-config validate_baseline_config phase assert-baseline assert_baseline_state +phase seed-legacy-runtime-deps-symlink seed_legacy_runtime_deps_symlink phase resolve-candidate resolve_candidate_version phase update-candidate update_candidate +phase assert-legacy-runtime-deps-symlink-repaired assert_legacy_runtime_deps_symlink_repaired phase doctor run_doctor phase validate-post-doctor-config validate_post_doctor_config phase assert-survival assert_survival diff --git a/scripts/e2e/upgrade-survivor-docker.sh b/scripts/e2e/upgrade-survivor-docker.sh index 0f301ae0b94..fadb899c087 100755 --- a/scripts/e2e/upgrade-survivor-docker.sh +++ b/scripts/e2e/upgrade-survivor-docker.sh @@ -83,6 +83,7 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then -e OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_KIND="$CANDIDATE_KIND" \ -e OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC="$CANDIDATE_SPEC" \ -e OPENCLAW_UPGRADE_SURVIVOR_SCENARIO="$SCENARIO" \ + -e OPENCLAW_UPGRADE_SURVIVOR_LEGACY_RUNTIME_DEPS_SYMLINK="${OPENCLAW_UPGRADE_SURVIVOR_LEGACY_RUNTIME_DEPS_SYMLINK:-}" \ -e OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON=/tmp/openclaw-upgrade-survivor-artifacts/summary.json \ -e OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS="${OPENCLAW_UPGRADE_SURVIVOR_START_BUDGET_SECONDS:-90}" \ -e OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS="${OPENCLAW_UPGRADE_SURVIVOR_STATUS_BUDGET_SECONDS:-30}" \ diff --git a/scripts/lib/bundled-runtime-deps-stage-state.mjs b/scripts/lib/bundled-runtime-deps-stage-state.mjs index fa246ada1fe..57050a6bb02 100644 --- a/scripts/lib/bundled-runtime-deps-stage-state.mjs +++ b/scripts/lib/bundled-runtime-deps-stage-state.mjs @@ -95,10 +95,22 @@ export function assertPathIsNotSymlink(targetPath, label) { } } -function isPathInside(parentPath, childPath) { +function isDirectChildPath(parentPath, childPath) { const relativePath = path.relative(parentPath, childPath); return ( - relativePath.length > 0 && !relativePath.startsWith("..") && !path.isAbsolute(relativePath) + relativePath.length > 0 && + !relativePath.startsWith("..") && + !path.isAbsolute(relativePath) && + !relativePath.includes(path.sep) + ); +} + +function isLegacyBundledRuntimeDepsNodeModulesPath(targetPath, repoRoot, linkedPath) { + const legacyRuntimeDepsRoot = path.resolve(repoRoot, ".local", "bundled-plugin-runtime-deps"); + const resolvedLinkedPath = path.resolve(path.dirname(targetPath), linkedPath); + return ( + path.basename(resolvedLinkedPath) === "node_modules" && + isDirectChildPath(legacyRuntimeDepsRoot, path.dirname(resolvedLinkedPath)) ); } @@ -116,18 +128,13 @@ export function removeLegacyBundledRuntimeDepsSymlink(targetPath, repoRoot) { return false; } - const legacyRuntimeDepsRoot = path.resolve(repoRoot, ".local", "bundled-plugin-runtime-deps"); let linkedPath; try { linkedPath = fs.readlinkSync(targetPath); } catch { return false; } - const resolvedLinkedPath = path.resolve(path.dirname(targetPath), linkedPath); - if ( - path.basename(resolvedLinkedPath) !== "node_modules" || - !isPathInside(legacyRuntimeDepsRoot, resolvedLinkedPath) - ) { + if (!isLegacyBundledRuntimeDepsNodeModulesPath(targetPath, repoRoot, linkedPath)) { return false; } diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index cfdca078aff..5a0c983aa25 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -46,6 +46,26 @@ describe("stageBundledPluginRuntimeDeps", () => { return path.join(repoRoot, ".artifacts", "bundled-runtime-deps-stamps", `${pluginId}.json`); } + function legacyRuntimeDepsNodeModulesPath( + repoRoot: string, + stageKey = "fixture-plugin-1234567890abcdef", + ) { + return path.join(repoRoot, ".local", "bundled-plugin-runtime-deps", stageKey, "node_modules"); + } + + function writeLegacyRuntimeDepsNodeModulesSymlink(params: { + pluginDir: string; + repoRoot: string; + stageKey?: string; + }) { + const legacyNodeModulesDir = legacyRuntimeDepsNodeModulesPath(params.repoRoot, params.stageKey); + const nodeModulesDir = path.join(params.pluginDir, "node_modules"); + fs.mkdirSync(legacyNodeModulesDir, { recursive: true }); + fs.writeFileSync(path.join(legacyNodeModulesDir, "legacy.js"), "module.exports = 0;\n", "utf8"); + fs.symlinkSync(legacyNodeModulesDir, nodeModulesDir); + return { legacyNodeModulesDir, nodeModulesDir }; + } + it("pins fallback install specs to exact installed versions", () => { const { repoRoot } = createBundledPluginFixture({ packageJson: { @@ -718,24 +738,17 @@ describe("stageBundledPluginRuntimeDeps", () => { }, }); const directDir = path.join(repoRoot, "node_modules", "direct"); - const legacyNodeModulesDir = path.join( - repoRoot, - ".local", - "bundled-plugin-runtime-deps", - "fixture-plugin-1234567890abcdef", - "node_modules", - ); - const nodeModulesDir = path.join(pluginDir, "node_modules"); fs.mkdirSync(directDir, { recursive: true }); - fs.mkdirSync(legacyNodeModulesDir, { 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 = 'direct';\n", "utf8"); - fs.writeFileSync(path.join(legacyNodeModulesDir, "legacy.js"), "module.exports = 0;\n", "utf8"); - fs.symlinkSync(legacyNodeModulesDir, nodeModulesDir); + const { legacyNodeModulesDir, nodeModulesDir } = writeLegacyRuntimeDepsNodeModulesSymlink({ + pluginDir, + repoRoot, + }); stageBundledPluginRuntimeDeps({ cwd: repoRoot }); @@ -746,6 +759,62 @@ describe("stageBundledPluginRuntimeDeps", () => { expect(fs.existsSync(path.join(legacyNodeModulesDir, "legacy.js"))).toBe(true); }); + it("removes legacy OpenClaw-owned symlinked plugin node_modules when deps converge to empty", () => { + const { pluginDir, repoRoot } = createBundledPluginFixture({ + packageJson: { + name: "@openclaw/fixture-plugin", + version: "1.0.0", + optionalDependencies: { optional: "1.0.0" }, + openclaw: { bundle: { stageRuntimeDependencies: true } }, + }, + }); + const rootNodeModulesDir = path.join(repoRoot, "node_modules"); + fs.mkdirSync(rootNodeModulesDir, { recursive: true }); + const { legacyNodeModulesDir, nodeModulesDir } = writeLegacyRuntimeDepsNodeModulesSymlink({ + pluginDir, + repoRoot, + }); + + stageBundledPluginRuntimeDeps({ cwd: repoRoot }); + + expect(fs.existsSync(nodeModulesDir)).toBe(false); + expect(fs.existsSync(path.join(legacyNodeModulesDir, "legacy.js"))).toBe(true); + }); + + it("refuses nested symlink targets under the legacy runtime deps root", () => { + 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 nestedLegacyNodeModulesDir = path.join( + repoRoot, + ".local", + "bundled-plugin-runtime-deps", + "fixture-plugin-1234567890abcdef", + "nested", + "node_modules", + ); + const nodeModulesDir = path.join(pluginDir, "node_modules"); + fs.mkdirSync(directDir, { recursive: true }); + fs.mkdirSync(nestedLegacyNodeModulesDir, { 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 = 'direct';\n", "utf8"); + fs.symlinkSync(nestedLegacyNodeModulesDir, nodeModulesDir); + + expect(() => stageBundledPluginRuntimeDeps({ cwd: repoRoot })).toThrow( + /refusing to replace runtime deps via symlinked path/u, + ); + }); + it("refuses to write a runtime deps stamp through a symlink", () => { const { repoRoot } = createBundledPluginFixture({ packageJson: {