refactor: dedupe command dispatch and process poll tests

This commit is contained in:
Peter Steinberger
2026-02-18 04:03:42 +00:00
parent adac9cb67f
commit 05b7bd2c22
3 changed files with 217 additions and 363 deletions

View File

@@ -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<typeof createProcessTool>,
callId: string,
sessionId: string,
timeout?: number | string,
) {
return processTool.execute(callId, {
action: "poll",
sessionId,
...(timeout === undefined ? {} : { timeout }),
});
}
function retryMs(result: Awaited<ReturnType<ReturnType<typeof createProcessTool>["execute"]>>) {
return (result.details as { retryInMs?: number }).retryInMs;
}
function pollStatus(result: Awaited<ReturnType<ReturnType<typeof createProcessTool>["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();
});

View File

@@ -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<ReturnType<typeof createSlackActions>["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<string, unknown>) {
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?.({

View File

@@ -266,6 +266,71 @@ function createArgMenusHarness() {
return { commands, actions, options, postEphemeral, ctx, account };
}
function requireHandler(
handlers: Map<string, (args: unknown) => Promise<void>>,
key: string,
label: string,
): (args: unknown) => Promise<void> {
const handler = handlers.get(key);
if (!handler) {
throw new Error(`Missing ${label} handler`);
}
return handler;
}
function createSlashCommand(overrides: Partial<Record<string, string>> = {}) {
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<void>) {
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<void>,
params: {
action: Record<string, unknown>;
userId?: string;
userName?: string;
channelId?: string;
channelName?: string;
respond?: ReturnType<typeof vi.fn>;
includeRespond?: boolean;
},
) {
const includeRespond = params.includeRespond ?? true;
const respond = params.respond ?? vi.fn().mockResolvedValue(undefined);
const payload: Record<string, unknown> = {
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<typeof createArgMenusHarness>;
let usageHandler: (args: unknown) => Promise<void>;
@@ -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(