test(outbound): make poll coverage order-independent (#121923)

This commit is contained in:
Peter Steinberger
2026-08-11 00:42:17 -07:00
committed by GitHub
parent 210aca6de3
commit 465aad50a7
3 changed files with 119 additions and 215 deletions
@@ -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<NonNullable<ChannelPlugin["outbound"]>["sendPoll"]>;
const pollerSendPoll = vi.fn<PollerSendPoll>(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<string, unknown>;
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);
});
});
@@ -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);
});
});
@@ -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<string, unknown>;
toolContext?: Record<string, unknown>;
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<string, unknown>;
};
}
| 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();
}