From 05b7bd2c22b687d697e45dd89ddf4b47a6de71c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Feb 2026 04:03:42 +0000 Subject: [PATCH] refactor: dedupe command dispatch and process poll tests --- .../bash-tools.process.poll-timeout.test.ts | 130 ++++---- src/channels/plugins/actions/actions.test.ts | 171 +++++------ src/slack/monitor/slash.test.ts | 279 ++++++------------ 3 files changed, 217 insertions(+), 363 deletions(-) diff --git a/src/agents/bash-tools.process.poll-timeout.test.ts b/src/agents/bash-tools.process.poll-timeout.test.ts index 4556f4e256..3e0aa2b802 100644 --- a/src/agents/bash-tools.process.poll-timeout.test.ts +++ b/src/agents/bash-tools.process.poll-timeout.test.ts @@ -37,24 +37,46 @@ function createBackgroundSession(id: string): ProcessSession { }; } +function createProcessSessionHarness(sessionId: string) { + const processTool = createProcessTool(); + const session = createBackgroundSession(sessionId); + addSession(session); + return { processTool, session }; +} + +async function pollSession( + processTool: ReturnType, + callId: string, + sessionId: string, + timeout?: number | string, +) { + return processTool.execute(callId, { + action: "poll", + sessionId, + ...(timeout === undefined ? {} : { timeout }), + }); +} + +function retryMs(result: Awaited["execute"]>>) { + return (result.details as { retryInMs?: number }).retryInMs; +} + +function pollStatus(result: Awaited["execute"]>>) { + return (result.details as { status?: string }).status; +} + test("process poll waits for completion when timeout is provided", async () => { vi.useFakeTimers(); try { - const processTool = createProcessTool(); const sessionId = "sess"; - const session = createBackgroundSession(sessionId); - addSession(session); + const { processTool, session } = createProcessSessionHarness(sessionId); setTimeout(() => { appendOutput(session, "stdout", "done\n"); markExited(session, 0, null, "completed"); }, 10); - const pollPromise = processTool.execute("toolcall", { - action: "poll", - sessionId, - timeout: 2000, - }); + const pollPromise = pollSession(processTool, "toolcall", sessionId, 2000); let resolved = false; void pollPromise.finally(() => { @@ -77,20 +99,14 @@ test("process poll waits for completion when timeout is provided", async () => { test("process poll accepts string timeout values", async () => { vi.useFakeTimers(); try { - const processTool = createProcessTool(); const sessionId = "sess-2"; - const session = createBackgroundSession(sessionId); - addSession(session); + const { processTool, session } = createProcessSessionHarness(sessionId); setTimeout(() => { appendOutput(session, "stdout", "done\n"); markExited(session, 0, null, "completed"); }, 10); - const pollPromise = processTool.execute("toolcall", { - action: "poll", - sessionId, - timeout: "2000", - }); + const pollPromise = pollSession(processTool, "toolcall", sessionId, "2000"); await vi.advanceTimersByTimeAsync(350); const poll = await pollPromise; const details = poll.details as { status?: string; aggregated?: string }; @@ -102,77 +118,39 @@ test("process poll accepts string timeout values", async () => { }); test("process poll exposes adaptive retryInMs for repeated no-output polls", async () => { - const processTool = createProcessTool(); const sessionId = "sess-retry"; - const session = createBackgroundSession(sessionId); - addSession(session); + const { processTool } = createProcessSessionHarness(sessionId); - const poll1 = await processTool.execute("toolcall-1", { - action: "poll", - sessionId, - }); - const poll2 = await processTool.execute("toolcall-2", { - action: "poll", - sessionId, - }); - const poll3 = await processTool.execute("toolcall-3", { - action: "poll", - sessionId, - }); - const poll4 = await processTool.execute("toolcall-4", { - action: "poll", - sessionId, - }); - const poll5 = await processTool.execute("toolcall-5", { - action: "poll", - sessionId, - }); + const polls = await Promise.all([ + pollSession(processTool, "toolcall-1", sessionId), + pollSession(processTool, "toolcall-2", sessionId), + pollSession(processTool, "toolcall-3", sessionId), + pollSession(processTool, "toolcall-4", sessionId), + pollSession(processTool, "toolcall-5", sessionId), + ]); - expect((poll1.details as { retryInMs?: number }).retryInMs).toBe(5000); - expect((poll2.details as { retryInMs?: number }).retryInMs).toBe(10000); - expect((poll3.details as { retryInMs?: number }).retryInMs).toBe(30000); - expect((poll4.details as { retryInMs?: number }).retryInMs).toBe(60000); - expect((poll5.details as { retryInMs?: number }).retryInMs).toBe(60000); + expect(polls.map((poll) => retryMs(poll))).toEqual([5000, 10000, 30000, 60000, 60000]); }); test("process poll resets retryInMs when output appears and clears on completion", async () => { - const processTool = createProcessTool(); const sessionId = "sess-reset"; - const session = createBackgroundSession(sessionId); - addSession(session); + const { processTool, session } = createProcessSessionHarness(sessionId); - const poll1 = await processTool.execute("toolcall-1", { - action: "poll", - sessionId, - }); - const poll2 = await processTool.execute("toolcall-2", { - action: "poll", - sessionId, - }); - expect((poll1.details as { retryInMs?: number }).retryInMs).toBe(5000); - expect((poll2.details as { retryInMs?: number }).retryInMs).toBe(10000); + const poll1 = await pollSession(processTool, "toolcall-1", sessionId); + const poll2 = await pollSession(processTool, "toolcall-2", sessionId); + expect(retryMs(poll1)).toBe(5000); + expect(retryMs(poll2)).toBe(10000); appendOutput(session, "stdout", "step complete\n"); - const pollWithOutput = await processTool.execute("toolcall-output", { - action: "poll", - sessionId, - }); - expect((pollWithOutput.details as { retryInMs?: number }).retryInMs).toBe(5000); + const pollWithOutput = await pollSession(processTool, "toolcall-output", sessionId); + expect(retryMs(pollWithOutput)).toBe(5000); markExited(session, 0, null, "completed"); - const pollCompleted = await processTool.execute("toolcall-completed", { - action: "poll", - sessionId, - }); - const completedDetails = pollCompleted.details as { status?: string; retryInMs?: number }; - expect(completedDetails.status).toBe("completed"); - expect(completedDetails.retryInMs).toBeUndefined(); + const pollCompleted = await pollSession(processTool, "toolcall-completed", sessionId); + expect(pollStatus(pollCompleted)).toBe("completed"); + expect(retryMs(pollCompleted)).toBeUndefined(); - const pollFinished = await processTool.execute("toolcall-finished", { - action: "poll", - sessionId, - }); - const finishedDetails = pollFinished.details as { status?: string; retryInMs?: number }; - expect(finishedDetails.status).toBe("completed"); - expect(finishedDetails.retryInMs).toBeUndefined(); + const pollFinished = await pollSession(processTool, "toolcall-finished", sessionId); + expect(pollStatus(pollFinished)).toBe("completed"); + expect(retryMs(pollFinished)).toBeUndefined(); }); diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 6d8fdf68ed..2261388397 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -30,6 +30,39 @@ const { telegramMessageActions } = await import("./telegram.js"); const { signalMessageActions } = await import("./signal.js"); const { createSlackActions } = await import("../slack.actions.js"); +function telegramCfg(): OpenClawConfig { + return { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; +} + +function slackHarness() { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + const actions = createSlackActions("slack"); + return { cfg, actions }; +} + +type SlackActionInput = Parameters< + NonNullable["handleAction"]> +>[0]; + +async function runSlackAction( + action: SlackActionInput["action"], + params: SlackActionInput["params"], +) { + const { cfg, actions } = slackHarness(); + await actions.handleAction?.({ + channel: "slack", + action, + cfg, + params, + }); + return { cfg, actions }; +} + +function expectFirstSlackAction(expected: Record) { + const [params] = handleSlackAction.mock.calls[0] ?? []; + expect(params).toMatchObject(expected); +} + beforeEach(() => { vi.clearAllMocks(); }); @@ -309,14 +342,14 @@ describe("handleDiscordMessageAction", () => { describe("telegramMessageActions", () => { it("excludes sticker actions when not enabled", () => { - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const cfg = telegramCfg(); const actions = telegramMessageActions.listActions?.({ cfg }) ?? []; expect(actions).not.toContain("sticker"); expect(actions).not.toContain("sticker-search"); }); it("allows media-only sends and passes asVoice", async () => { - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const cfg = telegramCfg(); await telegramMessageActions.handleAction?.({ channel: "telegram", @@ -343,7 +376,7 @@ describe("telegramMessageActions", () => { }); it("passes silent flag for silent sends", async () => { - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const cfg = telegramCfg(); await telegramMessageActions.handleAction?.({ channel: "telegram", @@ -369,7 +402,7 @@ describe("telegramMessageActions", () => { }); it("maps edit action params into editMessage", async () => { - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const cfg = telegramCfg(); await telegramMessageActions.handleAction?.({ channel: "telegram", @@ -398,7 +431,7 @@ describe("telegramMessageActions", () => { }); it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const cfg = telegramCfg(); const handleAction = telegramMessageActions.handleAction; if (!handleAction) { throw new Error("telegram handleAction unavailable"); @@ -473,7 +506,7 @@ describe("telegramMessageActions", () => { }); it("accepts numeric messageId and channelId for reactions", async () => { - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const cfg = telegramCfg(); await telegramMessageActions.handleAction?.({ channel: "telegram", @@ -500,7 +533,7 @@ describe("telegramMessageActions", () => { }); it("maps topic-create params into createForumTopic", async () => { - const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig; + const cfg = telegramCfg(); await telegramMessageActions.handleAction?.({ channel: "telegram", @@ -678,21 +711,12 @@ describe("signalMessageActions", () => { describe("slack actions adapter", () => { it("forwards threadId for read", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "read", - cfg, - params: { - channelId: "C1", - threadId: "171234.567", - }, + await runSlackAction("read", { + channelId: "C1", + threadId: "171234.567", }); - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ + expectFirstSlackAction({ action: "readMessages", channelId: "C1", threadId: "171234.567", @@ -700,42 +724,24 @@ describe("slack actions adapter", () => { }); it("forwards normalized limit for emoji-list", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "emoji-list", - cfg, - params: { - limit: "2.9", - }, + await runSlackAction("emoji-list", { + limit: "2.9", }); - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ + expectFirstSlackAction({ action: "emojiList", limit: 2, }); }); it("forwards blocks JSON for send", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "send", - cfg, - params: { - to: "channel:C1", - message: "", - blocks: JSON.stringify([{ type: "divider" }]), - }, + await runSlackAction("send", { + to: "channel:C1", + message: "", + blocks: JSON.stringify([{ type: "divider" }]), }); - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ + expectFirstSlackAction({ action: "sendMessage", to: "channel:C1", content: "", @@ -744,22 +750,13 @@ describe("slack actions adapter", () => { }); it("forwards blocks arrays for send", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "send", - cfg, - params: { - to: "channel:C1", - message: "", - blocks: [{ type: "section", text: { type: "mrkdwn", text: "hi" } }], - }, + await runSlackAction("send", { + to: "channel:C1", + message: "", + blocks: [{ type: "section", text: { type: "mrkdwn", text: "hi" } }], }); - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ + expectFirstSlackAction({ action: "sendMessage", to: "channel:C1", content: "", @@ -768,8 +765,7 @@ describe("slack actions adapter", () => { }); it("rejects invalid blocks JSON for send", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); + const { cfg, actions } = slackHarness(); await expect( actions.handleAction?.({ @@ -787,8 +783,7 @@ describe("slack actions adapter", () => { }); it("rejects empty blocks arrays for send", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); + const { cfg, actions } = slackHarness(); await expect( actions.handleAction?.({ @@ -806,8 +801,7 @@ describe("slack actions adapter", () => { }); it("rejects send when both blocks and media are provided", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); + const { cfg, actions } = slackHarness(); await expect( actions.handleAction?.({ @@ -826,23 +820,14 @@ describe("slack actions adapter", () => { }); it("forwards blocks JSON for edit", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "edit", - cfg, - params: { - channelId: "C1", - messageId: "171234.567", - message: "", - blocks: JSON.stringify([{ type: "divider" }]), - }, + await runSlackAction("edit", { + channelId: "C1", + messageId: "171234.567", + message: "", + blocks: JSON.stringify([{ type: "divider" }]), }); - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ + expectFirstSlackAction({ action: "editMessage", channelId: "C1", messageId: "171234.567", @@ -852,23 +837,14 @@ describe("slack actions adapter", () => { }); it("forwards blocks arrays for edit", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); - - await actions.handleAction?.({ - channel: "slack", - action: "edit", - cfg, - params: { - channelId: "C1", - messageId: "171234.567", - message: "", - blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], - }, + await runSlackAction("edit", { + channelId: "C1", + messageId: "171234.567", + message: "", + blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }], }); - const [params] = handleSlackAction.mock.calls[0] ?? []; - expect(params).toMatchObject({ + expectFirstSlackAction({ action: "editMessage", channelId: "C1", messageId: "171234.567", @@ -878,8 +854,7 @@ describe("slack actions adapter", () => { }); it("rejects edit when both message and blocks are missing", async () => { - const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; - const actions = createSlackActions("slack"); + const { cfg, actions } = slackHarness(); await expect( actions.handleAction?.({ diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts index 60271450d7..6653660a76 100644 --- a/src/slack/monitor/slash.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -266,6 +266,71 @@ function createArgMenusHarness() { return { commands, actions, options, postEphemeral, ctx, account }; } +function requireHandler( + handlers: Map Promise>, + key: string, + label: string, +): (args: unknown) => Promise { + const handler = handlers.get(key); + if (!handler) { + throw new Error(`Missing ${label} handler`); + } + return handler; +} + +function createSlashCommand(overrides: Partial> = {}) { + return { + user_id: "U1", + user_name: "Ada", + channel_id: "C1", + channel_name: "directmessage", + text: "", + trigger_id: "t1", + ...overrides, + }; +} + +async function runCommandHandler(handler: (args: unknown) => Promise) { + const respond = vi.fn().mockResolvedValue(undefined); + const ack = vi.fn().mockResolvedValue(undefined); + await handler({ + command: createSlashCommand(), + ack, + respond, + }); + return { respond, ack }; +} + +async function runArgMenuAction( + handler: (args: unknown) => Promise, + params: { + action: Record; + userId?: string; + userName?: string; + channelId?: string; + channelName?: string; + respond?: ReturnType; + includeRespond?: boolean; + }, +) { + const includeRespond = params.includeRespond ?? true; + const respond = params.respond ?? vi.fn().mockResolvedValue(undefined); + const payload: Record = { + ack: vi.fn().mockResolvedValue(undefined), + action: params.action, + body: { + user: { id: params.userId ?? "U1", name: params.userName ?? "Ada" }, + channel: { id: params.channelId ?? "C1", name: params.channelName ?? "directmessage" }, + trigger_id: "t1", + }, + }; + if (includeRespond) { + payload.respond = respond; + } + await handler(payload); + return respond; +} + describe("Slack native command argument menus", () => { let harness: ReturnType; let usageHandler: (args: unknown) => Promise; @@ -280,48 +345,14 @@ describe("Slack native command argument menus", () => { beforeAll(async () => { harness = createArgMenusHarness(); await registerCommands(harness.ctx, harness.account); - - const usage = harness.commands.get("/usage"); - if (!usage) { - throw new Error("Missing /usage handler"); - } - usageHandler = usage; - const report = harness.commands.get("/report"); - if (!report) { - throw new Error("Missing /report handler"); - } - reportHandler = report; - const reportCompact = harness.commands.get("/reportcompact"); - if (!reportCompact) { - throw new Error("Missing /reportcompact handler"); - } - reportCompactHandler = reportCompact; - const reportExternal = harness.commands.get("/reportexternal"); - if (!reportExternal) { - throw new Error("Missing /reportexternal handler"); - } - reportExternalHandler = reportExternal; - const reportLong = harness.commands.get("/reportlong"); - if (!reportLong) { - throw new Error("Missing /reportlong handler"); - } - reportLongHandler = reportLong; - const unsafeConfirm = harness.commands.get("/unsafeconfirm"); - if (!unsafeConfirm) { - throw new Error("Missing /unsafeconfirm handler"); - } - unsafeConfirmHandler = unsafeConfirm; - - const argMenu = harness.actions.get("openclaw_cmdarg"); - if (!argMenu) { - throw new Error("Missing arg-menu action handler"); - } - argMenuHandler = argMenu; - const argMenuOptions = harness.options.get("openclaw_cmdarg"); - if (!argMenuOptions) { - throw new Error("Missing arg-menu options handler"); - } - argMenuOptionsHandler = argMenuOptions; + usageHandler = requireHandler(harness.commands, "/usage", "/usage"); + reportHandler = requireHandler(harness.commands, "/report", "/report"); + reportCompactHandler = requireHandler(harness.commands, "/reportcompact", "/reportcompact"); + reportExternalHandler = requireHandler(harness.commands, "/reportexternal", "/reportexternal"); + reportLongHandler = requireHandler(harness.commands, "/reportlong", "/reportlong"); + unsafeConfirmHandler = requireHandler(harness.commands, "/unsafeconfirm", "/unsafeconfirm"); + argMenuHandler = requireHandler(harness.actions, "openclaw_cmdarg", "arg-menu action"); + argMenuOptionsHandler = requireHandler(harness.options, "openclaw_cmdarg", "arg-menu options"); }); beforeEach(() => { @@ -329,21 +360,7 @@ describe("Slack native command argument menus", () => { }); it("shows a button menu when required args are omitted", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - const ack = vi.fn().mockResolvedValue(undefined); - - await usageHandler({ - command: { - user_id: "U1", - user_name: "Ada", - channel_id: "C1", - channel_name: "directmessage", - text: "", - trigger_id: "t1", - }, - ack, - respond, - }); + const { respond } = await runCommandHandler(usageHandler); expect(respond).toHaveBeenCalledTimes(1); const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> }; @@ -357,21 +374,7 @@ describe("Slack native command argument menus", () => { }); it("shows a static_select menu when choices exceed button row size", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - const ack = vi.fn().mockResolvedValue(undefined); - - await reportHandler({ - command: { - user_id: "U1", - user_name: "Ada", - channel_id: "C1", - channel_name: "directmessage", - text: "", - trigger_id: "t1", - }, - ack, - respond, - }); + const { respond } = await runCommandHandler(reportHandler); expect(respond).toHaveBeenCalledTimes(1); const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> }; @@ -386,21 +389,7 @@ describe("Slack native command argument menus", () => { }); it("falls back to buttons when static_select value limit would be exceeded", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - const ack = vi.fn().mockResolvedValue(undefined); - - await reportLongHandler({ - command: { - user_id: "U1", - user_name: "Ada", - channel_id: "C1", - channel_name: "directmessage", - text: "", - trigger_id: "t1", - }, - ack, - respond, - }); + const { respond } = await runCommandHandler(reportLongHandler); expect(respond).toHaveBeenCalledTimes(1); const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> }; @@ -411,21 +400,7 @@ describe("Slack native command argument menus", () => { }); it("shows an overflow menu when choices fit compact range", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - const ack = vi.fn().mockResolvedValue(undefined); - - await reportCompactHandler({ - command: { - user_id: "U1", - user_name: "Ada", - channel_id: "C1", - channel_name: "directmessage", - text: "", - trigger_id: "t1", - }, - ack, - respond, - }); + const { respond } = await runCommandHandler(reportCompactHandler); expect(respond).toHaveBeenCalledTimes(1); const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> }; @@ -437,21 +412,7 @@ describe("Slack native command argument menus", () => { }); it("escapes mrkdwn characters in confirm dialog text", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - const ack = vi.fn().mockResolvedValue(undefined); - - await unsafeConfirmHandler({ - command: { - user_id: "U1", - user_name: "Ada", - channel_id: "C1", - channel_name: "directmessage", - text: "", - trigger_id: "t1", - }, - ack, - respond, - }); + const { respond } = await runCommandHandler(unsafeConfirmHandler); expect(respond).toHaveBeenCalledTimes(1); const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> }; @@ -465,18 +426,10 @@ describe("Slack native command argument menus", () => { }); it("dispatches the command when a menu button is clicked", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - await argMenuHandler({ - ack: vi.fn().mockResolvedValue(undefined), + await runArgMenuAction(argMenuHandler, { action: { value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }), }, - body: { - user: { id: "U1", name: "Ada" }, - channel: { id: "C1", name: "directmessage" }, - trigger_id: "t1", - }, - respond, }); expect(dispatchMock).toHaveBeenCalledTimes(1); @@ -485,20 +438,12 @@ describe("Slack native command argument menus", () => { }); it("dispatches the command when a static_select option is chosen", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - await argMenuHandler({ - ack: vi.fn().mockResolvedValue(undefined), + await runArgMenuAction(argMenuHandler, { action: { selected_option: { value: encodeValue({ command: "report", arg: "period", value: "month", userId: "U1" }), }, }, - body: { - user: { id: "U1", name: "Ada" }, - channel: { id: "C1", name: "directmessage" }, - trigger_id: "t1", - }, - respond, }); expect(dispatchMock).toHaveBeenCalledTimes(1); @@ -507,9 +452,7 @@ describe("Slack native command argument menus", () => { }); it("dispatches the command when an overflow option is chosen", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - await argMenuHandler({ - ack: vi.fn().mockResolvedValue(undefined), + await runArgMenuAction(argMenuHandler, { action: { selected_option: { value: encodeValue({ @@ -520,12 +463,6 @@ describe("Slack native command argument menus", () => { }), }, }, - body: { - user: { id: "U1", name: "Ada" }, - channel: { id: "C1", name: "directmessage" }, - trigger_id: "t1", - }, - respond, }); expect(dispatchMock).toHaveBeenCalledTimes(1); @@ -534,21 +471,7 @@ describe("Slack native command argument menus", () => { }); it("shows an external_select menu when choices exceed static_select options max", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - const ack = vi.fn().mockResolvedValue(undefined); - - await reportExternalHandler({ - command: { - user_id: "U1", - user_name: "Ada", - channel_id: "C1", - channel_name: "directmessage", - text: "", - trigger_id: "t1", - }, - ack, - respond, - }); + const { respond } = await runCommandHandler(reportExternalHandler); expect(respond).toHaveBeenCalledTimes(1); const payload = respond.mock.calls[0]?.[0] as { @@ -564,21 +487,7 @@ describe("Slack native command argument menus", () => { }); it("serves filtered options for external_select menus", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - const ack = vi.fn().mockResolvedValue(undefined); - - await reportExternalHandler({ - command: { - user_id: "U1", - user_name: "Ada", - channel_id: "C1", - channel_name: "directmessage", - text: "", - trigger_id: "t1", - }, - ack, - respond, - }); + const { respond } = await runCommandHandler(reportExternalHandler); const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string; block_id?: string }>; @@ -605,18 +514,12 @@ describe("Slack native command argument menus", () => { }); it("rejects menu clicks from other users", async () => { - const respond = vi.fn().mockResolvedValue(undefined); - await argMenuHandler({ - ack: vi.fn().mockResolvedValue(undefined), + const respond = await runArgMenuAction(argMenuHandler, { action: { value: encodeValue({ command: "usage", arg: "mode", value: "tokens", userId: "U1" }), }, - body: { - user: { id: "U2", name: "Eve" }, - channel: { id: "C1", name: "directmessage" }, - trigger_id: "t1", - }, - respond, + userId: "U2", + userName: "Eve", }); expect(dispatchMock).not.toHaveBeenCalled(); @@ -627,10 +530,9 @@ describe("Slack native command argument menus", () => { }); it("falls back to postEphemeral with token when respond is unavailable", async () => { - await argMenuHandler({ - ack: vi.fn().mockResolvedValue(undefined), + await runArgMenuAction(argMenuHandler, { action: { value: "garbage" }, - body: { user: { id: "U1" }, channel: { id: "C1" } }, + includeRespond: false, }); expect(harness.postEphemeral).toHaveBeenCalledWith( @@ -643,10 +545,9 @@ describe("Slack native command argument menus", () => { }); it("treats malformed percent-encoding as an invalid button (no throw)", async () => { - await argMenuHandler({ - ack: vi.fn().mockResolvedValue(undefined), + await runArgMenuAction(argMenuHandler, { action: { value: "cmdarg|%E0%A4%A|mode|on|U1" }, - body: { user: { id: "U1" }, channel: { id: "C1" } }, + includeRespond: false, }); expect(harness.postEphemeral).toHaveBeenCalledWith(