fix(doctor): move forced exit to top-level command

This commit is contained in:
Sebastian
2026-02-16 21:19:44 -05:00
parent 901d4cb310
commit 0aa28c71ca
5 changed files with 114 additions and 4 deletions

View File

@@ -0,0 +1,72 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const doctorCommand = vi.fn();
const dashboardCommand = vi.fn();
const resetCommand = vi.fn();
const uninstallCommand = vi.fn();
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
vi.mock("../../commands/doctor.js", () => ({
doctorCommand,
}));
vi.mock("../../commands/dashboard.js", () => ({
dashboardCommand,
}));
vi.mock("../../commands/reset.js", () => ({
resetCommand,
}));
vi.mock("../../commands/uninstall.js", () => ({
uninstallCommand,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: runtime,
}));
describe("registerMaintenanceCommands doctor action", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("exits with code 0 after successful doctor run", async () => {
doctorCommand.mockResolvedValue(undefined);
const { registerMaintenanceCommands } = await import("./register.maintenance.js");
const program = new Command();
registerMaintenanceCommands(program);
await program.parseAsync(["doctor", "--non-interactive", "--yes"], { from: "user" });
expect(doctorCommand).toHaveBeenCalledWith(
runtime,
expect.objectContaining({
nonInteractive: true,
yes: true,
}),
);
expect(runtime.exit).toHaveBeenCalledWith(0);
});
it("exits with code 1 when doctor fails", async () => {
doctorCommand.mockRejectedValue(new Error("doctor failed"));
const { registerMaintenanceCommands } = await import("./register.maintenance.js");
const program = new Command();
registerMaintenanceCommands(program);
await program.parseAsync(["doctor"], { from: "user" });
expect(runtime.error).toHaveBeenCalledWith("Error: doctor failed");
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.exit).not.toHaveBeenCalledWith(0);
});
});

View File

@@ -36,6 +36,7 @@ export function registerMaintenanceCommands(program: Command) {
generateGatewayToken: Boolean(opts.generateGatewayToken),
deep: Boolean(opts.deep),
});
defaultRuntime.exit(0);
});
});

View File

@@ -113,6 +113,7 @@ 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 { doctorCommand } = await import("../commands/doctor.js");
const { defaultRuntime } = await import("../runtime.js");
const { updateCommand, registerUpdateCli, updateStatusCommand, updateWizardCommand } =
await import("./update-cli.js");
@@ -200,6 +201,7 @@ describe("update-cli", () => {
vi.mocked(resolveNpmChannelTag).mockReset();
vi.mocked(runCommandWithTimeout).mockReset();
vi.mocked(runDaemonRestart).mockReset();
vi.mocked(doctorCommand).mockReset();
vi.mocked(defaultRuntime.log).mockReset();
vi.mocked(defaultRuntime.error).mockReset();
vi.mocked(defaultRuntime.exit).mockReset();
@@ -483,6 +485,41 @@ describe("update-cli", () => {
expect(runDaemonRestart).toHaveBeenCalled();
});
it("updateCommand continues after doctor sub-step and clears update flag", async () => {
const mockResult: UpdateRunResult = {
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
};
const envSnapshot = captureEnv(["OPENCLAW_UPDATE_IN_PROGRESS"]);
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0);
try {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
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);
} finally {
randomSpy.mockRestore();
envSnapshot.restore();
}
});
it("updateCommand skips restart when --no-restart is set", async () => {
const mockResult: UpdateRunResult = {
status: "ok",