| |
| import { Command } from "commander"; |
| import { afterEach, describe, expect, it, vi } from "vitest"; |
| import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; |
| import type { PluginPackageChannel } from "../plugins/manifest.js"; |
| import { mockProcessPlatform } from "../test-utils/vitest-spies.js"; |
| import { |
| resolveChannelsAddChannelFromArgv, |
| resolveChannelsAddOptions, |
| } from "./channels-cli-add-args.js"; |
| import { registerChannelsCli } from "./channels-cli.js"; |
|
|
| const listBundledPackageChannelMetadataMock = vi.hoisted(() => |
| vi.fn<() => readonly PluginPackageChannel[]>(() => []), |
| ); |
| const listRawChannelPluginCatalogEntriesMock = vi.hoisted(() => |
| vi.fn<() => ChannelPluginCatalogEntry[]>(() => []), |
| ); |
| const channelsAddCommandMock = vi.hoisted(() => |
| vi.fn<typeof import("../commands/channels.js").channelsAddCommand>(async () => undefined), |
| ); |
| const channelsLogsCommandMock = vi.hoisted(() => |
| vi.fn(async (_options: { channel?: string }, _runtime: unknown) => undefined), |
| ); |
| const channelsDeadLettersMocks = vi.hoisted(() => ({ |
| channelsDeadLettersListCommand: vi.fn( |
| async (_options: { account?: string }, _runtime: unknown) => undefined, |
| ), |
| channelsDeadLettersResubmitCommand: vi.fn( |
| async (_eventId: string, _options: { account?: string }, _runtime: unknown) => undefined, |
| ), |
| })); |
| const channelsResolveCommandMock = vi.hoisted(() => vi.fn(async () => undefined)); |
| const channelsCapabilitiesCommandMock = vi.hoisted(() => vi.fn(async () => undefined)); |
| const channelsRemoveCommandMock = vi.hoisted(() => vi.fn(async () => undefined)); |
| const channelAuthMocks = vi.hoisted(() => ({ |
| runChannelLogin: vi.fn(async () => undefined), |
| runChannelLogout: vi.fn(async () => undefined), |
| })); |
| const runtimeMock = vi.hoisted(() => ({ |
| log: vi.fn(), |
| error: vi.fn(), |
| exit: vi.fn(), |
| })); |
|
|
| vi.mock("../plugins/bundled-package-channel-metadata.js", () => ({ |
| listBundledPackageChannelMetadata: listBundledPackageChannelMetadataMock, |
| })); |
|
|
| vi.mock("../channels/plugins/catalog.js", () => ({ |
| listRawChannelPluginCatalogEntries: listRawChannelPluginCatalogEntriesMock, |
| })); |
|
|
| vi.mock("../commands/channels.js", () => ({ |
| channelsAddCommand: channelsAddCommandMock, |
| channelsLogsCommand: channelsLogsCommandMock, |
| channelsResolveCommand: channelsResolveCommandMock, |
| channelsCapabilitiesCommand: channelsCapabilitiesCommandMock, |
| channelsRemoveCommand: channelsRemoveCommandMock, |
| })); |
|
|
| vi.mock("../commands/channels/dead-letters.js", () => channelsDeadLettersMocks); |
|
|
| vi.mock("./channel-auth.js", () => channelAuthMocks); |
|
|
| vi.mock("../runtime.js", () => ({ |
| defaultRuntime: runtimeMock, |
| })); |
|
|
| function getChannelAddOptionFlags(program: Command): string[] { |
| const channels = program.commands.find((command) => command.name() === "channels"); |
| const add = channels?.commands.find((command) => command.name() === "add"); |
| return add?.options.map((option) => option.flags) ?? []; |
| } |
|
|
| function getChannelSubcommandNames(program: Command, parentName: string): string[] { |
| const channels = program.commands.find((command) => command.name() === "channels"); |
| const parent = channels?.commands.find((command) => command.name() === parentName); |
| return parent?.commands.map((command) => command.name()) ?? []; |
| } |
|
|
| async function runChannelsAddCli(args: string[]) { |
| const program = new Command().name("openclaw"); |
| await registerChannelsCli(program, ["node", "openclaw", ...args]); |
| await program.parseAsync(args, { from: "user" }); |
| return program; |
| } |
|
|
| describe("registerChannelsCli", () => { |
| const originalArgv = [...process.argv]; |
|
|
| afterEach(() => { |
| process.argv = [...originalArgv]; |
| vi.restoreAllMocks(); |
| vi.clearAllMocks(); |
| }); |
|
|
| it("loads channel-specific add options only for channels add invocations", async () => { |
| process.argv = ["node", "openclaw", "channels"]; |
| await registerChannelsCli(new Command().name("openclaw")); |
|
|
| expect(listBundledPackageChannelMetadataMock).not.toHaveBeenCalled(); |
| expect(listRawChannelPluginCatalogEntriesMock).not.toHaveBeenCalled(); |
|
|
| process.argv = ["node", "openclaw", "channels", "add", "clickclack", "--help"]; |
| await registerChannelsCli(new Command().name("openclaw")); |
|
|
| expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1); |
| expect(listRawChannelPluginCatalogEntriesMock).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("registers dead-letter inspection and resubmission commands", async () => { |
| const program = new Command().name("openclaw"); |
|
|
| await registerChannelsCli(program); |
|
|
| expect(getChannelSubcommandNames(program, "dead-letters")).toEqual(["list", "resubmit"]); |
| }); |
|
|
| it.each( |
| [ |
| { expected: "ops", label: "parent", leafAccount: undefined, parentAccount: "ops" }, |
| { expected: "ops", label: "leaf", leafAccount: "ops", parentAccount: undefined }, |
| { expected: "leaf", label: "leaf precedence", leafAccount: "leaf", parentAccount: "parent" }, |
| { expected: "", label: "blank parent", leafAccount: undefined, parentAccount: "" }, |
| { expected: "", label: "blank leaf", leafAccount: "", parentAccount: undefined }, |
| ].flatMap((accountCase) => [ |
| { ...accountCase, leaf: "list" as const }, |
| { ...accountCase, leaf: "resubmit" as const }, |
| ]), |
| )( |
| "passes a $label --account to dead-letters $leaf", |
| async ({ expected, leaf, leafAccount, parentAccount }) => { |
| const args = ["channels", "dead-letters"]; |
| if (parentAccount !== undefined) { |
| args.push("--account", parentAccount); |
| } |
| args.push(leaf); |
| if (leaf === "resubmit") { |
| args.push("event-1"); |
| } |
| args.push("--channel", "telegram"); |
| if (leafAccount !== undefined) { |
| args.push("--account", leafAccount); |
| } |
| const program = new Command().name("openclaw").enablePositionalOptions().exitOverride(); |
|
|
| await registerChannelsCli(program, ["node", "openclaw", ...args]); |
| await program.parseAsync(args, { from: "user" }); |
|
|
| const options = |
| leaf === "list" |
| ? channelsDeadLettersMocks.channelsDeadLettersListCommand.mock.calls[0]?.[0] |
| : channelsDeadLettersMocks.channelsDeadLettersResubmitCommand.mock.calls[0]?.[1]; |
| expect(options?.account).toBe(expected); |
| }, |
| ); |
|
|
| it.each([ |
| ["omitted", ["channels", "logs"], undefined], |
| ["explicit all", ["channels", "logs", "--channel", "all"], "all"], |
| ])("distinguishes an %s channels logs filter", async (_label, args, expectedChannel) => { |
| const program = new Command().name("openclaw").exitOverride(); |
|
|
| await registerChannelsCli(program, ["node", "openclaw", ...args]); |
| await program.parseAsync(args, { from: "user" }); |
|
|
| const optionsCall = channelsLogsCommandMock.mock.calls[0]; |
| expect(optionsCall).toBeDefined(); |
| const options = optionsCall![0]; |
| expect(options?.channel).toBe(expectedChannel); |
| }); |
|
|
| it.each(["auto", "user", "group", "channel"])( |
| "forwards the supported %s resolve target kind", |
| async (kind) => { |
| const program = new Command().name("openclaw").exitOverride(); |
| const args = ["channels", "resolve", "--kind", kind, "room"]; |
|
|
| await registerChannelsCli(program, ["node", "openclaw", ...args]); |
| await program.parseAsync(args, { from: "user" }); |
|
|
| expect(channelsResolveCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ kind, entries: ["room"] }), |
| runtimeMock, |
| ); |
| }, |
| ); |
|
|
| it.each( |
| ["add", "capabilities", "login", "logout", "remove", "resolve"].flatMap((leaf) => |
| ["parent", "leaf", "both"].map((position) => ({ leaf, position })), |
| ), |
| )("forwards the $position --agent option to channels $leaf", async ({ leaf, position }) => { |
| const parentArgs = |
| position === "leaf" ? [] : ["--agent", position === "both" ? "research" : "ops"]; |
| const leafArgs = position === "parent" ? [] : ["--agent", "ops"]; |
| const args = ["channels", ...parentArgs, leaf, ...leafArgs, "--channel", "telegram"]; |
| if (leaf === "resolve") { |
| args.push("room"); |
| } |
| const program = new Command().name("openclaw").enablePositionalOptions().exitOverride(); |
|
|
| await registerChannelsCli(program, ["node", "openclaw", ...args]); |
| await program.parseAsync(args, { from: "user" }); |
|
|
| const command = { |
| add: channelsAddCommandMock, |
| capabilities: channelsCapabilitiesCommandMock, |
| login: channelAuthMocks.runChannelLogin, |
| logout: channelAuthMocks.runChannelLogout, |
| remove: channelsRemoveCommandMock, |
| resolve: channelsResolveCommandMock, |
| }[leaf]; |
| expect(command).toHaveBeenCalledWith( |
| expect.objectContaining({ agent: "ops" }), |
| runtimeMock, |
| ...(leaf === "add" ? [{ hasFlags: false }] : leaf === "remove" ? [{ hasFlags: true }] : []), |
| ); |
| }); |
|
|
| it("rejects unsupported resolve target kinds before dispatching", async () => { |
| const writeErr = vi.fn(); |
| const program = new Command().name("openclaw").exitOverride().configureOutput({ writeErr }); |
| const args = ["channels", "resolve", "--kind", "person", "room"]; |
|
|
| await registerChannelsCli(program, ["node", "openclaw", ...args]); |
|
|
| await expect(program.parseAsync(args, { from: "user" })).rejects.toMatchObject({ |
| code: "commander.invalidArgument", |
| }); |
| expect(writeErr).toHaveBeenCalledWith( |
| expect.stringContaining("Allowed choices are auto, user, group, channel."), |
| ); |
| expect(channelsResolveCommandMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("registers ClickClack setup options before an external channel plugin is installed", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "clickclack", |
| cliAddOptions: [ |
| { |
| flags: "--code <code>", |
| description: "ClickClack one-time setup code or setup URL", |
| }, |
| { |
| flags: "--workspace <workspace>", |
| description: "ClickClack workspace id, slug, or name", |
| }, |
| ], |
| }, |
| ]); |
| process.argv = ["node", "openclaw", "channels", "add", "clickclack", "--help"]; |
| const program = new Command().name("openclaw"); |
|
|
| await registerChannelsCli(program); |
|
|
| expect(getChannelAddOptionFlags(program)).toContain("--code <code>"); |
| expect(getChannelAddOptionFlags(program)).toContain("--workspace <workspace>"); |
| }); |
|
|
| it("registers setup options from a non-bundled catalog channel", async () => { |
| listRawChannelPluginCatalogEntriesMock.mockReturnValueOnce([ |
| { |
| id: "installed-chat", |
| pluginId: "installed-chat", |
| origin: "global", |
| channel: { |
| id: "installed-chat", |
| label: "Installed Chat", |
| cliAddOptions: [{ flags: "--installed-key <key>", description: "Installed chat key" }], |
| }, |
| meta: { |
| id: "installed-chat", |
| label: "Installed Chat", |
| selectionLabel: "Installed Chat", |
| docsPath: "/channels/installed-chat", |
| blurb: "Installed test channel.", |
| }, |
| install: { npmSpec: "@openclaw/installed-chat" }, |
| }, |
| ]); |
| const program = new Command().name("openclaw"); |
|
|
| await registerChannelsCli(program, [ |
| "node", |
| "openclaw", |
| "channels", |
| "add", |
| "--channel", |
| "installed-chat", |
| "--help", |
| ]); |
|
|
| expect(getChannelAddOptionFlags(program)).toContain("--installed-key <key>"); |
| }); |
|
|
| it("dedupes options by switch identity across differing value placeholders", async () => { |
| listRawChannelPluginCatalogEntriesMock.mockReturnValueOnce([ |
| { |
| id: "chat-a", |
| pluginId: "chat-a", |
| origin: "global", |
| channel: { |
| id: "chat-a", |
| label: "Chat A", |
| cliAddOptions: [ |
| { flags: "--url <url>", description: "Chat A URL" }, |
| { flags: "--token <payload>", description: "Chat A token" }, |
| ], |
| }, |
| meta: { |
| id: "chat-a", |
| label: "Chat A", |
| selectionLabel: "Chat A", |
| docsPath: "/channels/chat-a", |
| blurb: "Chat A test channel.", |
| }, |
| install: { npmSpec: "@openclaw/chat-a" }, |
| }, |
| { |
| id: "chat-b", |
| pluginId: "chat-b", |
| origin: "global", |
| channel: { |
| id: "chat-b", |
| label: "Chat B", |
| cliAddOptions: [{ flags: "--url <server>", description: "Chat B server URL" }], |
| }, |
| meta: { |
| id: "chat-b", |
| label: "Chat B", |
| selectionLabel: "Chat B", |
| docsPath: "/channels/chat-b", |
| blurb: "Chat B test channel.", |
| }, |
| install: { npmSpec: "@openclaw/chat-b" }, |
| }, |
| ]); |
| const program = new Command().name("openclaw"); |
|
|
| |
| |
| |
| await registerChannelsCli(program, [ |
| "node", |
| "openclaw", |
| "channels", |
| "add", |
| "--channel", |
| "chat-a", |
| "--help", |
| ]); |
|
|
| const flags = getChannelAddOptionFlags(program); |
| expect(flags).toContain("--url <url>"); |
| expect(flags).not.toContain("--url <server>"); |
| expect(flags).toContain("--token <payload>"); |
| expect(flags).not.toContain("--token <token>"); |
| }); |
|
|
| it("prefers the selected channel's declaration for a shared switch", async () => { |
| listRawChannelPluginCatalogEntriesMock.mockReturnValueOnce([ |
| { |
| id: "chat-a", |
| pluginId: "chat-a", |
| origin: "global", |
| channel: { |
| id: "chat-a", |
| label: "Chat A", |
| cliAddOptions: [{ flags: "--url <url>", description: "Chat A URL" }], |
| }, |
| meta: { |
| id: "chat-a", |
| label: "Chat A", |
| selectionLabel: "Chat A", |
| docsPath: "/channels/chat-a", |
| blurb: "Chat A test channel.", |
| }, |
| install: { npmSpec: "@openclaw/chat-a" }, |
| }, |
| { |
| id: "chat-b", |
| pluginId: "chat-b", |
| origin: "global", |
| channel: { |
| id: "chat-b", |
| label: "Chat B", |
| cliAddOptions: [{ flags: "--url <server>", description: "Chat B server URL" }], |
| }, |
| meta: { |
| id: "chat-b", |
| label: "Chat B", |
| selectionLabel: "Chat B", |
| docsPath: "/channels/chat-b", |
| blurb: "Chat B test channel.", |
| }, |
| install: { npmSpec: "@openclaw/chat-b" }, |
| }, |
| ]); |
| const program = new Command().name("openclaw"); |
|
|
| await registerChannelsCli(program, [ |
| "node", |
| "openclaw", |
| "channels", |
| "add", |
| "--channel", |
| "chat-b", |
| ]); |
|
|
| const flags = getChannelAddOptionFlags(program); |
| expect(flags).toContain("--url <server>"); |
| expect(flags).not.toContain("--url <url>"); |
| }); |
|
|
| it("projects channel-owned setup fields into Commander options", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "signal", |
| setup: { |
| fields: [ |
| { |
| key: "signalTransport", |
| kind: "choice", |
| choices: ["external-native", "container"], |
| cli: { |
| flags: "--signal-transport <kind>", |
| description: "Signal transport kind", |
| }, |
| }, |
| { |
| key: "autoDiscover", |
| kind: "boolean", |
| cli: { |
| flags: "--auto-discover", |
| negatedFlags: "--no-auto-discover", |
| description: "Discover channels automatically", |
| }, |
| }, |
| ], |
| }, |
| }, |
| ]); |
| process.argv = ["node", "openclaw", "channels", "add", "--channel", "signal", "--help"]; |
| const program = new Command().name("openclaw"); |
|
|
| await registerChannelsCli(program); |
|
|
| expect(getChannelAddOptionFlags(program)).toContain("--signal-transport <kind>"); |
| expect(getChannelAddOptionFlags(program)).toContain("--no-auto-discover"); |
| }); |
|
|
| it("registers only the positional channel setup options", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "telegram", |
| setup: { |
| fields: [ |
| { |
| key: "token", |
| kind: "string", |
| cli: { flags: "--telegram-token <token>", description: "Telegram bot token" }, |
| }, |
| ], |
| }, |
| }, |
| { |
| id: "signal", |
| setup: { |
| fields: [ |
| { |
| key: "signalNumber", |
| kind: "string", |
| cli: { flags: "--signal-number <e164>", description: "Signal account number" }, |
| }, |
| ], |
| }, |
| }, |
| ]); |
| const program = new Command().name("openclaw"); |
|
|
| await registerChannelsCli(program, [ |
| "node", |
| "openclaw", |
| "channels", |
| "add", |
| "telegram", |
| "--help", |
| ]); |
|
|
| expect(getChannelAddOptionFlags(program)).toEqual( |
| expect.arrayContaining(["--telegram-token <token>"]), |
| ); |
| expect(getChannelAddOptionFlags(program)).not.toEqual( |
| expect.arrayContaining(["--signal-number <e164>"]), |
| ); |
| }); |
|
|
| it.each(["--help", "-h"])( |
| "keeps generic add help via %s limited to the shared control envelope", |
| async (helpFlag) => { |
| const program = new Command().name("openclaw"); |
|
|
| await registerChannelsCli(program, ["node", "openclaw", "channels", "add", helpFlag]); |
|
|
| expect(getChannelAddOptionFlags(program)).toEqual([ |
| "--channel <name>", |
| "--agent <id>", |
| "--account <id>", |
| "--name <name>", |
| ]); |
| expect(listBundledPackageChannelMetadataMock).not.toHaveBeenCalled(); |
| }, |
| ); |
|
|
| it("registers add help when channels reuse a long flag with different placeholders", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "example", |
| setup: { |
| fields: [ |
| { |
| key: "apiKey", |
| kind: "string", |
| cli: { flags: "--api-key <token>", description: "Example API key" }, |
| }, |
| { |
| key: "apiKeyJson", |
| kind: "string", |
| cli: { flags: "--api-key <json>", description: "Example API key JSON" }, |
| }, |
| ], |
| }, |
| }, |
| ]); |
| process.argv = ["node", "openclaw", "channels", "add", "example", "--help"]; |
| const program = new Command().name("openclaw"); |
|
|
| await registerChannelsCli(program); |
|
|
| expect( |
| getChannelAddOptionFlags(program).filter((flags) => flags.startsWith("--api-key ")), |
| ).toHaveLength(1); |
| }); |
|
|
| it("forwards only explicitly supplied setup options", () => { |
| const sources = new Map<string, "cli" | "default">([ |
| ["channel", "cli"], |
| ["signalTransport", "cli"], |
| ["useEnv", "default"], |
| ]); |
|
|
| expect( |
| resolveChannelsAddOptions( |
| undefined, |
| { channel: "signal", signalTransport: "container", useEnv: false }, |
| { |
| getOptionValueSource: (key) => sources.get(key), |
| } as Pick<Command, "getOptionValueSource">, |
| ), |
| ).toEqual({ channel: "signal", signalTransport: "container" }); |
| }); |
|
|
| it("preserves selected legacy channel defaults", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "legacy-chat", |
| cliAddOptions: [ |
| { |
| flags: "--legacy-mode <mode>", |
| description: "Legacy transport mode", |
| defaultValue: "socket", |
| }, |
| ], |
| }, |
| ]); |
|
|
| const program = await runChannelsAddCli([ |
| "channels", |
| "add", |
| "--channel", |
| "legacy-chat", |
| "--token", |
| "test-token", |
| ]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| channel: "legacy-chat", |
| legacyMode: "socket", |
| token: "test-token", |
| useEnv: false, |
| }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| expect(getChannelAddOptionFlags(program)).not.toContain("--secret-file <path>"); |
| expect(getChannelAddOptionFlags(program)).not.toContain("--workspace <workspace>"); |
| }); |
|
|
| it("omits empty legacy integer defaults while still rejecting explicit blanks", async () => { |
| const legacyIntChannel = [ |
| { |
| id: "legacy-chat", |
| cliAddOptions: [ |
| { |
| flags: "--limit <n>", |
| description: "Legacy integer limit", |
| defaultValue: "", |
| valueType: "int" as const, |
| }, |
| ], |
| }, |
| ]; |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce(legacyIntChannel); |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce(legacyIntChannel); |
|
|
| await runChannelsAddCli([ |
| "channels", |
| "add", |
| "--channel", |
| "legacy-chat", |
| "--token", |
| "test-token", |
| ]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| channel: "legacy-chat", |
| token: "test-token", |
| }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| const omittedLimitCall = channelsAddCommandMock.mock.calls[0]; |
| expect(omittedLimitCall).toBeDefined(); |
| const omittedLimitOpts = omittedLimitCall![0]; |
| expect(omittedLimitOpts).not.toHaveProperty("limit"); |
|
|
| channelsAddCommandMock.mockClear(); |
| await runChannelsAddCli([ |
| "channels", |
| "add", |
| "--channel", |
| "legacy-chat", |
| "--token", |
| "test-token", |
| "--limit", |
| "", |
| ]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| channel: "legacy-chat", |
| token: "test-token", |
| limit: "", |
| }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }); |
|
|
| it("preserves empty legacy text defaults when the flag is omitted", async () => { |
| const legacyTextChannel = [ |
| { |
| id: "legacy-chat", |
| cliAddOptions: [ |
| { |
| flags: "--note <text>", |
| description: "Legacy optional note", |
| defaultValue: "", |
| }, |
| ], |
| }, |
| ]; |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce(legacyTextChannel); |
|
|
| await runChannelsAddCli([ |
| "channels", |
| "add", |
| "--channel", |
| "legacy-chat", |
| "--token", |
| "test-token", |
| ]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| channel: "legacy-chat", |
| token: "test-token", |
| note: "", |
| }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }); |
|
|
| it("uses caller argv instead of raw process argv for channel-specific add options", async () => { |
| process.argv = ["node", "openclaw", "channels"]; |
|
|
| await registerChannelsCli(new Command().name("openclaw"), [ |
| "node", |
| "openclaw", |
| "channels", |
| "add", |
| "telegram", |
| "--help", |
| ]); |
|
|
| expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("can force channel-specific add options for completion generation", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "matrix", |
| cliAddOptions: [{ flags: "--homeserver <url>", description: "Matrix homeserver URL" }], |
| }, |
| ]); |
| process.argv = ["node", "openclaw", "completion", "--write-state"]; |
| const program = new Command().name("openclaw"); |
|
|
| await registerChannelsCli(program, process.argv, { includeSetupOptions: true }); |
|
|
| expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1); |
| expect(getChannelAddOptionFlags(program)).toContain("--homeserver <url>"); |
| }); |
|
|
| it("normalizes Windows launcher argv before channel-specific add option gating", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "matrix", |
| cliAddOptions: [{ flags: "--homeserver <url>", description: "Matrix homeserver URL" }], |
| }, |
| ]); |
| mockProcessPlatform("win32"); |
| process.argv = [ |
| "C:\\Program Files\\nodejs\\node.exe", |
| "C:\\Program Files\\nodejs\\node.exe", |
| "C:\\repo\\openclaw.js", |
| "channels", |
| "add", |
| "--channel", |
| "matrix", |
| "--homeserver", |
| "https://matrix.example.org", |
| ]; |
| const program = new Command().name("openclaw"); |
|
|
| await registerChannelsCli(program); |
|
|
| expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1); |
| expect(getChannelAddOptionFlags(program)).toContain("--homeserver <url>"); |
| }); |
|
|
| it.each([ |
| ["positional", ["channels", "add", "telegram"]], |
| ["--channel", ["channels", "add", "--channel", "telegram"]], |
| ])("keeps selection-only %s channel adds on the guided path", async (_label, args) => { |
| await runChannelsAddCli(args); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ channel: "telegram" }), |
| runtimeMock, |
| { hasFlags: false }, |
| ); |
| }); |
|
|
| it.each([ |
| ["token", ["--token", "test-token"]], |
| ["token file", ["--token-file", "/tmp/test-token"]], |
| ["environment", ["--use-env"]], |
| ["account", ["--account", "work"]], |
| ])("keeps explicit %s inputs on the direct path", async (_label, extraArgs) => { |
| await runChannelsAddCli(["channels", "add", "--channel", "telegram", ...extraArgs]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ channel: "telegram" }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }); |
|
|
| it("registers selected-channel options before Commander parses option-first argv", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "telegram", |
| setup: { |
| fields: [ |
| { |
| key: "token", |
| kind: "string", |
| cli: { flags: "--token <token>", description: "Telegram bot token" }, |
| }, |
| ], |
| }, |
| }, |
| ]); |
|
|
| await runChannelsAddCli(["channels", "add", "--token", "test-token", "--channel", "telegram"]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ channel: "telegram", token: "test-token" }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }); |
|
|
| it.each([{ parentArgs: ["--agent", "add"] }, { parentArgs: ["--agent=add"] }])( |
| "registers setup flags after parent owner options $parentArgs", |
| async ({ parentArgs }) => { |
| await runChannelsAddCli([ |
| "channels", |
| ...parentArgs, |
| "add", |
| "--channel", |
| "telegram", |
| "--token", |
| "fixture-token", |
| ]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ agent: "add", channel: "telegram", token: "fixture-token" }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }, |
| ); |
|
|
| it("prefers modern contract options when a channel also publishes cliAddOptions", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValue([ |
| { |
| id: "telegram", |
| setup: { |
| fields: [ |
| { |
| key: "token", |
| kind: "string", |
| cli: { flags: "--token <token>", description: "Telegram bot token" }, |
| }, |
| ], |
| }, |
| cliAddOptions: [{ flags: "--legacy-token <token>", description: "Retained legacy switch" }], |
| }, |
| ]); |
|
|
| const program = new Command().name("openclaw"); |
| const argv = ["channels", "add", "telegram", "--token", "test-token"]; |
| await registerChannelsCli(program, ["node", "openclaw", ...argv]); |
| const flags = getChannelAddOptionFlags(program); |
| expect(flags).toContain("--token <token>"); |
| expect(flags).not.toContain("--legacy-token <token>"); |
|
|
| await program.parseAsync(argv, { from: "user" }); |
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ channel: "telegram", token: "test-token" }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }); |
|
|
| it("resolves a positional channel after a value-taking channel option", async () => { |
| const metadata: PluginPackageChannel[] = [ |
| { |
| id: "telegram", |
| setup: { |
| fields: [ |
| { |
| key: "token", |
| kind: "string", |
| cli: { flags: "--token <token>", description: "Telegram bot token" }, |
| }, |
| ], |
| }, |
| }, |
| ]; |
| listBundledPackageChannelMetadataMock |
| .mockReturnValueOnce(metadata) |
| .mockReturnValueOnce(metadata); |
|
|
| await runChannelsAddCli(["channels", "add", "--token", "tok", "telegram"]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ channel: "telegram", token: "tok" }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }); |
|
|
| it("resolves a positional channel after a boolean channel option", async () => { |
| const metadata: PluginPackageChannel[] = [ |
| { |
| id: "telegram", |
| setup: { |
| fields: [ |
| { |
| key: "useEnv", |
| kind: "boolean", |
| cli: { flags: "--use-env", description: "Use Telegram environment credentials" }, |
| }, |
| ], |
| }, |
| }, |
| ]; |
| listBundledPackageChannelMetadataMock |
| .mockReturnValueOnce(metadata) |
| .mockReturnValueOnce(metadata); |
|
|
| await runChannelsAddCli(["channels", "add", "--use-env", "telegram"]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ channel: "telegram", useEnv: true }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }); |
|
|
| it("keeps an all-channel-unknown flag before a positional channel ambiguous", async () => { |
| await expect( |
| resolveChannelsAddChannelFromArgv([ |
| "node", |
| "openclaw", |
| "channels", |
| "add", |
| "--unknown-option", |
| "value", |
| "telegram", |
| ]), |
| ).resolves.toBeUndefined(); |
| }); |
|
|
| it("keeps conflicting all-channel flag arities before a positional channel ambiguous", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "chat-a", |
| setup: { |
| fields: [ |
| { |
| key: "mode", |
| kind: "string", |
| cli: { flags: "--mode <mode>", description: "Chat A mode" }, |
| }, |
| ], |
| }, |
| }, |
| { |
| id: "chat-b", |
| setup: { |
| fields: [ |
| { |
| key: "mode", |
| kind: "boolean", |
| cli: { flags: "--mode", description: "Enable Chat B mode" }, |
| }, |
| ], |
| }, |
| }, |
| ]); |
|
|
| await expect( |
| resolveChannelsAddChannelFromArgv([ |
| "node", |
| "openclaw", |
| "channels", |
| "add", |
| "--mode", |
| "telegram", |
| ]), |
| ).resolves.toBeUndefined(); |
| }); |
|
|
| it("finds a positional channel after shared option-value pairs", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "telegram", |
| setup: { |
| fields: [ |
| { |
| key: "token", |
| kind: "string", |
| cli: { flags: "--token <token>", description: "Telegram bot token" }, |
| }, |
| ], |
| }, |
| }, |
| ]); |
|
|
| await runChannelsAddCli(["channels", "add", "--account", "work", "telegram", "--token", "tok"]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ channel: "telegram", account: "work", token: "tok" }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }); |
|
|
| it("lets an explicit channel override the positional channel during option registration", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "telegram", |
| setup: { |
| fields: [ |
| { |
| key: "token", |
| kind: "string", |
| cli: { flags: "--token <token>", description: "Telegram bot token" }, |
| }, |
| ], |
| }, |
| }, |
| { |
| id: "signal", |
| setup: { |
| fields: [ |
| { |
| key: "signalNumber", |
| kind: "string", |
| cli: { flags: "--signal-number <e164>", description: "Signal account number" }, |
| }, |
| ], |
| }, |
| }, |
| ]); |
|
|
| await runChannelsAddCli([ |
| "channels", |
| "add", |
| "telegram", |
| "--channel", |
| "signal", |
| "--signal-number", |
| "+15555550123", |
| ]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ channel: "signal", signalNumber: "+15555550123" }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }); |
|
|
| it("treats plugin-provided config flags as direct automation inputs", async () => { |
| listBundledPackageChannelMetadataMock.mockReturnValueOnce([ |
| { |
| id: "matrix", |
| cliAddOptions: [{ flags: "--homeserver <url>", description: "Matrix homeserver URL" }], |
| }, |
| ]); |
|
|
| await runChannelsAddCli([ |
| "channels", |
| "add", |
| "--channel", |
| "matrix", |
| "--homeserver", |
| "https://matrix.example.org", |
| ]); |
|
|
| expect(channelsAddCommandMock).toHaveBeenCalledWith( |
| expect.objectContaining({ channel: "matrix", homeserver: "https://matrix.example.org" }), |
| runtimeMock, |
| { hasFlags: true }, |
| ); |
| }); |
| }); |
|
|