2026-02-21 17:40:17 +01:00
|
|
|
import fs from "node:fs/promises";
|
2026-01-17 11:40:02 +00:00
|
|
|
import path from "node:path";
|
2026-02-19 15:18:50 +00:00
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
2026-02-17 15:49:07 +09:00
|
|
|
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js";
|
2026-01-10 18:18:10 +00:00
|
|
|
import type { UpdateRunResult } from "../infra/update-runner.js";
|
2026-02-21 18:24:24 +00:00
|
|
|
import { withEnvAsync } from "../test-utils/env.js";
|
2026-01-10 18:18:10 +00:00
|
|
|
|
2026-01-22 07:05:00 +00:00
|
|
|
const confirm = vi.fn();
|
|
|
|
|
const select = vi.fn();
|
|
|
|
|
const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() }));
|
|
|
|
|
const isCancel = (value: unknown) => value === "cancel";
|
|
|
|
|
|
2026-02-14 15:36:04 +00:00
|
|
|
const readPackageName = vi.fn();
|
|
|
|
|
const readPackageVersion = vi.fn();
|
|
|
|
|
const resolveGlobalManager = vi.fn();
|
2026-02-16 23:17:18 +00:00
|
|
|
const serviceLoaded = vi.fn();
|
|
|
|
|
const prepareRestartScript = vi.fn();
|
|
|
|
|
const runRestartScript = vi.fn();
|
2026-02-19 08:32:56 -08:00
|
|
|
const mockedRunDaemonInstall = vi.fn();
|
2026-02-21 17:40:17 +01:00
|
|
|
const serviceReadRuntime = vi.fn();
|
|
|
|
|
const inspectPortUsage = vi.fn();
|
|
|
|
|
const classifyPortListener = vi.fn();
|
|
|
|
|
const formatPortDiagnostics = vi.fn();
|
2026-02-14 15:36:04 +00:00
|
|
|
|
2026-01-22 07:05:00 +00:00
|
|
|
vi.mock("@clack/prompts", () => ({
|
|
|
|
|
confirm,
|
|
|
|
|
select,
|
|
|
|
|
isCancel,
|
|
|
|
|
spinner,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-01-10 18:18:10 +00:00
|
|
|
// Mock the update-runner module
|
|
|
|
|
vi.mock("../infra/update-runner.js", () => ({
|
|
|
|
|
runGatewayUpdate: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
vi.mock("../infra/openclaw-root.js", () => ({
|
|
|
|
|
resolveOpenClawPackageRoot: vi.fn(),
|
2026-01-17 11:40:02 +00:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("../config/config.js", () => ({
|
|
|
|
|
readConfigFileSnapshot: vi.fn(),
|
2026-02-21 17:40:17 +01:00
|
|
|
resolveGatewayPort: vi.fn(() => 18789),
|
2026-01-17 11:40:02 +00:00
|
|
|
writeConfigFile: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-02-16 14:52:09 +00:00
|
|
|
vi.mock("../infra/update-check.js", async (importOriginal) => {
|
|
|
|
|
const actual = await importOriginal<typeof import("../infra/update-check.js")>();
|
2026-01-17 11:40:02 +00:00
|
|
|
return {
|
2026-02-16 14:52:09 +00:00
|
|
|
...actual,
|
2026-01-20 14:05:55 +00:00
|
|
|
checkUpdateStatus: vi.fn(),
|
2026-01-17 11:40:02 +00:00
|
|
|
fetchNpmTagVersion: vi.fn(),
|
2026-01-20 16:28:28 +00:00
|
|
|
resolveNpmChannelTag: vi.fn(),
|
2026-01-17 11:40:02 +00:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
chore: Migrate to tsdown, speed up JS bundling by ~10x (thanks @hyf0).
The previous migration to tsdown was reverted because it caused a ~20x slowdown when running OpenClaw from the repo. @hyf0 investigated and found that simply renaming the `dist` folder also caused the same slowdown. It turns out the Plugin script loader has a bunch of voodoo vibe logic to determine if it should load files from source and compile them, or if it should load them from dist. When building with tsdown, the filesystem layout is different (bundled), and so some files weren't in the right location, and the Plugin script loader decided to compile source files from scratch using Jiti.
The new implementation uses tsdown to embed `NODE_ENV: 'production'`, which we now use to determine if we are running OpenClaw from a "production environmen" (ie. from dist). This removes the slop in favor of a deterministic toggle, and doesn't rely on directory names or similar.
There is some code reaching into `dist` to load specific modules, primarily in the voice-call extension, which I simplified into loading an "officially" exported `extensionAPI.js` file. With tsdown, entry points need to be explicitly configured, so we should be able to avoid sloppy code reaching into internals from now on. This might break some existing users, but if it does, it's because they were using "private" APIs.
2026-02-02 17:20:24 +09:00
|
|
|
vi.mock("node:child_process", async () => {
|
|
|
|
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
spawnSync: vi.fn(() => ({
|
|
|
|
|
pid: 0,
|
|
|
|
|
output: [],
|
|
|
|
|
stdout: "",
|
|
|
|
|
stderr: "",
|
|
|
|
|
status: 0,
|
|
|
|
|
signal: null,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-21 06:00:50 +00:00
|
|
|
vi.mock("../process/exec.js", () => ({
|
|
|
|
|
runCommandWithTimeout: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-02-14 15:36:04 +00:00
|
|
|
vi.mock("./update-cli/shared.js", async (importOriginal) => {
|
|
|
|
|
const actual = await importOriginal<typeof import("./update-cli/shared.js")>();
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
readPackageName,
|
|
|
|
|
readPackageVersion,
|
|
|
|
|
resolveGlobalManager,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-16 23:17:18 +00:00
|
|
|
vi.mock("../daemon/service.js", () => ({
|
|
|
|
|
resolveGatewayService: vi.fn(() => ({
|
|
|
|
|
isLoaded: (...args: unknown[]) => serviceLoaded(...args),
|
2026-02-21 17:40:17 +01:00
|
|
|
readRuntime: (...args: unknown[]) => serviceReadRuntime(...args),
|
2026-02-16 23:17:18 +00:00
|
|
|
})),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-02-21 17:40:17 +01:00
|
|
|
vi.mock("../infra/ports.js", () => ({
|
|
|
|
|
inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args),
|
|
|
|
|
classifyPortListener: (...args: unknown[]) => classifyPortListener(...args),
|
|
|
|
|
formatPortDiagnostics: (...args: unknown[]) => formatPortDiagnostics(...args),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-02-16 23:17:18 +00:00
|
|
|
vi.mock("./update-cli/restart-helper.js", () => ({
|
|
|
|
|
prepareRestartScript: (...args: unknown[]) => prepareRestartScript(...args),
|
|
|
|
|
runRestartScript: (...args: unknown[]) => runRestartScript(...args),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-01-10 23:39:30 +01:00
|
|
|
// Mock doctor (heavy module; should not run in unit tests)
|
2026-01-10 23:39:14 +01:00
|
|
|
vi.mock("../commands/doctor.js", () => ({
|
|
|
|
|
doctorCommand: vi.fn(),
|
|
|
|
|
}));
|
2026-01-10 18:18:10 +00:00
|
|
|
// Mock the daemon-cli module
|
|
|
|
|
vi.mock("./daemon-cli.js", () => ({
|
2026-02-19 08:32:56 -08:00
|
|
|
runDaemonInstall: mockedRunDaemonInstall,
|
2026-01-10 18:18:10 +00:00
|
|
|
runDaemonRestart: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Mock the runtime
|
|
|
|
|
vi.mock("../runtime.js", () => ({
|
|
|
|
|
defaultRuntime: {
|
|
|
|
|
log: vi.fn(),
|
|
|
|
|
error: vi.fn(),
|
|
|
|
|
exit: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
2026-02-13 20:26:26 +00:00
|
|
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
|
|
|
|
const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js");
|
|
|
|
|
const { readConfigFileSnapshot, writeConfigFile } = await import("../config/config.js");
|
|
|
|
|
const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
|
|
|
|
|
await import("../infra/update-check.js");
|
|
|
|
|
const { runCommandWithTimeout } = await import("../process/exec.js");
|
2026-02-19 08:32:56 -08:00
|
|
|
const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js");
|
2026-02-16 21:19:44 -05:00
|
|
|
const { doctorCommand } = await import("../commands/doctor.js");
|
2026-02-13 20:26:26 +00:00
|
|
|
const { defaultRuntime } = await import("../runtime.js");
|
|
|
|
|
const { updateCommand, registerUpdateCli, updateStatusCommand, updateWizardCommand } =
|
|
|
|
|
await import("./update-cli.js");
|
|
|
|
|
|
2026-01-10 18:18:10 +00:00
|
|
|
describe("update-cli", () => {
|
2026-02-19 15:18:50 +00:00
|
|
|
const fixtureRoot = "/tmp/openclaw-update-tests";
|
2026-02-13 21:23:44 +00:00
|
|
|
let fixtureCount = 0;
|
|
|
|
|
|
2026-02-19 15:18:50 +00:00
|
|
|
const createCaseDir = (prefix: string) => {
|
2026-02-13 21:23:44 +00:00
|
|
|
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
2026-02-15 13:44:35 +00:00
|
|
|
// Tests only need a stable path; the directory does not have to exist because all I/O is mocked.
|
2026-02-13 21:23:44 +00:00
|
|
|
return dir;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-17 15:49:07 +09:00
|
|
|
const baseConfig = {} as OpenClawConfig;
|
|
|
|
|
const baseSnapshot: ConfigFileSnapshot = {
|
|
|
|
|
path: "/tmp/openclaw-config.json",
|
|
|
|
|
exists: true,
|
|
|
|
|
raw: "{}",
|
|
|
|
|
parsed: {},
|
|
|
|
|
resolved: baseConfig,
|
2026-01-17 11:40:02 +00:00
|
|
|
valid: true,
|
2026-02-17 15:49:07 +09:00
|
|
|
config: baseConfig,
|
2026-01-17 11:40:02 +00:00
|
|
|
issues: [],
|
2026-02-17 15:49:07 +09:00
|
|
|
warnings: [],
|
|
|
|
|
legacyIssues: [],
|
|
|
|
|
};
|
2026-01-17 11:40:02 +00:00
|
|
|
|
|
|
|
|
const setTty = (value: boolean | undefined) => {
|
|
|
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
|
|
|
value,
|
|
|
|
|
configurable: true,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setStdoutTty = (value: boolean | undefined) => {
|
|
|
|
|
Object.defineProperty(process.stdout, "isTTY", {
|
|
|
|
|
value,
|
|
|
|
|
configurable: true,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-18 12:04:15 +00:00
|
|
|
const mockPackageInstallStatus = (root: string) => {
|
|
|
|
|
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(root);
|
2026-02-14 23:58:26 +00:00
|
|
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
2026-02-18 12:04:15 +00:00
|
|
|
root,
|
2026-02-14 23:58:26 +00:00
|
|
|
installKind: "package",
|
|
|
|
|
packageManager: "npm",
|
|
|
|
|
deps: {
|
|
|
|
|
manager: "npm",
|
|
|
|
|
status: "ok",
|
|
|
|
|
lockfilePath: null,
|
|
|
|
|
markerPath: null,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-02-18 12:04:15 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const expectUpdateCallChannel = (channel: string) => {
|
|
|
|
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
|
|
|
|
expect(call?.channel).toBe(channel);
|
|
|
|
|
return call;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-19 15:18:50 +00:00
|
|
|
const makeOkUpdateResult = (overrides: Partial<UpdateRunResult> = {}): UpdateRunResult =>
|
|
|
|
|
({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
...overrides,
|
|
|
|
|
}) as UpdateRunResult;
|
|
|
|
|
|
2026-02-21 21:46:45 +00:00
|
|
|
const runRestartFallbackScenario = async (params: { daemonInstall: "ok" | "fail" }) => {
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
|
|
|
|
if (params.daemonInstall === "fail") {
|
|
|
|
|
vi.mocked(runDaemonInstall).mockRejectedValueOnce(new Error("refresh failed"));
|
|
|
|
|
} else {
|
|
|
|
|
vi.mocked(runDaemonInstall).mockResolvedValue(undefined);
|
|
|
|
|
}
|
|
|
|
|
prepareRestartScript.mockResolvedValue(null);
|
|
|
|
|
serviceLoaded.mockResolvedValue(true);
|
|
|
|
|
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
await updateCommand({});
|
|
|
|
|
|
|
|
|
|
expect(runDaemonInstall).toHaveBeenCalledWith({
|
|
|
|
|
force: true,
|
|
|
|
|
json: undefined,
|
|
|
|
|
});
|
|
|
|
|
expect(runDaemonRestart).toHaveBeenCalled();
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-18 12:04:15 +00:00
|
|
|
const setupNonInteractiveDowngrade = async () => {
|
2026-02-19 15:18:50 +00:00
|
|
|
const tempDir = createCaseDir("openclaw-update");
|
2026-02-18 12:04:15 +00:00
|
|
|
setTty(false);
|
|
|
|
|
readPackageVersion.mockResolvedValue("2.0.0");
|
|
|
|
|
|
|
|
|
|
mockPackageInstallStatus(tempDir);
|
2026-02-14 23:58:26 +00:00
|
|
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
|
|
|
|
tag: "latest",
|
|
|
|
|
version: "0.0.1",
|
|
|
|
|
});
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "npm",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
});
|
|
|
|
|
vi.mocked(defaultRuntime.error).mockClear();
|
|
|
|
|
vi.mocked(defaultRuntime.exit).mockClear();
|
|
|
|
|
|
|
|
|
|
return tempDir;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-13 20:26:26 +00:00
|
|
|
beforeEach(() => {
|
2026-02-22 00:19:57 +00:00
|
|
|
confirm.mockClear();
|
|
|
|
|
select.mockClear();
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockClear();
|
2026-02-22 00:08:07 +00:00
|
|
|
vi.mocked(resolveOpenClawPackageRoot).mockClear();
|
|
|
|
|
vi.mocked(readConfigFileSnapshot).mockClear();
|
|
|
|
|
vi.mocked(writeConfigFile).mockClear();
|
|
|
|
|
vi.mocked(checkUpdateStatus).mockClear();
|
|
|
|
|
vi.mocked(fetchNpmTagVersion).mockClear();
|
|
|
|
|
vi.mocked(resolveNpmChannelTag).mockClear();
|
|
|
|
|
vi.mocked(runCommandWithTimeout).mockClear();
|
2026-02-22 00:12:37 +00:00
|
|
|
vi.mocked(runDaemonRestart).mockClear();
|
2026-02-22 00:08:07 +00:00
|
|
|
vi.mocked(mockedRunDaemonInstall).mockClear();
|
2026-02-22 00:12:37 +00:00
|
|
|
vi.mocked(doctorCommand).mockClear();
|
2026-02-22 00:08:07 +00:00
|
|
|
vi.mocked(defaultRuntime.log).mockClear();
|
|
|
|
|
vi.mocked(defaultRuntime.error).mockClear();
|
|
|
|
|
vi.mocked(defaultRuntime.exit).mockClear();
|
|
|
|
|
readPackageName.mockClear();
|
|
|
|
|
readPackageVersion.mockClear();
|
|
|
|
|
resolveGlobalManager.mockClear();
|
|
|
|
|
serviceLoaded.mockClear();
|
|
|
|
|
serviceReadRuntime.mockClear();
|
|
|
|
|
prepareRestartScript.mockClear();
|
|
|
|
|
runRestartScript.mockClear();
|
|
|
|
|
inspectPortUsage.mockClear();
|
|
|
|
|
classifyPortListener.mockClear();
|
|
|
|
|
formatPortDiagnostics.mockClear();
|
2026-01-30 03:15:10 +01:00
|
|
|
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd());
|
2026-01-17 11:40:02 +00:00
|
|
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
|
|
|
|
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
|
|
|
|
tag: "latest",
|
|
|
|
|
version: "9999.0.0",
|
|
|
|
|
});
|
2026-01-20 16:28:28 +00:00
|
|
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
|
|
|
|
tag: "latest",
|
|
|
|
|
version: "9999.0.0",
|
|
|
|
|
});
|
2026-01-20 14:05:55 +00:00
|
|
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
|
|
|
|
root: "/test/path",
|
|
|
|
|
installKind: "git",
|
|
|
|
|
packageManager: "pnpm",
|
|
|
|
|
git: {
|
|
|
|
|
root: "/test/path",
|
|
|
|
|
sha: "abcdef1234567890",
|
|
|
|
|
tag: "v1.2.3",
|
|
|
|
|
branch: "main",
|
|
|
|
|
upstream: "origin/main",
|
|
|
|
|
dirty: false,
|
|
|
|
|
ahead: 0,
|
|
|
|
|
behind: 0,
|
|
|
|
|
fetchOk: true,
|
|
|
|
|
},
|
|
|
|
|
deps: {
|
|
|
|
|
manager: "pnpm",
|
|
|
|
|
status: "ok",
|
|
|
|
|
lockfilePath: "/test/path/pnpm-lock.yaml",
|
|
|
|
|
markerPath: "/test/path/node_modules",
|
|
|
|
|
},
|
|
|
|
|
registry: {
|
|
|
|
|
latestVersion: "1.2.3",
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-01-21 06:00:50 +00:00
|
|
|
vi.mocked(runCommandWithTimeout).mockResolvedValue({
|
|
|
|
|
stdout: "",
|
|
|
|
|
stderr: "",
|
|
|
|
|
code: 0,
|
|
|
|
|
signal: null,
|
|
|
|
|
killed: false,
|
2026-02-17 15:49:07 +09:00
|
|
|
termination: "exit",
|
2026-01-21 06:00:50 +00:00
|
|
|
});
|
2026-02-14 15:36:04 +00:00
|
|
|
readPackageName.mockResolvedValue("openclaw");
|
|
|
|
|
readPackageVersion.mockResolvedValue("1.0.0");
|
|
|
|
|
resolveGlobalManager.mockResolvedValue("npm");
|
2026-02-16 23:17:18 +00:00
|
|
|
serviceLoaded.mockResolvedValue(false);
|
2026-02-21 17:40:17 +01:00
|
|
|
serviceReadRuntime.mockResolvedValue({
|
|
|
|
|
status: "running",
|
|
|
|
|
pid: 4242,
|
|
|
|
|
state: "running",
|
|
|
|
|
});
|
2026-02-16 23:17:18 +00:00
|
|
|
prepareRestartScript.mockResolvedValue("/tmp/openclaw-restart-test.sh");
|
|
|
|
|
runRestartScript.mockResolvedValue(undefined);
|
2026-02-21 17:40:17 +01:00
|
|
|
inspectPortUsage.mockResolvedValue({
|
|
|
|
|
port: 18789,
|
|
|
|
|
status: "busy",
|
|
|
|
|
listeners: [{ pid: 4242, command: "openclaw-gateway" }],
|
|
|
|
|
hints: [],
|
|
|
|
|
});
|
|
|
|
|
classifyPortListener.mockReturnValue("gateway");
|
|
|
|
|
formatPortDiagnostics.mockReturnValue(["Port 18789 is already in use."]);
|
2026-02-19 17:43:29 +01:00
|
|
|
vi.mocked(runDaemonInstall).mockResolvedValue(undefined);
|
2026-02-22 00:12:37 +00:00
|
|
|
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
|
|
|
|
vi.mocked(doctorCommand).mockResolvedValue(undefined);
|
2026-02-22 00:19:57 +00:00
|
|
|
confirm.mockResolvedValue(false);
|
|
|
|
|
select.mockResolvedValue("stable");
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
2026-01-17 11:40:02 +00:00
|
|
|
setTty(false);
|
|
|
|
|
setStdoutTty(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-10 23:46:07 +01:00
|
|
|
it("exports updateCommand and registerUpdateCli", async () => {
|
|
|
|
|
expect(typeof updateCommand).toBe("function");
|
|
|
|
|
expect(typeof registerUpdateCli).toBe("function");
|
2026-01-22 07:05:00 +00:00
|
|
|
expect(typeof updateWizardCommand).toBe("function");
|
2026-01-10 23:46:07 +01:00
|
|
|
}, 20_000);
|
2026-01-10 18:18:10 +00:00
|
|
|
|
|
|
|
|
it("updateCommand runs update and outputs result", async () => {
|
|
|
|
|
const mockResult: UpdateRunResult = {
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
root: "/test/path",
|
|
|
|
|
before: { sha: "abc123", version: "1.0.0" },
|
|
|
|
|
after: { sha: "def456", version: "1.0.1" },
|
|
|
|
|
steps: [
|
|
|
|
|
{
|
|
|
|
|
name: "git fetch",
|
|
|
|
|
command: "git fetch",
|
|
|
|
|
cwd: "/test/path",
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
exitCode: 0,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
durationMs: 500,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
|
|
|
|
|
|
|
|
await updateCommand({ json: false });
|
|
|
|
|
|
|
|
|
|
expect(runGatewayUpdate).toHaveBeenCalled();
|
|
|
|
|
expect(defaultRuntime.log).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-20 14:05:55 +00:00
|
|
|
it("updateStatusCommand prints table output", async () => {
|
|
|
|
|
await updateStatusCommand({ json: false });
|
|
|
|
|
|
|
|
|
|
const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]);
|
2026-01-30 03:15:10 +01:00
|
|
|
expect(logs.join("\n")).toContain("OpenClaw update status");
|
2026-01-20 14:05:55 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("updateStatusCommand emits JSON", async () => {
|
|
|
|
|
await updateStatusCommand({ json: true });
|
|
|
|
|
|
|
|
|
|
const last = vi.mocked(defaultRuntime.log).mock.calls.at(-1)?.[0];
|
|
|
|
|
expect(typeof last).toBe("string");
|
|
|
|
|
const parsed = JSON.parse(String(last));
|
|
|
|
|
expect(parsed.channel.value).toBe("stable");
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 15:18:50 +00:00
|
|
|
it.each([
|
|
|
|
|
{
|
|
|
|
|
name: "defaults to dev channel for git installs when unset",
|
|
|
|
|
mode: "git" as const,
|
|
|
|
|
options: {},
|
|
|
|
|
prepare: async () => {},
|
|
|
|
|
expectedChannel: "dev" as const,
|
|
|
|
|
expectedTag: undefined as string | undefined,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "defaults to stable channel for package installs when unset",
|
|
|
|
|
mode: "npm" as const,
|
|
|
|
|
options: { yes: true },
|
|
|
|
|
prepare: async () => {
|
|
|
|
|
const tempDir = createCaseDir("openclaw-update");
|
|
|
|
|
mockPackageInstallStatus(tempDir);
|
|
|
|
|
},
|
|
|
|
|
expectedChannel: "stable" as const,
|
|
|
|
|
expectedTag: "latest",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "uses stored beta channel when configured",
|
|
|
|
|
mode: "git" as const,
|
|
|
|
|
options: {},
|
|
|
|
|
prepare: async () => {
|
|
|
|
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
config: { update: { channel: "beta" } } as OpenClawConfig,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
expectedChannel: "beta" as const,
|
|
|
|
|
expectedTag: undefined as string | undefined,
|
|
|
|
|
},
|
|
|
|
|
])("$name", async ({ mode, options, prepare, expectedChannel, expectedTag }) => {
|
|
|
|
|
await prepare();
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode }));
|
|
|
|
|
|
|
|
|
|
await updateCommand(options);
|
|
|
|
|
|
|
|
|
|
const call = expectUpdateCallChannel(expectedChannel);
|
|
|
|
|
if (expectedTag !== undefined) {
|
|
|
|
|
expect(call?.tag).toBe(expectedTag);
|
|
|
|
|
}
|
2026-01-17 11:40:02 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-20 16:28:28 +00:00
|
|
|
it("falls back to latest when beta tag is older than release", async () => {
|
2026-02-19 15:18:50 +00:00
|
|
|
const tempDir = createCaseDir("openclaw-update");
|
2026-02-13 21:23:44 +00:00
|
|
|
|
2026-02-18 12:04:15 +00:00
|
|
|
mockPackageInstallStatus(tempDir);
|
2026-02-13 21:23:44 +00:00
|
|
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
|
|
|
|
...baseSnapshot,
|
2026-02-17 15:49:07 +09:00
|
|
|
config: { update: { channel: "beta" } } as OpenClawConfig,
|
2026-02-13 21:23:44 +00:00
|
|
|
});
|
|
|
|
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
|
|
|
|
tag: "latest",
|
|
|
|
|
version: "1.2.3-1",
|
|
|
|
|
});
|
2026-02-19 15:18:50 +00:00
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(
|
|
|
|
|
makeOkUpdateResult({
|
|
|
|
|
mode: "npm",
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-01-20 16:28:28 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
await updateCommand({});
|
2026-01-20 16:28:28 +00:00
|
|
|
|
2026-02-18 12:04:15 +00:00
|
|
|
const call = expectUpdateCallChannel("beta");
|
2026-02-13 21:23:44 +00:00
|
|
|
expect(call?.tag).toBe("latest");
|
2026-01-20 16:28:28 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-17 11:40:02 +00:00
|
|
|
it("honors --tag override", async () => {
|
2026-02-19 15:18:50 +00:00
|
|
|
const tempDir = createCaseDir("openclaw-update");
|
2026-01-20 13:33:31 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
|
2026-02-19 15:18:50 +00:00
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(
|
|
|
|
|
makeOkUpdateResult({
|
|
|
|
|
mode: "npm",
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-01-20 13:33:31 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
await updateCommand({ tag: "next" });
|
2026-01-20 13:33:31 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
|
|
|
|
expect(call?.tag).toBe("next");
|
2026-01-17 11:40:02 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-10 18:18:10 +00:00
|
|
|
it("updateCommand outputs JSON when --json is set", async () => {
|
2026-02-19 15:18:50 +00:00
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
2026-01-10 18:18:10 +00:00
|
|
|
vi.mocked(defaultRuntime.log).mockClear();
|
|
|
|
|
|
|
|
|
|
await updateCommand({ json: true });
|
|
|
|
|
|
|
|
|
|
const logCalls = vi.mocked(defaultRuntime.log).mock.calls;
|
|
|
|
|
const jsonOutput = logCalls.find((call) => {
|
|
|
|
|
try {
|
|
|
|
|
JSON.parse(call[0] as string);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
expect(jsonOutput).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("updateCommand exits with error on failure", async () => {
|
|
|
|
|
const mockResult: UpdateRunResult = {
|
|
|
|
|
status: "error",
|
|
|
|
|
mode: "git",
|
|
|
|
|
reason: "rebase-failed",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
|
|
|
vi.mocked(defaultRuntime.exit).mockClear();
|
|
|
|
|
|
|
|
|
|
await updateCommand({});
|
|
|
|
|
|
|
|
|
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-23 11:49:59 +00:00
|
|
|
it("updateCommand restarts daemon by default", async () => {
|
2026-02-19 15:18:50 +00:00
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
2026-01-10 22:38:01 +01:00
|
|
|
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
2026-01-10 18:18:10 +00:00
|
|
|
|
2026-01-23 11:49:59 +00:00
|
|
|
await updateCommand({});
|
2026-01-10 18:18:10 +00:00
|
|
|
|
|
|
|
|
expect(runDaemonRestart).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 08:32:56 -08:00
|
|
|
it("updateCommand refreshes gateway service env when service is already installed", async () => {
|
|
|
|
|
const mockResult: UpdateRunResult = {
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
|
|
|
vi.mocked(runDaemonInstall).mockResolvedValue(undefined);
|
|
|
|
|
serviceLoaded.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
await updateCommand({});
|
|
|
|
|
|
|
|
|
|
expect(runDaemonInstall).toHaveBeenCalledWith({
|
|
|
|
|
force: true,
|
|
|
|
|
json: undefined,
|
|
|
|
|
});
|
2026-02-19 18:54:55 +01:00
|
|
|
expect(runRestartScript).toHaveBeenCalled();
|
2026-02-19 08:32:56 -08:00
|
|
|
expect(runDaemonRestart).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 17:40:17 +01:00
|
|
|
it("updateCommand refreshes service env from updated install root when available", async () => {
|
|
|
|
|
const root = createCaseDir("openclaw-updated-root");
|
|
|
|
|
await fs.mkdir(path.join(root, "dist"), { recursive: true });
|
|
|
|
|
await fs.writeFile(path.join(root, "dist", "entry.js"), "console.log('ok');\n", "utf8");
|
|
|
|
|
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "npm",
|
|
|
|
|
root,
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
});
|
|
|
|
|
serviceLoaded.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
await updateCommand({});
|
|
|
|
|
|
|
|
|
|
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
|
|
|
|
[
|
|
|
|
|
expect.stringMatching(/node/),
|
|
|
|
|
path.join(root, "dist", "entry.js"),
|
|
|
|
|
"gateway",
|
|
|
|
|
"install",
|
|
|
|
|
"--force",
|
|
|
|
|
],
|
|
|
|
|
expect.objectContaining({ timeoutMs: 60_000 }),
|
|
|
|
|
);
|
|
|
|
|
expect(runDaemonInstall).not.toHaveBeenCalled();
|
|
|
|
|
expect(runRestartScript).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 08:32:56 -08:00
|
|
|
it("updateCommand falls back to restart when env refresh install fails", async () => {
|
2026-02-21 21:46:45 +00:00
|
|
|
await runRestartFallbackScenario({ daemonInstall: "fail" });
|
2026-02-19 18:54:55 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("updateCommand falls back to restart when no detached restart script is available", async () => {
|
2026-02-21 21:46:45 +00:00
|
|
|
await runRestartFallbackScenario({ daemonInstall: "ok" });
|
2026-02-19 08:32:56 -08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("updateCommand does not refresh service env when --no-restart is set", async () => {
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
|
|
|
|
serviceLoaded.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
await updateCommand({ restart: false });
|
|
|
|
|
|
|
|
|
|
expect(runDaemonInstall).not.toHaveBeenCalled();
|
|
|
|
|
expect(runRestartScript).not.toHaveBeenCalled();
|
|
|
|
|
expect(runDaemonRestart).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-16 21:19:44 -05:00
|
|
|
it("updateCommand continues after doctor sub-step and clears update flag", async () => {
|
|
|
|
|
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
|
|
|
|
|
try {
|
2026-02-21 18:24:24 +00:00
|
|
|
await withEnvAsync({ OPENCLAW_UPDATE_IN_PROGRESS: undefined }, async () => {
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
|
|
|
|
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
|
|
|
|
vi.mocked(doctorCommand).mockResolvedValue(undefined);
|
|
|
|
|
vi.mocked(defaultRuntime.log).mockClear();
|
|
|
|
|
|
|
|
|
|
await updateCommand({});
|
|
|
|
|
|
|
|
|
|
expect(doctorCommand).toHaveBeenCalledWith(
|
|
|
|
|
defaultRuntime,
|
|
|
|
|
expect.objectContaining({ nonInteractive: true }),
|
|
|
|
|
);
|
|
|
|
|
expect(process.env.OPENCLAW_UPDATE_IN_PROGRESS).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
|
|
|
|
|
expect(
|
|
|
|
|
logLines.some((line) =>
|
|
|
|
|
line.includes("Leveled up! New skills unlocked. You're welcome."),
|
|
|
|
|
),
|
|
|
|
|
).toBe(true);
|
|
|
|
|
});
|
2026-02-16 21:19:44 -05:00
|
|
|
} finally {
|
|
|
|
|
randomSpy.mockRestore();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-23 11:49:59 +00:00
|
|
|
it("updateCommand skips restart when --no-restart is set", async () => {
|
2026-02-19 15:18:50 +00:00
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
2026-01-23 11:49:59 +00:00
|
|
|
|
|
|
|
|
await updateCommand({ restart: false });
|
|
|
|
|
|
|
|
|
|
expect(runDaemonRestart).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-10 22:52:09 +01:00
|
|
|
it("updateCommand skips success message when restart does not run", async () => {
|
2026-02-19 15:18:50 +00:00
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
2026-01-10 22:52:09 +01:00
|
|
|
vi.mocked(runDaemonRestart).mockResolvedValue(false);
|
|
|
|
|
vi.mocked(defaultRuntime.log).mockClear();
|
|
|
|
|
|
|
|
|
|
await updateCommand({ restart: true });
|
|
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
|
|
|
|
|
expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(false);
|
2026-01-10 22:52:09 +01:00
|
|
|
});
|
|
|
|
|
|
2026-02-19 15:18:50 +00:00
|
|
|
it.each([
|
|
|
|
|
{
|
|
|
|
|
name: "update command",
|
|
|
|
|
run: async () => await updateCommand({ timeout: "invalid" }),
|
|
|
|
|
requireTty: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "update status command",
|
|
|
|
|
run: async () => await updateStatusCommand({ timeout: "invalid" }),
|
|
|
|
|
requireTty: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "update wizard command",
|
|
|
|
|
run: async () => await updateWizardCommand({ timeout: "invalid" }),
|
|
|
|
|
requireTty: true,
|
|
|
|
|
},
|
|
|
|
|
])("validates timeout option for $name", async ({ run, requireTty }) => {
|
|
|
|
|
setTty(requireTty);
|
2026-01-10 18:18:10 +00:00
|
|
|
vi.mocked(defaultRuntime.error).mockClear();
|
|
|
|
|
vi.mocked(defaultRuntime.exit).mockClear();
|
|
|
|
|
|
2026-02-19 15:18:50 +00:00
|
|
|
await run();
|
2026-02-18 22:49:15 +00:00
|
|
|
|
|
|
|
|
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
|
|
|
|
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-17 11:40:02 +00:00
|
|
|
it("persists update channel when --channel is set", async () => {
|
2026-02-19 15:18:50 +00:00
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
2026-01-17 11:40:02 +00:00
|
|
|
|
|
|
|
|
await updateCommand({ channel: "beta" });
|
|
|
|
|
|
|
|
|
|
expect(writeConfigFile).toHaveBeenCalled();
|
|
|
|
|
const call = vi.mocked(writeConfigFile).mock.calls[0]?.[0] as {
|
|
|
|
|
update?: { channel?: string };
|
|
|
|
|
};
|
|
|
|
|
expect(call?.update?.channel).toBe("beta");
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 15:18:50 +00:00
|
|
|
it.each([
|
|
|
|
|
{
|
|
|
|
|
name: "requires confirmation without --yes",
|
|
|
|
|
options: {},
|
|
|
|
|
shouldExit: true,
|
|
|
|
|
shouldRunUpdate: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "allows downgrade with --yes",
|
|
|
|
|
options: { yes: true },
|
|
|
|
|
shouldExit: false,
|
|
|
|
|
shouldRunUpdate: true,
|
|
|
|
|
},
|
|
|
|
|
])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunUpdate }) => {
|
2026-02-14 23:58:26 +00:00
|
|
|
await setupNonInteractiveDowngrade();
|
2026-02-19 15:18:50 +00:00
|
|
|
await updateCommand(options);
|
|
|
|
|
|
|
|
|
|
const downgradeMessageSeen = vi
|
|
|
|
|
.mocked(defaultRuntime.error)
|
|
|
|
|
.mock.calls.some((call) => String(call[0]).includes("Downgrade confirmation required."));
|
|
|
|
|
expect(downgradeMessageSeen).toBe(shouldExit);
|
|
|
|
|
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(
|
|
|
|
|
shouldExit,
|
2026-02-13 21:23:44 +00:00
|
|
|
);
|
2026-02-19 15:18:50 +00:00
|
|
|
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(shouldRunUpdate);
|
2026-01-21 03:39:39 +00:00
|
|
|
});
|
2026-01-22 07:05:00 +00:00
|
|
|
|
|
|
|
|
it("updateWizardCommand requires a TTY", async () => {
|
|
|
|
|
setTty(false);
|
|
|
|
|
vi.mocked(defaultRuntime.error).mockClear();
|
|
|
|
|
vi.mocked(defaultRuntime.exit).mockClear();
|
|
|
|
|
|
|
|
|
|
await updateWizardCommand({});
|
|
|
|
|
|
|
|
|
|
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining("Update wizard requires a TTY"),
|
|
|
|
|
);
|
|
|
|
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("updateWizardCommand offers dev checkout and forwards selections", async () => {
|
2026-02-19 15:18:50 +00:00
|
|
|
const tempDir = createCaseDir("openclaw-update-wizard");
|
2026-02-21 18:24:24 +00:00
|
|
|
await withEnvAsync({ OPENCLAW_GIT_DIR: tempDir }, async () => {
|
2026-01-22 07:05:00 +00:00
|
|
|
setTty(true);
|
|
|
|
|
|
|
|
|
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
|
|
|
|
root: "/test/path",
|
|
|
|
|
installKind: "package",
|
|
|
|
|
packageManager: "npm",
|
|
|
|
|
deps: {
|
|
|
|
|
manager: "npm",
|
|
|
|
|
status: "ok",
|
|
|
|
|
lockfilePath: null,
|
|
|
|
|
markerPath: null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
select.mockResolvedValue("dev");
|
|
|
|
|
confirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await updateWizardCommand({});
|
|
|
|
|
|
|
|
|
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
|
|
|
|
expect(call?.channel).toBe("dev");
|
2026-02-21 18:24:24 +00:00
|
|
|
});
|
2026-01-22 07:05:00 +00:00
|
|
|
});
|
2026-01-10 18:18:10 +00:00
|
|
|
});
|