fix(cli): route node status hints to stdout (#85780)

This commit is contained in:
Gio Della-Libera
2026-05-24 18:11:38 -07:00
committed by GitHub
parent 3a72a30074
commit 749692ec37
3 changed files with 119 additions and 2 deletions

View File

@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
import { runNodeDaemonStatus } from "./daemon.js";
const mocks = vi.hoisted(() => {
const service = {
label: "Node service",
loadedText: "loaded",
notLoadedText: "not loaded",
stage: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
stop: vi.fn(),
restart: vi.fn(),
isLoaded: vi.fn(async () => true),
readCommand: vi.fn(async () => null),
readRuntime: vi.fn<() => Promise<GatewayServiceRuntime>>(async () => ({ status: "running" })),
};
return {
runtime: {
log: vi.fn<(line: string) => void>(),
error: vi.fn<(line: string) => void>(),
writeJson: vi.fn(),
exit: vi.fn(),
},
service,
};
});
vi.mock("../../runtime.js", () => ({
defaultRuntime: mocks.runtime,
}));
vi.mock("../../daemon/node-service.js", () => ({
resolveNodeService: () => mocks.service,
}));
vi.mock("../../daemon/runtime-hints.js", () => ({
buildPlatformRuntimeLogHints: () => [
"Logs: node service log",
"Restart attempts: node restart log",
],
buildPlatformServiceStartHints: () => ["openclaw node install", "openclaw node start"],
}));
vi.mock("../../terminal/theme.js", async () => {
const actual =
await vi.importActual<typeof import("../../terminal/theme.js")>("../../terminal/theme.js");
return {
...actual,
colorize: (_rich: boolean, _theme: unknown, text: string) => text,
};
});
vi.mock("../daemon-cli/shared.js", async () => {
const actual =
await vi.importActual<typeof import("../daemon-cli/shared.js")>("../daemon-cli/shared.js");
return {
...actual,
createCliStatusTextStyles: () => ({
rich: false,
label: (text: string) => text,
accent: (text: string) => text,
infoText: (text: string) => text,
okText: (text: string) => text,
warnText: (text: string) => text,
errorText: (text: string) => text,
}),
formatRuntimeStatus: (runtime: GatewayServiceRuntime | undefined) => runtime?.status ?? "",
resolveRuntimeStatusColor: () => "",
};
});
describe("runNodeDaemonStatus", () => {
function stdout(): string {
return mocks.runtime.log.mock.calls.map(([line]) => line).join("\n");
}
function stderr(): string {
return mocks.runtime.error.mock.calls.map(([line]) => line).join("\n");
}
beforeEach(() => {
mocks.runtime.log.mockClear();
mocks.runtime.error.mockClear();
mocks.runtime.writeJson.mockClear();
mocks.runtime.exit.mockClear();
mocks.service.isLoaded.mockReset().mockResolvedValue(true);
mocks.service.readCommand.mockReset().mockResolvedValue(null);
mocks.service.readRuntime.mockReset().mockResolvedValue({ status: "running" });
});
it("keeps missing service-unit status on stderr and prints recovery hints on stdout", async () => {
mocks.service.readRuntime.mockResolvedValue({ status: "stopped", missingUnit: true });
await runNodeDaemonStatus();
expect(stderr()).toContain("Service unit not found.");
expect(stdout()).toContain("Logs: node service log");
expect(stdout()).toContain("Restart attempts: node restart log");
expect(stderr()).not.toContain("Logs: node service log");
expect(stderr()).not.toContain("Restart attempts: node restart log");
});
it("keeps stopped status on stderr and prints recovery hints on stdout", async () => {
mocks.service.readRuntime.mockResolvedValue({ status: "stopped" });
await runNodeDaemonStatus();
expect(stderr()).toContain("Service is loaded but not running.");
expect(stdout()).toContain("Logs: node service log");
expect(stdout()).toContain("Restart attempts: node restart log");
expect(stderr()).not.toContain("Logs: node service log");
expect(stderr()).not.toContain("Restart attempts: node restart log");
});
});

View File

@@ -277,7 +277,7 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
if (runtime?.missingUnit) {
defaultRuntime.error(errorText("Service unit not found."));
for (const hint of buildNodeRuntimeHints(hintEnv)) {
defaultRuntime.error(errorText(hint));
defaultRuntime.log(errorText(hint));
}
return;
}
@@ -285,7 +285,7 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
if (runtime?.status === "stopped") {
defaultRuntime.error(errorText("Service is loaded but not running."));
for (const hint of buildNodeRuntimeHints(hintEnv)) {
defaultRuntime.error(errorText(hint));
defaultRuntime.log(errorText(hint));
}
}
}