From 465aad50a7fa3f27dcf15e244de8d75a2fe3ef6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 00:42:17 -0700 Subject: [PATCH] test(outbound): make poll coverage order-independent (#121923) --- .../message-action-execution.poll.test.ts | 183 ++++++++++++------ .../message-action-runner.poll.test.ts | 34 ---- .../message-action-runner.test-helpers.ts | 117 ----------- 3 files changed, 119 insertions(+), 215 deletions(-) delete mode 100644 src/infra/outbound/message-action-runner.poll.test.ts diff --git a/src/infra/outbound/message-action-execution.poll.test.ts b/src/infra/outbound/message-action-execution.poll.test.ts index 8e6828b5b326..8baa03f1a51b 100644 --- a/src/infra/outbound/message-action-execution.poll.test.ts +++ b/src/infra/outbound/message-action-execution.poll.test.ts @@ -1,28 +1,105 @@ -// Covers message-action poll handling through plugin dispatch and core gateway -// poll fallback. -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - clearMessageActionPollMocks, - messageActionRunnerMocks as mocks, - pollerConfig, - pollerTestPlugin, - resetMessageActionPollMocks, - runPollAction, -} from "./message-action-runner.test-helpers.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +// Covers message-action poll normalization and direct provider delivery. +import type { + ChannelPlugin, + ChannelThreadingToolContext, +} from "../../channels/plugins/types.public.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { executeMessagePoll } from "./message-action-execution.js"; -describe("runMessageAction poll handling", () => { - beforeEach(() => { - resetMessageActionPollMocks(); - }); +const pollerConfig = { + channels: { + poller: { + botToken: "poller-test", + }, + }, +} as OpenClawConfig; - afterEach(() => { - clearMessageActionPollMocks(); - }); - it("passes shared poll fields and auto threadId to executePollAction", async () => { - const call = await runPollAction({ +type PollerSendPoll = NonNullable["sendPoll"]>; + +const pollerSendPoll = vi.fn(async () => ({ + messageId: "poll-test", +})); + +const pollerTestPlugin: ChannelPlugin = { + id: "poller", + meta: { + id: "poller", + label: "Poller", + selectionLabel: "Poller", + docsPath: "/channels/poller", + blurb: "Poller test plugin.", + }, + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ botToken: "poller-test" }), + isConfigured: () => true, + }, + outbound: { + deliveryMode: "direct", + sendPoll: pollerSendPoll, + }, + messaging: { + targetResolver: { + looksLikeId: () => true, + resolveTarget: async ({ normalized }) => ({ + to: normalized, + kind: "user", + source: "normalized", + }), + }, + }, + threading: { + resolveAutoThreadId: ({ toolContext, to, replyToId }) => { + if (replyToId || toolContext?.currentChannelId !== to) { + return undefined; + } + return toolContext.currentThreadTs; + }, + }, +}; + +async function runPollAction(params: { + actionParams: Record; + toolContext?: ChannelThreadingToolContext; +}) { + const target = params.actionParams.target; + if (typeof target !== "string") { + throw new Error("poll test target is required"); + } + const actionParams = { ...params.actionParams, to: target }; + await executeMessagePoll({ + cfg: pollerConfig, + params: actionParams, + channel: "poller", + channelPlugin: pollerTestPlugin, + mediaAccess: {}, + accountId: "default", + dryRun: false, + input: { cfg: pollerConfig, + action: "poll", + params: actionParams, + toolContext: params.toolContext, + }, + }); + const call = pollerSendPoll.mock.calls[0]?.[0]; + if (!call) { + throw new Error("expected poller sendPoll call"); + } + return call; +} + +describe("executeMessagePoll", () => { + beforeEach(() => { + pollerSendPoll.mockReset(); + pollerSendPoll.mockResolvedValue({ messageId: "poll-test" }); + }); + + it("passes normalized poll fields and auto threadId to the provider", async () => { + const call = await runPollAction({ actionParams: { - channel: "poller", target: "poller:123", pollQuestion: "Lunch?", pollOption: ["Pizza", "Sushi"], @@ -34,11 +111,13 @@ describe("runMessageAction poll handling", () => { }, }); - expect(call?.durationHours).toBe(2); - expect(call?.threadId).toBe("42"); - expect(call?.ctx?.params?.threadId).toBe("42"); - expect(call?.ctx?.plugin).toBe(pollerTestPlugin); - expect(mocks.resolveOutboundChannelPlugin).toHaveBeenCalledTimes(1); + expect(call.poll).toMatchObject({ + question: "Lunch?", + options: ["Pizza", "Sushi"], + durationHours: 2, + maxSelections: 1, + }); + expect(call.threadId).toBe("42"); }); it.each([0, -1, 1.5, "1.5", "soon"])( @@ -46,9 +125,7 @@ describe("runMessageAction poll handling", () => { async (pollDurationHours) => { await expect( runPollAction({ - cfg: pollerConfig, actionParams: { - channel: "poller", target: "poller:123", pollQuestion: "Lunch?", pollOption: ["Pizza", "Sushi"], @@ -59,41 +136,9 @@ describe("runMessageAction poll handling", () => { }, ); - it("passes inbound event kind to poll execution", async () => { - const call = await runPollAction({ - cfg: pollerConfig, - actionParams: { - channel: "poller", - target: "poller:123", - pollQuestion: "Lunch?", - pollOption: ["Pizza", "Sushi"], - }, - inboundEventKind: "room_event", - }); - - expect(call?.ctx?.inboundEventKind).toBe("room_event"); - }); - - it("copies the normalized idempotency key into poll execution context", async () => { - const call = await runPollAction({ - cfg: pollerConfig, - actionParams: { - channel: "poller", - target: "poller:123", - pollQuestion: "Lunch?", - pollOption: ["Pizza", "Sushi"], - idempotencyKey: " run-1:message-tool:poll-1:fingerprint ", - }, - }); - - expect(call?.ctx?.idempotencyKey).toBe("run-1:message-tool:poll-1:fingerprint"); - }); - it("expands maxSelections when pollMulti is enabled", async () => { const call = await runPollAction({ - cfg: pollerConfig, actionParams: { - channel: "poller", target: "poller:123", pollQuestion: "Lunch?", pollOption: ["Pizza", "Sushi", "Soup"], @@ -101,20 +146,30 @@ describe("runMessageAction poll handling", () => { }, }); - expect(call?.maxSelections).toBe(3); + expect(call.poll.maxSelections).toBe(3); }); it("defaults maxSelections to one choice when pollMulti is omitted", async () => { const call = await runPollAction({ - cfg: pollerConfig, actionParams: { - channel: "poller", target: "poller:123", pollQuestion: "Lunch?", pollOption: ["Pizza", "Sushi", "Soup"], }, }); - expect(call?.maxSelections).toBe(1); + expect(call.poll.maxSelections).toBe(1); + }); + + it("requires at least two poll options", async () => { + await expect( + runPollAction({ + actionParams: { + target: "poller:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza"], + }, + }), + ).rejects.toThrow(/pollOption requires at least two values/i); }); }); diff --git a/src/infra/outbound/message-action-runner.poll.test.ts b/src/infra/outbound/message-action-runner.poll.test.ts deleted file mode 100644 index 6e6a10e47d3c..000000000000 --- a/src/infra/outbound/message-action-runner.poll.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Covers message-action poll handling through plugin dispatch and core gateway -// poll fallback. -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - clearMessageActionPollMocks, - messageActionRunnerMocks as mocks, - pollerConfig, - resetMessageActionPollMocks, - runPollAction, -} from "./message-action-runner.test-helpers.js"; - -describe("runMessageAction poll handling", () => { - beforeEach(() => { - resetMessageActionPollMocks(); - }); - - afterEach(() => { - clearMessageActionPollMocks(); - }); - it("requires at least two poll options", async () => { - await expect( - runPollAction({ - cfg: pollerConfig, - actionParams: { - channel: "poller", - target: "poller:123", - pollQuestion: "Lunch?", - pollOption: ["Pizza"], - }, - }), - ).rejects.toThrow(/pollOption requires at least two values/i); - expect(mocks.executePollAction).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/infra/outbound/message-action-runner.test-helpers.ts b/src/infra/outbound/message-action-runner.test-helpers.ts index 4ebccd6ff82e..70dfd495b26c 100644 --- a/src/infra/outbound/message-action-runner.test-helpers.ts +++ b/src/infra/outbound/message-action-runner.test-helpers.ts @@ -7,7 +7,6 @@ import type { ChannelMessageActionName, ChannelPlugin, } from "../../channels/plugins/types.public.js"; -import type { OpenClawConfig } from "../../config/config.js"; import { normalizeMessagePresentation, renderMessagePresentationFallbackText, @@ -349,119 +348,3 @@ export async function resetMessageActionMediaMocks() { mocks.loadWebMedia.mockReset(); mocks.loadWebMedia.mockImplementation(actualLoadWebMedia); } - -export const pollerConfig = { - channels: { - poller: { - botToken: "poller-test", - }, - }, -} as OpenClawConfig; - -export const pollerTestPlugin: ChannelPlugin = { - id: "poller", - meta: { - id: "poller", - label: "Poller", - selectionLabel: "Poller", - docsPath: "/channels/poller", - blurb: "Poller test plugin.", - }, - capabilities: { chatTypes: ["direct", "group"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ botToken: "poller-test" }), - isConfigured: () => true, - }, - outbound: { - deliveryMode: "gateway", - sendPoll: async () => ({ - messageId: "poll-test", - }), - }, - messaging: { - targetResolver: { - looksLikeId: () => true, - resolveTarget: async ({ normalized }) => ({ - to: normalized, - kind: "user", - source: "normalized", - }), - }, - }, - threading: { - resolveAutoThreadId: ({ toolContext, to, replyToId }) => { - if (replyToId) { - return undefined; - } - if (toolContext?.currentChannelId !== to) { - return undefined; - } - return toolContext.currentThreadTs; - }, - }, -}; - -export async function runPollAction(params: { - cfg: OpenClawConfig; - actionParams: Record; - toolContext?: Record; - inboundEventKind?: "user_request" | "room_event"; -}) { - await runMessageAction({ - cfg: params.cfg, - action: "poll", - params: params.actionParams as never, - toolContext: params.toolContext as never, - inboundEventKind: params.inboundEventKind, - }); - const call = messageActionRunnerMocks.executePollAction.mock.calls[0]?.[0] as - | { - resolveCorePoll?: () => { - durationHours?: number; - maxSelections?: number; - threadId?: string; - }; - ctx?: { - plugin?: ChannelPlugin; - inboundEventKind?: string; - idempotencyKey?: string; - params?: Record; - }; - } - | undefined; - if (!call) { - throw new Error("expected executePollAction call"); - } - return { - ...call.resolveCorePoll?.(), - ctx: call.ctx, - }; -} - -export function resetMessageActionPollMocks() { - setActivePluginRegistry( - createTestRegistry([{ pluginId: "poller", source: "test", plugin: pollerTestPlugin }]), - ); - const mocks = messageActionRunnerMocks; - mocks.resolveOutboundChannelPlugin.mockReset(); - mocks.resolveOutboundChannelPlugin.mockImplementation( - ({ channel }: { channel: string }) => - getActivePluginRegistry()?.channels.find((entry) => entry?.plugin?.id === channel)?.plugin, - ); - mocks.executeSendAction.mockReset(); - mocks.executeSendAction.mockImplementation(async () => { - throw new Error("executeSendAction should not run in poll tests"); - }); - mocks.executePollAction.mockReset(); - mocks.executePollAction.mockImplementation(async (input) => ({ - handledBy: "core", - payload: { ok: true, corePoll: input.resolveCorePoll() }, - pollResult: { ok: true }, - })); -} - -export function clearMessageActionPollMocks() { - setActivePluginRegistry(createTestRegistry([])); - messageActionRunnerMocks.executePollAction.mockReset(); -}