2026-01-17 11:40:02 +00:00
|
|
|
import fs from "node:fs/promises";
|
|
|
|
|
import os from "node:os";
|
|
|
|
|
import path from "node:path";
|
2026-02-13 21:23:44 +00:00
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
2026-01-10 18:18:10 +00:00
|
|
|
import type { UpdateRunResult } from "../infra/update-runner.js";
|
|
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
writeConfigFile: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock("../infra/update-check.js", async () => {
|
|
|
|
|
const actual = await vi.importActual<typeof import("../infra/update-check.js")>(
|
|
|
|
|
"../infra/update-check.js",
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
...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-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", () => ({
|
|
|
|
|
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");
|
|
|
|
|
const { runDaemonRestart } = await import("./daemon-cli.js");
|
|
|
|
|
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-13 21:23:44 +00:00
|
|
|
let fixtureRoot = "";
|
|
|
|
|
let fixtureCount = 0;
|
|
|
|
|
|
|
|
|
|
const createCaseDir = async (prefix: string) => {
|
|
|
|
|
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
|
|
|
|
await fs.mkdir(dir, { recursive: true });
|
|
|
|
|
return dir;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-tests-"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-17 11:40:02 +00:00
|
|
|
const baseSnapshot = {
|
|
|
|
|
valid: true,
|
|
|
|
|
config: {},
|
|
|
|
|
issues: [],
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
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-13 20:26:26 +00:00
|
|
|
beforeEach(() => {
|
2026-01-17 11:40:02 +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-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-01-20 13:33:31 +00:00
|
|
|
it("defaults to dev channel for git installs when unset", async () => {
|
2026-01-17 11:40:02 +00:00
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await updateCommand({});
|
|
|
|
|
|
|
|
|
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
2026-01-20 13:33:31 +00:00
|
|
|
expect(call?.channel).toBe("dev");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("defaults to stable channel for package installs when unset", async () => {
|
2026-02-13 21:23:44 +00:00
|
|
|
const tempDir = await createCaseDir("openclaw-update");
|
|
|
|
|
await fs.writeFile(
|
|
|
|
|
path.join(tempDir, "package.json"),
|
|
|
|
|
JSON.stringify({ name: "openclaw", version: "1.0.0" }),
|
|
|
|
|
"utf-8",
|
|
|
|
|
);
|
2026-01-20 13:33:31 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
|
|
|
|
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
|
|
|
|
root: tempDir,
|
|
|
|
|
installKind: "package",
|
|
|
|
|
packageManager: "npm",
|
|
|
|
|
deps: {
|
|
|
|
|
manager: "npm",
|
2026-01-20 13:33:31 +00:00
|
|
|
status: "ok",
|
2026-02-13 21:23:44 +00:00
|
|
|
lockfilePath: null,
|
|
|
|
|
markerPath: null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "npm",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
});
|
2026-01-20 13:33:31 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
await updateCommand({ yes: true });
|
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?.channel).toBe("stable");
|
|
|
|
|
expect(call?.tag).toBe("latest");
|
2026-01-17 11:40:02 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses stored beta channel when configured", async () => {
|
|
|
|
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
config: { update: { channel: "beta" } },
|
|
|
|
|
});
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await updateCommand({});
|
|
|
|
|
|
|
|
|
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
2026-01-20 13:33:31 +00:00
|
|
|
expect(call?.channel).toBe("beta");
|
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-13 21:23:44 +00:00
|
|
|
const tempDir = await createCaseDir("openclaw-update");
|
|
|
|
|
await fs.writeFile(
|
|
|
|
|
path.join(tempDir, "package.json"),
|
|
|
|
|
JSON.stringify({ name: "openclaw", version: "1.0.0" }),
|
|
|
|
|
"utf-8",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
|
|
|
|
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
|
|
|
|
...baseSnapshot,
|
|
|
|
|
config: { update: { channel: "beta" } },
|
|
|
|
|
});
|
|
|
|
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
|
|
|
|
root: tempDir,
|
|
|
|
|
installKind: "package",
|
|
|
|
|
packageManager: "npm",
|
|
|
|
|
deps: {
|
|
|
|
|
manager: "npm",
|
2026-01-20 16:28:28 +00:00
|
|
|
status: "ok",
|
2026-02-13 21:23:44 +00:00
|
|
|
lockfilePath: null,
|
|
|
|
|
markerPath: null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
|
|
|
|
tag: "latest",
|
|
|
|
|
version: "1.2.3-1",
|
|
|
|
|
});
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "npm",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
});
|
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-13 21:23:44 +00:00
|
|
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
|
|
|
|
expect(call?.channel).toBe("beta");
|
|
|
|
|
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-13 21:23:44 +00:00
|
|
|
const tempDir = await createCaseDir("openclaw-update");
|
|
|
|
|
await fs.writeFile(
|
|
|
|
|
path.join(tempDir, "package.json"),
|
|
|
|
|
JSON.stringify({ name: "openclaw", version: "1.0.0" }),
|
|
|
|
|
"utf-8",
|
|
|
|
|
);
|
2026-01-20 13:33:31 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "npm",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
});
|
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 () => {
|
|
|
|
|
const mockResult: UpdateRunResult = {
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
|
|
|
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-01-10 18:18:10 +00:00
|
|
|
const mockResult: UpdateRunResult = {
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
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-01-23 11:49:59 +00:00
|
|
|
it("updateCommand skips restart when --no-restart is set", async () => {
|
|
|
|
|
const mockResult: UpdateRunResult = {
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
|
|
|
|
|
|
|
|
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 () => {
|
|
|
|
|
const mockResult: UpdateRunResult = {
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
|
|
|
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-01-10 18:18:10 +00:00
|
|
|
it("updateCommand validates timeout option", async () => {
|
|
|
|
|
vi.mocked(defaultRuntime.error).mockClear();
|
|
|
|
|
vi.mocked(defaultRuntime.exit).mockClear();
|
|
|
|
|
|
|
|
|
|
await updateCommand({ timeout: "invalid" });
|
|
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
|
2026-01-10 18:18:10 +00:00
|
|
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
|
|
|
|
});
|
2026-01-17 11:40:02 +00:00
|
|
|
|
|
|
|
|
it("persists update channel when --channel is set", async () => {
|
|
|
|
|
const mockResult: UpdateRunResult = {
|
|
|
|
|
status: "ok",
|
|
|
|
|
mode: "git",
|
|
|
|
|
steps: [],
|
|
|
|
|
durationMs: 100,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("requires confirmation on downgrade when non-interactive", async () => {
|
2026-02-13 21:23:44 +00:00
|
|
|
const tempDir = await createCaseDir("openclaw-update");
|
|
|
|
|
setTty(false);
|
|
|
|
|
await fs.writeFile(
|
|
|
|
|
path.join(tempDir, "package.json"),
|
|
|
|
|
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
|
|
|
|
|
"utf-8",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
|
|
|
|
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
|
|
|
|
root: tempDir,
|
|
|
|
|
installKind: "package",
|
|
|
|
|
packageManager: "npm",
|
|
|
|
|
deps: {
|
|
|
|
|
manager: "npm",
|
2026-01-17 11:40:02 +00:00
|
|
|
status: "ok",
|
2026-02-13 21:23:44 +00:00
|
|
|
lockfilePath: null,
|
|
|
|
|
markerPath: null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
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();
|
2026-01-17 11:40:02 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
await updateCommand({});
|
2026-01-17 11:40:02 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining("Downgrade confirmation required."),
|
|
|
|
|
);
|
|
|
|
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
2026-01-17 11:40:02 +00:00
|
|
|
});
|
2026-01-21 03:39:39 +00:00
|
|
|
|
|
|
|
|
it("allows downgrade with --yes in non-interactive mode", async () => {
|
2026-02-13 21:23:44 +00:00
|
|
|
const tempDir = await createCaseDir("openclaw-update");
|
|
|
|
|
setTty(false);
|
|
|
|
|
await fs.writeFile(
|
|
|
|
|
path.join(tempDir, "package.json"),
|
|
|
|
|
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
|
|
|
|
|
"utf-8",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir);
|
|
|
|
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
|
|
|
|
root: tempDir,
|
|
|
|
|
installKind: "package",
|
|
|
|
|
packageManager: "npm",
|
|
|
|
|
deps: {
|
|
|
|
|
manager: "npm",
|
2026-01-21 03:39:39 +00:00
|
|
|
status: "ok",
|
2026-02-13 21:23:44 +00:00
|
|
|
lockfilePath: null,
|
|
|
|
|
markerPath: null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
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();
|
2026-01-21 03:39:39 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
await updateCommand({ yes: true });
|
2026-01-21 03:39:39 +00:00
|
|
|
|
2026-02-13 21:23:44 +00:00
|
|
|
expect(defaultRuntime.error).not.toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining("Downgrade confirmation required."),
|
|
|
|
|
);
|
|
|
|
|
expect(runGatewayUpdate).toHaveBeenCalled();
|
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-13 21:23:44 +00:00
|
|
|
const tempDir = await createCaseDir("openclaw-update-wizard");
|
2026-01-30 03:15:10 +01:00
|
|
|
const previousGitDir = process.env.OPENCLAW_GIT_DIR;
|
2026-01-22 07:05:00 +00:00
|
|
|
try {
|
|
|
|
|
setTty(true);
|
2026-01-30 03:15:10 +01:00
|
|
|
process.env.OPENCLAW_GIT_DIR = tempDir;
|
2026-01-22 07:05:00 +00:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
} finally {
|
2026-01-30 03:15:10 +01:00
|
|
|
process.env.OPENCLAW_GIT_DIR = previousGitDir;
|
2026-01-22 07:05:00 +00:00
|
|
|
}
|
|
|
|
|
});
|
2026-01-10 18:18:10 +00:00
|
|
|
});
|