Skip to content

Commit 0de0c43

Browse files
committed
cli: silence commander tests and rethrow exits
1 parent 0957b7a commit 0de0c43

4 files changed

Lines changed: 84 additions & 32 deletions

File tree

src/cli/program/build-program.test.ts

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import process from "node:process";
2-
import { Command } from "commander";
2+
import { Command, CommanderError } from "commander";
33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { buildProgram } from "./build-program.js";
55
import type { ProgramContext } from "./context.js";
@@ -31,14 +31,26 @@ vi.mock("./program-context.js", () => ({
3131
}));
3232

3333
describe("buildProgram", () => {
34-
function mockProcessExit() {
35-
return vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
36-
throw new Error(`process.exit:${String(code)}`);
37-
}) as typeof process.exit);
34+
function mockProcessOutput() {
35+
vi.spyOn(process.stdout, "write").mockImplementation(
36+
((() => true) as unknown) as typeof process.stdout.write,
37+
);
38+
vi.spyOn(process.stderr, "write").mockImplementation(
39+
((() => true) as unknown) as typeof process.stderr.write,
40+
);
41+
}
42+
43+
async function expectCommanderExit(promise: Promise<unknown>, exitCode: number) {
44+
const error = await promise.catch((err) => err);
45+
46+
expect(error).toBeInstanceOf(CommanderError);
47+
expect(error).toMatchObject({ exitCode });
48+
return error as CommanderError;
3849
}
3950

4051
beforeEach(() => {
4152
vi.clearAllMocks();
53+
mockProcessOutput();
4254
createProgramContextMock.mockReturnValue({
4355
programVersion: "9.9.9-test",
4456
channelOptions: ["telegram"],
@@ -72,62 +84,57 @@ describe("buildProgram", () => {
7284

7385
it("sets exitCode to 1 on argument errors (fixes #60905)", async () => {
7486
const program = buildProgram();
75-
const exitSpy = mockProcessExit();
7687
program.command("test").description("Test command");
7788

78-
await expect(program.parseAsync(["test", "unexpected-arg"], { from: "user" })).rejects.toThrow(
79-
"process.exit:1",
89+
const error = await expectCommanderExit(
90+
program.parseAsync(["test", "unexpected-arg"], { from: "user" }),
91+
1,
8092
);
81-
expect(exitSpy).toHaveBeenCalledWith(1);
93+
94+
expect(error.code).toBe("commander.excessArguments");
8295
expect(process.exitCode).toBe(1);
8396
});
8497

8598
it("does not run the command action after an argument error", async () => {
8699
const program = buildProgram();
87-
const exitSpy = mockProcessExit();
88100
const actionSpy = vi.fn();
89101
program.command("test").action(actionSpy);
90102

91-
await expect(program.parseAsync(["test", "unexpected-arg"], { from: "user" })).rejects.toThrow(
92-
"process.exit:1",
93-
);
94-
expect(exitSpy).toHaveBeenCalledWith(1);
103+
await expectCommanderExit(program.parseAsync(["test", "unexpected-arg"], { from: "user" }), 1);
104+
95105
expect(actionSpy).not.toHaveBeenCalled();
96106
});
97107

98108
it("preserves exitCode 0 for help display", async () => {
99109
const program = buildProgram();
100-
const exitSpy = mockProcessExit();
101110
program.command("test").description("Test command");
102111

103-
await expect(program.parseAsync(["--help"], { from: "user" })).rejects.toThrow(
104-
"process.exit:0",
105-
);
106-
expect(exitSpy).toHaveBeenCalledWith(0);
112+
const error = await expectCommanderExit(program.parseAsync(["--help"], { from: "user" }), 0);
113+
114+
expect(error.code).toBe("commander.helpDisplayed");
107115
expect(process.exitCode).toBe(0);
108116
});
109117

110118
it("preserves exitCode 0 for version display", async () => {
111119
const program = buildProgram();
112-
const exitSpy = mockProcessExit();
113120
program.version("1.0.0");
114121

115-
await expect(program.parseAsync(["--version"], { from: "user" })).rejects.toThrow(
116-
"process.exit:0",
117-
);
118-
expect(exitSpy).toHaveBeenCalledWith(0);
122+
const error = await expectCommanderExit(program.parseAsync(["--version"], { from: "user" }), 0);
123+
124+
expect(error.code).toBe("commander.version");
119125
expect(process.exitCode).toBe(0);
120126
});
121127

122128
it("preserves non-zero exitCode for help error flows", async () => {
123129
const program = buildProgram();
124-
const exitSpy = mockProcessExit();
125130
program.helpCommand("help [command]");
126131

127-
await expect(program.parseAsync(["help", "missing"], { from: "user" })).rejects.toThrow(
128-
"process.exit:1",
132+
const error = await expectCommanderExit(
133+
program.parseAsync(["help", "missing"], { from: "user" }),
134+
1,
129135
);
130-
expect(exitSpy).toHaveBeenCalledWith(1);
136+
137+
expect(error.code).toBe("commander.help");
131138
expect(process.exitCode).toBe(1);
132139
});
133140
});

src/cli/program/build-program.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import process from "node:process";
12
import { Command } from "commander";
23
import { registerProgramCommands } from "./command-registry.js";
34
import { createProgramContext } from "./context.js";
@@ -8,11 +9,12 @@ import { setProgramContext } from "./program-context.js";
89
export function buildProgram() {
910
const program = new Command();
1011
program.enablePositionalOptions();
11-
// Ensure Commander.js sets exit code on argument errors (fixes #60905)
12-
// Without this, commands like `openclaw sessions list` would print an error
13-
// but exit with code 0, breaking scripts and monitoring tools.
12+
// Preserve Commander-computed exit codes while still aborting parse flow.
13+
// Without this, commands like `openclaw sessions list` can print an error
14+
// but still report success when exits are intercepted.
1415
program.exitOverride((err) => {
1516
process.exitCode = typeof err.exitCode === "number" ? err.exitCode : 1;
17+
throw err;
1618
});
1719
const ctx = createProgramContext();
1820
const argv = process.argv;

src/cli/run-main.exit.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import process from "node:process";
2+
import { CommanderError } from "commander";
23
import { beforeEach, describe, expect, it, vi } from "vitest";
34
import { runCli } from "./run-main.js";
45

@@ -14,6 +15,9 @@ const startTaskRegistryMaintenanceMock = vi.hoisted(() => vi.fn());
1415
const outputRootHelpMock = vi.hoisted(() => vi.fn());
1516
const outputPrecomputedRootHelpTextMock = vi.hoisted(() => vi.fn(() => false));
1617
const buildProgramMock = vi.hoisted(() => vi.fn());
18+
const getProgramContextMock = vi.hoisted(() => vi.fn(() => null));
19+
const registerCoreCliByNameMock = vi.hoisted(() => vi.fn());
20+
const registerSubCliByNameMock = vi.hoisted(() => vi.fn());
1721
const maybeRunCliInContainerMock = vi.hoisted(() =>
1822
vi.fn<
1923
(argv: string[]) => { handled: true; exitCode: number } | { handled: false; argv: string[] }
@@ -73,11 +77,24 @@ vi.mock("./program.js", () => ({
7377
buildProgram: buildProgramMock,
7478
}));
7579

80+
vi.mock("./program/program-context.js", () => ({
81+
getProgramContext: getProgramContextMock,
82+
}));
83+
84+
vi.mock("./program/command-registry.js", () => ({
85+
registerCoreCliByName: registerCoreCliByNameMock,
86+
}));
87+
88+
vi.mock("./program/register.subclis.js", () => ({
89+
registerSubCliByName: registerSubCliByNameMock,
90+
}));
91+
7692
describe("runCli exit behavior", () => {
7793
beforeEach(() => {
7894
vi.clearAllMocks();
7995
hasMemoryRuntimeMock.mockReturnValue(false);
8096
outputPrecomputedRootHelpTextMock.mockReturnValue(false);
97+
getProgramContextMock.mockReturnValue(null);
8198
});
8299

83100
it("does not force process.exit after successful routed command", async () => {
@@ -149,4 +166,22 @@ describe("runCli exit behavior", () => {
149166
expect(process.exitCode).toBe(7);
150167
process.exitCode = exitCode;
151168
});
169+
170+
it("swallows Commander parse exits after recording the exit code", async () => {
171+
const exitCode = process.exitCode;
172+
buildProgramMock.mockReturnValueOnce({
173+
commands: [{ name: () => "status" }],
174+
parseAsync: vi
175+
.fn()
176+
.mockRejectedValueOnce(
177+
new CommanderError(1, "commander.excessArguments", "too many arguments for 'status'"),
178+
),
179+
});
180+
181+
await expect(runCli(["node", "openclaw", "status"])).resolves.toBeUndefined();
182+
183+
expect(registerSubCliByNameMock).toHaveBeenCalledWith(expect.anything(), "status");
184+
expect(process.exitCode).toBe(1);
185+
process.exitCode = exitCode;
186+
});
152187
});

src/cli/run-main.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
22
import path from "node:path";
33
import process from "node:process";
44
import { fileURLToPath } from "node:url";
5+
import { CommanderError } from "commander";
56
import type { OpenClawConfig } from "../config/config.js";
67
import { resolveStateDir } from "../config/paths.js";
78
import { normalizeEnv } from "../infra/env.js";
@@ -231,7 +232,14 @@ export async function runCli(argv: string[] = process.argv) {
231232
}
232233
}
233234

234-
await program.parseAsync(parseArgv);
235+
try {
236+
await program.parseAsync(parseArgv);
237+
} catch (error) {
238+
if (!(error instanceof CommanderError)) {
239+
throw error;
240+
}
241+
process.exitCode = error.exitCode;
242+
}
235243
} finally {
236244
await closeCliMemoryManagers();
237245
}

0 commit comments

Comments
 (0)