From fdbfdec341f2b442f459ad04923cf7640e6a9851 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 20:13:53 +0000 Subject: [PATCH] test: add channel resolution helper coverage --- src/infra/exec-approval-surface.test.ts | 196 ++++++++++++++++++ src/infra/outbound/channel-resolution.test.ts | 156 ++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 src/infra/exec-approval-surface.test.ts create mode 100644 src/infra/outbound/channel-resolution.test.ts diff --git a/src/infra/exec-approval-surface.test.ts b/src/infra/exec-approval-surface.test.ts new file mode 100644 index 00000000000..b263330104a --- /dev/null +++ b/src/infra/exec-approval-surface.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadConfigMock = vi.hoisted(() => vi.fn()); +const listEnabledDiscordAccountsMock = vi.hoisted(() => vi.fn()); +const isDiscordExecApprovalClientEnabledMock = vi.hoisted(() => vi.fn()); +const listEnabledTelegramAccountsMock = vi.hoisted(() => vi.fn()); +const isTelegramExecApprovalClientEnabledMock = vi.hoisted(() => vi.fn()); +const normalizeMessageChannelMock = vi.hoisted(() => vi.fn()); + +vi.mock("../config/config.js", () => ({ + loadConfig: (...args: unknown[]) => loadConfigMock(...args), +})); + +vi.mock("../discord/accounts.js", () => ({ + listEnabledDiscordAccounts: (...args: unknown[]) => listEnabledDiscordAccountsMock(...args), +})); + +vi.mock("../discord/exec-approvals.js", () => ({ + isDiscordExecApprovalClientEnabled: (...args: unknown[]) => + isDiscordExecApprovalClientEnabledMock(...args), +})); + +vi.mock("../telegram/accounts.js", () => ({ + listEnabledTelegramAccounts: (...args: unknown[]) => listEnabledTelegramAccountsMock(...args), +})); + +vi.mock("../telegram/exec-approvals.js", () => ({ + isTelegramExecApprovalClientEnabled: (...args: unknown[]) => + isTelegramExecApprovalClientEnabledMock(...args), +})); + +vi.mock("../utils/message-channel.js", () => ({ + INTERNAL_MESSAGE_CHANNEL: "web", + normalizeMessageChannel: (...args: unknown[]) => normalizeMessageChannelMock(...args), +})); + +import { + hasConfiguredExecApprovalDmRoute, + resolveExecApprovalInitiatingSurfaceState, +} from "./exec-approval-surface.js"; + +describe("resolveExecApprovalInitiatingSurfaceState", () => { + beforeEach(() => { + loadConfigMock.mockReset(); + listEnabledDiscordAccountsMock.mockReset(); + isDiscordExecApprovalClientEnabledMock.mockReset(); + listEnabledTelegramAccountsMock.mockReset(); + isTelegramExecApprovalClientEnabledMock.mockReset(); + normalizeMessageChannelMock.mockReset(); + normalizeMessageChannelMock.mockImplementation((value?: string | null) => + typeof value === "string" ? value.trim().toLowerCase() : undefined, + ); + }); + + it("treats web UI, terminal UI, and missing channels as enabled", () => { + expect(resolveExecApprovalInitiatingSurfaceState({ channel: null })).toEqual({ + kind: "enabled", + channel: undefined, + channelLabel: "this platform", + }); + expect(resolveExecApprovalInitiatingSurfaceState({ channel: "tui" })).toEqual({ + kind: "enabled", + channel: "tui", + channelLabel: "terminal UI", + }); + expect(resolveExecApprovalInitiatingSurfaceState({ channel: "web" })).toEqual({ + kind: "enabled", + channel: "web", + channelLabel: "Web UI", + }); + }); + + it("uses the provided cfg for telegram and discord client enablement", () => { + isTelegramExecApprovalClientEnabledMock.mockReturnValueOnce(true); + isDiscordExecApprovalClientEnabledMock.mockReturnValueOnce(false); + const cfg = { channels: {} }; + + expect( + resolveExecApprovalInitiatingSurfaceState({ + channel: "telegram", + accountId: "main", + cfg: cfg as never, + }), + ).toEqual({ + kind: "enabled", + channel: "telegram", + channelLabel: "Telegram", + }); + expect( + resolveExecApprovalInitiatingSurfaceState({ + channel: "discord", + accountId: "main", + cfg: cfg as never, + }), + ).toEqual({ + kind: "disabled", + channel: "discord", + channelLabel: "Discord", + }); + + expect(loadConfigMock).not.toHaveBeenCalled(); + }); + + it("loads config lazily when cfg is omitted and marks unsupported channels", () => { + loadConfigMock.mockReturnValueOnce({ loaded: true }); + isTelegramExecApprovalClientEnabledMock.mockReturnValueOnce(false); + + expect( + resolveExecApprovalInitiatingSurfaceState({ + channel: "telegram", + accountId: "main", + }), + ).toEqual({ + kind: "disabled", + channel: "telegram", + channelLabel: "Telegram", + }); + expect(loadConfigMock).toHaveBeenCalledOnce(); + + expect(resolveExecApprovalInitiatingSurfaceState({ channel: "signal" })).toEqual({ + kind: "unsupported", + channel: "signal", + channelLabel: "Signal", + }); + }); +}); + +describe("hasConfiguredExecApprovalDmRoute", () => { + beforeEach(() => { + listEnabledDiscordAccountsMock.mockReset(); + listEnabledTelegramAccountsMock.mockReset(); + }); + + it("returns true when any enabled account routes approvals to DM or both", () => { + listEnabledDiscordAccountsMock.mockReturnValueOnce([ + { + config: { + execApprovals: { + enabled: true, + approvers: ["a"], + target: "channel", + }, + }, + }, + ]); + listEnabledTelegramAccountsMock.mockReturnValueOnce([ + { + config: { + execApprovals: { + enabled: true, + approvers: ["a"], + target: "both", + }, + }, + }, + ]); + + expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(true); + }); + + it("returns false when exec approvals are disabled or have no DM route", () => { + listEnabledDiscordAccountsMock.mockReturnValueOnce([ + { + config: { + execApprovals: { + enabled: false, + approvers: ["a"], + target: "dm", + }, + }, + }, + ]); + listEnabledTelegramAccountsMock.mockReturnValueOnce([ + { + config: { + execApprovals: { + enabled: true, + approvers: [], + target: "dm", + }, + }, + }, + { + config: { + execApprovals: { + enabled: true, + approvers: ["a"], + target: "channel", + }, + }, + }, + ]); + + expect(hasConfiguredExecApprovalDmRoute({} as never)).toBe(false); + }); +}); diff --git a/src/infra/outbound/channel-resolution.test.ts b/src/infra/outbound/channel-resolution.test.ts new file mode 100644 index 00000000000..407994b152f --- /dev/null +++ b/src/infra/outbound/channel-resolution.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn()); +const resolveAgentWorkspaceDirMock = vi.hoisted(() => vi.fn()); +const getChannelPluginMock = vi.hoisted(() => vi.fn()); +const applyPluginAutoEnableMock = vi.hoisted(() => vi.fn()); +const loadOpenClawPluginsMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryKeyMock = vi.hoisted(() => vi.fn()); +const normalizeMessageChannelMock = vi.hoisted(() => vi.fn()); +const isDeliverableMessageChannelMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveDefaultAgentId: (...args: unknown[]) => resolveDefaultAgentIdMock(...args), + resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args), +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), +})); + +vi.mock("../../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: (...args: unknown[]) => applyPluginAutoEnableMock(...args), +})); + +vi.mock("../../plugins/loader.js", () => ({ + loadOpenClawPlugins: (...args: unknown[]) => loadOpenClawPluginsMock(...args), +})); + +vi.mock("../../plugins/runtime.js", () => ({ + getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args), + getActivePluginRegistryKey: (...args: unknown[]) => getActivePluginRegistryKeyMock(...args), +})); + +vi.mock("../../utils/message-channel.js", () => ({ + normalizeMessageChannel: (...args: unknown[]) => normalizeMessageChannelMock(...args), + isDeliverableMessageChannel: (...args: unknown[]) => isDeliverableMessageChannelMock(...args), +})); + +import { importFreshModule } from "../../../test/helpers/import-fresh.js"; + +async function importChannelResolution(scope: string) { + return await importFreshModule( + import.meta.url, + `./channel-resolution.js?scope=${scope}`, + ); +} + +describe("outbound channel resolution", () => { + beforeEach(() => { + resolveDefaultAgentIdMock.mockReset(); + resolveAgentWorkspaceDirMock.mockReset(); + getChannelPluginMock.mockReset(); + applyPluginAutoEnableMock.mockReset(); + loadOpenClawPluginsMock.mockReset(); + getActivePluginRegistryMock.mockReset(); + getActivePluginRegistryKeyMock.mockReset(); + normalizeMessageChannelMock.mockReset(); + isDeliverableMessageChannelMock.mockReset(); + + normalizeMessageChannelMock.mockImplementation((value?: string | null) => + typeof value === "string" ? value.trim().toLowerCase() : undefined, + ); + isDeliverableMessageChannelMock.mockImplementation((value?: string) => + ["telegram", "discord", "slack"].includes(String(value)), + ); + getActivePluginRegistryMock.mockReturnValue({ channels: [] }); + getActivePluginRegistryKeyMock.mockReturnValue("registry-key"); + applyPluginAutoEnableMock.mockReturnValue({ config: { autoEnabled: true } }); + resolveDefaultAgentIdMock.mockReturnValue("main"); + resolveAgentWorkspaceDirMock.mockReturnValue("/tmp/workspace"); + }); + + it("normalizes deliverable channels and rejects unknown ones", async () => { + const channelResolution = await importChannelResolution("normalize"); + + expect(channelResolution.normalizeDeliverableOutboundChannel(" Telegram ")).toBe("telegram"); + expect(channelResolution.normalizeDeliverableOutboundChannel("unknown")).toBeUndefined(); + expect(channelResolution.normalizeDeliverableOutboundChannel(null)).toBeUndefined(); + }); + + it("returns the already-registered plugin without bootstrapping", async () => { + const plugin = { id: "telegram" }; + getChannelPluginMock.mockReturnValueOnce(plugin); + const channelResolution = await importChannelResolution("existing-plugin"); + + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "telegram", + cfg: {} as never, + }), + ).toBe(plugin); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + + it("falls back to the active registry when getChannelPlugin misses", async () => { + const plugin = { id: "telegram" }; + getChannelPluginMock.mockReturnValue(undefined); + getActivePluginRegistryMock.mockReturnValue({ + channels: [{ plugin }], + }); + const channelResolution = await importChannelResolution("direct-registry"); + + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "telegram", + cfg: {} as never, + }), + ).toBe(plugin); + }); + + it("bootstraps plugins once per registry key and returns the newly loaded plugin", async () => { + const plugin = { id: "telegram" }; + getChannelPluginMock.mockReturnValueOnce(undefined).mockReturnValueOnce(plugin); + const channelResolution = await importChannelResolution("bootstrap-success"); + + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "telegram", + cfg: { channels: {} } as never, + }), + ).toBe(plugin); + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith({ + config: { autoEnabled: true }, + workspaceDir: "/tmp/workspace", + }); + + getChannelPluginMock.mockReturnValue(undefined); + channelResolution.resolveOutboundChannelPlugin({ + channel: "telegram", + cfg: { channels: {} } as never, + }); + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); + }); + + it("retries bootstrap after a transient load failure", async () => { + getChannelPluginMock.mockReturnValue(undefined); + loadOpenClawPluginsMock.mockImplementationOnce(() => { + throw new Error("transient"); + }); + const channelResolution = await importChannelResolution("bootstrap-retry"); + + expect( + channelResolution.resolveOutboundChannelPlugin({ + channel: "telegram", + cfg: { channels: {} } as never, + }), + ).toBeUndefined(); + + channelResolution.resolveOutboundChannelPlugin({ + channel: "telegram", + cfg: { channels: {} } as never, + }); + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2); + }); +});