openclaw / src /cli /update-cli /update-command-execution.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
39.5 kB
// Install the fixture mocks before loading the execution owner and its dependencies.
import "./update-command-execution.test-support.js";
import { once } from "node:events";
import fs from "node:fs/promises";
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../test/helpers/promise.js";
import * as configFile from "../../config/config.js";
import * as gatewayService from "../../daemon/service.js";
import { mockSystemAccountHome } from "../../daemon/service.test-helpers.js";
import * as gatewayCall from "../../gateway/call.js";
import { gatewayHealthResponse } from "../../gateway/health-response.test-support.js";
import * as portInspection from "../../infra/ports-inspect.js";
import * as tempRoot from "../../infra/tmp-openclaw-dir.js";
import { UpdateRequesterRevokedError } from "../../infra/update-requester-authority.js";
import { createUpdateRun } from "../../infra/update-run-ledger.js";
import {
updateRunStepsFromResultStep,
updateRunWarningMessages,
} from "../../infra/update-run-step.js";
import type { UpdateStepProgress, UpdateStepResult } from "../../infra/update-runner.js";
import { withTestDir } from "../../test-helpers/temp-dir.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { mockProcessPlatform } from "../../test-utils/vitest-spies.js";
import * as utils from "../../utils.js";
import * as restartProbe from "../daemon-cli/restart-health-probe.js";
import { UpdatePreMutationError } from "./shared.js";
import { executeMutableUpdate } from "./update-command-execution.js";
import { withUpdateCommandExecutor } from "./update-command-executor.js";
import {
gatewayServiceCommandUsesRoot,
GatewayServiceUpdateOwnershipError,
} from "./update-command-service-plan.js";
const { executionParams, inspectOrStopService, mocks, schemaContext, successfulUpdate } =
await import("./update-command-execution.test-support.js");
describe("mutable update execution", () => {
it.each(["package", "git"] as const)(
"continues the %s update with the recorded readiness warning instead of inference repair",
async (kind) => {
const message =
"Readiness probe http://127.0.0.1:18789/readyz failed: HTTP 502. Check the configured proxy.";
const step: UpdateStepResult = {
name: "candidate gateway canary",
command: "gateway run",
cwd: "/candidate",
durationMs: 1,
exitCode: null,
advisory: { kind: "candidate-runtime-unavailable", message },
failureFacts: [{ check: "readyz", code: "candidate-readiness-probe-failed", message }],
};
mocks.validateCanary.mockImplementation(async ({ onStep }) => {
onStep(step);
return {
status: "ok",
phase: "readiness",
steps: [step],
durationMs: 1,
logTail: [message],
};
});
const repair = await import("./update-command-repair.js");
const runRepair = vi.spyOn(repair, "runUpdateCommandRepair");
const accepted = vi.fn();
const runStagedUpdate = async ({
validateCandidate,
}: {
validateCandidate?: (root: string) => Promise<unknown>;
}) => {
expect(validateCandidate).toBeTypeOf("function");
await validateCandidate?.("/candidate");
accepted();
return successfulUpdate;
};
mocks.runPackageUpdate.mockImplementation(runStagedUpdate);
mocks.runGitUpdate.mockImplementation(runStagedUpdate);
const onStepComplete = vi.fn<NonNullable<UpdateStepProgress["onStepComplete"]>>();
const execution = await executeMutableUpdate({
...executionParams(kind),
progress: { onStepComplete },
});
expect(execution?.result.status).toBe("ok");
expect(accepted).toHaveBeenCalledOnce();
expect(runRepair).not.toHaveBeenCalled();
expect(onStepComplete).toHaveBeenCalledWith(expect.objectContaining(step));
const recorded = onStepComplete.mock.calls.flatMap(([completed]) =>
updateRunStepsFromResultStep(completed),
);
expect(updateRunWarningMessages(recorded)).toEqual([message]);
expect(recorded.every((entry) => entry.status === "completed")).toBe(true);
},
);
it.each(
(["package", "git"] as const).flatMap((kind) =>
[undefined, 30_000, 600_000].map((timeoutMs) => ({ kind, timeoutMs })),
),
)(
"passes only the operator's $timeoutMs ms deadline to $kind candidate validation",
async ({ kind, timeoutMs }) => {
const runStagedUpdate = async ({
validateCandidate,
}: {
validateCandidate?: (root: string) => Promise<unknown>;
}) => {
expect(validateCandidate).toBeTypeOf("function");
await validateCandidate?.("/candidate");
return successfulUpdate;
};
mocks.runPackageUpdate.mockImplementation(runStagedUpdate);
mocks.runGitUpdate.mockImplementation(runStagedUpdate);
const execution = await executeMutableUpdate({
...executionParams(kind),
timeoutMs,
updateStepTimeoutMs: timeoutMs ?? 30 * 60_000,
});
expect(execution?.result.status).toBe("ok");
expect(mocks.validateCanary).toHaveBeenCalledOnce();
expect(mocks.validateCanary.mock.calls[0]?.[0].root).toBe("/candidate");
expect(mocks.validateCanary.mock.calls[0]?.[0].timeoutMs).toBe(timeoutMs);
},
);
it.each([
["measured startup", undefined, true, undefined],
["explicit allowance", 450_000, true, undefined],
["explicit deadline", 30_000, false, undefined],
["terminal version mismatch", undefined, false, "version"],
["replaced executor", undefined, false, "executor"],
] as const)(
"preserves previous Gateway verification through slow readiness (%s)",
async (_allowance, timeoutMs, verified, failure) =>
withTestDir({ prefix: "previous-gateway-readiness-" }, async (root) => {
const readyAtMs = 400_000;
mockProcessPlatform("linux");
let elapsedMs = 0;
const epochMs = Date.now();
vi.spyOn(performance, "now").mockImplementation(() => elapsedMs);
vi.spyOn(Date, "now").mockImplementation(() => epochMs + elapsedMs);
vi.spyOn(utils, "sleep").mockImplementation(async (delayMs) => {
elapsedMs += delayMs;
});
let readyObservedAtMs: number | undefined;
let stoppedAtMs: number | undefined;
let replaceExecutor: (() => void) | undefined;
const server = createServer((request, response) => {
const ready = elapsedMs >= readyAtMs;
if (request.url === "/readyz" && ready) {
readyObservedAtMs = elapsedMs;
replaceExecutor?.();
}
response.writeHead(request.url === "/readyz" && !ready ? 503 : 200).end();
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Missing synthetic Gateway listener");
}
try {
await fs.mkdir(path.join(root, "dist"));
await fs.writeFile(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.0" }),
);
await fs.writeFile(path.join(root, "dist", "index.js"), "");
const context = schemaContext("default");
const config = { gateway: { mode: "local" as const, port: address.port } };
const configSnapshot = {
...context.configSnapshot,
config,
sourceConfig: config,
};
const managedEnv = { HOME: root, OPENCLAW_STATE_DIR: path.join(root, ".openclaw") };
vi.spyOn(os, "userInfo").mockReturnValue({
uid: 1000,
gid: 1000,
username: "operator",
homedir: root,
shell: "/bin/sh",
});
mocks.captureManagedContext.mockResolvedValue({
env: managedEnv,
configSnapshot,
pluginInstallRecords: {},
});
vi.spyOn(configFile, "readConfigFileSnapshot").mockResolvedValue(configSnapshot);
vi.spyOn(restartProbe, "resolveGatewayRestartProbeContext").mockResolvedValue({
config,
auth: {},
});
const service = gatewayService.resolveGatewayService();
vi.spyOn(gatewayService, "resolveGatewayService").mockReturnValue(service);
vi.spyOn(service, "readRuntime").mockResolvedValue({ status: "running", pid: 8000 });
vi.spyOn(service, "readCommand").mockResolvedValue({
programArguments: [process.execPath, path.join(root, "dist", "index.js"), "gateway"],
});
expect(await gatewayServiceCommandUsesRoot({ root, env: managedEnv })).toBe(true);
vi.spyOn(portInspection, "inspectPortUsage").mockImplementation(async (port) => ({
port,
status: "busy",
listeners: [{ pid: 8000, commandLine: "openclaw-gateway" }],
hints: [],
}));
vi.spyOn(gatewayCall, "callGateway").mockImplementation(
gatewayHealthResponse({
server: {
version: failure === "version" ? "0.9.0" : "1.0.0",
bootId: "previous-boot",
},
}),
);
mocks.validateCanary.mockResolvedValue({
status: "ok",
phase: "readiness",
durationMs: 70_000,
logTail: [],
steps: [
{
name: "candidate gateway canary",
command: "gateway run",
cwd: root,
durationMs: 70_000,
exitCode: 0,
},
],
});
mocks.maybeStopService.mockImplementation(async ({ phase }) => {
if (phase === "prepare") {
stoppedAtMs = elapsedMs;
}
return inspectOrStopService(phase);
});
mocks.runPackageUpdate.mockImplementation(
async (
params: Parameters<
typeof import("./update-command-package.js").runPackageInstallUpdate
>[0],
) => {
await params.validateCandidate(root);
await params.beforeActivate();
return successfulUpdate;
},
);
const params = {
...executionParams("package"),
root,
timeoutMs,
updateStepTimeoutMs: timeoutMs ?? 20 * 60_000,
};
if (failure === "executor") {
replaceExecutor = () => {
params.opts.run = { runId: "replacement-run", env: { OPENCLAW_STATE_DIR: root } };
};
}
const execution = await executeMutableUpdate(params);
if (failure === "executor") {
expect(execution?.result.status).toBe("error");
expect(execution?.failure?.detail).toContain("lost its original executor");
expect(stoppedAtMs).toBeUndefined();
expect(execution?.previousVerified).toBe(false);
return;
}
expect(execution?.result.status, JSON.stringify(mocks.runtimeError.mock.calls)).toBe(
"ok",
);
expect(
execution?.previousVerified,
JSON.stringify({ readyObservedAtMs, stoppedAtMs }),
).toBe(verified);
if (verified) {
expect(readyObservedAtMs).toBeGreaterThanOrEqual(readyAtMs);
expect(stoppedAtMs).toBeGreaterThanOrEqual(readyObservedAtMs!);
} else {
expect(readyObservedAtMs).toBeUndefined();
expect(stoppedAtMs).toBeLessThan(readyAtMs);
if (failure === "version") {
expect(stoppedAtMs).toBe(0);
}
}
} finally {
server.closeAllConnections();
const closed = once(server, "close");
server.close();
await closed;
}
}),
);
it("retains the live update run when stopped-service context capture fails", async () => {
await withTestDir({ prefix: "partial-stop-recovery-owner-" }, async (dir) => {
const control = path.join(dir, "leases");
await fs.mkdir(control);
vi.spyOn(tempRoot, "resolvePreferredOpenClawTmpDir").mockReturnValue(control);
const env = { OPENCLAW_STATE_DIR: dir };
const runId = createUpdateRun({ trigger: "cli" }, { env }).runId;
const params = executionParams("package");
params.root = dir;
params.opts.run = { runId, env };
mocks.maybeStopService.mockImplementation(async () => ({
...inspectOrStopService("prepare"),
serviceEnv: env,
serviceUpdateVerdict: {
kind: "owned",
root: dir,
fingerprint: "original",
refreshDefinition: false,
},
}));
mocks.captureManagedContext.mockRejectedValueOnce(
new Error("fixture config became unreadable"),
);
let recoveryRun: typeof params.opts.run;
mocks.maybeRestartService.mockImplementation(async (request) => {
recoveryRun = request.updateRun;
recoveryRun?.executorFence?.assertCurrent();
return "healthy";
});
await withUpdateCommandExecutor(runId, async (executor) => {
params.opts.run!.executorFence = await executor.enter(dir, { preflight: true });
const result = await executeMutableUpdate(params);
expect(result?.result.status).toBe("error");
expect(mocks.maybeRestartService).toHaveBeenCalledOnce();
expect(recoveryRun).toBe(params.opts.run);
expect(mocks.serviceStopped).toBe(true);
expect(mocks.runPackageUpdate).not.toHaveBeenCalled();
});
});
});
it("refuses service admission before mutable startup housekeeping", async () => {
mocks.maybeStopService.mockImplementation(async ({ phase, handoffFromGateway }) => {
if (handoffFromGateway) {
throw new UpdatePreMutationError("managed-service-preflight", "service owner changed");
}
return inspectOrStopService(phase);
});
const execution = await executeMutableUpdate(executionParams("package"));
expect(execution).toMatchObject({
mutationStarted: false,
result: { status: "error", reason: "managed-service-preflight" },
});
expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled();
expect(mocks.runPackageUpdate).not.toHaveBeenCalled();
expect(mocks.serviceStopped).toBe(false);
});
it.each(
(["package", "git"] as const).flatMap((kind) =>
[false, true].map((shouldRestart) => ({ kind, shouldRestart })),
),
)(
"explains FreeBSD $kind refusal before mutation with restart=$shouldRestart",
async ({ kind, shouldRestart }) =>
withEnvAsync({ OPENCLAW_SUPERVISOR_MODE: undefined }, async () => {
mockProcessPlatform("freebsd");
mockSystemAccountHome();
const maintenance = await vi.importActual<
typeof import("./update-command-service-maintenance.js")
>("./update-command-service-maintenance.js");
mocks.maybeStopService.mockImplementation(
maintenance.maybeStopManagedServiceBeforeMutableUpdate,
);
const execution = await executeMutableUpdate({
...executionParams(kind),
shouldRestart,
opts: { json: true, restart: shouldRestart },
});
expect(execution).toMatchObject({
mutationStarted: false,
result: { status: "error", reason: "managed-service-preflight" },
});
expect(execution?.failure?.detail).toContain(
"Gateway service inspection is not supported by this CLI on FreeBSD",
);
expect(execution?.failure?.detail).toContain("service-owned state directories");
expect(execution?.failure?.detail).toContain(
"For updates, use the original package manager or installer",
);
expect(execution?.failure?.detail).toContain("keep pkg-owned files under pkg management");
expect(execution?.failure?.detail).not.toContain("gateway status");
expect(mocks.captureSchemaContext).not.toHaveBeenCalled();
expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled();
expect(mocks.runPackageUpdate).not.toHaveBeenCalled();
expect(mocks.runGitUpdate).not.toHaveBeenCalled();
}),
);
it.each(["admission", "execution"] as const)(
"preserves native inspection reasons through %s refusal",
async (phase) => {
mocks.maybeStopService.mockImplementation(async ({ handoffFromGateway }) => {
if (phase === "admission" || handoffFromGateway) {
return {
stopped: false,
inspected: false,
runtimeInspected: false,
running: false,
serviceMutationAllowed: false,
serviceUpdateVerdict: {
kind: "unavailable",
message: "The systemd user session bus is unavailable.",
inspectionReason: "systemd-user-bus-unavailable",
},
blockMessage: "The systemd user session bus is unavailable.",
};
}
return inspectOrStopService("inspect");
});
const execution = await executeMutableUpdate(executionParams("package"));
expect(execution?.result).toMatchObject({
status: "error",
reason: "managed-service-preflight",
steps: [
{
failureFacts: [
{
check: "managed-service",
code: "systemd-user-bus-unavailable",
message: "The systemd user session bus is unavailable.",
},
],
},
],
});
expect(mocks.serviceStopped).toBe(false);
expect(mocks.runPackageUpdate).not.toHaveBeenCalled();
},
);
it.each(["available", "incompatible", "changed-owner"] as const)(
"admits local artifacts from the staged version before rehearsal: %s",
async (outcome) => {
await withTestDir({ prefix: "openclaw-staged-plugin-admission-" }, async (stage) => {
await fs.writeFile(
path.join(stage, "package.json"),
JSON.stringify({ name: "openclaw", version: "1.0.7" }),
);
const events: string[] = [];
mocks.pluginPreflight.mockImplementation(async ({ targetVersion }) => {
events.push("preflight");
expect(targetVersion).toBe("1.0.7");
expect(mocks.serviceStopped).toBe(false);
if (outcome === "incompatible") {
return [
{
pluginId: "fixture",
reason: "Installed plugin is incompatible and its replacement is unavailable.",
message: "Fixture plugin update needs a retry.",
guidance: [],
},
];
}
return [];
});
mocks.revalidateSchemaContext.mockImplementation(async (context) => {
if (outcome === "changed-owner" && events.includes("preflight")) {
throw new UpdatePreMutationError("database-schema-preflight", "fixture owner changed");
}
return context;
});
mocks.validateCanary.mockImplementation(async () => {
events.push("rehearsal");
return { status: "ok", phase: "readiness", steps: [], durationMs: 1, logTail: [] };
});
mocks.runPackageUpdate.mockImplementation(async ({ validateCandidate }) => {
events.push("staged");
expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled();
try {
await validateCandidate(stage);
return successfulUpdate;
} catch (error) {
if (!(error instanceof UpdatePreMutationError)) {
throw error;
}
return { ...successfulUpdate, status: "error", reason: "package-update-failed" };
}
});
const execution = await executeMutableUpdate({
...executionParams("package"),
tag: "/tmp/candidate.tgz",
packageInstallSpec: "/tmp/candidate.tgz",
packageTargetVersion: undefined,
});
expect(events).toEqual(
outcome === "changed-owner"
? ["staged", "preflight"]
: ["staged", "preflight", "rehearsal"],
);
expect(execution?.mutationStarted).toBe(false);
expect(mocks.serviceStopped).toBe(false);
expect(execution?.result.status).toBe(outcome === "changed-owner" ? "error" : "ok");
if (outcome === "changed-owner") {
expect(mocks.validateCanary).not.toHaveBeenCalled();
expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled();
expect(execution?.result.reason).toBe("database-schema-preflight");
}
});
},
);
it.each(["registry", "artifact", "artifact-state-change"] as const)(
"refuses incompatible staged %s schemas before candidate rehearsal or activation",
async (target) => {
await withTestDir({ prefix: "openclaw-staged-schema-admission-" }, async (stage) => {
await fs.writeFile(
path.join(stage, "package.json"),
JSON.stringify({
name: "openclaw",
version: "2026.7.1",
openclaw: { schemaVersions: { state: 1, agent: 1 } },
}),
);
let databaseAdvanced = target !== "artifact-state-change";
mocks.pluginPreflight.mockImplementation(async () => {
databaseAdvanced = true;
return [];
});
mocks.checkTargetSchemas.mockImplementation(async (versions) => ({
incompatible:
versions?.state === 1 && databaseAdvanced
? [
{
kind: "state",
path: "/fixture/default/state.sqlite",
foundVersion: 17,
supportedVersion: 1,
},
]
: [],
indeterminate: [],
}));
mocks.runPackageUpdate.mockImplementation(async ({ validateCandidate, beforeActivate }) => {
await validateCandidate(stage);
await beforeActivate();
return successfulUpdate;
});
const params = executionParams("package");
if (target !== "registry") {
params.tag = "/tmp/candidate.tgz";
params.packageInstallSpec = "/tmp/candidate.tgz";
params.packageTargetVersion = undefined;
params.packageTargetSchemaVersions = undefined;
}
const execution = await executeMutableUpdate(params);
expect(mocks.validateCanary.mock.calls.length).toBe(0);
expect(execution).toMatchObject({
mutationStarted: false,
result: { status: "error", reason: "database-schema-preflight" },
});
expect(mocks.serviceStopped).toBe(false);
if (target !== "registry") {
expect(mocks.pluginPreflight).toHaveBeenCalledTimes(
target === "artifact-state-change" ? 1 : 0,
);
expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled();
}
});
},
);
it.each([
{ metadata: "missing", openclaw: undefined },
{ metadata: "malformed", openclaw: { schemaVersions: { state: "15", agent: 19 } } },
])(
"retains registry schema admission when staged metadata is $metadata",
async ({ openclaw }) => {
await withTestDir({ prefix: "openclaw-staged-schema-retention-" }, async (stage) => {
await fs.writeFile(
path.join(stage, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.9.2", openclaw }),
);
let databaseAdvanced = false;
mocks.checkTargetSchemas.mockImplementation(async (versions) => ({
incompatible:
databaseAdvanced && versions?.state === 15
? [
{
kind: "state",
path: "/fixture/default/state.sqlite",
foundVersion: 17,
supportedVersion: 15,
},
]
: [],
indeterminate: [],
}));
mocks.runPackageUpdate.mockImplementation(async ({ validateCandidate, beforeActivate }) => {
databaseAdvanced = true;
await validateCandidate(stage);
await beforeActivate();
return successfulUpdate;
});
const execution = await executeMutableUpdate({
...executionParams("package"),
tag: "2026.9.2",
packageInstallSpec: "openclaw@2026.9.2",
packageTargetVersion: "2026.9.2",
});
expect(mocks.validateCanary.mock.calls.length).toBe(0);
expect(execution).toMatchObject({
mutationStarted: false,
result: { status: "error", reason: "database-schema-preflight" },
});
expect(mocks.serviceStopped).toBe(false);
});
},
);
it("leaves a staged local same-version no-op free of plugin or mutable preparation", async () => {
mocks.runPackageUpdate.mockResolvedValue({
...successfulUpdate,
status: "skipped",
reason: "already-current",
});
const execution = await executeMutableUpdate({
...executionParams("package"),
tag: "/tmp/candidate.tgz",
packageInstallSpec: "/tmp/candidate.tgz",
packageTargetVersion: undefined,
});
expect(execution?.result.reason).toBe("already-current");
expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled();
expect(mocks.pluginPreflight).not.toHaveBeenCalled();
expect(mocks.serviceStopped).toBe(false);
});
it.each([
{ failure: "missing", contract: "api", range: ">=1.0.0", incompatible: false },
{ failure: "metadata", contract: "api", range: ">=1.0.0", incompatible: false },
{ failure: "throw", contract: "api", range: ">=1.0.0", incompatible: false },
{ failure: "missing", contract: "api", range: ">=1.0.0 <1.0.1", incompatible: true },
{ failure: "metadata", contract: "api", range: ">=1.0.0 <1.0.1", incompatible: true },
{ failure: "throw", contract: "api", range: ">=1.0.0 <1.0.1", incompatible: true },
{ failure: "metadata", contract: "host", range: ">=1.0.2", incompatible: true },
{ failure: "throw", contract: "host", range: ">=1.0.2", incompatible: true },
])(
"preserves plugin admission and exception handling ($failure, $contract, $range)",
async ({ failure, contract, range, incompatible }) => {
await withTestDir({ prefix: "openclaw-plugin-admission-" }, async (installPath) => {
await fs.writeFile(
path.join(installPath, "package.json"),
JSON.stringify({
name: "@example/demo",
version: "1.0.0",
openclaw:
contract === "api"
? { compat: { pluginApi: range } }
: { install: { minHostVersion: range } },
}),
);
mocks.pluginRecords.mockResolvedValue({
demo: { source: "npm", spec: "@example/demo@1.0.1", version: "1.0.0", installPath },
});
mocks.pluginTargets.mockResolvedValue([{ pluginId: "demo", spec: "@example/demo@1.0.1" }]);
const error =
failure === "missing"
? "No matching version found"
: "registry connection failed: ECONNRESET";
const metadataFailure = new Error(error);
if (failure === "throw") {
mocks.npmMetadata.mockRejectedValue(metadataFailure);
} else {
mocks.npmMetadata.mockResolvedValue({
ok: false,
category: failure === "metadata" ? "metadata-env" : undefined,
error,
});
}
const actual = await vi.importActual<typeof import("./update-command-plugin-preflight.js")>(
"./update-command-plugin-preflight.js",
);
mocks.pluginPreflight.mockImplementation(actual.preflightConfiguredNpmPluginTargets);
const execution = await executeMutableUpdate(executionParams("package"));
const unclassifiedFailure = incompatible && failure === "throw";
expect(execution?.result.status).toBe(unclassifiedFailure ? "error" : "ok");
expect(mocks.npmMetadata).toHaveBeenCalledTimes(incompatible ? 1 : 0);
expect(mocks.serviceStopped).toBe(false);
if (unclassifiedFailure) {
expect(execution?.result.reason).toBe("update-failed");
expect(execution?.failure?.cause).toBe(metadataFailure);
expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled();
expect(mocks.runPackageUpdate).not.toHaveBeenCalled();
} else {
const warnings = await mocks.pluginPreflight.mock.results[0]?.value;
expect(execution?.result.reason).toBeUndefined();
expect(mocks.prepareMutableUpdate).toHaveBeenCalledOnce();
expect(mocks.runPackageUpdate).toHaveBeenCalledOnce();
if (incompatible) {
expect(warnings).toEqual([
expect.objectContaining({
pluginId: "demo",
reason: expect.stringContaining(range),
message:
'Plugin "demo" update availability could not be confirmed; the core update can continue.',
guidance: [],
}),
]);
expect(warnings[0]?.reason).toContain("Installed 1.0.0");
expect(warnings[0]?.reason).toContain("@example/demo@1.0.1");
expect(warnings[0]?.reason).toContain(error);
if (failure === "metadata") {
expect(warnings[0]?.reason).toContain("registry could not be reached");
}
expect(mocks.runtimeError).toHaveBeenCalledWith(warnings[0]?.message);
} else {
expect(warnings).toEqual([]);
}
}
});
},
);
it("waits for plugin availability before preparing a package update", async () => {
const available = createDeferred<[]>();
mocks.pluginPreflight.mockImplementation(() => available.promise);
const execution = executeMutableUpdate(executionParams("package"));
try {
await vi.waitFor(() => expect(mocks.pluginPreflight).toHaveBeenCalledOnce());
expect(mocks.serviceStopped).toBe(false);
expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled();
expect(mocks.runPackageUpdate).not.toHaveBeenCalled();
} finally {
available.resolve([]);
}
expect((await execution)?.result).toBe(successfulUpdate);
expect(mocks.runPackageUpdate).toHaveBeenCalledOnce();
});
it("refuses configuration drift during plugin admission before mutable preparation", async () => {
let configChanged = false;
mocks.pluginPreflight.mockImplementation(async () => {
configChanged = true;
return [];
});
mocks.revalidateSchemaContext.mockImplementation(async (context) => {
if (configChanged) {
throw new UpdatePreMutationError("database-schema-preflight", "Configuration changed");
}
return context;
});
const execution = await executeMutableUpdate(executionParams("package"));
expect(execution?.result.reason).toBe("database-schema-preflight");
expect(mocks.prepareMutableUpdate).not.toHaveBeenCalled();
expect(mocks.serviceStopped).toBe(false);
expect(mocks.runPackageUpdate).not.toHaveBeenCalled();
});
it("captures the package target and admitted service environment before schema awaits", async () => {
const events: string[] = [];
mocks.runPackageUpdate.mockImplementation(async () => {
events.push("install");
return successfulUpdate;
});
const serviceState = inspectOrStopService("inspect");
mocks.maybeStopService.mockImplementation(async ({ phase }) => {
if (phase === "prepare") {
events.push("stop");
return inspectOrStopService(phase);
}
return serviceState;
});
mocks.prepareMutableUpdate.mockImplementation(async (env) => {
expect(env).toEqual({ OPENCLAW_PROFILE: "default" });
events.push("mutable-prepare");
});
const schemaGate = createDeferred();
mocks.checkTargetSchemas.mockImplementation(async (_versions, contexts) => {
expect(contexts.map((context) => context.env.OPENCLAW_PROFILE)).toEqual([
"invoker",
"default",
]);
events.push(
events.includes("mutable-prepare") ? "schema-after-inspection" : "schema-before-inspection",
);
if (events.includes("mutable-prepare")) {
await schemaGate.promise;
}
return { incompatible: [], indeterminate: [] };
});
const params = executionParams("package");
const pendingExecution = executeMutableUpdate(params);
try {
await vi.waitFor(() => expect(events).toContain("schema-after-inspection"));
expect(events.indexOf("schema-before-inspection")).toBeLessThan(
events.indexOf("mutable-prepare"),
);
expect(events.at(-1)).toBe("schema-after-inspection");
expect(mocks.serviceStopped).toBe(false);
expect(mocks.runPackageUpdate).not.toHaveBeenCalled();
params.packageInstallSpec = "openclaw@changed-during-schema-check";
serviceState.serviceEnv = { OPENCLAW_PROFILE: "revalidated" };
} finally {
schemaGate.resolve();
await pendingExecution;
}
const execution = await pendingExecution;
expect(events.at(-1)).toBe("install");
expect(mocks.prepareMutableUpdate).toHaveBeenCalledOnce();
expect(execution?.result).toBe(successfulUpdate);
expect(mocks.runPackageUpdate).toHaveBeenCalledOnce();
expect(mocks.runPackageUpdate).toHaveBeenCalledWith(
expect.objectContaining({
installSpec: "openclaw@1.0.1",
managedServiceEnv: { OPENCLAW_PROFILE: "default" },
}),
);
});
it.each(["before-prepare", "after-prepare"] as const)(
"refuses schema mismatch at %s without invoking the package updater",
async (phase) => {
mocks.checkTargetSchemas.mockImplementation(async () => ({
incompatible:
phase === "before-prepare" || mocks.prepareMutableUpdate.mock.calls.length > 0
? [
{
kind: "agent",
path: "/fixture/default/worker.sqlite",
foundVersion: 999,
supportedVersion: 19,
},
]
: [],
indeterminate: [],
}));
const execution = await executeMutableUpdate(executionParams("package"));
expect(mocks.serviceStopped).toBe(false);
expect(mocks.prepareMutableUpdate).toHaveBeenCalledTimes(phase === "after-prepare" ? 1 : 0);
expect(execution?.result.reason).toBe("database-schema-preflight");
expect(mocks.runPackageUpdate).not.toHaveBeenCalled();
},
);
it.each(["activation", "requester revocation", "service ownership"])(
"reports %s exceptions without retrying a fallback package updater",
async (kind) => {
const failure =
kind === "requester revocation"
? new UpdateRequesterRevokedError()
: kind === "service ownership"
? new GatewayServiceUpdateOwnershipError(
"Service manager returned EACCES.",
undefined,
"service-manager-access-denied",
)
: new Error("activation failed");
mocks.runPackageUpdate.mockRejectedValue(failure);
const execution = await executeMutableUpdate(executionParams("package"));
expect(mocks.runPackageUpdate).toHaveBeenCalledOnce();
expect(execution?.failure?.cause).toBe(failure);
expect(execution?.result).toMatchObject({
status: "error",
reason: kind === "requester revocation" ? "requester-revoked" : "update-failed",
recovery: { serviceRestartSafe: false, reason: "runtime-verification-failed" },
steps: [expect.objectContaining({ name: "update", exitCode: 1 })],
});
expect(mocks.verifyPackageRecovery).not.toHaveBeenCalled();
if (kind === "service ownership") {
expect(execution?.result.steps[0]?.failureFacts).toEqual([
{
check: "managed-service",
code: "service-manager-access-denied",
message: "Service manager returned EACCES.",
},
]);
}
},
);
it("keeps Git candidate selection online and delegates its later activation", async () => {
const events: string[] = [];
mocks.maybeStopService.mockImplementation(async ({ phase }) => {
if (phase === "prepare") {
events.push("stop");
}
return inspectOrStopService(phase);
});
mocks.prepareMutableUpdate.mockImplementation(async () => {
events.push("mutable-prepare");
});
mocks.runGitUpdate.mockImplementation(
async (params: Parameters<typeof import("./update-command-git.js").updateGitInstall>[0]) => {
if (!params.inspectGitTarget || !params.beforeGitMutation) {
throw new Error("Expected both real Git admission callbacks");
}
const target = { schemaVersions: { state: 15, agent: 19 } };
await params.inspectGitTarget(target);
events.push("git");
return { ...successfulUpdate, mode: "git" };
},
);
const execution = await executeMutableUpdate(executionParams("git"));
expect(events).toEqual(["mutable-prepare", "git"]);
expect(mocks.serviceStopped).toBe(false);
expect(execution?.result.mode).toBe("git");
expect(mocks.runPackageUpdate).not.toHaveBeenCalled();
});
it("retains rejected Git canary findings in the terminal result", async () => {
const fact = {
check: "core/doctor/config-readable",
code: "doctor-failed",
message: "The configured state directory is not readable.",
affectedKey: "stateDir",
};
mocks.validateCanary.mockResolvedValue({
status: "error",
reason: "doctor-failed",
phase: "doctor",
durationMs: 1,
logTail: [fact.message],
steps: [
{
name: "candidate doctor",
command: "openclaw doctor",
cwd: "/candidate",
durationMs: 1,
exitCode: 1,
stderrTail: fact.message,
failureFacts: [fact],
},
],
});
const repair = await import("./update-command-repair.js");
vi.spyOn(repair, "runUpdateCommandRepair").mockResolvedValue({
status: "unavailable",
attempts: [],
finalValidation: { ok: false, score: 0, summary: fact.message },
});
mocks.runGitUpdate.mockImplementation(
async (params: Parameters<typeof import("./update-command-git.js").updateGitInstall>[0]) => {
if (!params.validateCandidate) {
throw new Error("Expected the Git candidate validation callback");
}
await params.validateCandidate("/candidate");
return { ...successfulUpdate, mode: "git" };
},
);
const execution = await executeMutableUpdate(executionParams("git"));
expect(execution?.result).toMatchObject({
status: "error",
reason: "doctor-failed",
steps: [{ failureFacts: [fact] }],
});
expect(mocks.serviceStopped).toBe(false);
});
});