test: cover legacy runtime deps update recovery (#75288)

This commit is contained in:
Peter Steinberger
2026-05-01 11:13:08 +01:00
parent af34a5db6e
commit 018f77cdc2
4 changed files with 172 additions and 19 deletions

View File

@@ -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

View File

@@ -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}" \

View File

@@ -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;
}

View File

@@ -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: {