openclaw/scripts/tests/update-restart-swap-fixture.mjs
Peter Steinberger 52ed106840 test(update): allow the backup-path helper in the swap fixture
Fix the fixture dependency omission introduced by #162246 (9a62cc8e64). Load the production backup-path helper while preserving the unexpected-dependency guard.

Regression: test/scripts/update-restart-module-outcome.test.ts > retains package backups after unverified post-swap module failures (three nested filesystem-swap cases).

Verified: the whole test file passes, including all 19 nested cases; oxfmt and git diff --check pass.
2026-09-30 20:15:57 -07:00

190 lines
7.1 KiB
JavaScript

// Filesystem companion to the production restart-seam regression. It exercises
// real swap/integrity/retirement owners, not a packaged CLI or service supervisor.
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
import vm from "node:vm";
import { transformSync } from "esbuild";
export async function createDiskSwap(sourceRoot, base) {
const require = createRequire(path.join(sourceRoot, "package.json"));
const expected = JSON.parse(await fs.readFile(path.join(sourceRoot, "package.json"), "utf8"))
.dependencies["@openclaw/fs-safe"];
const installed = JSON.parse(
await fs.readFile(require.resolve("@openclaw/fs-safe/package.json"), "utf8"),
).version;
assert.equal(installed, expected, "filesystem dependency must match the candidate manifest");
const atomic = await import(pathToFileURL(require.resolve("@openclaw/fs-safe/atomic")).href);
const fsSafe = new Map();
for (const subpath of ["errors", "root"]) {
const specifier = `@openclaw/fs-safe/${subpath}`;
fsSafe.set(specifier, await import(pathToFileURL(require.resolve(specifier)).href));
}
const unexpected = [];
// Logging, failure-fact presentation, and manifest parsing are bounded seams.
// Package fingerprints, rename/copy/removal, transaction policy and deadlines
// run their complete production bodies. Unexpected service/repair calls fail.
const values = {
formatErrorMessage: String,
hasErrnoCode: (error, code) => error?.code === code,
isErrno: (error) => typeof error?.code === "string",
createSubsystemLogger: () => ({ debug() {} }),
readPackageVersion: async (root) =>
JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")).version,
UPDATE_RUNNER_TIMEOUT_MS: 30_000,
MAX_TIMER_TIMEOUT_MS: 2_147_483_647,
movePathWithCopyFallback: atomic.movePathWithCopyFallback,
createUpdateFailureFact: (value) => value,
};
const context = vm.createContext({
// Model an unowned npm prefix; OS package-manager ownership is a separate contract.
// The imported filesystem and fs-safe primitives still run on the real host.
process: { env: {}, platform: "linux", pid: process.pid },
Date,
Error,
AggregateError,
AbortController,
Buffer,
performance,
setTimeout,
clearTimeout,
});
const files = [
"infra/errno",
"infra/fs-safe-remove",
"infra/mutation-authority",
"infra/package-update-backup-paths",
"infra/package-update-swap",
"infra/package-update-swap-results",
"infra/package-update-swap-target",
"infra/package-update-swap-retirement",
"infra/package-update-filesystem",
"infra/package-update-integrity",
"infra/package-update-npm-root",
"infra/package-update-local-overrides",
"infra/package-update-swap-contract",
"infra/update-npm-prefix",
"utils/absolute-deadline",
];
const modules = new Map(),
external = new Map();
for (const name of files) {
const filename = path.join(sourceRoot, "src", name + ".ts");
const code = transformSync(await fs.readFile(filename, "utf8"), {
sourcefile: filename,
loader: "ts",
target: "esnext",
format: "esm",
tsconfigRaw: { compilerOptions: { verbatimModuleSyntax: true } },
}).code;
const mod = new vm.SourceTextModule(code, { context, identifier: filename });
modules.set(path.basename(name) + ".js", mod);
// Native namespaces must survive mixed, namespace, and side-effect imports.
for (const specifier of mod.dependencySpecifiers) {
if (specifier.startsWith("node:") || fsSafe.has(specifier)) {
external.set(specifier, new Set());
}
}
for (const match of code.matchAll(
/(?:import|export)\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/gs,
)) {
if (!external.has(match[2])) {
external.set(match[2], new Set());
}
match[1]
.split(",")
.map((s) => s.trim().split(/\s+as\s+/)[0])
.filter(Boolean)
.forEach((exportName) => external.get(match[2]).add(exportName));
}
for (const match of code.matchAll(/import\s+(\w+)\s+from\s*["']([^"']+)["']/g)) {
if (!external.has(match[2])) {
external.set(match[2], new Set());
}
external.get(match[2]).add("default");
}
}
const stubs = new Map();
for (const [specifier, namesSet] of external) {
if (modules.has(path.basename(specifier))) {
continue;
}
const native = specifier.startsWith("node:") ? await import(specifier) : fsSafe.get(specifier);
const names = native ? Object.keys(native) : [...namesSet];
stubs.set(
specifier,
new vm.SyntheticModule(
names,
function () {
for (const name of names) {
this.setExport(
name,
native
? native[name]
: Object.hasOwn(values, name)
? values[name]
: function () {
const message = "Unexpected package dependency: " + specifier + ":" + name;
unexpected.push(message);
throw new Error(message);
},
);
}
},
{ context, identifier: specifier },
),
);
}
const entry = modules.get("package-update-swap.js");
await entry.link((specifier) => modules.get(path.basename(specifier)) ?? stubs.get(specifier));
await entry.evaluate();
const prefix = path.join(base, "live");
const globalRoot = path.join(prefix, "lib/node_modules");
const root = path.join(globalRoot, "openclaw");
const stagePrefix = path.join(base, "stage");
const stagedRoot = path.join(stagePrefix, "lib/node_modules/openclaw");
for (const [directory, version, chunk] of [
[root, "2026.9.3", "old-142102.mjs"],
[stagedRoot, "2026.9.4", "new-142102.mjs"],
]) {
await fs.mkdir(path.join(directory, "dist"), { recursive: true });
await fs.writeFile(
path.join(directory, "package.json"),
JSON.stringify({ name: "openclaw", version, type: "module" }),
);
await fs.writeFile(
path.join(directory, "dist", chunk),
"export const version = " + JSON.stringify(version) + ";\n",
);
await fs.writeFile(
path.join(directory, "dist/entry.mjs"),
'export const late = () => import("./' + chunk + '");\n',
);
}
// Import only the old entry; its old hashed child remains genuinely cold.
const oldEntry = await import(pathToFileURL(path.join(root, "dist/entry.mjs")).href);
let transaction;
const result = await entry.namespace.swapStagedPackageInstall({
packageName: "openclaw",
installTarget: { globalRoot, packageRoot: root },
stage: {
prefix: stagePrefix,
packageRoot: stagedRoot,
layout: {
prefix: stagePrefix,
globalRoot: path.dirname(stagedRoot),
binDir: path.join(stagePrefix, "bin"),
},
},
onTransaction: (value) => {
transaction = value;
},
});
assert.equal(result.status, "committed", result.step.stderrTail);
assert.ok(transaction);
assert.deepEqual(unexpected, []);
return { root, transaction, oldEntry, unexpected };
}