fix(cli): render QR JSON failures (#126884)

This commit is contained in:
Peter Steinberger
2026-08-20 17:02:59 -07:00
committed by GitHub
parent a0786cf741
commit a73166b49d
3 changed files with 88 additions and 16 deletions
+51 -10
View File
@@ -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 () => {
+3 -6
View File
@@ -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);
}
});
});
}
+34
View File
@@ -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) => {