diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index d3c2553996fa..16ebde9b0ce2 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -3,6 +3,7 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.public.js"; +import { ExpectedCliError } from "../../cli/failure-output.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { channelsCapabilitiesCommand } from "./capabilities.js"; @@ -191,6 +192,77 @@ describe("channelsCapabilitiesCommand", () => { expect(logs).toStrictEqual([JSON.stringify({ channels: [] }, null, 2)]); }); + it.each([ + { + name: "account without a channel", + options: { account: "ghost", json: true }, + message: "--account requires a specific --channel. Run openclaw channels list to choose one.", + discoversChannels: false, + }, + { + name: "account with all channels", + options: { channel: "all", account: "ghost" }, + message: "--account requires a specific --channel. Run openclaw channels list to choose one.", + discoversChannels: false, + }, + { + name: "target without a channel", + options: { target: "channel:1", json: true }, + message: "--target requires a specific --channel. Run openclaw channels list to choose one.", + discoversChannels: false, + }, + { + name: "target with all channels", + options: { channel: "all", target: "channel:1" }, + message: "--target requires a specific --channel. Run openclaw channels list to choose one.", + discoversChannels: false, + }, + { + name: "account before target when both lack a channel", + options: { account: "ghost", target: "channel:1" }, + message: "--account requires a specific --channel. Run openclaw channels list to choose one.", + discoversChannels: false, + }, + { + name: "unknown channel after installable plugin lookup", + options: { channel: "definitely-not-a-channel", json: true }, + message: + 'Unknown channel "definitely-not-a-channel". Run `openclaw channels list --all` to see configured and installable channels.', + discoversChannels: true, + }, + ])("rejects $name before resolving or probing an account", async (testCase) => { + const plugin = buildPlugin({ id: "slack" }); + const listAccountIds = vi.fn(() => ["default"]); + const resolveAccount = vi.fn(() => ({ accountId: "default" })); + const probeAccount = vi.fn(async () => ({ ok: true })); + plugin.config.listAccountIds = listAccountIds; + plugin.config.resolveAccount = resolveAccount; + plugin.status = { probeAccount }; + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]); + + const failure = channelsCapabilitiesCommand(testCase.options, runtime); + + await expect(failure).rejects.toBeInstanceOf(ExpectedCliError); + await expect(failure).rejects.toMatchObject({ + message: testCase.message, + humanOutput: testCase.message, + machineOutput: testCase.message, + }); + expect(logs).toStrictEqual([]); + expect(errors).toStrictEqual([]); + expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledTimes( + testCase.discoversChannels ? 1 : 0, + ); + expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledTimes( + testCase.discoversChannels ? 1 : 0, + ); + expect(listAccountIds).not.toHaveBeenCalled(); + expect(resolveAccount).not.toHaveBeenCalled(); + expect(probeAccount).not.toHaveBeenCalled(); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + expect(mocks.refreshPluginRegistryAfterConfigMutation).not.toHaveBeenCalled(); + }); + it("rejects malformed timeouts before capability probes", async () => { const probeAccount = vi.fn(async () => ({ ok: true })); const plugin = buildPlugin({ diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 1860539d26af..d333309369ee 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -18,6 +18,7 @@ import type { } from "../../channels/plugins/types.public.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { formatUnknownChannelMessage } from "../../cli/error-format.js"; +import { ExpectedCliError } from "../../cli/failure-output.js"; import { parseTimeoutMsWithFallback } from "../../cli/parse-timeout.js"; import { readConfigFileSnapshot } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -314,23 +315,10 @@ export async function channelsCapabilitiesCommand( const rawChannel = normalizeLowercaseStringOrEmpty(opts.channel); const rawTarget = normalizeOptionalString(opts.target) ?? ""; - if (opts.account && (!rawChannel || rawChannel === "all")) { - runtime.error( - danger( - `--account requires a specific --channel. Run ${formatCliCommand("openclaw channels list")} to choose one.`, - ), - ); - runtime.exit(1); - return; - } - if (rawTarget && (!rawChannel || rawChannel === "all")) { - runtime.error( - danger( - `--target requires a specific --channel. Run ${formatCliCommand("openclaw channels list")} to choose one.`, - ), - ); - runtime.exit(1); - return; + if ((!rawChannel || rawChannel === "all") && (opts.account || rawTarget)) { + const option = opts.account ? "--account" : "--target"; + const message = `${option} requires a specific --channel. Run ${formatCliCommand("openclaw channels list")} to choose one.`; + throw new ExpectedCliError({ message, humanOutput: danger(message), machineOutput: message }); } const plugins = listReadOnlyChannelPluginsForConfig(cfg, { @@ -371,9 +359,8 @@ export async function channelsCapabilitiesCommand( ); return; } - runtime.error(danger(formatUnknownChannelMessage({ channel: rawChannel }))); - runtime.exit(1); - return; + const message = formatUnknownChannelMessage({ channel: rawChannel }); + throw new ExpectedCliError({ message, humanOutput: danger(message), machineOutput: message }); } const reports: ChannelCapabilitiesReport[] = []; diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index c76ad27eb314..1622adbe608c 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -331,6 +331,84 @@ describe("cli json stdout contract", () => { ); }); + it.each([ + { + name: "account validation in human mode", + args: ["channels", "capabilities", "--account", "ghost"], + message: "--account requires a specific --channel. Run openclaw channels list to choose one.", + human: true, + }, + { + name: "account validation with JSON before its option", + args: ["channels", "capabilities", "--json", "--account", "ghost"], + message: "--account requires a specific --channel. Run openclaw channels list to choose one.", + }, + { + name: "target validation with JSON after its option and explicit Commander routing", + args: ["channels", "capabilities", "--target", "channel:1", "--json"], + message: "--target requires a specific --channel. Run openclaw channels list to choose one.", + commander: true, + }, + { + name: "unknown channel validation with JSON before its option", + args: ["channels", "capabilities", "--json", "--channel", "definitely-not-a-channel"], + message: + 'Unknown channel "definitely-not-a-channel". Run `openclaw channels list --all` to see configured and installable channels.', + }, + { + name: "account validation through dual-TTY finalization", + args: ["channels", "capabilities", "--account", "ghost", "--json"], + message: "--account requires a specific --channel. Run openclaw channels list to choose one.", + tty: true, + }, + ])( + "renders channels capabilities $name through the canonical failure owner", + async (testCase) => { + await withTempHome( + async (tempHome) => { + const preload = Buffer.from( + [ + 'import net from "node:net";', + 'net.Socket.prototype.connect = function () { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };', + 'globalThis.fetch = async () => { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };', + ...("tty" in testCase + ? [ + 'Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });', + 'Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true });', + ] + : []), + ].join("\n"), + ).toString("base64"); + const result = runBuiltCli(tempHome, testCase.args, { + NODE_OPTIONS: `--import=data:text/javascript;base64,${preload}`, + OPENCLAW_STATE_DIR: path.join(tempHome, "isolated-state"), + OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"), + OPENCLAW_GATEWAY_PORT: "29871", + ...("commander" in testCase ? { OPENCLAW_DISABLE_ROUTE_FIRST: "1" } : {}), + ...("tty" in testCase ? { FORCE_COLOR: "1" } : {}), + }); + + expect(result.status, result.stderr).toBe(1); + if ("human" in testCase) { + expect(result.stdout).toBe(""); + } else { + expect(result.stdout, result.stderr).not.toMatch(/[\u001B\u0007]/u); + expect(JSON.parse(result.stdout)).toEqual({ + ok: false, + error: { type: "cli_error", message: testCase.message }, + }); + } + expect(result.stderr).toContain(testCase.message); + expect(result.stderr).not.toContain("AUTOQA_NETWORK_FORBIDDEN"); + if ("tty" in testCase) { + expect(result.stderr).toContain("\u001B[?25h"); + } + }, + { prefix: "openclaw-channels-capabilities-failure-e2e-" }, + ); + }, + ); + it("returns one canonical document for a command that previously failed on stderr only", async () => { await withTempHome( async (tempHome) => {