openclaw / src /cli /update-cli /update-command-candidate-exit.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
10.4 kB
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import {
createNpmTarget,
writePackageRoot,
} from "../../infra/package-update-steps.test-support.js";
import * as repairAgent from "../../infra/update-repair-agent.js";
import { createUpdateRun, getUpdateRun } from "../../infra/update-run-ledger.js";
import * as processRunner from "../../process/exec.js";
import { defaultRuntime } from "../../runtime.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { executeMutableUpdate } from "./update-command-execution.js";
import { finishUpdate } from "./update-command-post-update.js";
import { withUpdateFailureTriage } from "./update-command-triage.js";
const mocks = vi.hoisted(() => ({
captureManagedPreflight:
vi.fn<
typeof import("./update-command-managed-context.js").captureOwnedManagedUpdatePreflightContext
>(),
captureSchemaContext:
vi.fn<typeof import("./schema-preflight.js").captureTargetDatabaseSchemaContext>(),
inspectService:
vi.fn<
typeof import("./update-command-service.js").maybeStopManagedServiceBeforeMutableUpdate
>(),
validateCanary:
vi.fn<typeof import("../../infra/update-candidate-canary.js").validateUpdateCandidateCanary>(),
}));
vi.mock("../../infra/update-candidate-canary.js", () => ({
validateUpdateCandidateCanary: mocks.validateCanary,
}));
vi.mock("./schema-preflight.js", async (original) => ({
...(await original<typeof import("./schema-preflight.js")>()),
captureTargetDatabaseSchemaContext: mocks.captureSchemaContext,
checkTargetDatabaseSchemasForContexts: async () => ({ incompatible: [], indeterminate: [] }),
}));
vi.mock("./update-command-managed-context.js", async (original) => ({
...(await original<typeof import("./update-command-managed-context.js")>()),
captureOwnedManagedUpdateContext: async () => undefined,
captureOwnedManagedUpdatePreflightContext: mocks.captureManagedPreflight,
revalidateUpdateDatabaseContext: async (context: unknown) => context,
}));
vi.mock("./update-command-service.js", async (original) => ({
...(await original<typeof import("./update-command-service.js")>()),
maybeStopManagedServiceBeforeMutableUpdate: mocks.inspectService,
maybeRestartServiceAfterFailedMutableUpdate: async () => undefined,
}));
vi.mock("../../infra/update-triage.js", () => ({
prepareUpdateFailureTriage: async () => async () => ({ status: "completed", hint: "" }),
}));
const dirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => {
closeOpenClawStateDatabaseForTest();
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("keeps successful candidate repair separate from a failed update and its process exit", async () => {
const base = dirs.make("candidate-repair-exit-");
const stateDir = path.join(base, "state");
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(stateDir);
await fs.writeFile(configPath, "{}");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
vi.stubEnv("OPENCLAW_UPDATE_RUN_HANDOFF", "");
const env = { ...process.env };
const globalRoot = path.join(base, "prefix", "lib", "node_modules");
const target = createNpmTarget(globalRoot);
const root = path.join(globalRoot, "openclaw");
await writePackageRoot(root, "2026.9.3");
const launcher = path.join(base, "prefix", "bin", "openclaw");
await fs.mkdir(path.dirname(launcher), { recursive: true });
await fs.writeFile(launcher, "original launcher");
const run = { runId: createUpdateRun({ trigger: "cli" }, { env }).runId, env };
const events: string[] = [];
vi.spyOn(processRunner, "runCommandWithTimeout").mockImplementation(async (argv) => {
let stdout = "";
if (argv.join(" ") === "npm --version") {
stdout = "12.0.0\n";
} else if (argv.join(" ") === "npm root -g") {
stdout = `${globalRoot}\n`;
} else if (argv.includes("--prefix") && (argv.includes("install") || argv.includes("i"))) {
const prefix = argv[argv.indexOf("--prefix") + 1];
if (!prefix) {
throw new Error("Missing stage prefix");
}
await writePackageRoot(path.join(prefix, "lib", "node_modules", "openclaw"), "2026.9.4");
await fs.mkdir(path.join(prefix, "bin"), { recursive: true });
await fs.writeFile(path.join(prefix, "bin", "openclaw"), "candidate launcher");
} else {
throw new Error(`Unexpected package command: ${argv.join(" ")}`);
}
return { stdout, stderr: "", code: 0, signal: null, killed: false, termination: "exit" };
});
const failure = (candidateRoot: string) => ({
status: "error" as const,
reason: "runtime-verification-failed" as const,
phase: "snapshot" as const,
steps: [
{
name: "candidate snapshot",
command: "candidate validation",
cwd: candidateRoot,
durationMs: 1,
exitCode: 1,
stderrTail: "ENOSPC: no space left on device",
},
],
durationMs: 1,
logTail: ["ENOSPC: no space left on device"],
});
mocks.validateCanary.mockImplementation(
async ({ root: candidateRoot, rehearsal }: { root: string; rehearsal?: unknown }) => {
events.push(rehearsal ? "rehearsal passes" : "fresh snapshot fails");
return rehearsal
? { status: "ok", phase: "readiness", steps: [], durationMs: 1, logTail: [] }
: failure(candidateRoot);
},
);
vi.spyOn(repairAgent, "prepareUnattendedUpdateRepair").mockImplementation(async (repair) => {
const validation = await repair.validate(new AbortController().signal);
expect(validation.ok).toBe(true);
repair.onEvent?.({ type: "stopped", status: "repaired" });
return { status: "repaired", attempts: [], finalValidation: validation };
});
const configSnapshot = await readConfigFileSnapshot({
pluginValidation: "core-only",
observe: false,
});
const context = { env, readEnv: env, config: configSnapshot.config, configSnapshot };
mocks.captureSchemaContext.mockResolvedValue(context);
mocks.captureManagedPreflight.mockResolvedValue(context);
mocks.inspectService.mockResolvedValue({
stopped: false,
inspected: true,
runtimeInspected: true,
running: true,
serviceEnv: env,
});
const execution = await executeMutableUpdate({
root,
installKind: "package",
updateInstallKind: "package",
switchToGit: false,
timeoutMs: 30_000,
updateStepTimeoutMs: 30_000,
startedAt: Date.now(),
progress: {},
stop: () => {},
channel: "stable",
tag: "2026.9.4",
shouldRestart: false,
managedServiceRootRedirect: null,
recoveryState: { triageTarget: { env } },
prepareMutableUpdate: async () => {},
packageTargetSchemaVersions: { state: 15, agent: 19 },
packageInstallSpec: "openclaw@2026.9.4",
packageTargetVersion: "2026.9.4",
packageInstallTarget: target,
opts: { json: true, yes: true, run },
});
expect(execution).not.toBeNull();
if (!execution) {
throw new Error("Missing execution result");
}
expect(events, JSON.stringify(execution.result)).toEqual([
"fresh snapshot fails",
"rehearsal passes",
"fresh snapshot fails",
]);
expect(execution.result).toMatchObject({
status: "error",
reason: "runtime-verification-failed",
before: { version: "2026.9.3" },
after: { version: null },
});
expect(await fs.readFile(launcher, "utf8")).toBe("original launcher");
expect(JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")).version).toBe(
"2026.9.3",
);
const output = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => undefined);
await expect(
withUpdateFailureTriage({ json: true, yes: true, run }, { root, env }, async () => {
await finishUpdate({
...execution,
root,
installKindChanged: false,
configSnapshot,
requestedChannel: null,
storedChannel: "stable",
channel: "stable",
downgradeRisk: false,
shouldRestart: false,
preUpdatePluginInstallRecords: {},
updateStepTimeoutMs: 1000,
opts: { json: true, yes: true, run },
startedAt: Date.now(),
controlPlaneUpdateSentinelMeta: null,
ownedManagedUpdateEnv: env,
});
}),
).rejects.toMatchObject({ name: "ExitError", code: 1 });
expect(output).toHaveBeenCalledWith(
expect.objectContaining({ status: "error", reason: "runtime-verification-failed" }),
);
expect(getUpdateRun(run.runId, { env })).toMatchObject({
status: "failed",
reason: "runtime-verification-failed",
repair: [expect.objectContaining({ status: "succeeded" })],
});
const childSource = path.join(base, "exit-proof.mjs");
await fs.writeFile(
childSource,
`
import fs from "node:fs/promises";
import { runCliWithExitFinalization } from ${JSON.stringify(new URL("../one-shot-exit.ts", import.meta.url).href)};
import { withUpdateFailureTriage } from ${JSON.stringify(new URL("./update-command-triage.ts", import.meta.url).href)};
import { UpdateCommandFailure } from ${JSON.stringify(new URL("./update-command-result.ts", import.meta.url).href)};
const result = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
await runCliWithExitFinalization({
run: () => withUpdateFailureTriage({ yes: true, json: true, dryRun: true }, { env: process.env }, async () => {
process.stdout.write("observed-failed-update:" + result.reason + "\\n");
throw new UpdateCommandFailure(result);
}),
onError: (error) => { process.stderr.write(String(error)); process.exitCode = 19; },
});
`,
);
const childResultPath = path.join(base, "failed-result.json");
await fs.writeFile(childResultPath, JSON.stringify(execution.result));
const child = await processRunner.runCommandBuffered(
[process.execPath, "--import", path.resolve("scripts/tsx.mjs"), childSource, childResultPath],
{ baseEnv: env, timeoutMs: 30_000 },
);
expect(child.stdout.toString()).toContain("observed-failed-update:runtime-verification-failed");
expect(child.code, child.stderr.toString()).toBe(1);
});