test: dedupe utility and config suites

This commit is contained in:
Peter Steinberger
2026-03-28 00:26:10 +00:00
parent d8f97358d7
commit fef688fb7a
24 changed files with 1178 additions and 1312 deletions

View File

@@ -159,6 +159,12 @@ const { defaultRuntime } = await import("../runtime.js");
const { updateCommand, updateStatusCommand, updateWizardCommand } = await import("./update-cli.js");
const { resolveGitInstallDir } = await import("./update-cli/shared.js");
type UpdateCliScenario = {
name: string;
run: () => Promise<void>;
assert: () => void;
};
describe("update-cli", () => {
const fixtureRoot = "/tmp/openclaw-update-tests";
let fixtureCount = 0;
@@ -235,6 +241,12 @@ describe("update-cli", () => {
...overrides,
}) as UpdateRunResult;
const runUpdateCliScenario = async (testCase: UpdateCliScenario) => {
vi.clearAllMocks();
await testCase.run();
testCase.assert();
};
const runRestartFallbackScenario = async (params: { daemonInstall: "ok" | "fail" }) => {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
if (params.daemonInstall === "fail") {
@@ -396,79 +408,67 @@ describe("update-cli", () => {
setStdoutTty(false);
});
it("updateCommand dry-run previews without mutating and bypasses downgrade confirmation", async () => {
const cases = [
{
name: "preview mode",
run: async () => {
vi.mocked(defaultRuntime.log).mockClear();
serviceLoaded.mockResolvedValue(true);
await updateCommand({ dryRun: true, channel: "beta" });
},
assert: () => {
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.");
},
it.each([
{
name: "preview mode",
run: async () => {
vi.mocked(defaultRuntime.log).mockClear();
serviceLoaded.mockResolvedValue(true);
await updateCommand({ dryRun: true, channel: "beta" });
},
{
name: "downgrade bypass",
run: async () => {
await setupNonInteractiveDowngrade();
vi.mocked(defaultRuntime.exit).mockClear();
await updateCommand({ dryRun: true });
},
assert: () => {
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(
false,
);
expect(runGatewayUpdate).not.toHaveBeenCalled();
},
},
] as const;
assert: () => {
expect(writeConfigFile).not.toHaveBeenCalled();
expect(runGatewayUpdate).not.toHaveBeenCalled();
expect(runDaemonInstall).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
for (const testCase of cases) {
vi.clearAllMocks();
await testCase.run();
testCase.assert();
}
});
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");
},
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.");
},
{
name: "json output",
options: { json: true },
assert: () => {
const last = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0];
expect(last).toBeDefined();
const parsed = last as Record<string, unknown>;
const channel = parsed.channel as { value?: unknown };
expect(channel.value).toBe("stable");
},
},
{
name: "downgrade bypass",
run: async () => {
await setupNonInteractiveDowngrade();
vi.mocked(defaultRuntime.exit).mockClear();
await updateCommand({ dryRun: true });
},
] as const;
assert: () => {
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false);
expect(runGatewayUpdate).not.toHaveBeenCalled();
},
},
] as const)("updateCommand dry-run behavior: $name", runUpdateCliScenario);
for (const testCase of cases) {
vi.mocked(defaultRuntime.log).mockClear();
await updateStatusCommand(testCase.options);
testCase.assert();
}
});
it.each([
{
name: "table output",
run: async () => {
vi.mocked(defaultRuntime.log).mockClear();
await updateStatusCommand({ 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",
run: async () => {
vi.mocked(defaultRuntime.log).mockClear();
await updateStatusCommand({ json: true });
},
assert: () => {
const last = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0];
expect(last).toBeDefined();
const parsed = last as Record<string, unknown>;
const channel = parsed.channel as { value?: unknown };
expect(channel.value).toBe("stable");
},
},
] as const)("updateStatusCommand rendering: $name", runUpdateCliScenario);
it("parses update status --json as the subcommand option", async () => {
const program = new Command();
@@ -607,55 +607,56 @@ describe("update-cli", () => {
);
});
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",
it.each([
{
name: "explicit dist-tag",
run: async () => {
mockPackageInstallStatus(createCaseDir("openclaw-update"));
await updateCommand({ tag: "next" });
},
{
name: "main shorthand",
run: async () => {
mockPackageInstallStatus(createCaseDir("openclaw-update"));
await updateCommand({ yes: true, tag: "main" });
},
expectedSpec: "github:openclaw/openclaw#main",
expectedSpec: "openclaw@next",
},
{
name: "main shorthand",
run: async () => {
mockPackageInstallStatus(createCaseDir("openclaw-update"));
await updateCommand({ yes: true, tag: "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",
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" });
},
{
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",
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",
},
] as const)(
"resolves package install specs from tags and env overrides: $name",
async ({ run, expectedSpec }) => {
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);
}
});
await run();
expectPackageInstallSpec(expectedSpec);
},
);
it("fails package updates when the installed correction version does not match the requested target", async () => {
const tempDir = createCaseDir("openclaw-update");
@@ -762,45 +763,37 @@ describe("update-cli", () => {
expect(updateOptions?.env?.NODE_LLAMA_CPP_SKIP_DOWNLOAD).toBe("1");
});
it("updateCommand reports success and failure outcomes", async () => {
const cases = [
{
name: "outputs JSON when --json is set",
run: async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(defaultRuntime.writeJson).mockClear();
await updateCommand({ json: true });
},
assert: () => {
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0];
expect(jsonOutput).toBeDefined();
},
it.each([
{
name: "outputs JSON when --json is set",
run: async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(defaultRuntime.writeJson).mockClear();
await updateCommand({ json: true });
},
{
name: "exits with error on failure",
run: async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "error",
mode: "git",
reason: "rebase-failed",
steps: [],
durationMs: 100,
} satisfies UpdateRunResult);
vi.mocked(defaultRuntime.exit).mockClear();
await updateCommand({});
},
assert: () => {
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
},
assert: () => {
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0];
expect(jsonOutput).toBeDefined();
},
] as const;
for (const testCase of cases) {
vi.clearAllMocks();
await testCase.run();
testCase.assert();
}
});
},
{
name: "exits with error on failure",
run: async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "error",
mode: "git",
reason: "rebase-failed",
steps: [],
durationMs: 100,
} satisfies UpdateRunResult);
vi.mocked(defaultRuntime.exit).mockClear();
await updateCommand({});
},
assert: () => {
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
},
},
] as const)("updateCommand reports outcomes: $name", runUpdateCliScenario);
it("persists the requested channel only after a successful package update", async () => {
const tempDir = createCaseDir("openclaw-update");
@@ -888,96 +881,88 @@ describe("update-cli", () => {
expect(lastWrite?.update?.channel).toBe("beta");
});
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);
it.each([
{
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();
},
await updateCommand({});
},
{
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();
},
assert: () => {
expect(runDaemonInstall).toHaveBeenCalledWith({
force: true,
json: undefined,
});
expect(runRestartScript).toHaveBeenCalled();
expect(runDaemonRestart).not.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: "falls back to daemon restart when service env refresh cannot complete",
run: async () => {
vi.mocked(runDaemonRestart).mockResolvedValue(true);
await runRestartFallbackScenario({ daemonInstall: "fail" });
},
{
name: "skips service env refresh when --no-restart is set",
run: async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
serviceLoaded.mockResolvedValue(true);
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);
await updateCommand({ restart: false });
},
assert: () => {
expect(runDaemonInstall).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
},
await updateCommand({ restart: false });
},
{
name: "skips success message when restart does not run",
run: async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(runDaemonRestart).mockResolvedValue(false);
vi.mocked(defaultRuntime.log).mockClear();
await updateCommand({ restart: true });
},
assert: () => {
const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(
false,
);
},
assert: () => {
expect(runDaemonInstall).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
},
] as const;
for (const testCase of cases) {
vi.clearAllMocks();
await testCase.run();
testCase.assert();
}
});
},
{
name: "skips success message when restart does not run",
run: async () => {
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
vi.mocked(runDaemonRestart).mockResolvedValue(false);
vi.mocked(defaultRuntime.log).mockClear();
await updateCommand({ restart: true });
},
assert: () => {
const logLines = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
expect(logLines.some((line) => line.includes("Daemon restarted successfully."))).toBe(
false,
);
},
},
] as const)("updateCommand service refresh behavior: $name", runUpdateCliScenario);
it.each([
{
@@ -1109,47 +1094,46 @@ describe("update-cli", () => {
}
});
it("validates update command invocation errors", async () => {
const cases = [
{
name: "update command invalid timeout",
run: async () => await updateCommand({ timeout: "invalid" }),
requireTty: false,
expectedError: "timeout",
},
{
name: "update status command invalid timeout",
run: async () => await updateStatusCommand({ timeout: "invalid" }),
requireTty: false,
expectedError: "timeout",
},
{
name: "update wizard invalid timeout",
run: async () => await updateWizardCommand({ timeout: "invalid" }),
requireTty: true,
expectedError: "timeout",
},
{
name: "update wizard requires a TTY",
run: async () => await updateWizardCommand({}),
requireTty: false,
expectedError: "Update wizard requires a TTY",
},
] as const;
for (const testCase of cases) {
setTty(testCase.requireTty);
it.each([
{
name: "update command invalid timeout",
run: async () => await updateCommand({ timeout: "invalid" }),
requireTty: false,
expectedError: "timeout",
},
{
name: "update status command invalid timeout",
run: async () => await updateStatusCommand({ timeout: "invalid" }),
requireTty: false,
expectedError: "timeout",
},
{
name: "update wizard invalid timeout",
run: async () => await updateWizardCommand({ timeout: "invalid" }),
requireTty: true,
expectedError: "timeout",
},
{
name: "update wizard requires a TTY",
run: async () => await updateWizardCommand({}),
requireTty: false,
expectedError: "Update wizard requires a TTY",
},
] as const)(
"validates update command invocation errors: $name",
async ({ run, requireTty, expectedError, name }) => {
setTty(requireTty);
vi.mocked(defaultRuntime.error).mockClear();
vi.mocked(defaultRuntime.exit).mockClear();
await testCase.run();
await run();
expect(defaultRuntime.error, testCase.name).toHaveBeenCalledWith(
expect.stringContaining(testCase.expectedError),
expect(defaultRuntime.error, name).toHaveBeenCalledWith(
expect.stringContaining(expectedError),
);
expect(defaultRuntime.exit, testCase.name).toHaveBeenCalledWith(1);
}
});
expect(defaultRuntime.exit, name).toHaveBeenCalledWith(1);
},
);
it.each([
{