Preserve no-restart during update doctor fixes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
giulio-leone 2026-03-23 23:45:45 +01:00 committed by Peter Steinberger
parent 3359dcfdcf
commit d8aada9d45
4 changed files with 147 additions and 1 deletions

View File

@ -1,4 +1,5 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createDoctorPrompter } from "./doctor-prompter.js";
const service = vi.hoisted(() => ({
isLoaded: vi.fn(),
@ -99,6 +100,7 @@ vi.mock("./health.js", () => ({
describe("maybeRepairGatewayDaemon", () => {
let maybeRepairGatewayDaemon: typeof import("./doctor-gateway-daemon-flow.js").maybeRepairGatewayDaemon;
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const originalUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
beforeAll(async () => {
({ maybeRepairGatewayDaemon } = await import("./doctor-gateway-daemon-flow.js"));
@ -121,6 +123,11 @@ describe("maybeRepairGatewayDaemon", () => {
if (originalPlatformDescriptor) {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
if (originalUpdateInProgress === undefined) {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
} else {
process.env.OPENCLAW_UPDATE_IN_PROGRESS = originalUpdateInProgress;
}
});
function setPlatform(platform: NodeJS.Platform) {
@ -191,4 +198,44 @@ describe("maybeRepairGatewayDaemon", () => {
expect(sleep).not.toHaveBeenCalled();
expect(healthCommand).not.toHaveBeenCalled();
});
it("skips gateway install during non-interactive update repairs", async () => {
setPlatform("linux");
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
service.isLoaded.mockResolvedValue(false);
await maybeRepairGatewayDaemon({
cfg: { gateway: {} },
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
prompter: createDoctorPrompter({
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: { repair: true, nonInteractive: true },
}),
options: { deep: false, repair: true, nonInteractive: true },
gatewayDetailsMessage: "details",
healthOk: false,
});
expect(service.install).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
});
it("skips gateway restart during non-interactive update repairs", async () => {
setPlatform("linux");
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
await maybeRepairGatewayDaemon({
cfg: { gateway: {} },
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
prompter: createDoctorPrompter({
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: { repair: true, nonInteractive: true },
}),
options: { deep: false, repair: true, nonInteractive: true },
gatewayDetailsMessage: "details",
healthOk: false,
});
expect(service.restart).not.toHaveBeenCalled();
});
});

View File

@ -11,6 +11,7 @@ vi.mock("@clack/prompts", () => ({
describe("createDoctorPrompter", () => {
const originalStdinIsTTY = process.stdin.isTTY;
const originalUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
afterEach(() => {
vi.resetAllMocks();
@ -18,6 +19,11 @@ describe("createDoctorPrompter", () => {
value: originalStdinIsTTY,
configurable: true,
});
if (originalUpdateInProgress === undefined) {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
} else {
process.env.OPENCLAW_UPDATE_IN_PROGRESS = originalUpdateInProgress;
}
});
it("auto-accepts repairs in non-interactive fix mode", async () => {
@ -86,6 +92,40 @@ describe("createDoctorPrompter", () => {
expect(confirmMock).not.toHaveBeenCalled();
});
it("keeps skip-in-non-interactive prompts disabled during update-mode repairs", async () => {
Object.defineProperty(process.stdin, "isTTY", {
value: false,
configurable: true,
});
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
const prompter = createDoctorPrompter({
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
options: {
repair: true,
nonInteractive: true,
},
});
await expect(
prompter.confirmRepair({
message: "Repair gateway service config?",
initialValue: false,
}),
).resolves.toBe(true);
await expect(
prompter.confirmSkipInNonInteractive({
message: "Restart gateway service now?",
initialValue: true,
}),
).resolves.toBe(false);
expect(confirmMock).not.toHaveBeenCalled();
});
it("auto-accepts aggressive repairs only with --force in non-interactive fix mode", async () => {
Object.defineProperty(process.stdin, "isTTY", {
value: false,

View File

@ -1,4 +1,5 @@
import { confirm, select } from "@clack/prompts";
import { isTruthyEnvValue } from "../infra/env.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js";
import { guardCancel } from "./onboard-helpers.js";
@ -33,6 +34,7 @@ export function createDoctorPrompter(params: {
const shouldForce = params.options.force === true;
const isTty = Boolean(process.stdin.isTTY);
const nonInteractive = requestedNonInteractive || (!isTty && !yes);
const updateInProgress = isTruthyEnvValue(process.env.OPENCLAW_UPDATE_IN_PROGRESS);
const canPrompt = isTty && !yes && !nonInteractive;
const confirmDefault = async (p: Parameters<typeof confirm>[0]) => {
@ -78,7 +80,12 @@ export function createDoctorPrompter(params: {
params.runtime,
);
},
confirmSkipInNonInteractive: confirmDefault,
confirmSkipInNonInteractive: async (p) => {
if (updateInProgress && nonInteractive) {
return false;
}
return confirmDefault(p);
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt || shouldRepair) {
return fallback;

View File

@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
confirm,
createDoctorRuntime,
mockDoctorConfigSnapshot,
serviceInstall,
serviceIsLoaded,
serviceRestart,
} from "./doctor.e2e-harness.js";
let doctorCommand: typeof import("./doctor.js").doctorCommand;
let healthCommand: typeof import("./health.js").healthCommand;
describe("doctor command update-mode repairs", () => {
beforeEach(async () => {
vi.resetModules();
({ doctorCommand } = await import("./doctor.js"));
({ healthCommand } = await import("./health.js"));
});
it("skips gateway installs during non-interactive update repairs", async () => {
mockDoctorConfigSnapshot();
vi.mocked(healthCommand).mockRejectedValueOnce(new Error("gateway closed"));
serviceIsLoaded.mockResolvedValueOnce(false);
serviceInstall.mockClear();
serviceRestart.mockClear();
confirm.mockClear();
await doctorCommand(createDoctorRuntime(), { repair: true, nonInteractive: true });
expect(serviceInstall).not.toHaveBeenCalled();
expect(serviceRestart).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
});
it("skips gateway restarts during non-interactive update repairs", async () => {
mockDoctorConfigSnapshot();
vi.mocked(healthCommand).mockRejectedValueOnce(new Error("gateway closed"));
serviceIsLoaded.mockResolvedValueOnce(true);
serviceRestart.mockClear();
confirm.mockClear();
await doctorCommand(createDoctorRuntime(), { repair: true, nonInteractive: true });
expect(serviceRestart).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
});
});