Download src/flows/channel-setup.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 74.4 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/flows/channel-setup.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/flows/channel-setup.test.ts
-
curl -L -o channel-setup.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/flows/channel-setup.test.ts
74.4 kB
| // Channel setup tests cover setup flow prompts and config output. | |
| import { expectDefined } from "@openclaw/normalization-core/expect"; | |
| import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; | |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; | |
| import { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; | |
| import type { ChannelOnboardingPostWriteHook } from "../channels/plugins/setup-wizard-types.js"; | |
| import { createTestRuntime } from "../commands/test-runtime-config-helpers.js"; | |
| import type { OpenClawConfig } from "../config/types.openclaw.js"; | |
| import { withPluginMetadataSnapshotScope } from "../plugins/current-plugin-metadata-snapshot.js"; | |
| import { createPluginCache, getPluginCache, withPluginCache } from "../plugins/plugin-cache.js"; | |
| import { PluginInstance } from "../plugins/plugin-instance.js"; | |
| import { hasPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js"; | |
| import * as pluginMetadata from "../plugins/plugin-metadata-snapshot.js"; | |
| import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; | |
| import { createInstallAccountPolicyFixture } from "../plugins/test-helpers/install-account-policy.test-support.js"; | |
| import { resolveChannelAccountEntry } from "../routing/account-lookup.js"; | |
| import { WizardCancelledError, WizardNavigationError } from "../wizard/prompts.js"; | |
| import { | |
| makeCatalogEntry, | |
| makeChannelSetupEntries, | |
| makeMeta, | |
| } from "./channel-setup.test-helpers.js"; | |
| type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").ChannelSetupPlugin; | |
| type ChannelSetupWizardAdapter = | |
| import("../channels/plugins/setup-wizard-types.js").ChannelSetupWizardAdapter; | |
| type ResolveChannelSetupEntries = | |
| typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; | |
| type CollectChannelStatus = typeof import("./channel-setup.status.js").collectChannelStatus; | |
| type EnsureChannelSetupPluginInstalled = | |
| typeof import("../commands/channel-setup/plugin-install.js").ensureChannelSetupPluginInstalled; | |
| type LoadChannelSetupPluginRegistrySnapshotForChannel = | |
| typeof import("../commands/channel-setup/plugin-install.js").loadChannelSetupPluginRegistrySnapshotForChannel; | |
| type PluginRegistry = ReturnType<LoadChannelSetupPluginRegistrySnapshotForChannel>; | |
| function makeSetupPlugin(params: { | |
| id: string; | |
| label: string; | |
| setupWizard?: ChannelSetupPlugin["setupWizard"]; | |
| }): ChannelSetupPlugin { | |
| return { | |
| id: params.id as ChannelSetupPlugin["id"], | |
| meta: makeMeta(params.id, params.label), | |
| capabilities: { chatTypes: [] }, | |
| config: { | |
| resolveAccount: vi.fn(() => ({})), | |
| } as unknown as ChannelSetupPlugin["config"], | |
| ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}), | |
| }; | |
| } | |
| function makeExternalChatSetupPlugin( | |
| setupWizard: Pick<ChannelSetupWizardAdapter, "configure"> & Partial<ChannelSetupWizardAdapter>, | |
| ): ChannelSetupPlugin { | |
| return makeSetupPlugin({ | |
| id: "external-chat", | |
| label: "External Chat", | |
| setupWizard: { | |
| channel: "external-chat", | |
| getStatus: vi.fn(async () => ({ | |
| channel: "external-chat", | |
| configured: false, | |
| statusLines: [], | |
| })), | |
| ...setupWizard, | |
| }, | |
| }); | |
| } | |
| function externalChatSetupEntries(overrides: Partial<ReturnType<ResolveChannelSetupEntries>> = {}) { | |
| return makeChannelSetupEntries({ | |
| entries: [ | |
| { | |
| id: "external-chat", | |
| meta: makeMeta("external-chat", "External Chat"), | |
| }, | |
| ], | |
| ...overrides, | |
| }); | |
| } | |
| function makePluginRegistry(overrides: Partial<PluginRegistry> = {}): PluginRegistry { | |
| const registry = createEmptyPluginRegistry(); | |
| for (const key of Object.keys(overrides) as Array<keyof PluginRegistry>) { | |
| const value = overrides[key]; | |
| if (value !== undefined) { | |
| Object.assign(registry, { [key]: value }); | |
| } | |
| } | |
| return registry; | |
| } | |
| function callArg<T>(mock: { mock: { calls: unknown[][] } }, index = 0, _type?: (value: T) => T): T { | |
| const call = expectDefined(mock.mock.calls[index], `mock call ${index}`); | |
| return call[0] as T; | |
| } | |
| function expectExternalCatalogInstallCall(index = 0) { | |
| const input = callArg<{ | |
| entry?: { id?: string; install?: { npmSpec?: string } }; | |
| autoConfirmSingleSource?: boolean; | |
| }>(ensureChannelSetupPluginInstalled, index); | |
| expect(input.entry?.id).toBe("external-chat"); | |
| expect(input.entry?.install?.npmSpec).toBe("@vendor/external-chat-plugin"); | |
| expect(input.autoConfirmSingleSource).toBe(true); | |
| } | |
| const resolveAgentWorkspaceDir = vi.hoisted(() => | |
| vi.fn((_cfg?: unknown, _agentId?: unknown) => "/tmp/openclaw-workspace"), | |
| ); | |
| const resolveDefaultAgentId = vi.hoisted(() => vi.fn((_cfg?: unknown) => "default")); | |
| const listTrustedChannelPluginCatalogEntries = vi.hoisted(() => | |
| vi.fn((_params?: unknown): unknown[] => []), | |
| ); | |
| const getTrustedChannelPluginCatalogEntry = vi.hoisted(() => | |
| vi.fn((_channelId: string, _params?: unknown): unknown => undefined), | |
| ); | |
| const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => undefined)); | |
| const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); | |
| const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); | |
| const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() => | |
| vi.fn((_params: Parameters<LoadChannelSetupPluginRegistrySnapshotForChannel>[0]) => | |
| makePluginRegistry(), | |
| ), | |
| ); | |
| const ensureChannelSetupPluginInstalled = vi.hoisted(() => | |
| vi.fn(async ({ cfg, entry }: Parameters<EnsureChannelSetupPluginInstalled>[0]) => ({ | |
| cfg, | |
| installed: true, | |
| pluginId: entry?.pluginId, | |
| status: "installed", | |
| })), | |
| ); | |
| const resolveChannelSetupEntries = vi.hoisted(() => | |
| vi.fn( | |
| ( | |
| _params: Parameters<ResolveChannelSetupEntries>[0], | |
| ): ReturnType<ResolveChannelSetupEntries> => ({ | |
| entries: [], | |
| installedCatalogEntries: [], | |
| installableCatalogEntries: [], | |
| installedCatalogById: new Map(), | |
| installableCatalogById: new Map(), | |
| }), | |
| ), | |
| ); | |
| const collectChannelStatus = vi.hoisted(() => | |
| vi.fn(async (_params: Parameters<CollectChannelStatus>[0]) => ({ | |
| installedPlugins: [], | |
| catalogEntries: [], | |
| installedCatalogEntries: [], | |
| statusByChannel: new Map(), | |
| statusLines: [], | |
| })), | |
| ); | |
| const resolveChannelSetupWorkspaceDir = vi.hoisted(() => | |
| vi.fn((_cfg?: unknown) => "/tmp/openclaw-workspace"), | |
| ); | |
| const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channel?: unknown) => true)); | |
| vi.mock("../agents/agent-scope.js", () => ({ | |
| resolveAgentWorkspaceDir: (cfg?: unknown, agentId?: unknown) => | |
| resolveAgentWorkspaceDir(cfg, agentId), | |
| resolveDefaultAgentId: (cfg?: unknown) => resolveDefaultAgentId(cfg), | |
| })); | |
| vi.mock("../channels/plugins/setup-registry.js", () => ({ | |
| getChannelSetupPlugin: (channel?: unknown) => getChannelSetupPlugin(channel), | |
| listActiveChannelSetupPlugins: () => listActiveChannelSetupPlugins(), | |
| listChannelSetupPlugins: () => listChannelSetupPlugins(), | |
| })); | |
| vi.mock("../channels/registry.js", () => ({ | |
| getChatChannelMeta: (channelId: string) => ({ id: channelId, label: channelId }), | |
| listChatChannels: () => [], | |
| normalizeAnyChannelId: (channelId?: unknown) => | |
| typeof channelId === "string" ? channelId.trim().toLowerCase() || null : null, | |
| normalizeChatChannelId: (channelId?: unknown) => | |
| typeof channelId === "string" ? channelId.trim().toLowerCase() || null : null, | |
| })); | |
| vi.mock("../commands/channel-setup/discovery.js", () => ({ | |
| resolveChannelSetupEntries: (params: Parameters<ResolveChannelSetupEntries>[0]) => | |
| resolveChannelSetupEntries(params), | |
| shouldShowChannelInSetup: () => true, | |
| })); | |
| vi.mock("../commands/channel-setup/plugin-install.js", () => ({ | |
| ensureChannelSetupPluginInstalled: (params: Parameters<EnsureChannelSetupPluginInstalled>[0]) => | |
| ensureChannelSetupPluginInstalled(params), | |
| loadChannelSetupPluginRegistrySnapshotForChannel: ( | |
| params: Parameters<LoadChannelSetupPluginRegistrySnapshotForChannel>[0], | |
| ) => loadChannelSetupPluginRegistrySnapshotForChannel(params), | |
| })); | |
| vi.mock("../commands/channel-setup/registry.js", () => ({ | |
| resolveChannelSetupWizardAdapterForPlugin: (plugin?: { setupWizard?: unknown }) => | |
| plugin?.setupWizard, | |
| })); | |
| vi.mock("../commands/channel-setup/trusted-catalog.js", () => ({ | |
| listTrustedChannelPluginCatalogEntries: (params?: unknown) => | |
| listTrustedChannelPluginCatalogEntries(params), | |
| getTrustedChannelPluginCatalogEntry: (channelId: string, params?: unknown) => | |
| getTrustedChannelPluginCatalogEntry(channelId, params), | |
| })); | |
| vi.mock("../config/channel-configured.js", () => ({ | |
| isChannelConfigured: (cfg?: unknown, channel?: unknown) => isChannelConfigured(cfg, channel), | |
| })); | |
| vi.mock("./channel-setup.prompts.js", () => ({ | |
| maybeConfigureCommandOwner: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => cfg), | |
| maybeConfigureDmPolicies: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => cfg), | |
| promptConfiguredAction: vi.fn(), | |
| promptRemovalAccountId: vi.fn(), | |
| formatAccountLabel: vi.fn(), | |
| })); | |
| vi.mock("./channel-setup.status.js", () => ({ | |
| collectChannelStatus: (params: Parameters<CollectChannelStatus>[0]) => | |
| collectChannelStatus(params), | |
| findBundledSourceForCatalogChannel: vi.fn(() => undefined), | |
| noteChannelPrimer: vi.fn(), | |
| noteChannelStatus: vi.fn(), | |
| resolveCatalogChannelSelectionHint: vi.fn(() => "download from <npm>"), | |
| resolveChannelSelectionNoteLines: vi.fn(() => []), | |
| resolveChannelSetupSelectionContributions: vi.fn(() => []), | |
| resolveChannelSetupWorkspaceDir: (cfg?: unknown) => resolveChannelSetupWorkspaceDir(cfg), | |
| resolveQuickstartDefault: vi.fn(() => undefined), | |
| })); | |
| import { setupChannels } from "./channel-setup.js"; | |
| const DEFERRED_CHANNEL_SETUP_OPTIONS = { | |
| deferStatusUntilSelection: true, | |
| skipConfirm: true, | |
| skipDmPolicyPrompt: true, | |
| } satisfies NonNullable<Parameters<typeof setupChannels>[3]>; | |
| const QUICKSTART_CHANNEL_SETUP_OPTIONS = { | |
| quickstartDefaults: true, | |
| skipConfirm: true, | |
| skipDmPolicyPrompt: true, | |
| } satisfies NonNullable<Parameters<typeof setupChannels>[3]>; | |
| const TARGETED_CHANNEL_SETUP_OPTIONS = { | |
| initialSelection: ["external-chat"], | |
| finishAfterInitialSelection: true, | |
| deferStatusUntilSelection: true, | |
| skipDmPolicyPrompt: true, | |
| } satisfies NonNullable<Parameters<typeof setupChannels>[3]>; | |
| function runChannelSetup( | |
| cfg: OpenClawConfig, | |
| prompter: Record<string, unknown>, | |
| options?: Parameters<typeof setupChannels>[3], | |
| ) { | |
| return setupChannels( | |
| cfg, | |
| {} as never, | |
| { | |
| confirm: vi.fn(async () => true), | |
| note: vi.fn(async () => undefined), | |
| ...prompter, | |
| } as never, | |
| options, | |
| ); | |
| } | |
| describe("setupChannels workspace shadow exclusion", () => { | |
| const tempDirs = useAutoCleanupTempDirTracker(afterEach); | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace"); | |
| resolveDefaultAgentId.mockReturnValue("default"); | |
| resolveChannelSetupWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace"); | |
| listTrustedChannelPluginCatalogEntries.mockReturnValue([ | |
| { | |
| id: "external-chat", | |
| pluginId: "@vendor/external-chat-plugin", | |
| origin: "bundled", | |
| }, | |
| ]); | |
| getTrustedChannelPluginCatalogEntry.mockReturnValue(undefined); | |
| getChannelSetupPlugin.mockReturnValue(undefined); | |
| listActiveChannelSetupPlugins.mockReturnValue([]); | |
| listChannelSetupPlugins.mockReturnValue([]); | |
| loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); | |
| ensureChannelSetupPluginInstalled.mockImplementation(async ({ cfg, entry }) => ({ | |
| cfg, | |
| installed: true, | |
| pluginId: entry?.pluginId, | |
| status: "installed", | |
| })); | |
| resolveChannelSetupEntries.mockReturnValue(makeChannelSetupEntries()); | |
| collectChannelStatus.mockResolvedValue({ | |
| installedPlugins: [], | |
| catalogEntries: [], | |
| installedCatalogEntries: [], | |
| statusByChannel: new Map(), | |
| statusLines: [], | |
| }); | |
| isChannelConfigured.mockReturnValue(true); | |
| }); | |
| it("preloads configured external plugins from the trusted catalog boundary", async () => { | |
| await runChannelSetup({}, { confirm: vi.fn(async () => false) }); | |
| const trustedInput = callArg<{ cfg?: unknown; workspaceDir?: string }>( | |
| listTrustedChannelPluginCatalogEntries, | |
| ); | |
| expect(trustedInput.cfg).toEqual({}); | |
| expect(trustedInput.workspaceDir).toBe("/tmp/openclaw-workspace"); | |
| const registryInput = callArg<{ | |
| channel?: string; | |
| pluginId?: string; | |
| workspaceDir?: string; | |
| }>(loadChannelSetupPluginRegistrySnapshotForChannel); | |
| expect(registryInput.channel).toBe("external-chat"); | |
| expect(registryInput.pluginId).toBe("@vendor/external-chat-plugin"); | |
| expect(registryInput.workspaceDir).toBe("/tmp/openclaw-workspace"); | |
| }); | |
| it("resolves plugin discovery through the channel setup workspace owner", async () => { | |
| const cfg = { | |
| agents: { | |
| ownership: "explicit", | |
| defaults: { systemAgent: { agentId: "main" } }, | |
| entries: { main: {}, helper: {}, third: {} }, | |
| }, | |
| } as unknown as OpenClawConfig; | |
| resolveDefaultAgentId.mockImplementationOnce(() => { | |
| throw new Error("legacy default resolver must not own channel setup"); | |
| }); | |
| await runChannelSetup(cfg, { confirm: vi.fn(async () => false) }); | |
| expect(resolveChannelSetupWorkspaceDir).toHaveBeenCalledWith(cfg); | |
| }); | |
| it("does not load or probe channels when optional deferred setup is declined", async () => { | |
| const cfg = { agents: { ownership: "explicit" as const, entries: { alpha: {}, beta: {} } } }; | |
| const result = await runChannelSetup( | |
| cfg, | |
| { confirm: vi.fn(async () => false) }, | |
| { workspaceDir: "/tmp/beta-workspace", deferStatusUntilSelection: true }, | |
| ); | |
| expect(result).toBe(cfg); | |
| expect(collectChannelStatus).not.toHaveBeenCalled(); | |
| expect(listTrustedChannelPluginCatalogEntries).not.toHaveBeenCalled(); | |
| expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); | |
| }); | |
| it("keeps trusted workspace overrides eligible during preload", async () => { | |
| listTrustedChannelPluginCatalogEntries.mockReturnValue([ | |
| { id: "external-chat", pluginId: "trusted-external-chat-shadow", origin: "workspace" }, | |
| ]); | |
| await runChannelSetup( | |
| { | |
| plugins: { | |
| enabled: true, | |
| allow: ["trusted-external-chat-shadow"], | |
| }, | |
| } as never, | |
| { confirm: vi.fn(async () => false) }, | |
| ); | |
| const registryInput = callArg<{ | |
| channel?: string; | |
| pluginId?: string; | |
| workspaceDir?: string; | |
| }>(loadChannelSetupPluginRegistrySnapshotForChannel); | |
| expect(registryInput.channel).toBe("external-chat"); | |
| expect(registryInput.pluginId).toBe("trusted-external-chat-shadow"); | |
| expect(registryInput.workspaceDir).toBe("/tmp/openclaw-workspace"); | |
| }); | |
| it("defers status and setup-plugin loads until a channel is selected", async () => { | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| const select = vi.fn(async () => "__done__"); | |
| await runChannelSetup( | |
| {}, | |
| { select }, | |
| { | |
| deferStatusUntilSelection: true, | |
| skipConfirm: true, | |
| }, | |
| ); | |
| expect(callArg<{ message?: string }>(select).message).toBe("Select a channel"); | |
| expect(collectChannelStatus).not.toHaveBeenCalled(); | |
| expect(listTrustedChannelPluginCatalogEntries).not.toHaveBeenCalled(); | |
| expect(listChannelSetupPlugins).not.toHaveBeenCalled(); | |
| expect(getChannelSetupPlugin).not.toHaveBeenCalled(); | |
| expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); | |
| }); | |
| it("puts skip first and selected by default in QuickStart channel selection", async () => { | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| const select = vi.fn(async () => "__skip__"); | |
| await runChannelSetup( | |
| {}, | |
| { select }, | |
| { | |
| deferStatusUntilSelection: true, | |
| quickstartDefaults: true, | |
| skipConfirm: true, | |
| }, | |
| ); | |
| const prompt = callArg<{ | |
| message?: string; | |
| options?: Array<{ value: string; label: string }>; | |
| initialValue?: string; | |
| searchable?: boolean; | |
| }>(select); | |
| expect(prompt.message).toBe("Select channel (QuickStart)"); | |
| expect(prompt.options?.[0]).toEqual( | |
| expect.objectContaining({ | |
| value: "__skip__", | |
| label: "Skip for now", | |
| }), | |
| ); | |
| expect(prompt.initialValue).toBe("__skip__"); | |
| expect(prompt.searchable).toBe(true); | |
| }); | |
| it("keeps already-active setup plugins in the deferred picker without registry fallback", async () => { | |
| const activePlugin = { | |
| ...makeSetupPlugin({ id: "custom-chat", label: "Custom Chat" }), | |
| }; | |
| listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); | |
| resolveChannelSetupEntries.mockImplementation(() => ({ | |
| entries: [], | |
| installedCatalogEntries: [], | |
| installableCatalogEntries: [], | |
| installedCatalogById: new Map(), | |
| installableCatalogById: new Map(), | |
| })); | |
| const select = vi.fn(async () => "__done__"); | |
| await runChannelSetup( | |
| {}, | |
| { select }, | |
| { | |
| deferStatusUntilSelection: true, | |
| skipConfirm: true, | |
| }, | |
| ); | |
| expect( | |
| callArg<{ installedPlugins?: unknown[] }>(resolveChannelSetupEntries).installedPlugins, | |
| ).toEqual([activePlugin]); | |
| expect(listChannelSetupPlugins).not.toHaveBeenCalled(); | |
| expect(collectChannelStatus).not.toHaveBeenCalled(); | |
| }); | |
| it("enables an active deferred setup plugin when explicitly selected", async () => { | |
| const setupWizard = { | |
| channel: "custom-chat", | |
| getStatus: vi.fn(async () => ({ | |
| channel: "custom-chat", | |
| configured: false, | |
| statusLines: [], | |
| })), | |
| configure: vi.fn(async ({ cfg }: { cfg: Record<string, unknown> }) => ({ | |
| cfg: { | |
| ...cfg, | |
| channels: { | |
| "custom-chat": { token: "secret" }, | |
| }, | |
| }, | |
| })), | |
| }; | |
| const activePlugin = makeSetupPlugin({ | |
| id: "custom-chat", | |
| label: "Custom Chat", | |
| setupWizard, | |
| }); | |
| listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| makeChannelSetupEntries({ | |
| entries: [ | |
| { | |
| id: "custom-chat", | |
| meta: makeMeta("custom-chat", "Custom Chat"), | |
| }, | |
| ], | |
| installedCatalogEntries: [], | |
| installableCatalogEntries: [], | |
| installedCatalogById: new Map(), | |
| installableCatalogById: new Map(), | |
| }), | |
| ); | |
| const select = vi.fn().mockResolvedValueOnce("custom-chat").mockResolvedValueOnce("__done__"); | |
| const next = await runChannelSetup({}, { select }, DEFERRED_CHANNEL_SETUP_OPTIONS); | |
| expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); | |
| expect(callArg<{ cfg?: unknown }>(setupWizard.configure).cfg).toEqual({ | |
| plugins: { | |
| entries: { | |
| "custom-chat": { enabled: true }, | |
| }, | |
| }, | |
| }); | |
| expect(next).toEqual({ | |
| plugins: { | |
| entries: { | |
| "custom-chat": { enabled: true }, | |
| }, | |
| }, | |
| channels: { | |
| "custom-chat": { token: "secret" }, | |
| }, | |
| }); | |
| }); | |
| it("normalizes official external compatibility output from interactive setup", async () => { | |
| const setupWizard = { | |
| channel: "qqbot", | |
| getStatus: vi.fn(async () => ({ | |
| channel: "qqbot", | |
| configured: false, | |
| statusLines: [], | |
| })), | |
| configure: vi.fn(async () => ({ | |
| cfg: { | |
| channels: { | |
| qqbot: { | |
| appId: "app-id", | |
| clientSecret: "secret", | |
| allowFrom: ["*"], | |
| }, | |
| }, | |
| }, | |
| })), | |
| }; | |
| const activePlugin = makeSetupPlugin({ id: "qqbot", label: "QQ Bot", setupWizard }); | |
| listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| makeChannelSetupEntries({ | |
| entries: [{ id: "qqbot", meta: makeMeta("qqbot", "QQ Bot") }], | |
| }), | |
| ); | |
| const select = vi.fn().mockResolvedValueOnce("qqbot").mockResolvedValueOnce("__done__"); | |
| const next = await runChannelSetup({}, { select }, DEFERRED_CHANNEL_SETUP_OPTIONS); | |
| expect(next.channels?.qqbot).toMatchObject({ | |
| dmPolicy: "open", | |
| allowFrom: ["openclaw:approval-disabled"], | |
| }); | |
| }); | |
| it("allowlists ClickClack when it is explicitly selected for setup", async () => { | |
| const setupWizard = { | |
| channel: "clickclack", | |
| getStatus: vi.fn(async () => ({ | |
| channel: "clickclack", | |
| configured: false, | |
| statusLines: [], | |
| })), | |
| configure: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ | |
| cfg: { | |
| ...cfg, | |
| channels: { | |
| ...cfg.channels, | |
| clickclack: { | |
| ...cfg.channels?.clickclack, | |
| token: "secret", | |
| }, | |
| }, | |
| }, | |
| })), | |
| }; | |
| const clickClackPlugin = makeSetupPlugin({ | |
| id: "clickclack", | |
| label: "ClickClack", | |
| setupWizard, | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| makeChannelSetupEntries({ | |
| entries: [ | |
| { | |
| id: "clickclack", | |
| meta: makeMeta("clickclack", "ClickClack"), | |
| }, | |
| ], | |
| }), | |
| ); | |
| loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( | |
| makePluginRegistry({ | |
| channelSetups: [ | |
| { | |
| pluginId: "clickclack", | |
| source: "bundled", | |
| enabled: true, | |
| plugin: clickClackPlugin, | |
| }, | |
| ], | |
| }), | |
| ); | |
| const select = vi.fn().mockResolvedValueOnce("clickclack").mockResolvedValueOnce("__done__"); | |
| const next = await runChannelSetup( | |
| { | |
| plugins: { | |
| allow: ["memory-core"], | |
| }, | |
| } as never, | |
| { select }, | |
| DEFERRED_CHANNEL_SETUP_OPTIONS, | |
| ); | |
| expect(next.plugins?.allow).toEqual(["memory-core", "clickclack"]); | |
| expect(next.plugins?.entries?.clickclack?.enabled).toBe(true); | |
| expect(next.channels?.clickclack).toEqual({ | |
| enabled: true, | |
| token: "secret", | |
| }); | |
| }); | |
| it("loads the selected bundled catalog plugin without writing explicit plugin enablement", async () => { | |
| const configure = vi.fn(async ({ cfg }: { cfg: Record<string, unknown> }) => ({ | |
| cfg: { | |
| ...cfg, | |
| channels: { | |
| "external-chat": { token: "secret" }, | |
| }, | |
| } as never, | |
| })); | |
| const setupWizard = { | |
| channel: "external-chat", | |
| getStatus: vi.fn(async () => ({ | |
| channel: "external-chat", | |
| configured: false, | |
| statusLines: [], | |
| })), | |
| configure, | |
| } as ChannelSetupPlugin["setupWizard"]; | |
| const externalChatPlugin = makeSetupPlugin({ | |
| id: "external-chat", | |
| label: "External Chat", | |
| setupWizard, | |
| }); | |
| const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "external-chat", | |
| origin: "bundled", | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| externalChatSetupEntries({ | |
| installedCatalogEntries: [installedCatalogEntry], | |
| installedCatalogById: new Map([["external-chat", installedCatalogEntry]]), | |
| }), | |
| ); | |
| loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( | |
| makePluginRegistry({ | |
| channels: [ | |
| { | |
| pluginId: "external-chat", | |
| source: "bundled", | |
| plugin: externalChatPlugin, | |
| }, | |
| ], | |
| }), | |
| ); | |
| const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); | |
| const next = await runChannelSetup({}, { select }, DEFERRED_CHANNEL_SETUP_OPTIONS); | |
| expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(2); | |
| const firstRegistryInput = callArg<{ | |
| channel?: string; | |
| pluginId?: string; | |
| workspaceDir?: string; | |
| forceSetupOnlyChannelPlugins?: boolean; | |
| }>(loadChannelSetupPluginRegistrySnapshotForChannel, 0); | |
| expect(firstRegistryInput.channel).toBe("external-chat"); | |
| expect(firstRegistryInput.pluginId).toBe("external-chat"); | |
| expect(firstRegistryInput.workspaceDir).toBe("/tmp/openclaw-workspace"); | |
| expect(firstRegistryInput.forceSetupOnlyChannelPlugins).toBe(true); | |
| const secondRegistryInput = callArg<{ | |
| channel?: string; | |
| workspaceDir?: string; | |
| forceSetupOnlyChannelPlugins?: boolean; | |
| }>(loadChannelSetupPluginRegistrySnapshotForChannel, 1); | |
| expect(secondRegistryInput.channel).toBe("external-chat"); | |
| expect(secondRegistryInput.workspaceDir).toBe("/tmp/openclaw-workspace"); | |
| expect(secondRegistryInput.forceSetupOnlyChannelPlugins).toBe(true); | |
| expect(getChannelSetupPlugin).not.toHaveBeenCalled(); | |
| expect(collectChannelStatus).not.toHaveBeenCalled(); | |
| expect(callArg<{ cfg?: unknown }>(configure).cfg).toEqual({}); | |
| expect(next).toEqual({ | |
| channels: { | |
| "external-chat": { token: "secret" }, | |
| }, | |
| }); | |
| }); | |
| it("returns to quickstart selection when install-on-demand is skipped", async () => { | |
| const configure = vi.fn(async ({ cfg }: { cfg: Record<string, unknown> }) => ({ cfg })); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); | |
| const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| externalChatSetupEntries({ | |
| installableCatalogEntries: [installableCatalogEntry], | |
| installableCatalogById: new Map([["external-chat", installableCatalogEntry]]), | |
| }), | |
| ); | |
| ensureChannelSetupPluginInstalled | |
| .mockResolvedValueOnce({ | |
| cfg: {}, | |
| installed: false, | |
| pluginId: "@vendor/external-chat-plugin", | |
| status: "skipped", | |
| }) | |
| .mockResolvedValueOnce({ | |
| cfg: {}, | |
| installed: true, | |
| pluginId: "@vendor/external-chat-plugin", | |
| status: "installed", | |
| }); | |
| loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( | |
| makePluginRegistry({ | |
| channelSetups: [ | |
| { | |
| pluginId: "@vendor/external-chat-plugin", | |
| source: "global", | |
| enabled: true, | |
| plugin: externalChatPlugin, | |
| }, | |
| ], | |
| }), | |
| ); | |
| let quickstartSelectionCount = 0; | |
| const select = vi.fn(async ({ message }: { message: string }) => { | |
| if (message === "Select channel (QuickStart)") { | |
| quickstartSelectionCount += 1; | |
| return "external-chat"; | |
| } | |
| return "__done__"; | |
| }); | |
| await runChannelSetup({}, { select }, QUICKSTART_CHANNEL_SETUP_OPTIONS); | |
| expect(quickstartSelectionCount).toBe(2); | |
| expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(2); | |
| expect(configure).toHaveBeenCalledTimes(1); | |
| }); | |
| it("returns an external install confirmation to channel selection before installing", async () => { | |
| const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| externalChatSetupEntries({ | |
| installableCatalogEntries: [installableCatalogEntry], | |
| installableCatalogById: new Map([["external-chat", installableCatalogEntry]]), | |
| }), | |
| ); | |
| ensureChannelSetupPluginInstalled.mockImplementationOnce(async ({ cfg, prompter }) => { | |
| await prompter.confirm({ | |
| message: "Install External Chat?", | |
| initialValue: true, | |
| }); | |
| return { | |
| cfg, | |
| installed: true, | |
| pluginId: "@vendor/external-chat-plugin", | |
| status: "installed", | |
| }; | |
| }); | |
| const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); | |
| const confirm = vi.fn(async () => { | |
| throw new WizardNavigationError("back"); | |
| }); | |
| const cfg = { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig; | |
| const result = await runChannelSetup(cfg, { confirm, select }, DEFERRED_CHANNEL_SETUP_OPTIONS); | |
| expect(confirm).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| message: "Install External Chat?", | |
| navigation: { canGoBack: true, canGoForward: false }, | |
| }), | |
| ); | |
| expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); | |
| expect(result).toEqual(cfg); | |
| }); | |
| it.each(["installed-catalog", "trusted-catalog-fallback"] as const)( | |
| "returns the %s reinstall confirmation to channel selection", | |
| async (source) => { | |
| const catalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| install: { npmSpec: "@vendor/external-chat-plugin" }, | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| source === "installed-catalog" | |
| ? externalChatSetupEntries({ | |
| installedCatalogEntries: [catalogEntry], | |
| installedCatalogById: new Map([["external-chat", catalogEntry]]), | |
| }) | |
| : externalChatSetupEntries(), | |
| ); | |
| if (source === "trusted-catalog-fallback") { | |
| getTrustedChannelPluginCatalogEntry.mockReturnValue(catalogEntry); | |
| } | |
| loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); | |
| ensureChannelSetupPluginInstalled.mockImplementationOnce(async ({ cfg, prompter }) => { | |
| await prompter.confirm({ | |
| message: "Reinstall External Chat?", | |
| initialValue: true, | |
| }); | |
| return { | |
| cfg, | |
| installed: true, | |
| pluginId: "@vendor/external-chat-plugin", | |
| status: "installed", | |
| }; | |
| }); | |
| const confirm = vi.fn(async () => { | |
| throw new WizardNavigationError("back"); | |
| }); | |
| const cfg = { channels: { "external-chat": { token: "keep" } } } as OpenClawConfig; | |
| const result = await runChannelSetup( | |
| cfg, | |
| { | |
| confirm, | |
| select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"), | |
| }, | |
| DEFERRED_CHANNEL_SETUP_OPTIONS, | |
| ); | |
| expect(confirm).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| message: "Reinstall External Chat?", | |
| navigation: { canGoBack: true, canGoForward: false }, | |
| }), | |
| ); | |
| expect(result).toEqual(cfg); | |
| }, | |
| ); | |
| it("enters an explicitly targeted channel before the generic setup prompts", async () => { | |
| const promptOrder: string[] = []; | |
| const configureInteractive = vi.fn(async ({ cfg }) => { | |
| promptOrder.push("channel setup"); | |
| return { | |
| cfg: { | |
| ...cfg, | |
| channels: { ...cfg.channels, "external-chat": { token: "configured" } }, | |
| }, | |
| accountId: "external-account", | |
| }; | |
| }); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ | |
| configure: vi.fn(), | |
| configureInteractive, | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const confirm = vi.fn(async () => { | |
| promptOrder.push("setup confirmation"); | |
| return true; | |
| }); | |
| const select = vi.fn(async () => { | |
| promptOrder.push("channel picker"); | |
| return "__done__"; | |
| }); | |
| const result = await runChannelSetup({}, { confirm, select }, TARGETED_CHANNEL_SETUP_OPTIONS); | |
| expect(promptOrder).toEqual(["channel setup"]); | |
| expect(confirm).not.toHaveBeenCalled(); | |
| expect(configureInteractive).toHaveBeenCalledTimes(1); | |
| expect(result).toMatchObject({ | |
| channels: { "external-chat": { token: "configured" } }, | |
| plugins: { entries: { "external-chat": { enabled: true } } }, | |
| }); | |
| }); | |
| it("fails visibly when configured channel setup has no post-write hook sink", async () => { | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ | |
| configure: vi.fn(), | |
| configureInteractive: vi.fn(async ({ cfg }) => ({ | |
| cfg: { | |
| ...cfg, | |
| channels: { ...cfg.channels, "external-chat": { token: "configured" } }, | |
| }, | |
| accountId: "external-account", | |
| })), | |
| afterConfigWritten: vi.fn(async () => {}), | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| await expect( | |
| setupChannels( | |
| {} as OpenClawConfig, | |
| {} as never, | |
| { | |
| confirm: vi.fn(async () => true), | |
| note: vi.fn(async () => undefined), | |
| select: vi.fn(async () => "__done__"), | |
| } as never, | |
| { | |
| initialSelection: ["external-chat"], | |
| finishAfterInitialSelection: true, | |
| deferStatusUntilSelection: true, | |
| skipDmPolicyPrompt: true, | |
| }, | |
| ), | |
| ).rejects.toThrow(/post-write hook.*transaction sink/i); | |
| }); | |
| it("keeps completed setup when the follow-up status refresh fails", async () => { | |
| const getStatus = vi | |
| .fn() | |
| .mockResolvedValueOnce({ | |
| channel: "external-chat", | |
| configured: false, | |
| statusLines: [], | |
| }) | |
| .mockRejectedValue(new Error("controlled status failure")); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ | |
| getStatus, | |
| configure: vi.fn(async ({ cfg }) => ({ | |
| cfg: { | |
| ...cfg, | |
| channels: { ...cfg.channels, "external-chat": { token: "configured" } }, | |
| }, | |
| accountId: "external-account", | |
| })), | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const note = vi.fn(async () => undefined); | |
| const result = await runChannelSetup( | |
| {}, | |
| { note, select: vi.fn(async () => "__done__") }, | |
| TARGETED_CHANNEL_SETUP_OPTIONS, | |
| ); | |
| expect(result).toMatchObject({ | |
| channels: { "external-chat": { token: "configured" } }, | |
| }); | |
| expect(note).toHaveBeenCalledWith( | |
| "Status unavailable (controlled status failure).\n" + | |
| "Retry: openclaw channels status --channel external-chat", | |
| "Channel status", | |
| ); | |
| }); | |
| it("returns targeted channel setup Back navigation to the channel picker", async () => { | |
| const promptOrder: string[] = []; | |
| const configureInteractive = vi.fn(async ({ prompter }) => { | |
| promptOrder.push("channel setup"); | |
| await prompter.text({ message: "External Chat token" }); | |
| return { | |
| cfg: { | |
| channels: { "external-chat": { token: "should-not-apply" } }, | |
| } as OpenClawConfig, | |
| accountId: "external-account", | |
| }; | |
| }); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ | |
| configure: vi.fn(), | |
| configureInteractive, | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const select = vi.fn(async () => { | |
| promptOrder.push("channel picker"); | |
| return "__done__"; | |
| }); | |
| const cfg = { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig; | |
| const result = await runChannelSetup( | |
| cfg, | |
| { | |
| select, | |
| text: vi.fn(async () => { | |
| throw new WizardNavigationError("back"); | |
| }), | |
| }, | |
| TARGETED_CHANNEL_SETUP_OPTIONS, | |
| ); | |
| expect(promptOrder).toEqual(["channel setup", "channel picker"]); | |
| expect(select).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| message: "Select a channel", | |
| }), | |
| ); | |
| expect(result).toEqual(cfg); | |
| }); | |
| it("returns custom channel setup to channel selection when its first prompt goes back", async () => { | |
| const configureInteractive = vi.fn(async ({ prompter }) => { | |
| await prompter.text({ message: "Custom channel token" }); | |
| return { | |
| cfg: { | |
| channels: { "external-chat": { token: "should-not-apply" } }, | |
| } as OpenClawConfig, | |
| accountId: "custom-account", | |
| }; | |
| }); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ | |
| configure: vi.fn(), | |
| configureInteractive, | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); | |
| const text = vi.fn(async () => { | |
| throw new WizardNavigationError("back"); | |
| }); | |
| const result = await runChannelSetup( | |
| { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig, | |
| { select, text }, | |
| DEFERRED_CHANNEL_SETUP_OPTIONS, | |
| ); | |
| expect(configureInteractive).toHaveBeenCalledTimes(1); | |
| expect(text).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| message: "Custom channel token", | |
| navigation: { canGoBack: true, canGoForward: false }, | |
| }), | |
| ); | |
| expect(select).toHaveBeenCalledTimes(2); | |
| expect(result).toEqual({ channels: { telegram: { botToken: "keep" } } }); | |
| }); | |
| it("returns declarative channel setup to channel selection when its first prompt goes back", async () => { | |
| const configure = vi.fn(async ({ cfg, prompter }) => { | |
| await prompter.text({ message: "Declarative channel token" }); | |
| return { | |
| cfg: { | |
| ...cfg, | |
| channels: { ...cfg.channels, "external-chat": { token: "should-not-apply" } }, | |
| }, | |
| accountId: "declarative-account", | |
| }; | |
| }); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); | |
| const text = vi.fn(async () => { | |
| throw new WizardNavigationError("back"); | |
| }); | |
| const result = await runChannelSetup( | |
| { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig, | |
| { select, text }, | |
| DEFERRED_CHANNEL_SETUP_OPTIONS, | |
| ); | |
| expect(configure).toHaveBeenCalledTimes(1); | |
| expect(text).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| message: "Declarative channel token", | |
| navigation: { canGoBack: true, canGoForward: false }, | |
| }), | |
| ); | |
| expect(select).toHaveBeenCalledTimes(2); | |
| expect(result).toEqual({ channels: { telegram: { botToken: "keep" } } }); | |
| }); | |
| it("returns configured channel actions to selection when their first prompt goes back", async () => { | |
| const configureWhenConfigured = vi.fn(async ({ cfg, prompter }) => { | |
| await prompter.select({ | |
| message: "Configured channel action", | |
| options: [{ value: "update", label: "Update" }], | |
| }); | |
| return { | |
| cfg: { | |
| ...cfg, | |
| channels: { ...cfg.channels, "external-chat": { token: "should-not-apply" } }, | |
| }, | |
| }; | |
| }); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ | |
| getStatus: vi.fn(async () => ({ | |
| channel: "external-chat", | |
| configured: true, | |
| statusLines: [], | |
| })), | |
| configure: vi.fn(), | |
| configureWhenConfigured, | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const select = vi | |
| .fn() | |
| .mockResolvedValueOnce("external-chat") | |
| .mockRejectedValueOnce(new WizardNavigationError("back")) | |
| .mockResolvedValueOnce("__done__"); | |
| const cfg = { | |
| channels: { "external-chat": { token: "keep" } }, | |
| } as OpenClawConfig; | |
| const result = await runChannelSetup(cfg, { select }, DEFERRED_CHANNEL_SETUP_OPTIONS); | |
| expect(configureWhenConfigured).toHaveBeenCalledTimes(1); | |
| expect(select).toHaveBeenNthCalledWith( | |
| 2, | |
| expect.objectContaining({ | |
| message: "Configured channel action", | |
| navigation: { canGoBack: true, canGoForward: false }, | |
| }), | |
| ); | |
| expect(result).toEqual(cfg); | |
| }); | |
| it("rolls back reversible plugin enablement when channel setup goes back", async () => { | |
| const configure = vi.fn(async ({ cfg, prompter }) => { | |
| await prompter.text({ message: "ClickClack token" }); | |
| return { cfg }; | |
| }); | |
| const clickClackPlugin = makeSetupPlugin({ | |
| id: "clickclack", | |
| label: "ClickClack", | |
| setupWizard: { | |
| channel: "clickclack", | |
| getStatus: vi.fn(async () => ({ | |
| channel: "clickclack", | |
| configured: false, | |
| statusLines: [], | |
| })), | |
| configure, | |
| } as ChannelSetupPlugin["setupWizard"], | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| makeChannelSetupEntries({ | |
| entries: [{ id: "clickclack", meta: makeMeta("clickclack", "ClickClack") }], | |
| }), | |
| ); | |
| loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( | |
| makePluginRegistry({ | |
| channelSetups: [ | |
| { | |
| pluginId: "clickclack", | |
| source: "bundled", | |
| enabled: true, | |
| plugin: clickClackPlugin, | |
| }, | |
| ], | |
| }), | |
| ); | |
| const select = vi.fn().mockResolvedValueOnce("clickclack").mockResolvedValueOnce("__done__"); | |
| const text = vi.fn(async () => { | |
| throw new WizardNavigationError("back"); | |
| }); | |
| const cfg = { plugins: { allow: ["memory-core"] } } as OpenClawConfig; | |
| const result = await runChannelSetup(cfg, { select, text }, DEFERRED_CHANNEL_SETUP_OPTIONS); | |
| expect(result).toEqual(cfg); | |
| expect(result.plugins?.allow).toEqual(["memory-core"]); | |
| expect(result.plugins?.entries?.clickclack).toBeUndefined(); | |
| }); | |
| it("preserves accepted external install metadata when later channel setup goes back", async () => { | |
| const configure = vi.fn(async ({ cfg, prompter }) => { | |
| await prompter.text({ message: "External Chat token" }); | |
| return { cfg, accountId: "external-account" }; | |
| }); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); | |
| const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| externalChatSetupEntries({ | |
| installableCatalogEntries: [installableCatalogEntry], | |
| installableCatalogById: new Map([["external-chat", installableCatalogEntry]]), | |
| }), | |
| ); | |
| loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( | |
| makePluginRegistry({ | |
| channelSetups: [ | |
| { | |
| pluginId: "@vendor/external-chat-plugin", | |
| source: "global", | |
| enabled: true, | |
| plugin: externalChatPlugin, | |
| }, | |
| ], | |
| }), | |
| ); | |
| ensureChannelSetupPluginInstalled.mockImplementationOnce(async (params) => { | |
| await params.beforePersistentEffect?.(); | |
| return { | |
| cfg: { | |
| ...params.cfg, | |
| plugins: { | |
| ...params.cfg.plugins, | |
| entries: { | |
| ...params.cfg.plugins?.entries, | |
| "@vendor/external-chat-plugin": { enabled: true }, | |
| }, | |
| installs: { | |
| ...params.cfg.plugins?.installs, | |
| "@vendor/external-chat-plugin": { | |
| source: "npm", | |
| spec: "@vendor/external-chat-plugin@1.0.0", | |
| }, | |
| }, | |
| }, | |
| }, | |
| installed: true, | |
| pluginId: "@vendor/external-chat-plugin", | |
| status: "installed", | |
| }; | |
| }); | |
| const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); | |
| const beforePersistentEffect = vi.fn(async () => undefined); | |
| const result = await runChannelSetup( | |
| { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig, | |
| { | |
| select, | |
| text: vi.fn(async () => { | |
| throw new WizardNavigationError("back"); | |
| }), | |
| }, | |
| { | |
| beforePersistentEffect, | |
| ...DEFERRED_CHANNEL_SETUP_OPTIONS, | |
| }, | |
| ); | |
| expect(result.channels).toEqual({ telegram: { botToken: "keep" } }); | |
| expect(result.plugins?.entries?.["@vendor/external-chat-plugin"]?.enabled).toBe(true); | |
| expect(result.plugins?.installs?.["@vendor/external-chat-plugin"]?.spec).toBe( | |
| "@vendor/external-chat-plugin@1.0.0", | |
| ); | |
| expect(beforePersistentEffect).toHaveBeenCalledTimes(1); | |
| }); | |
| it("replays earlier prompts when Back is used inside channel setup", async () => { | |
| const configure = vi.fn(async ({ cfg, prompter }) => { | |
| const username = await prompter.text({ message: "Username" }); | |
| const token = await prompter.text({ message: "Token" }); | |
| return { | |
| cfg: { ...cfg, channels: { ...cfg.channels, "external-chat": { username, token } } }, | |
| }; | |
| }); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const text = vi | |
| .fn() | |
| .mockResolvedValueOnce("old-name") | |
| .mockRejectedValueOnce(new WizardNavigationError("back")) | |
| .mockResolvedValueOnce("new-name") | |
| .mockResolvedValueOnce("secret-token"); | |
| const result = await runChannelSetup( | |
| {}, | |
| { | |
| select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"), | |
| text, | |
| }, | |
| DEFERRED_CHANNEL_SETUP_OPTIONS, | |
| ); | |
| expect(configure).toHaveBeenCalledTimes(2); | |
| expect(result.channels?.["external-chat"]).toEqual({ | |
| username: "new-name", | |
| token: "secret-token", | |
| }); | |
| expect(expectDefined(text.mock.calls[2], "third text prompt call")[0]).toEqual( | |
| expect.objectContaining({ | |
| initialValue: "old-name", | |
| navigation: { canGoBack: true, canGoForward: true }, | |
| }), | |
| ); | |
| }); | |
| it("disables Back after a channel declares a persistent effect boundary", async () => { | |
| const configure = vi.fn(async ({ cfg, prompter, options }) => { | |
| await prompter.text({ message: "Before effect" }); | |
| await options.beforePersistentEffect?.(); | |
| await prompter.text({ message: "After effect" }); | |
| return { cfg }; | |
| }); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const navigationError = new WizardNavigationError("back"); | |
| const text = vi.fn().mockResolvedValueOnce("ready").mockRejectedValueOnce(navigationError); | |
| const beforePersistentEffect = vi.fn(async () => undefined); | |
| await expect( | |
| setupChannels( | |
| {} as OpenClawConfig, | |
| {} as never, | |
| { | |
| confirm: vi.fn(async () => true), | |
| note: vi.fn(async () => undefined), | |
| select: vi.fn().mockResolvedValueOnce("external-chat"), | |
| text, | |
| } as never, | |
| { | |
| beforePersistentEffect, | |
| deferStatusUntilSelection: true, | |
| skipConfirm: true, | |
| skipDmPolicyPrompt: true, | |
| }, | |
| ), | |
| ).rejects.toBe(navigationError); | |
| expect(beforePersistentEffect).toHaveBeenCalledTimes(1); | |
| expect(expectDefined(text.mock.calls[1], "second text prompt call")[0]).toEqual( | |
| expect.objectContaining({ | |
| navigation: { canGoBack: false, canGoForward: false }, | |
| }), | |
| ); | |
| }); | |
| it("propagates Ctrl-C cancellation from channel setup", async () => { | |
| const cancelled = new WizardCancelledError(); | |
| const configure = vi.fn(async ({ prompter }) => { | |
| await prompter.text({ message: "Token" }); | |
| return { cfg: {} as OpenClawConfig }; | |
| }); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| await expect( | |
| setupChannels( | |
| {} as OpenClawConfig, | |
| {} as never, | |
| { | |
| confirm: vi.fn(async () => true), | |
| note: vi.fn(async () => undefined), | |
| select: vi.fn().mockResolvedValueOnce("external-chat"), | |
| text: vi.fn(async () => { | |
| throw cancelled; | |
| }), | |
| } as never, | |
| { | |
| deferStatusUntilSelection: true, | |
| skipConfirm: true, | |
| skipDmPolicyPrompt: true, | |
| }, | |
| ), | |
| ).rejects.toBe(cancelled); | |
| }); | |
| it("keeps an explicitly disabled channel unchanged when setup resume is declined", async () => { | |
| const setupWizard = { | |
| channel: "external-chat", | |
| getStatus: vi.fn(async () => ({ | |
| channel: "external-chat", | |
| configured: true, | |
| statusLines: [], | |
| })), | |
| configure: vi.fn(), | |
| }; | |
| const externalChatPlugin = makeSetupPlugin({ | |
| id: "external-chat", | |
| label: "External Chat", | |
| setupWizard: setupWizard as ChannelSetupPlugin["setupWizard"], | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); | |
| const confirm = vi.fn(async () => false); | |
| const cfg = { | |
| channels: { | |
| "external-chat": { enabled: false, token: "secret" }, | |
| }, | |
| }; | |
| const next = await runChannelSetup( | |
| cfg as never, | |
| { confirm, select }, | |
| DEFERRED_CHANNEL_SETUP_OPTIONS, | |
| ); | |
| expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); | |
| expect(confirm).toHaveBeenCalledWith({ | |
| message: "external-chat is disabled. Enable it and continue setup now?", | |
| initialValue: true, | |
| }); | |
| expect(setupWizard.configure).not.toHaveBeenCalled(); | |
| expect(next).toEqual({ | |
| channels: { | |
| "external-chat": { enabled: false, token: "secret" }, | |
| }, | |
| }); | |
| }); | |
| it("resumes an explicitly selected disabled channel without replacing its config", async () => { | |
| const setupWizard = { | |
| channel: "external-chat", | |
| getStatus: vi.fn(async () => ({ | |
| channel: "external-chat", | |
| configured: true, | |
| statusLines: [], | |
| })), | |
| configure: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ | |
| cfg, | |
| accountId: "default", | |
| })), | |
| }; | |
| const externalChatPlugin = makeSetupPlugin({ | |
| id: "external-chat", | |
| label: "External Chat", | |
| setupWizard: setupWizard as ChannelSetupPlugin["setupWizard"], | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const onSelection = vi.fn(); | |
| const cfg = { | |
| channels: { | |
| "external-chat": { enabled: false, token: "saved-secret" }, | |
| }, | |
| }; | |
| const next = await runChannelSetup( | |
| cfg as never, | |
| { select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__") }, | |
| { ...DEFERRED_CHANNEL_SETUP_OPTIONS, onSelection }, | |
| ); | |
| expect(setupWizard.configure).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| cfg: { | |
| channels: { | |
| "external-chat": { enabled: true, token: "saved-secret" }, | |
| }, | |
| plugins: { | |
| entries: { | |
| "external-chat": { enabled: true }, | |
| }, | |
| }, | |
| }, | |
| }), | |
| ); | |
| expect(onSelection).toHaveBeenCalledWith(["external-chat"]); | |
| expect(next).toEqual({ | |
| channels: { | |
| "external-chat": { enabled: true, token: "saved-secret" }, | |
| }, | |
| plugins: { | |
| entries: { | |
| "external-chat": { enabled: true }, | |
| }, | |
| }, | |
| }); | |
| }); | |
| it("enables an explicitly selected disabled plugin and continues setup", async () => { | |
| const setupWizard = { | |
| channel: "external-chat", | |
| getStatus: vi.fn(async () => ({ | |
| channel: "external-chat", | |
| configured: true, | |
| statusLines: [], | |
| })), | |
| configure: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ | |
| cfg, | |
| accountId: "default", | |
| })), | |
| }; | |
| const externalChatPlugin = makeSetupPlugin({ | |
| id: "external-chat", | |
| label: "External Chat", | |
| setupWizard: setupWizard as ChannelSetupPlugin["setupWizard"], | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const confirm = vi.fn(async () => true); | |
| const next = await runChannelSetup( | |
| { | |
| channels: { | |
| "external-chat": { enabled: true, token: "saved-secret" }, | |
| }, | |
| plugins: { | |
| entries: { | |
| "external-chat": { enabled: false }, | |
| }, | |
| }, | |
| } as never, | |
| { | |
| confirm, | |
| select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"), | |
| }, | |
| DEFERRED_CHANNEL_SETUP_OPTIONS, | |
| ); | |
| expect(confirm).toHaveBeenCalledWith({ | |
| message: "external-chat plugin is disabled. Enable it and continue setup now?", | |
| initialValue: true, | |
| }); | |
| expect(setupWizard.configure).toHaveBeenCalledOnce(); | |
| expect(next.plugins?.entries?.["external-chat"]?.enabled).toBe(true); | |
| expect(next.channels?.["external-chat"]?.enabled).toBe(true); | |
| }); | |
| it("returns paused setup state without marking the channel configured", async () => { | |
| const pausedConfig = { | |
| channels: { | |
| "external-chat": { | |
| enabled: false, | |
| relayUrl: "wss://relay.example.com", | |
| privateKey: "saved-secret", | |
| }, | |
| }, | |
| }; | |
| const setupWizard = { | |
| channel: "external-chat", | |
| getStatus: vi.fn(async () => ({ | |
| channel: "external-chat", | |
| configured: false, | |
| statusLines: [], | |
| })), | |
| configure: vi.fn(async () => ({ | |
| cfg: pausedConfig as OpenClawConfig, | |
| completion: "paused" as const, | |
| })), | |
| }; | |
| const externalChatPlugin = makeSetupPlugin({ | |
| id: "external-chat", | |
| label: "External Chat", | |
| setupWizard: setupWizard as ChannelSetupPlugin["setupWizard"], | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); | |
| const select = vi.fn().mockResolvedValue("external-chat"); | |
| const onSelection = vi.fn(); | |
| const next = await runChannelSetup( | |
| {}, | |
| { select }, | |
| { ...DEFERRED_CHANNEL_SETUP_OPTIONS, onSelection }, | |
| ); | |
| expect(select).toHaveBeenCalledOnce(); | |
| expect(onSelection).toHaveBeenCalledWith([]); | |
| expect(next).toBe(pausedConfig); | |
| }); | |
| it("honors global plugin disablement before lazy channel setup loads plugins", async () => { | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); | |
| const note = vi.fn(async () => undefined); | |
| const cfg = { | |
| plugins: { enabled: false }, | |
| channels: { | |
| "external-chat": { enabled: true, token: "secret" }, | |
| }, | |
| }; | |
| await runChannelSetup(cfg as never, { note, select }, DEFERRED_CHANNEL_SETUP_OPTIONS); | |
| expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); | |
| expect(note).toHaveBeenCalledWith( | |
| "external-chat cannot be configured while plugins disabled. Enable it before setup.", | |
| "Channel setup", | |
| ); | |
| }); | |
| it( | |
| "setupChannels reinstalls the external plugin via catalog when a stale channel config " + | |
| "declares an already-installed plugin whose runtime cannot be loaded", | |
| async () => { | |
| // Regression: users who uninstalled an externalized channel plugin | |
| // (qqbot / imessage / discord / ...) while a non-empty | |
| // `channels.<id>` entry remained in their config got dead-ended with | |
| // "<channel> plugin not available" because the installed-catalog | |
| // branch did not fall back to the catalog install flow. | |
| const fixture = createInstallAccountPolicyFixture( | |
| tempDirs.make("openclaw-wizard-install-policy-"), | |
| "external-chat", | |
| ); | |
| const cfg = fixture.config; | |
| const beforeInstall = fixture.readMetadata(); | |
| const resolveMetadata = vi | |
| .spyOn(pluginMetadata, "resolvePluginMetadataSnapshot") | |
| .mockImplementation(({ config, workspaceDir }) => | |
| fixture.readMetadata(config, workspaceDir), | |
| ); | |
| const readAccount = (config: OpenClawConfig) => | |
| resolveChannelAccountEntry( | |
| asOptionalRecord(asOptionalRecord(config.channels?.["external-chat"])?.accounts), | |
| "work-phone", | |
| "external-chat", | |
| ); | |
| const statusAccounts: unknown[] = []; | |
| const postWriteAccounts: unknown[] = []; | |
| const hooks: ChannelOnboardingPostWriteHook[] = []; | |
| const configure = vi.fn(async ({ cfg: current }: { cfg: OpenClawConfig }) => ({ | |
| cfg: applyAccountNameToChannelSection({ | |
| cfg: current, | |
| channelKey: "external-chat", | |
| accountId: "work-phone", | |
| name: "Work calls", | |
| }), | |
| accountId: "work-phone", | |
| })); | |
| const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| install: { npmSpec: "@vendor/external-chat-plugin" }, | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| externalChatSetupEntries({ | |
| installedCatalogEntries: [installedCatalogEntry], | |
| installedCatalogById: new Map([["external-chat", installedCatalogEntry]]), | |
| }), | |
| ); | |
| // First snapshot (pre-install) is empty — plugin runtime is gone. | |
| // After `ensureChannelSetupPluginInstalled` runs, subsequent snapshots | |
| // resolve the plugin as expected. | |
| loadChannelSetupPluginRegistrySnapshotForChannel | |
| .mockReturnValueOnce(makePluginRegistry()) | |
| .mockImplementation(() => { | |
| const instance = new PluginInstance("installed-channel"); | |
| getPluginCache().instances.add(instance); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ | |
| configure, | |
| configureInteractive: instance.wrap(configure), | |
| getStatus: instance.wrap(async ({ cfg: current }) => { | |
| const account = readAccount(current); | |
| statusAccounts.push(account); | |
| return { channel: "external-chat", configured: Boolean(account), statusLines: [] }; | |
| }), | |
| afterConfigWritten: instance.wrap(({ cfg: current }) => { | |
| expect(hasPluginLifecycleLease()).toBe(false); | |
| postWriteAccounts.push(readAccount(current)); | |
| }), | |
| }); | |
| return makePluginRegistry({ | |
| channels: [ | |
| { | |
| pluginId: "@vendor/external-chat-plugin", | |
| source: "global", | |
| plugin: externalChatPlugin, | |
| }, | |
| ], | |
| }); | |
| }); | |
| ensureChannelSetupPluginInstalled.mockImplementationOnce(async () => { | |
| expect(hasPluginLifecycleLease()).toBe(true); | |
| fixture.installPolicy(); | |
| return { | |
| cfg, | |
| installed: true, | |
| pluginId: "@vendor/external-chat-plugin", | |
| status: "installed", | |
| }; | |
| }); | |
| isChannelConfigured.mockReturnValue(false); | |
| const note = vi.fn(async () => undefined); | |
| const select = vi | |
| .fn() | |
| .mockResolvedValueOnce("external-chat") | |
| .mockResolvedValueOnce("__done__"); | |
| try { | |
| await using cache = createPluginCache(); | |
| await withPluginCache(cache, async () => { | |
| const next = await withPluginMetadataSnapshotScope(beforeInstall, () => { | |
| expect(readAccount(cfg)).toBeUndefined(); | |
| return runChannelSetup( | |
| cfg, | |
| { note, select }, | |
| { | |
| ...DEFERRED_CHANNEL_SETUP_OPTIONS, | |
| onPostWriteHook: (hook) => hooks.push(hook), | |
| }, | |
| ); | |
| }); | |
| const account = { account: "+12025550123", name: "Work calls" }; | |
| expect(next.channels?.["external-chat"]).toEqual({ accounts: { "Work Phone": account } }); | |
| expect(statusAccounts.at(-1)).toEqual(account); | |
| expect(hooks).toHaveLength(1); | |
| await withPluginMetadataSnapshotScope(fixture.readMetadata(next), () => | |
| expectDefined(hooks[0], "collected setup hook").run({ | |
| cfg: next, | |
| runtime: createTestRuntime(), | |
| }), | |
| ); | |
| expect(postWriteAccounts).toEqual([account]); | |
| }); | |
| expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); | |
| expectExternalCatalogInstallCall(); | |
| expect(note).not.toHaveBeenCalledWith( | |
| "external-chat plugin not available.", | |
| "Channel setup", | |
| ); | |
| expect(configure).toHaveBeenCalledTimes(1); | |
| } finally { | |
| resolveMetadata.mockRestore(); | |
| } | |
| }, | |
| ); | |
| it( | |
| "returns to channel selection when catalog-fallback install is declined " + | |
| "from the installed-catalog branch", | |
| async () => { | |
| const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| install: { npmSpec: "@vendor/external-chat-plugin" }, | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| externalChatSetupEntries({ | |
| installedCatalogEntries: [installedCatalogEntry], | |
| installedCatalogById: new Map([["external-chat", installedCatalogEntry]]), | |
| }), | |
| ); | |
| loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); | |
| ensureChannelSetupPluginInstalled.mockResolvedValueOnce({ | |
| cfg: {}, | |
| installed: false, | |
| pluginId: "@vendor/external-chat-plugin", | |
| status: "skipped", | |
| }); | |
| isChannelConfigured.mockReturnValue(false); | |
| let quickstartSelectionCount = 0; | |
| const select = vi.fn(async ({ message }: { message: string }) => { | |
| if (message === "Select channel (QuickStart)") { | |
| quickstartSelectionCount += 1; | |
| if (quickstartSelectionCount === 1) { | |
| return "external-chat"; | |
| } | |
| } | |
| return "__skip__"; | |
| }); | |
| const note = vi.fn(async () => undefined); | |
| await runChannelSetup({}, { note, select }, QUICKSTART_CHANNEL_SETUP_OPTIONS); | |
| // Install prompt ran once, was declined; user returned to channel | |
| // selection (quickstartSelectionCount === 2) rather than being | |
| // dead-ended with a "plugin not available" note. | |
| expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); | |
| expect(quickstartSelectionCount).toBe(2); | |
| expect(note).not.toHaveBeenCalledWith("external-chat plugin not available.", "Channel setup"); | |
| }, | |
| ); | |
| it( | |
| "auto-installs external plugin from catalog when both discovery buckets " + | |
| "are empty due to a stale `channels.<id>` config entry", | |
| async () => { | |
| // Regression test for the real-world repro: `channels.qqbot` has stale | |
| // fields (appId/secret) from an earlier install, so | |
| // `isStaticallyChannelConfigured` drops qqbot from | |
| // `installableCatalogEntries`; qqbot isn't on disk either, so | |
| // `manifestInstalledIds` doesn't include it. Both discovery buckets | |
| // come back empty, but the channel is still selectable (entries list | |
| // does not apply the static-config filter). Before the fix, onboard | |
| // fell through to `enableBundledPluginForSetup` which just printed | |
| // "qqbot plugin not available." and exited the flow. The fix consults | |
| // the catalog directly and drives `ensureChannelSetupPluginInstalled`. | |
| const configure = vi.fn(async ({ cfg }: { cfg: Record<string, unknown> }) => ({ | |
| cfg: { ...cfg, channels: { "external-chat": { token: "secret" } } }, | |
| })); | |
| const externalChatPlugin = makeExternalChatSetupPlugin({ configure }); | |
| // Entries list exposes the channel in the menu, but BOTH discovery | |
| // buckets are empty — faithfully reproducing the observed bug. | |
| resolveChannelSetupEntries.mockReturnValue( | |
| externalChatSetupEntries({ | |
| installedCatalogEntries: [], | |
| installableCatalogEntries: [], | |
| installedCatalogById: new Map(), | |
| installableCatalogById: new Map(), | |
| }), | |
| ); | |
| const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| install: { npmSpec: "@vendor/external-chat-plugin" }, | |
| }); | |
| getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry); | |
| ensureChannelSetupPluginInstalled.mockResolvedValueOnce({ | |
| cfg: {}, | |
| installed: true, | |
| pluginId: "@vendor/external-chat-plugin", | |
| status: "installed", | |
| }); | |
| loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( | |
| makePluginRegistry({ | |
| channels: [ | |
| { | |
| pluginId: "@vendor/external-chat-plugin", | |
| source: "global", | |
| plugin: externalChatPlugin, | |
| }, | |
| ], | |
| }), | |
| ); | |
| isChannelConfigured.mockReturnValue(false); | |
| const note = vi.fn(async () => undefined); | |
| const select = vi | |
| .fn() | |
| .mockResolvedValueOnce("external-chat") | |
| .mockResolvedValueOnce("__done__"); | |
| await runChannelSetup({}, { note, select }, DEFERRED_CHANNEL_SETUP_OPTIONS); | |
| const catalogLookupCall = expectDefined( | |
| getTrustedChannelPluginCatalogEntry.mock.calls[0], | |
| "catalog lookup call", | |
| ) as [string, { workspaceDir?: string } | undefined]; | |
| expect(catalogLookupCall[0]).toBe("external-chat"); | |
| expect(catalogLookupCall[1]?.workspaceDir).toBe("/tmp/openclaw-workspace"); | |
| expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); | |
| expectExternalCatalogInstallCall(); | |
| expect(note).not.toHaveBeenCalledWith("external-chat plugin not available.", "Channel setup"); | |
| expect(configure).toHaveBeenCalledTimes(1); | |
| }, | |
| ); | |
| it( | |
| "returns to channel selection when the catalog-fallback install is " + | |
| "declined from the bundled-enable branch", | |
| async () => { | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| install: { npmSpec: "@vendor/external-chat-plugin" }, | |
| }); | |
| getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry); | |
| ensureChannelSetupPluginInstalled.mockResolvedValueOnce({ | |
| cfg: {}, | |
| installed: false, | |
| pluginId: "@vendor/external-chat-plugin", | |
| status: "skipped", | |
| }); | |
| isChannelConfigured.mockReturnValue(false); | |
| let quickstartSelectionCount = 0; | |
| const select = vi.fn(async ({ message }: { message: string }) => { | |
| if (message === "Select channel (QuickStart)") { | |
| quickstartSelectionCount += 1; | |
| if (quickstartSelectionCount === 1) { | |
| return "external-chat"; | |
| } | |
| } | |
| return "__skip__"; | |
| }); | |
| const note = vi.fn(async () => undefined); | |
| await runChannelSetup({}, { note, select }, QUICKSTART_CHANNEL_SETUP_OPTIONS); | |
| expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); | |
| expect(quickstartSelectionCount).toBe(2); | |
| expect(note).not.toHaveBeenCalledWith("external-chat plugin not available.", "Channel setup"); | |
| }, | |
| ); | |
| it("fails closed when the catalog-fallback install guard rejects", async () => { | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| install: { npmSpec: "@vendor/external-chat-plugin" }, | |
| }); | |
| getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry); | |
| isChannelConfigured.mockReturnValue(false); | |
| const guardError = new Error("verified inference owner changed"); | |
| const beforePersistentEffect = vi.fn(async () => { | |
| throw guardError; | |
| }); | |
| ensureChannelSetupPluginInstalled.mockImplementationOnce(async (params) => { | |
| await params.beforePersistentEffect?.(); | |
| return { | |
| cfg: params.cfg, | |
| installed: true, | |
| pluginId: params.entry.pluginId, | |
| status: "installed", | |
| }; | |
| }); | |
| const select = vi.fn().mockResolvedValueOnce("external-chat"); | |
| await expect( | |
| setupChannels( | |
| {} as never, | |
| {} as never, | |
| { | |
| confirm: vi.fn(async () => true), | |
| note: vi.fn(async () => undefined), | |
| select, | |
| } as never, | |
| { | |
| deferStatusUntilSelection: true, | |
| skipConfirm: true, | |
| skipDmPolicyPrompt: true, | |
| beforePersistentEffect, | |
| }, | |
| ), | |
| ).rejects.toBe(guardError); | |
| expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); | |
| expect(beforePersistentEffect).toHaveBeenCalledTimes(1); | |
| }); | |
| it( | |
| "refuses catalog-fallback install from empty discovery buckets when the " + | |
| "channel is explicitly disabled in config", | |
| async () => { | |
| // Review-note regression: the bundled-enable `else` branch used to rely | |
| // on `enableBundledPluginForSetup`'s own disabled-config guard. The | |
| // new catalog fallback runs BEFORE that helper, so it must re-apply | |
| // the same `resolveConfigDisabledHint` check — otherwise an operator- | |
| // disabled channel with a stale `channels.<id>` entry could be | |
| // reinstalled/re-enabled silently. | |
| // | |
| // We intentionally do NOT pass `deferStatusUntilSelection` here so the | |
| // top-level `deferredDisabledHint` guard in `handleChannelChoice` is | |
| // bypassed. That isolates the guard newly added inside the catalog | |
| // fallback; without it, the test would pass against an unguarded | |
| // fallback because the QuickStart path's early guard would catch the | |
| // disabled state first. | |
| resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); | |
| const fallbackCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| install: { npmSpec: "@vendor/external-chat-plugin" }, | |
| }); | |
| getTrustedChannelPluginCatalogEntry.mockReturnValue(fallbackCatalogEntry); | |
| const select = vi | |
| .fn() | |
| .mockResolvedValueOnce("external-chat") | |
| .mockResolvedValueOnce("__done__"); | |
| const note = vi.fn(async () => undefined); | |
| // Operator has explicitly disabled the plugin while a stale | |
| // `channels.<id>` entry lingers in config. | |
| const cfg = { | |
| plugins: { entries: { "external-chat": { enabled: false } } }, | |
| channels: { | |
| "external-chat": { | |
| enabled: true, | |
| appId: "999999", | |
| clientSecret: "stale", | |
| }, | |
| }, | |
| }; | |
| await runChannelSetup( | |
| cfg as never, | |
| { note, select }, | |
| { | |
| skipConfirm: true, | |
| skipDmPolicyPrompt: true, | |
| }, | |
| ); | |
| // The new catalog fallback must NOT drive an install. | |
| expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled(); | |
| // Instead, the same "Enable it before setup." note used by | |
| // `enableBundledPluginForSetup` should be shown. | |
| expect(note).toHaveBeenCalledWith( | |
| "external-chat cannot be configured while plugin disabled. Enable it before setup.", | |
| "Channel setup", | |
| ); | |
| }, | |
| ); | |
| it( | |
| "refuses the installed-catalog install fallback when the channel is " + | |
| "explicitly disabled in config", | |
| async () => { | |
| // Symmetric guard for the `installedCatalogEntry` fallback path. When | |
| // `loadScopedChannelPlugin` returns null and the catalog entry carries | |
| // `install.npmSpec`, the fix reaches for the catalog install flow — | |
| // but must first respect an operator-level disable, matching the | |
| // guard inside `enableBundledPluginForSetup`. | |
| // | |
| // As in the sibling test, we omit `deferStatusUntilSelection` to skip | |
| // the top-level guard and isolate the new inline guard. | |
| const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { | |
| pluginId: "@vendor/external-chat-plugin", | |
| install: { npmSpec: "@vendor/external-chat-plugin" }, | |
| }); | |
| resolveChannelSetupEntries.mockReturnValue( | |
| externalChatSetupEntries({ | |
| installedCatalogEntries: [installedCatalogEntry], | |
| installedCatalogById: new Map([["external-chat", installedCatalogEntry]]), | |
| }), | |
| ); | |
| loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); | |
| isChannelConfigured.mockReturnValue(false); | |
| const select = vi | |
| .fn() | |
| .mockResolvedValueOnce("external-chat") | |
| .mockResolvedValueOnce("__done__"); | |
| const note = vi.fn(async () => undefined); | |
| const cfg = { | |
| plugins: { entries: { "external-chat": { enabled: false } } }, | |
| channels: { | |
| "external-chat": { | |
| enabled: true, | |
| appId: "999999", | |
| clientSecret: "stale", | |
| }, | |
| }, | |
| }; | |
| await runChannelSetup( | |
| cfg as never, | |
| { note, select }, | |
| { | |
| skipConfirm: true, | |
| skipDmPolicyPrompt: true, | |
| }, | |
| ); | |
| expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled(); | |
| expect(note).toHaveBeenCalledWith( | |
| "external-chat cannot be configured while plugin disabled. Enable it before setup.", | |
| "Channel setup", | |
| ); | |
| }, | |
| ); | |
| }); | |
| /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ | |