2026-03-12 23:41:28 +00: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-03-02 09:45:47 +00:00
|
|
|
const pathExists = vi.fn();
|
|
|
|
|
const syncPluginsForUpdateChannel = vi.fn();
|
|
|
|
|
const updateNpmInstalledPlugins = 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-03-02 09:45:47 +00:00
|
|
|
vi.mock("../utils.js", async (importOriginal) => {
|
|
|
|
|
const actual = await importOriginal<typeof import("../utils.js")>();
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
pathExists: (...args: unknown[]) => pathExists(...args),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
vi.mock("../plugins/update.js", () => ({
|
|
|
|
|
syncPluginsForUpdateChannel: (...args: unknown[]) => syncPluginsForUpdateChannel(...args),
|
|
|
|
|
updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args),
|
|
|
|
|
}));
|
|
|
|
|
|
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");
|
2026-03-02 09:45:47 +00:00
|
|
|
const { updateCommand, updateStatusCommand, updateWizardCommand } = await import("./update-cli.js");
|
2026-02-13 20:26:26 +00:00
|
|
|
|
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-03-17 08:19:00 +00:00
|
|
|
const expectPackageInstallSpec = (spec: string) => {
|
|
|
|
|
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
|
|
|
|
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
|
|
|
|
["npm", "i", "-g", spec, "--no-fund", "--no-audit", "--loglevel=error"],
|
|
|
|
|
expect.any(Object),
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
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-03-17 09:29:19 +00:00
|
|
|
const setupUpdatedRootRefresh = (params?: {
|
|
|
|
|
gatewayUpdateImpl?: () => Promise<UpdateRunResult>;
|
|
|
|
|
}) => {
|
|
|
|
|
const root = createCaseDir("openclaw-updated-root");
|
|
|
|
|
const entryPath = path.join(root, "dist", "entry.js");
|
|
|
|
|
pathExists.mockImplementation(async (candidate: string) => candidate === entryPath);
|
|
|
|
|
if (params?.gatewayUpdateImpl) {
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockImplementation(params.gatewayUpdateImpl);
|
|
|
|
|
} else {
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "npm",
|
|
|
|
|
root,
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
serviceLoaded.mockResolvedValue(true);
|
|
|
|
|
return { root, entryPath };
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-13 20:26:26 +00:00
|
|
|
beforeEach(() => {
|
2026-03-02 09:45:47 +00:00
|
|
|
vi.clearAllMocks();
|
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-03-02 09:45:47 +00:00
|
|
|
pathExists.mockResolvedValue(false);
|
|
|
|
|
syncPluginsForUpdateChannel.mockResolvedValue({
|
|
|
|
|
changed: false,
|
|
|
|
|
config: baseConfig,
|
|
|
|
|
summary: {
|
|
|
|
|
switchedToBundled: [],
|
|
|
|
|
switchedToNpm: [],
|
|
|
|
|
warnings: [],
|
|
|
|
|
errors: [],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
updateNpmInstalledPlugins.mockResolvedValue({
|
|
|
|
|
changed: false,
|
|
|
|
|
config: baseConfig,
|
|
|
|
|
outcomes: [],
|
|
|
|
|
});
|
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-02-22 17:11:24 +01:00
|
|
|
it("updateCommand --dry-run previews without mutating", async () => {
|
|
|
|
|
vi.mocked(defaultRuntime.log).mockClear();
|
|
|
|
|
serviceLoaded.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
await updateCommand({ dryRun: true, channel: "beta" });
|
|
|
|
|
|
|
|
|
|
expect(writeConfigFile).not.toHaveBeenCalled();
|
|
|
|
|
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
|
|
|
|
expect(runDaemonInstall).not.toHaveBeenCalled();
|
|
|
|
|
expect(runRestartScript).not.toHaveBeenCalled();
|
|
|
|
|
expect(runDaemonRestart).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
|
|
|
|
|
expect(logs.join("\n")).toContain("Update dry-run");
|
|
|
|
|
expect(logs.join("\n")).toContain("No changes were applied.");
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-17 09:32:59 +00:00
|
|
|
it("updateStatusCommand renders table and json output", async () => {
|
|
|
|
|
const cases = [
|
|
|
|
|
{
|
|
|
|
|
name: "table output",
|
|
|
|
|
options: { json: false },
|
|
|
|
|
assert: () => {
|
|
|
|
|
const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]);
|
|
|
|
|
expect(logs.join("\n")).toContain("OpenClaw update status");
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "json output",
|
|
|
|
|
options: { json: true },
|
|
|
|
|
assert: () => {
|
|
|
|
|
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");
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
] as const;
|
2026-01-20 14:05:55 +00:00
|
|
|
|
2026-03-17 09:32:59 +00:00
|
|
|
for (const testCase of cases) {
|
|
|
|
|
vi.mocked(defaultRuntime.log).mockClear();
|
|
|
|
|
await updateStatusCommand(testCase.options);
|
|
|
|
|
testCase.assert();
|
|
|
|
|
}
|
2026-01-20 14:05:55 +00:00
|
|
|
});
|
|
|
|
|
|
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",
|
|
|
|
|
options: { yes: true },
|
|
|
|
|
prepare: async () => {
|
|
|
|
|
const tempDir = createCaseDir("openclaw-update");
|
|
|
|
|
mockPackageInstallStatus(tempDir);
|
|
|
|
|
},
|
2026-03-12 23:41:28 +00:00
|
|
|
expectedChannel: undefined as "stable" | undefined,
|
|
|
|
|
expectedTag: undefined as string | undefined,
|
2026-02-19 15:18:50 +00:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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();
|
2026-03-12 23:41:28 +00:00
|
|
|
if (mode) {
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult({ mode }));
|
|
|
|
|
}
|
2026-02-19 15:18:50 +00:00
|
|
|
|
|
|
|
|
await updateCommand(options);
|
|
|
|
|
|
2026-03-12 23:41:28 +00:00
|
|
|
if (expectedChannel !== undefined) {
|
|
|
|
|
const call = expectUpdateCallChannel(expectedChannel);
|
|
|
|
|
if (expectedTag !== undefined) {
|
|
|
|
|
expect(call?.tag).toBe(expectedTag);
|
|
|
|
|
}
|
|
|
|
|
return;
|
2026-02-19 15:18:50 +00:00
|
|
|
}
|
2026-03-12 23:41:28 +00:00
|
|
|
|
|
|
|
|
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
|
|
|
|
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
|
|
|
|
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
|
|
|
|
|
expect.any(Object),
|
|
|
|
|
);
|
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",
|
|
|
|
|
});
|
|
|
|
|
await updateCommand({});
|
2026-01-20 16:28:28 +00:00
|
|
|
|
2026-03-12 23:41:28 +00:00
|
|
|
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
|
|
|
|
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
|
|
|
|
["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"],
|
|
|
|
|
expect.any(Object),
|
|
|
|
|
);
|
2026-01-20 16:28:28 +00:00
|
|
|
});
|
|
|
|
|
|
2026-03-17 08:19:00 +00:00
|
|
|
it("resolves package install specs from tags and env overrides", async () => {
|
|
|
|
|
for (const scenario of [
|
|
|
|
|
{
|
|
|
|
|
name: "explicit dist-tag",
|
|
|
|
|
run: async () => {
|
|
|
|
|
mockPackageInstallStatus(createCaseDir("openclaw-update"));
|
|
|
|
|
await updateCommand({ tag: "next" });
|
|
|
|
|
},
|
|
|
|
|
expectedSpec: "openclaw@next",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "main shorthand",
|
|
|
|
|
run: async () => {
|
|
|
|
|
mockPackageInstallStatus(createCaseDir("openclaw-update"));
|
|
|
|
|
await updateCommand({ yes: true, tag: "main" });
|
|
|
|
|
},
|
|
|
|
|
expectedSpec: "github:openclaw/openclaw#main",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "explicit git package spec",
|
|
|
|
|
run: async () => {
|
|
|
|
|
mockPackageInstallStatus(createCaseDir("openclaw-update"));
|
|
|
|
|
await updateCommand({ yes: true, tag: "github:openclaw/openclaw#main" });
|
|
|
|
|
},
|
|
|
|
|
expectedSpec: "github:openclaw/openclaw#main",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "OPENCLAW_UPDATE_PACKAGE_SPEC override",
|
|
|
|
|
run: async () => {
|
|
|
|
|
mockPackageInstallStatus(createCaseDir("openclaw-update"));
|
|
|
|
|
await withEnvAsync(
|
|
|
|
|
{ OPENCLAW_UPDATE_PACKAGE_SPEC: "http://10.211.55.2:8138/openclaw-next.tgz" },
|
|
|
|
|
async () => {
|
|
|
|
|
await updateCommand({ yes: true, tag: "latest" });
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
expectedSpec: "http://10.211.55.2:8138/openclaw-next.tgz",
|
|
|
|
|
},
|
|
|
|
|
]) {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
readPackageName.mockResolvedValue("openclaw");
|
|
|
|
|
readPackageVersion.mockResolvedValue("1.0.0");
|
|
|
|
|
resolveGlobalManager.mockResolvedValue("npm");
|
|
|
|
|
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd());
|
|
|
|
|
await scenario.run();
|
|
|
|
|
expectPackageInstallSpec(scenario.expectedSpec);
|
|
|
|
|
}
|
2026-03-12 23:41:28 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("prepends portable Git PATH for package updates on Windows", async () => {
|
|
|
|
|
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
|
|
|
|
const tempDir = createCaseDir("openclaw-update");
|
|
|
|
|
const localAppData = createCaseDir("openclaw-localappdata");
|
|
|
|
|
const portableGitMingw = path.join(
|
|
|
|
|
localAppData,
|
|
|
|
|
"OpenClaw",
|
|
|
|
|
"deps",
|
|
|
|
|
"portable-git",
|
|
|
|
|
"mingw64",
|
|
|
|
|
"bin",
|
|
|
|
|
);
|
|
|
|
|
const portableGitUsr = path.join(
|
|
|
|
|
localAppData,
|
|
|
|
|
"OpenClaw",
|
|
|
|
|
"deps",
|
|
|
|
|
"portable-git",
|
|
|
|
|
"usr",
|
|
|
|
|
"bin",
|
|
|
|
|
);
|
|
|
|
|
await fs.mkdir(portableGitMingw, { recursive: true });
|
|
|
|
|
await fs.mkdir(portableGitUsr, { recursive: true });
|
|
|
|
|
mockPackageInstallStatus(tempDir);
|
|
|
|
|
pathExists.mockImplementation(
|
|
|
|
|
async (candidate: string) => candidate === portableGitMingw || candidate === portableGitUsr,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await withEnvAsync({ LOCALAPPDATA: localAppData }, async () => {
|
|
|
|
|
await updateCommand({ yes: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
platformSpy.mockRestore();
|
|
|
|
|
|
|
|
|
|
const updateCall = vi
|
|
|
|
|
.mocked(runCommandWithTimeout)
|
|
|
|
|
.mock.calls.find(
|
|
|
|
|
(call) =>
|
|
|
|
|
Array.isArray(call[0]) &&
|
|
|
|
|
call[0][0] === "npm" &&
|
|
|
|
|
call[0][1] === "i" &&
|
|
|
|
|
call[0][2] === "-g",
|
|
|
|
|
);
|
2026-03-13 01:01:56 +00:00
|
|
|
const updateOptions =
|
|
|
|
|
typeof updateCall?.[1] === "object" && updateCall[1] !== null ? updateCall[1] : undefined;
|
|
|
|
|
const mergedPath = updateOptions?.env?.Path ?? updateOptions?.env?.PATH ?? "";
|
2026-03-12 23:41:28 +00:00
|
|
|
expect(mergedPath.split(path.delimiter).slice(0, 2)).toEqual([
|
|
|
|
|
portableGitMingw,
|
|
|
|
|
portableGitUsr,
|
|
|
|
|
]);
|
2026-03-13 01:01:56 +00:00
|
|
|
expect(updateOptions?.env?.NPM_CONFIG_SCRIPT_SHELL).toBe("cmd.exe");
|
|
|
|
|
expect(updateOptions?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1");
|
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-03-17 09:39:45 +00:00
|
|
|
it("updateCommand handles service env refresh and restart behavior", async () => {
|
|
|
|
|
const cases = [
|
|
|
|
|
{
|
|
|
|
|
name: "refreshes service env when already installed",
|
|
|
|
|
run: async () => {
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
} satisfies UpdateRunResult);
|
|
|
|
|
vi.mocked(runDaemonInstall).mockResolvedValue(undefined);
|
|
|
|
|
serviceLoaded.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
await updateCommand({});
|
|
|
|
|
},
|
|
|
|
|
assert: () => {
|
|
|
|
|
expect(runDaemonInstall).toHaveBeenCalledWith({
|
|
|
|
|
force: true,
|
|
|
|
|
json: undefined,
|
|
|
|
|
});
|
|
|
|
|
expect(runRestartScript).toHaveBeenCalled();
|
|
|
|
|
expect(runDaemonRestart).not.toHaveBeenCalled();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "falls back to daemon restart when service env refresh cannot complete",
|
|
|
|
|
run: async () => {
|
|
|
|
|
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
|
|
|
|
await runRestartFallbackScenario({ daemonInstall: "fail" });
|
|
|
|
|
},
|
|
|
|
|
assert: () => {
|
|
|
|
|
expect(runDaemonInstall).toHaveBeenCalledWith({
|
|
|
|
|
force: true,
|
|
|
|
|
json: undefined,
|
|
|
|
|
});
|
|
|
|
|
expect(runDaemonRestart).toHaveBeenCalled();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "keeps going when daemon install succeeds but restart fallback still handles relaunch",
|
|
|
|
|
run: async () => {
|
|
|
|
|
vi.mocked(runDaemonRestart).mockResolvedValue(true);
|
|
|
|
|
await runRestartFallbackScenario({ daemonInstall: "ok" });
|
|
|
|
|
},
|
|
|
|
|
assert: () => {
|
|
|
|
|
expect(runDaemonInstall).toHaveBeenCalledWith({
|
|
|
|
|
force: true,
|
|
|
|
|
json: undefined,
|
|
|
|
|
});
|
|
|
|
|
expect(runDaemonRestart).toHaveBeenCalled();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "skips service env refresh when --no-restart is set",
|
|
|
|
|
run: async () => {
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
|
|
|
|
serviceLoaded.mockResolvedValue(true);
|
2026-02-19 08:32:56 -08:00
|
|
|
|
2026-03-17 09:39:45 +00:00
|
|
|
await updateCommand({ restart: false });
|
|
|
|
|
},
|
|
|
|
|
assert: () => {
|
|
|
|
|
expect(runDaemonInstall).not.toHaveBeenCalled();
|
|
|
|
|
expect(runRestartScript).not.toHaveBeenCalled();
|
|
|
|
|
expect(runDaemonRestart).not.toHaveBeenCalled();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
] as const;
|
2026-02-19 08:32:56 -08:00
|
|
|
|
2026-03-17 09:39:45 +00:00
|
|
|
for (const testCase of cases) {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
await testCase.run();
|
|
|
|
|
testCase.assert();
|
|
|
|
|
}
|
2026-02-19 08:32:56 -08:00
|
|
|
});
|
|
|
|
|
|
2026-03-17 09:29:19 +00:00
|
|
|
it.each([
|
|
|
|
|
{
|
|
|
|
|
name: "updateCommand refreshes service env from updated install root when available",
|
|
|
|
|
invoke: async () => {
|
2026-03-13 17:27:12 -04:00
|
|
|
await updateCommand({});
|
|
|
|
|
},
|
2026-03-17 09:29:19 +00:00
|
|
|
expectedOptions: (root: string) => expect.objectContaining({ cwd: root, timeoutMs: 60_000 }),
|
|
|
|
|
assertExtra: () => {
|
|
|
|
|
expect(runDaemonInstall).not.toHaveBeenCalled();
|
|
|
|
|
expect(runRestartScript).toHaveBeenCalled();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "updateCommand preserves invocation-relative service env overrides during refresh",
|
|
|
|
|
invoke: async () => {
|
|
|
|
|
await withEnvAsync(
|
|
|
|
|
{
|
|
|
|
|
OPENCLAW_STATE_DIR: "./state",
|
|
|
|
|
OPENCLAW_CONFIG_PATH: "./config/openclaw.json",
|
|
|
|
|
},
|
|
|
|
|
async () => {
|
|
|
|
|
await updateCommand({});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
expectedOptions: (root: string) =>
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
cwd: root,
|
|
|
|
|
env: expect.objectContaining({
|
|
|
|
|
OPENCLAW_STATE_DIR: path.resolve("./state"),
|
|
|
|
|
OPENCLAW_CONFIG_PATH: path.resolve("./config/openclaw.json"),
|
|
|
|
|
}),
|
|
|
|
|
timeoutMs: 60_000,
|
2026-03-13 17:27:12 -04:00
|
|
|
}),
|
2026-03-17 09:29:19 +00:00
|
|
|
assertExtra: () => {
|
|
|
|
|
expect(runDaemonInstall).not.toHaveBeenCalled();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "updateCommand reuses the captured invocation cwd when process.cwd later fails",
|
|
|
|
|
invoke: async () => {
|
|
|
|
|
const originalCwd = process.cwd();
|
|
|
|
|
let restoreCwd: (() => void) | undefined;
|
|
|
|
|
const { root } = setupUpdatedRootRefresh({
|
|
|
|
|
gatewayUpdateImpl: async () => {
|
|
|
|
|
const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => {
|
|
|
|
|
throw new Error("ENOENT: current working directory is gone");
|
|
|
|
|
});
|
|
|
|
|
restoreCwd = () => cwdSpy.mockRestore();
|
|
|
|
|
return {
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "npm",
|
|
|
|
|
root,
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
await withEnvAsync(
|
|
|
|
|
{
|
|
|
|
|
OPENCLAW_STATE_DIR: "./state",
|
|
|
|
|
},
|
|
|
|
|
async () => {
|
|
|
|
|
await updateCommand({});
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
restoreCwd?.();
|
|
|
|
|
}
|
|
|
|
|
return { originalCwd };
|
|
|
|
|
},
|
|
|
|
|
customSetup: true,
|
|
|
|
|
expectedOptions: (_root: string, context?: { originalCwd: string }) =>
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
cwd: expect.any(String),
|
|
|
|
|
env: expect.objectContaining({
|
|
|
|
|
OPENCLAW_STATE_DIR: path.resolve(context?.originalCwd ?? process.cwd(), "./state"),
|
|
|
|
|
}),
|
|
|
|
|
timeoutMs: 60_000,
|
|
|
|
|
}),
|
|
|
|
|
assertExtra: () => {
|
|
|
|
|
expect(runDaemonInstall).not.toHaveBeenCalled();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
])("$name", async (testCase) => {
|
|
|
|
|
const setup = testCase.customSetup ? undefined : setupUpdatedRootRefresh();
|
|
|
|
|
const context = await testCase.invoke();
|
|
|
|
|
const root = setup?.root ?? runCommandWithTimeout.mock.calls[0]?.[1]?.cwd;
|
|
|
|
|
const entryPath = setup?.entryPath ?? path.join(String(root), "dist", "entry.js");
|
2026-03-13 18:09:01 -04:00
|
|
|
|
|
|
|
|
expect(runCommandWithTimeout).toHaveBeenCalledWith(
|
|
|
|
|
[expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"],
|
2026-03-17 09:29:19 +00:00
|
|
|
testCase.expectedOptions(String(root), context),
|
2026-03-13 18:09:01 -04:00
|
|
|
);
|
2026-03-17 09:29:19 +00:00
|
|
|
testCase.assertExtra();
|
2026-03-13 18:09:01 -04:00
|
|
|
});
|
|
|
|
|
|
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-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,
|
2026-03-12 23:41:28 +00:00
|
|
|
shouldRunPackageUpdate: false,
|
2026-02-19 15:18:50 +00:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "allows downgrade with --yes",
|
|
|
|
|
options: { yes: true },
|
|
|
|
|
shouldExit: false,
|
2026-03-12 23:41:28 +00:00
|
|
|
shouldRunPackageUpdate: true,
|
2026-02-19 15:18:50 +00:00
|
|
|
},
|
2026-03-12 23:41:28 +00:00
|
|
|
])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunPackageUpdate }) => {
|
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-03-12 23:41:28 +00:00
|
|
|
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(false);
|
|
|
|
|
expect(
|
|
|
|
|
vi
|
|
|
|
|
.mocked(runCommandWithTimeout)
|
|
|
|
|
.mock.calls.some((call) => Array.isArray(call[0]) && call[0][0] === "npm"),
|
|
|
|
|
).toBe(shouldRunPackageUpdate);
|
2026-01-21 03:39:39 +00:00
|
|
|
});
|
2026-01-22 07:05:00 +00:00
|
|
|
|
2026-02-22 17:11:24 +01:00
|
|
|
it("dry-run bypasses downgrade confirmation checks in non-interactive mode", async () => {
|
|
|
|
|
await setupNonInteractiveDowngrade();
|
|
|
|
|
vi.mocked(defaultRuntime.exit).mockClear();
|
|
|
|
|
|
|
|
|
|
await updateCommand({ dryRun: true });
|
|
|
|
|
|
|
|
|
|
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false);
|
|
|
|
|
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
});
|