mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(cli): render channel capability JSON failures (#128155)
This commit is contained in:
committed by
GitHub
parent
a0bf9cf693
commit
0a70356c55
@@ -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({
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user