openclaw / src /cli /update-cli /status.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
29.7 kB
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTempDirTracker } from "../../../test/helpers/temp-dir.js";
import {
createSessionSqliteMigrationRun,
updateMigrationManifestTarget,
writeSessionSqliteMigrationManifest,
} from "../../commands/doctor-session-sqlite-migration-run.js";
import { buildStatusUpdateRows } from "../../commands/status-update-restart.js";
import { recordDeferredPluginMigrations } from "../../infra/deferred-plugin-migrations.js";
import * as runtimeGuard from "../../infra/runtime-guard.js";
import { createRetainedUpdateRecovery } from "../../infra/update-retained-recovery.test-support.js";
import { readUpdateRunDriver } from "../../infra/update-run-driver.js";
import {
createUpdateRun,
findActiveUpdateRun,
finishUpdateRun,
getUpdateRun,
listUpdateRuns,
recordUpdateRunPhase,
recordUpdateRunVerification,
} from "../../infra/update-run-ledger.js";
import { ABANDONED_UPDATE_RUN_MS } from "../../infra/update-run-timeouts.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import { claimOpenClawStateOwnership } from "../../state/openclaw-state-ownership-operations.js";
import { updateStatusCommand } from "./status.js";
const runtime = vi.hoisted(() => ({
log: vi.fn(),
error: vi.fn(),
writeJson: vi.fn(),
exit: vi.fn(),
}));
const service = vi.hoisted(() => ({
readCommand: vi.fn(),
resolveNodeRuntimeInfo: vi.fn(),
}));
const confirmGatewayReachable = vi.hoisted(() =>
vi.fn<typeof import("../daemon-cli/restart-health-probe.js").confirmGatewayReachable>(),
);
vi.mock("../daemon-cli/restart-health-probe.js", () => ({ confirmGatewayReachable }));
vi.mock("../../daemon/service.js", () => ({
resolveGatewayService: () => ({ readCommand: service.readCommand }),
}));
vi.mock("../../daemon/runtime-paths.js", () => ({
resolveNodeRuntimeInfo: service.resolveNodeRuntimeInfo,
}));
vi.mock("../../config/paths.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../config/paths.js")>()),
isDefaultInstallIdentity: () => true,
}));
vi.mock("../../runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../runtime.js")>()),
defaultRuntime: runtime,
}));
vi.mock("../../infra/update-check.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../infra/update-check.js")>()),
checkUpdateStatus: async () => ({
root: "/fixture/openclaw",
installKind: "package",
packageManager: "npm",
registry: { latestVersion: "2026.9.2" },
}),
}));
vi.mock("./shared.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./shared.js")>()),
resolveUpdateRoot: async () => "/fixture/openclaw",
}));
const tempDirs = createTempDirTracker();
beforeEach(() => {
vi.clearAllMocks();
service.readCommand.mockResolvedValue(null);
const stateDir = tempDirs.make("openclaw-update-status-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
});
describe("update status Node runtime findings", () => {
it.each(
[true, false].flatMap((json) => [
{ json, stored: true, sqliteVersion: "3.51.2", unavailable: true },
{ json, stored: false, sqliteVersion: "3.51.2", unavailable: false },
{ json, stored: true, sqliteVersion: "3.51.3", unavailable: false },
]),
)(
"preserves diagnostics with stored=$stored SQLite $sqliteVersion (JSON: $json)",
async ({ json, stored, sqliteVersion, unavailable }) => {
const recorded = stored ? createUpdateRun({ trigger: "cli" }) : undefined;
closeOpenClawStateDatabaseForTest();
vi.resetModules();
const sqlitePrototype: {
prepare: (this: DatabaseSync, sql: string) => ReturnType<DatabaseSync["prepare"]>;
} = DatabaseSync.prototype;
const realPrepare = sqlitePrototype.prepare;
const prepare = vi
.spyOn(DatabaseSync.prototype, "prepare")
.mockImplementation(function (this: DatabaseSync, sql) {
return realPrepare.call(
this,
sql === "SELECT sqlite_version() AS version"
? `SELECT '${sqliteVersion}' AS version`
: sql,
);
});
const freshGuard = await import("../../infra/runtime-guard.js");
vi.spyOn(freshGuard, "detectRuntime").mockResolvedValue({
kind: "node",
version: process.versions.node,
execPath: "/fixture/node",
pathEnv: "/fixture",
hasNodeSqlite: true,
sqliteVersion,
sqliteProbe: {
available: true,
version: sqliteVersion,
text: true,
blob: true,
json: true,
},
});
const command = await import("./status.js");
const ledger = await import("../../infra/update-run-ledger.js");
if (unavailable) {
expect(() => ledger.findActiveUpdateRun()).toThrow(
"SQLite support is unavailable or unsafe",
);
}
await expect(command.updateStatusCommand({ json })).resolves.toBeUndefined();
if (json) {
const result = runtime.writeJson.mock.lastCall?.[0];
expect(result).toHaveProperty("availability");
expect(Boolean(result?.runStatusError)).toBe(unavailable);
if (sqliteVersion === "3.51.2") {
expect(result?.runtimeFindings).toEqual([
expect.objectContaining({
severity: "error",
message: expect.stringContaining("SQLite 3.51.2"),
fixHint: expect.stringContaining("nvm install 26"),
}),
]);
}
expect(result?.activeRun).toEqual(unavailable ? undefined : recorded);
expect(result?.lastRun).toEqual(unavailable ? undefined : recorded);
expect(result).not.toHaveProperty("abandonedRun");
} else {
const output = runtime.log.mock.calls.flat().join("\n");
expect(output).toContain("OpenClaw update status");
expect(output.includes("Update run status unavailable:")).toBe(unavailable);
if (sqliteVersion === "3.51.2") {
expect(output).toContain("SQLite 3.51.2");
expect(output).toContain("nvm install 26");
}
}
if (!stored) {
expect(prepare).not.toHaveBeenCalled();
}
prepare.mockRestore();
expect(recorded && ledger.getUpdateRun(recorded.runId)).toEqual(recorded);
},
);
it.each(["cli", "service"])(
"renders admitted %s runtime information without a missing hint",
async (source) => {
if (source === "cli") {
vi.spyOn(runtimeGuard, "detectRuntime").mockResolvedValue({
kind: "node",
version: "24.15.0",
execPath: "/fixture/node",
pathEnv: "/fixture",
hasNodeSqlite: true,
sqliteVersion: "3.53.4",
sqliteProbe: { available: true, version: "3.53.4", text: true, blob: true, json: true },
});
} else {
service.readCommand.mockResolvedValue({ programArguments: ["/fixture/node", "gateway"] });
service.resolveNodeRuntimeInfo.mockResolvedValue({
status: "supported",
version: "24.15.0",
note: "Node 24.15.0: unsupported version, capability probe passed.",
});
}
await updateStatusCommand({});
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("capability probe passed"));
expect(runtime.log).not.toHaveBeenCalledWith(undefined);
},
);
it.each([
{ version: "22.23.2", source: "cli" },
{ version: "26.0.0", source: "cli" },
{ version: "22.23.2", source: "gateway-service" },
{ version: "26.0.0", source: "gateway-service" },
])(
"reports unsupported $source Node $version with recovery instructions",
async ({ version, source }) => {
vi.stubGlobal("process", {
...process,
versions: { ...process.versions, node: source === "cli" ? version : "26.8.1" },
});
if (source === "cli") {
vi.spyOn(runtimeGuard, "detectRuntime").mockResolvedValue({
kind: "node",
version,
execPath: "/fixture/node",
pathEnv: "/fixture",
hasNodeSqlite: true,
sqliteVersion: "3.53.4",
sqliteProbe: { available: true, version: "3.53.4", text: false, blob: true, json: true },
});
}
if (source === "gateway-service") {
service.readCommand.mockResolvedValue({
programArguments: ["/fixture/node", "openclaw.mjs", "gateway"],
});
service.resolveNodeRuntimeInfo.mockResolvedValue({
status: "unsupported",
version,
sqliteVersion: "3.50.2",
nodeSharedSqlite: false,
});
}
await updateStatusCommand({ json: true });
expect(runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
runtimeFindings: [
expect.objectContaining({
source,
message: expect.stringContaining(version),
requirement: expect.stringContaining(">=24.16.0 <25, or >=26.1.0"),
fixHint: expect.stringContaining("https://openclaw.ai/install.sh"),
}),
],
}),
);
await updateStatusCommand({});
const output = runtime.log.mock.calls.map(([line]) => String(line)).join("\n");
expect(output).toContain(version);
expect(output).toContain("npm");
expect(output).toContain("nvm install 26");
},
);
it("does not report a supported CLI or recorded service Node", async () => {
vi.stubGlobal("process", { ...process, versions: { ...process.versions, node: "26.8.1" } });
service.readCommand.mockResolvedValue({
programArguments: ["/fixture/node", "openclaw.mjs", "gateway"],
});
service.resolveNodeRuntimeInfo.mockResolvedValue({
status: "supported",
version: "26.8.1",
sqliteVersion: "3.53.0",
nodeSharedSqlite: false,
});
await updateStatusCommand({ json: true });
expect(runtime.writeJson.mock.lastCall?.[0].runtimeFindings ?? []).toEqual([]);
});
});
afterEach(() => {
closeOpenClawStateDatabaseForTest();
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
tempDirs.cleanup();
});
describe("update status readiness outcome", () => {
it("shows installed but unverified as a closed non-success outcome", async () => {
const run = createUpdateRun({ trigger: "cli" });
recordUpdateRunVerification(run.runId, { serviceRunning: true, readyz: false, settled: false });
const finished = finishUpdateRun(run.runId, {
status: "skipped",
reason: "gateway-readiness-unverified",
after: { version: "2026.9.4" },
});
await updateStatusCommand({});
expect(runtime.log.mock.calls.flat().join("\n")).toContain(
"OpenClaw 2026.9.4 installed; Gateway readiness unverified; recovery backups retained.",
);
await updateStatusCommand({ json: true });
expect(runtime.writeJson.mock.lastCall?.[0]).toMatchObject({
lastRun: {
...finished,
phase: "finished",
confirmedAtMs: null,
finishedAtMs: expect.any(Number),
},
});
expect(runtime.writeJson.mock.lastCall?.[0].activeRun).toBeUndefined();
expect(getUpdateRun(run.runId)).toEqual(finished);
});
});
describe("update status abandoned-run reporting", () => {
it.each([true, false])(
"qualifies historical recovery advice using the recorded port (responding=%s)",
async (responding) => {
const advice =
"Managed gateway remains stopped. Keep the gateway stopped until the update succeeds.";
const created = createUpdateRun({ trigger: "cli", origin: { nextAction: advice } });
recordUpdateRunVerification(created.runId, {
port: 19123,
serviceRunning: false,
versionMatch: false,
});
const finished = finishUpdateRun(created.runId, {
status: "failed",
reason: "restart-unhealthy",
after: { version: "2026.9.4" },
});
confirmGatewayReachable.mockResolvedValue({
reachable: responding,
gatewayVersion: responding ? "2026.9.4" : null,
gatewayBuildId: undefined,
activatedPluginErrors: [],
unavailablePlugins: [],
channelProbeErrors: [],
});
await updateStatusCommand({});
const output = runtime.log.mock.calls.flat().join("\n");
expect(output).toContain("service identity unavailable");
expect(output).not.toContain("version mismatch");
expect(runtime.log).not.toHaveBeenCalledWith(advice);
expect(output).toContain("Historical recovery advice:");
expect(output).toContain(
responding ? "supersedes saved claims" : "Current health unavailable",
);
expect(confirmGatewayReachable).toHaveBeenCalledWith(
expect.objectContaining({ port: 19123 }),
);
expect(getUpdateRun(created.runId)).toEqual(finished);
await updateStatusCommand({ json: true });
expect(runtime.writeJson.mock.lastCall?.[0].lastRun).toEqual(finished);
},
);
it.each([true, false])(
"reports unreadable pending migration status without losing availability (JSON: %s)",
async (json) => {
recordDeferredPluginMigrations({
pending: [
{
pluginId: "codex",
reason: "The configured plugin package is missing.",
command: "openclaw plugins install @openclaw/codex",
},
],
});
openOpenClawStateDatabase()
.db.prepare("UPDATE migration_runs SET report_json = ? WHERE id = ?")
.run("not-json", "deferred-plugin-migration:codex");
await expect(updateStatusCommand({ json })).resolves.toBeUndefined();
if (json) {
const result = runtime.writeJson.mock.lastCall?.[0];
expect(result).toHaveProperty("availability");
expect(result.migrationWarningsError).toEqual(expect.any(String));
expect(result).not.toHaveProperty("migrationWarnings");
} else {
const output = runtime.log.mock.calls.flat().join("\n");
expect(output).toContain("OpenClaw update status");
expect(output).toContain("Pending migration status unavailable:");
}
},
);
it.each([true, false])(
"reports retained session migration warnings without an update ledger (JSON: %s)",
async (json) => {
const stateDir = process.env.OPENCLAW_STATE_DIR!;
const targets = ["main", "other"].map((agentId) => ({
agentId,
storePath: path.join(stateDir, "agents", agentId, "sessions", "sessions.json"),
sqlitePath: path.join(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite"),
}));
const invalidEntry = {
code: "entry_invalid",
message: "Session entry is missing a valid sessionId.",
sessionKey: "agent:main:invalid",
};
const malformedTranscript = {
code: "transcript_malformed",
message: `${path.join(path.dirname(targets[1]!.storePath), "broken.jsonl")}: SyntaxError: malformed JSONL line`,
sessionKey: "agent:other:broken",
};
const first = createSessionSqliteMigrationRun(process.env, targets);
for (const [index, target] of targets.entries()) {
updateMigrationManifestTarget(
first,
target,
[index === 0 ? invalidEntry : malformedTranscript],
{ validationBeforeArchive: "passed" },
);
}
first.manifest.completedAt = new Date().toISOString();
writeSessionSqliteMigrationManifest(first);
const expectedWarnings = [
`${targets[0]!.storePath}: [entry_invalid] ${invalidEntry.message}`,
`${targets[1]!.storePath}: [transcript_malformed] ${malformedTranscript.message}`,
];
const expectWarnings = async (warnings: string[]) => {
runtime.log.mockClear();
runtime.writeJson.mockClear();
await updateStatusCommand({ json });
if (json) {
const result = runtime.writeJson.mock.lastCall?.[0];
expect(result.migrationWarnings).toEqual(warnings);
expect(result).not.toHaveProperty("lastRun");
} else {
const output = runtime.log.mock.calls.flat().join("\n");
for (const warning of expectedWarnings) {
expect(output.includes(warning)).toBe(warnings.includes(warning));
}
}
};
await expectWarnings(expectedWarnings);
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 1_000);
const retry = createSessionSqliteMigrationRun(process.env, [targets[0]!]);
await expectWarnings(expectedWarnings);
retry.manifest.completedAt = new Date().toISOString();
updateMigrationManifestTarget(retry, targets[0]!, [], {
validationBeforeArchive: "passed",
});
await expectWarnings(expectedWarnings.slice(1));
},
);
it.each([true, false])(
"reports current migration warnings absent from historical update steps (JSON: %s)",
async (json) => {
const run = createUpdateRun({ trigger: "cli" });
const history = finishUpdateRun(run.runId, { status: "succeeded" });
const pending = {
pluginId: "codex",
reason: "The configured plugin package is missing.",
command: "openclaw plugins install @openclaw/codex",
};
recordDeferredPluginMigrations({ pending: [pending] });
await updateStatusCommand({ json });
if (json) {
expect(runtime.writeJson.mock.lastCall?.[0].migrationWarnings).toEqual([
expect.stringContaining('Plugin "codex" state migration is pending:'),
]);
expect(runtime.writeJson.mock.lastCall?.[0].migrationWarnings[0]).toContain(
pending.command,
);
} else {
const output = runtime.log.mock.calls.flat().join("\n");
expect(output).toContain('Plugin "codex" state migration is pending:');
expect(output).toContain(pending.command);
}
expect(getUpdateRun(run.runId)).toEqual(history);
recordDeferredPluginMigrations({ pending: [], resolvedPluginIds: [pending.pluginId] });
runtime.log.mockClear();
runtime.writeJson.mockClear();
await updateStatusCommand({ json });
if (json) {
expect(runtime.writeJson.mock.lastCall?.[0]).not.toHaveProperty("migrationWarnings");
} else {
expect(runtime.log.mock.calls.flat().join("\n")).not.toContain(
'Plugin "codex" state migration is pending:',
);
}
expect(getUpdateRun(run.runId)).toEqual(history);
},
);
it.each([true, false])(
"reports an activation timeout without abandonment (JSON: %s)",
async (json) => {
const created = createUpdateRun({ trigger: "cli" });
recordUpdateRunPhase(created.runId, "activating");
const finished = finishUpdateRun(created.runId, {
status: "failed",
reason: "update-activation-timeout",
});
await updateStatusCommand({ json });
if (json) {
expect(runtime.writeJson.mock.lastCall?.[0]).toMatchObject({ lastRun: finished });
expect(runtime.writeJson.mock.lastCall?.[0]).not.toHaveProperty("activeRun");
expect(runtime.writeJson.mock.lastCall?.[0]).not.toHaveProperty("abandonedRun");
} else {
const output = runtime.log.mock.calls.flat().join("\n");
expect(output).toContain("update-activation-timeout");
expect(output).toContain("openclaw doctor");
expect(output).toContain("Wait for the owning updater and its child processes to stop");
expect(output).toContain("openclaw update repair");
expect(output).not.toContain("Abandoned update detected");
}
expect(getUpdateRun(created.runId)).toEqual(finished);
},
);
it.each(["json", "text", "status"])(
"preserves readable history when reconciliation is refused through %s",
async (surface) => {
const now = Date.now();
const clock = vi.spyOn(Date, "now").mockReturnValue(now - 25 * 60 * 60_000);
const run = createUpdateRun({ trigger: "cli" });
clock.mockReturnValue(now);
claimOpenClawStateOwnership("test-supervisor", {
env: { ...process.env, OPENCLAW_SUPERVISOR_MODE: "external" },
});
vi.stubEnv("OPENCLAW_SUPERVISOR_MODE", "");
if (surface === "status") {
const rows = buildStatusUpdateRows(null);
expect(rows).toContainEqual(
expect.objectContaining({
Item: "Update run",
Value: expect.stringContaining("update in progress: requested"),
}),
);
expect(rows).toContainEqual(
expect.objectContaining({
Item: "Update reconciliation",
Value: expect.stringContaining("externally supervised"),
}),
);
} else {
await updateStatusCommand({ json: surface === "json" });
if (surface === "json") {
const result = runtime.writeJson.mock.lastCall?.[0];
expect(result).toMatchObject({
activeRun: run,
lastRun: run,
runReconciliationError: expect.stringContaining("externally supervised"),
});
expect(result).not.toHaveProperty("runStatusError");
} else {
const output = runtime.log.mock.calls.flat().join("\n");
expect(output).toContain(run.runId);
expect(output).toContain("Update run reconciliation failed:");
expect(output).not.toContain("Update run status unavailable:");
}
}
expect(getUpdateRun(run.runId)).toEqual(run);
},
);
it("does not advertise expiry for a legacy admission reserved by recovery", async () => {
const now = Date.now();
vi.spyOn(Date, "now").mockReturnValue(now - 25 * 60 * 60_000);
const legacy = createUpdateRun({ trigger: "cli", before: { version: "2026.9.2" } });
const from = {
root: process.env.OPENCLAW_STATE_DIR ?? "/fixture",
nodePath: process.execPath,
version: "2026.9.2",
buildId: null,
};
createRetainedUpdateRecovery(
{ runId: legacy.runId, from, to: { ...from, version: "2026.9.3" } },
{},
);
vi.mocked(Date.now).mockReturnValue(now);
await updateStatusCommand({ json: true });
expect(getUpdateRun(legacy.runId)).toEqual(legacy);
expect(runtime.writeJson.mock.lastCall?.[0]).not.toHaveProperty("abandonedRun");
expect(runtime.writeJson.mock.lastCall?.[0]).not.toHaveProperty("advisories");
});
it.each(
["none", "succeeded-before-expiry", "succeeded-after-expiry", "active"].flatMap((laterRun) =>
["json", "text", "status"].map((surface) => ({ laterRun, surface })),
),
)(
"keeps expired admission history with $laterRun through $surface",
async ({ laterRun, surface }) => {
const now = Date.now();
const clock = vi.spyOn(Date, "now").mockReturnValue(now - 25 * 60 * 60_000);
const legacy = createUpdateRun({ trigger: "cli", before: { version: "2026.9.2" } });
clock.mockReturnValue(now);
if (laterRun === "succeeded-after-expiry" || laterRun === "active") {
await updateStatusCommand({ json: true });
expect(getUpdateRun(legacy.runId)?.reason).toBe("legacy-driver-expired");
runtime.writeJson.mockClear();
}
let currentRunId = legacy.runId;
if (laterRun !== "none") {
clock.mockReturnValue(now + 1);
currentRunId = createUpdateRun({ trigger: "cli" }).runId;
if (laterRun !== "active") {
finishUpdateRun(currentRunId, { status: "succeeded" });
}
}
let output: string;
if (surface === "status") {
output = JSON.stringify(buildStatusUpdateRows(null));
} else {
await updateStatusCommand({ json: surface === "json" });
output =
surface === "json"
? JSON.stringify(runtime.writeJson.mock.lastCall?.[0])
: runtime.log.mock.calls.flat().join("\n");
}
const expired = getUpdateRun(legacy.runId);
expect(expired).toMatchObject({
phase: "finished",
status: "failed",
reason: "legacy-driver-expired",
});
expect(output).toContain("treated as abandoned after 24 h");
expect(output.includes("Historical update:")).toBe(laterRun !== "none");
expect(output.includes("run `openclaw update` to retry.")).toBe(laterRun === "none");
expect(findActiveUpdateRun()?.runId).toBe(laterRun === "active" ? currentRunId : undefined);
// A later read must still surface the advisory after the terminal write.
await updateStatusCommand({ json: true });
const result = runtime.writeJson.mock.lastCall?.[0];
expect((result.activeRun ?? result.lastRun)?.runId).toBe(currentRunId);
expect(result.advisories).toEqual([
{
runId: legacy.runId,
reason: "legacy-driver-expired",
message: expect.stringContaining("treated as abandoned after 24 h"),
},
]);
expect(getUpdateRun(legacy.runId)).toEqual(expired);
},
);
it.each([true, false])(
"does not publish partial history when the latest terminal row is unreadable (JSON: %s)",
async (json) => {
const now = Date.now();
vi.spyOn(Date, "now").mockReturnValue(now);
const active = createUpdateRun({ trigger: "cli" });
vi.mocked(Date.now).mockReturnValue(now + 1);
const latest = createUpdateRun({ trigger: "cli" });
finishUpdateRun(latest.runId, { status: "succeeded" });
closeOpenClawStateDatabaseForTest();
const database = new DatabaseSync(resolveOpenClawStateSqlitePath());
try {
database
.prepare("UPDATE update_runs SET origin_json = ? WHERE run_id = ?")
.run("not-json", latest.runId);
} finally {
database.close();
}
expect(findActiveUpdateRun()).toEqual(active);
expect(() => listUpdateRuns({ limit: 1 })).toThrow();
await updateStatusCommand({ json });
if (json) {
const result = runtime.writeJson.mock.lastCall?.[0];
expect(result?.runStatusError).toEqual(expect.any(String));
expect(result).toHaveProperty("availability");
for (const field of ["activeRun", "lastRun", "staleRun", "abandonedRun"]) {
expect(result).not.toHaveProperty(field);
}
} else {
const output = runtime.log.mock.calls.flat().join("\n");
expect(output).toContain("OpenClaw update status");
expect(output).toContain("Update run status unavailable:");
expect(output).not.toContain(active.runId);
}
expect(getUpdateRun(active.runId)).toEqual(active);
expect(() => listUpdateRuns({ limit: 1 })).toThrow();
},
);
it.each([true, false])(
"gives explicit recovery guidance for stale identityless history (JSON: %s)",
async (json) => {
const now = Date.now();
const lastActivity = now - ABANDONED_UPDATE_RUN_MS - 10;
vi.spyOn(Date, "now").mockReturnValue(lastActivity);
const recorded = createUpdateRun({ trigger: "control-ui", before: { version: "2026.9.2" } });
vi.mocked(Date.now).mockReturnValue(now);
await updateStatusCommand({ json });
const guidance = `no activity since ${new Date(lastActivity).toISOString()}; if no update is running, run \`openclaw update repair\` or start a new \`openclaw update\``;
expect(getUpdateRun(recorded.runId)).toEqual(recorded);
if (json) {
expect(runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
activeRun: recorded,
staleRun: { runId: recorded.runId, guidance },
}),
);
expect(runtime.writeJson.mock.lastCall?.[0]).not.toHaveProperty("abandonedRun");
} else {
const output = runtime.log.mock.calls.map(([line]) => String(line)).join("\n");
expect(output).toContain(guidance);
expect(output).not.toContain("update in progress:");
}
},
);
it.each([true, false])("reports abandonment read-only (JSON: %s)", async (json) => {
const now = Date.now();
const driver = readUpdateRunDriver();
if (!driver) {
throw new Error("Test process identity is unavailable");
}
vi.spyOn(Date, "now").mockReturnValue(now - ABANDONED_UPDATE_RUN_MS - 10);
const created = createUpdateRun({
trigger: "control-ui",
before: { version: "2026.9.2" },
// This live PID has a different start identity than the exited driver.
origin: { driver: { ...driver, startIdentity: String(Number(driver.startIdentity) + 1) } },
});
const recorded = recordUpdateRunPhase(created.runId, "staging");
vi.mocked(Date.now).mockReturnValue(now);
await updateStatusCommand({ json });
expect(getUpdateRun(created.runId)).toEqual(recorded);
if (json) {
expect(runtime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
activeRun: recorded,
lastRun: recorded,
abandonedRun: { runId: created.runId, rule: "inactive-driver-dead" },
}),
);
} else {
const output = runtime.log.mock.calls.map(([line]) => String(line)).join("\n");
expect(output).toContain("Abandoned update detected;");
expect(output).toContain("openclaw update repair");
expect(output).not.toContain("update in progress:");
expect(output).not.toContain("update failed:");
}
});
});