diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 05e937e31279..7b25b1e07b03 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -7,6 +7,8 @@ import { PAIRING_SETUP_BOOTSTRAP_PROFILE, VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, } from "../shared/device-bootstrap-profile.js"; +import { formatCliJsonFailure } from "./failure-output.js"; +import { runCliWithExitFinalization } from "./one-shot-exit.js"; import { createCliRuntimeCapture, mockRuntimeModule } from "./test-runtime-capture.js"; const mocks = vi.hoisted(() => ({ @@ -264,17 +266,56 @@ describe("registerQrCli", () => { ); }); - it("rejects combining --limited with --voice-node", async () => { - loadConfig.mockReturnValue({ - gateway: { - bind: "custom", - customBindHost: "127.0.0.1", - auth: { mode: "token", token: "tok" }, - }, - }); + const conflictingQrOptions = [ + { + name: "access profiles", + args: ["--limited", "--voice-node"], + message: "Use either --limited or --voice-node, not both.", + }, + { + name: "authentication overrides", + args: ["--token", "test-token", "--password", "test-password"], + message: "Use either --token or --password, not both.", + }, + ]; - await expect(runQr(["--setup-code-only", "--limited", "--voice-node"])).rejects.toThrow("exit"); - expect(runtime.error).toHaveBeenCalledWith("Use either --limited or --voice-node, not both."); + it.each(conflictingQrOptions)("rejects conflicting $name in human mode", async (testCase) => { + await expect(runQr(["--setup-code-only", ...testCase.args])).rejects.toThrow("exit"); + + expect(runtimeError).toHaveBeenCalledExactlyOnceWith(testCase.message); + expect(runtimeExit).toHaveBeenCalledExactlyOnceWith(1); + expect(loadConfig).not.toHaveBeenCalled(); + }); + + it.each(conflictingQrOptions)("renders conflicting $name as canonical JSON", async (testCase) => { + const args = ["--json", ...testCase.args]; + const originalArgv = process.argv; + let exitCode: number | undefined; + process.argv = ["node", "openclaw", "qr", ...args]; + try { + await runCliWithExitFinalization({ + runtime, + run: async () => await runQr(args), + onError: (error) => { + runtime.writeJson(formatCliJsonFailure(error)); + exitCode = 1; + }, + }); + } finally { + process.argv = originalArgv; + } + + const expected = { + ok: false, + error: { type: "cli_error", message: testCase.message }, + }; + expect(exitCode).toBe(1); + expect(runtime.writeJson).toHaveBeenCalledExactlyOnceWith(expected); + expect(runtimeLog).toHaveBeenCalledOnce(); + expect(JSON.parse(readRuntimeCallText(runtimeLog.mock.calls[0]))).toEqual(expected); + expect(runtimeError).not.toHaveBeenCalled(); + expect(runtimeExit).not.toHaveBeenCalled(); + expect(loadConfig).not.toHaveBeenCalled(); }); it("renders ASCII QR by default", async () => { diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index 88390ccdc9ba..6fecffc57f7f 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -7,7 +7,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { trimToUndefined } from "../gateway/credentials.js"; import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js"; -import { formatErrorMessage } from "../infra/errors.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; import { renderQrTerminal } from "../media/qr-terminal.ts"; import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js"; @@ -17,6 +16,7 @@ import { PAIRING_SETUP_BOOTSTRAP_PROFILE, VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, } from "../shared/device-bootstrap-profile.js"; +import { runCommandWithRuntime } from "./cli-utils.js"; import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js"; import { getQrRemoteCommandSecretTargetIds } from "./command-secret-targets.js"; @@ -125,7 +125,7 @@ export function registerQrCli(program: Command) { .option("--no-ascii", "Skip ASCII QR rendering") .option("--json", "Output JSON", false) .action(async (opts: QrCliOptions) => { - try { + await runCommandWithRuntime(defaultRuntime, async () => { if (opts.token && opts.password) { throw new Error("Use either --token or --password, not both."); } @@ -284,9 +284,6 @@ export function registerQrCli(program: Command) { ); defaultRuntime.log(lines.join("\n")); - } catch (err) { - defaultRuntime.error(formatErrorMessage(err)); - defaultRuntime.exit(1); - } + }); }); } diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index 6b300ac98567..a08e1cad0066 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -295,6 +295,40 @@ describe("cli json stdout contract", () => { ); }); + it.each([ + { name: "qr", command: ["qr"] }, + { name: "clawbot qr", command: ["clawbot", "qr"] }, + ])("renders conflicting $name options as one canonical JSON document", async (testCase) => { + await withTempHome( + async (tempHome) => { + for (const conflict of [ + { + args: ["--limited", "--voice-node"], + message: "Use either --limited or --voice-node, not both.", + }, + { + args: ["--token", "test-token", "--password", "test-password"], + message: "Use either --token or --password, not both.", + }, + ]) { + const result = runBuiltCli(tempHome, [...testCase.command, "--json", ...conflict.args], { + OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"), + OPENCLAW_STATE_DIR: path.join(tempHome, "isolated-state"), + }); + + expect(result.status, result.stderr).toBe(1); + expect(JSON.parse(result.stdout)).toEqual({ + ok: false, + error: { type: "cli_error", message: conflict.message }, + }); + expect(result.stdout).not.toContain("[openclaw]"); + expect(result.stderr).toContain(conflict.message); + } + }, + { prefix: "openclaw-qr-json-failure-e2e-" }, + ); + }); + it("returns one canonical document when docs search fails", async () => { await withTempHome( async (tempHome) => {