diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index 4d9ee3cf879..97a70b60227 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -48,17 +48,62 @@ describe("handleSlackAction", () => { return { cfg, context, hasRepliedRef }; } + function requireRecord(value: unknown, label: string): Record { + expect(typeof value).toBe("object"); + expect(value).not.toBeNull(); + if (typeof value !== "object" || value === null) { + throw new Error(`${label} was not an object`); + } + return value as Record; + } + + function requireArray(value: unknown, label: string): unknown[] { + expect(Array.isArray(value)).toBe(true); + if (!Array.isArray(value)) { + throw new Error(`${label} was not an array`); + } + return value; + } + + function expectRecordFields(record: Record, fields: Record) { + for (const [key, value] of Object.entries(fields)) { + expect(record[key]).toEqual(value); + } + } + + function requireSlackSendCall(index: number) { + const call = sendSlackMessage.mock.calls[index] as unknown[] | undefined; + expect(call).toBeDefined(); + if (!call) { + throw new Error(`missing Slack send call ${index + 1}`); + } + return call; + } + + function expectSlackSendCall( + index: number, + target: string, + content: string, + optionFields: Record, + ) { + const [actualTarget, actualContent, options] = requireSlackSendCall(index); + expect(actualTarget).toBe(target); + expect(actualContent).toBe(content); + expectRecordFields(requireRecord(options, "Slack send options"), optionFields); + return requireRecord(options, "Slack send options"); + } + function expectLastSlackSend(content: string, cfg: OpenClawConfig, threadTs?: string) { - expect(sendSlackMessage).toHaveBeenLastCalledWith( - "channel:C123", - content, - expect.objectContaining({ - cfg, - mediaUrl: undefined, - threadTs, - blocks: undefined, - }), - ); + expectSlackSendCall(sendSlackMessage.mock.calls.length - 1, "channel:C123", content, { + cfg, + mediaUrl: undefined, + threadTs, + blocks: undefined, + }); + } + + function requireDetails(result: Awaited>) { + return requireRecord(result.details, "action result details"); } async function sendSecondMessageAndExpectNoThread(params: { @@ -199,16 +244,12 @@ describe("handleSlackAction", () => { }, cfg, ); - expect(sendSlackMessage).toHaveBeenCalledWith( - "channel:C123", - "Hello thread", - expect.objectContaining({ - cfg, - mediaUrl: undefined, - threadTs: "1234567890.123456", - blocks: undefined, - }), - ); + expectSlackSendCall(0, "channel:C123", "Hello thread", { + cfg, + mediaUrl: undefined, + threadTs: "1234567890.123456", + blocks: undefined, + }); }); it("returns a friendly error when downloadFile cannot fetch the attachment", async () => { @@ -220,15 +261,11 @@ describe("handleSlackAction", () => { }, slackConfig(), ); - expect(downloadSlackFile).toHaveBeenCalledWith( - "F123", - expect.objectContaining({ maxBytes: 20 * 1024 * 1024 }), - ); - expect(result).toEqual( - expect.objectContaining({ - details: expect.objectContaining({ ok: false }), - }), + expect(downloadSlackFile.mock.calls[0]?.[0]).toBe("F123"); + expect(requireRecord(downloadSlackFile.mock.calls[0]?.[1], "download options").maxBytes).toBe( + 20 * 1024 * 1024, ); + expect(requireDetails(result).ok).toBe(false); }); it("passes download scope (channel/thread) to downloadSlackFile", async () => { @@ -244,18 +281,12 @@ describe("handleSlackAction", () => { slackConfig(), ); - expect(downloadSlackFile).toHaveBeenCalledWith( - "F123", - expect.objectContaining({ - channelId: "C1", - threadId: "123.456", - }), - ); - expect(result).toEqual( - expect.objectContaining({ - details: expect.objectContaining({ ok: false }), - }), - ); + expect(downloadSlackFile.mock.calls[0]?.[0]).toBe("F123"); + expectRecordFields(requireRecord(downloadSlackFile.mock.calls[0]?.[1], "download options"), { + channelId: "C1", + threadId: "123.456", + }); + expect(requireDetails(result).ok).toBe(false); }); it("returns non-image downloadFile results as file metadata instead of image content", async () => { @@ -274,25 +305,21 @@ describe("handleSlackAction", () => { ); expect(result.content).toHaveLength(1); - expect(result.content[0]).toEqual( - expect.objectContaining({ - type: "text", - text: expect.stringContaining("/tmp/openclaw-media/report.pdf"), - }), - ); + const firstContent = requireRecord(result.content[0], "first content item"); + expect(firstContent.type).toBe("text"); + expect(String(firstContent.text)).toContain("/tmp/openclaw-media/report.pdf"); expect(result.content.some((entry) => entry.type === "image")).toBe(false); - expect(result.details).toEqual( - expect.objectContaining({ - ok: true, - fileId: "F123", - path: "/tmp/openclaw-media/report.pdf", - contentType: "application/pdf", - media: { - mediaUrl: "/tmp/openclaw-media/report.pdf", - contentType: "application/pdf", - }, - }), - ); + const details = requireDetails(result); + expectRecordFields(details, { + ok: true, + fileId: "F123", + path: "/tmp/openclaw-media/report.pdf", + contentType: "application/pdf", + }); + expect(details.media).toEqual({ + mediaUrl: "/tmp/openclaw-media/report.pdf", + contentType: "application/pdf", + }); }); it("forwards resolved botToken to action functions instead of relying on config re-read", async () => { @@ -343,16 +370,12 @@ describe("handleSlackAction", () => { }, cfg, ); - expect(sendSlackMessage).toHaveBeenCalledWith( - "channel:C123", - "", - expect.objectContaining({ - cfg, - mediaUrl: undefined, - threadTs: undefined, - blocks: expectedBlocks, - }), - ); + expectSlackSendCall(0, "channel:C123", "", { + cfg, + mediaUrl: undefined, + threadTs: undefined, + blocks: expectedBlocks, + }); }); it.each([ @@ -404,17 +427,13 @@ describe("handleSlackAction", () => { cfg, ); - expect(sendSlackMessage).toHaveBeenCalledWith( - "user:U123", - "fresh report", - expect.objectContaining({ - cfg, - mediaUrl: "/tmp/report.png", - threadTs: "111.222", - uploadFileName: "report-final.png", - uploadTitle: "Report Final", - }), - ); + expectSlackSendCall(0, "user:U123", "fresh report", { + cfg, + mediaUrl: "/tmp/report.png", + threadTs: "111.222", + uploadFileName: "report-final.png", + uploadTitle: "Report Final", + }); }); it("sends media before a separate blocks message", async () => { @@ -434,27 +453,17 @@ describe("handleSlackAction", () => { ); expect(sendSlackMessage).toHaveBeenCalledTimes(2); - expect(sendSlackMessage).toHaveBeenNthCalledWith( - 1, - "channel:C123", - "", - expect.objectContaining({ - cfg, - mediaUrl: "https://example.com/file.png", - threadTs: undefined, - }), - ); + expectSlackSendCall(0, "channel:C123", "", { + cfg, + mediaUrl: "https://example.com/file.png", + threadTs: undefined, + }); expect(sendSlackMessage.mock.calls[0]?.[2]).not.toHaveProperty("blocks"); - expect(sendSlackMessage).toHaveBeenNthCalledWith( - 2, - "channel:C123", - "hello", - expect.objectContaining({ - cfg, - blocks: [{ type: "divider" }], - threadTs: undefined, - }), - ); + expectSlackSendCall(1, "channel:C123", "hello", { + cfg, + blocks: [{ type: "divider" }], + threadTs: undefined, + }); expect(sendSlackMessage.mock.calls[1]?.[2]).not.toHaveProperty("mediaUrl"); expect(result.details).toEqual({ ok: true, @@ -485,15 +494,13 @@ describe("handleSlackAction", () => { }, cfg, ); - expect(editSlackMessage).toHaveBeenCalledWith( - "C123", - "123.456", - "", - expect.objectContaining({ - cfg, - blocks: expectedBlocks, - }), - ); + expect(editSlackMessage.mock.calls[0]?.[0]).toBe("C123"); + expect(editSlackMessage.mock.calls[0]?.[1]).toBe("123.456"); + expect(editSlackMessage.mock.calls[0]?.[2]).toBe(""); + expectRecordFields(requireRecord(editSlackMessage.mock.calls[0]?.[3], "edit options"), { + cfg, + blocks: expectedBlocks, + }); }); it("requires content or blocks for editMessage", async () => { @@ -613,16 +620,12 @@ describe("handleSlackAction", () => { replyToMode: "all", }, ); - expect(sendSlackMessage).toHaveBeenCalledWith( - "channel:C999", - "Other channel", - expect.objectContaining({ - cfg, - mediaUrl: undefined, - threadTs: undefined, - blocks: undefined, - }), - ); + expectSlackSendCall(0, "channel:C999", "Other channel", { + cfg, + mediaUrl: undefined, + threadTs: undefined, + blocks: undefined, + }); }); it("explicit threadTs overrides context threadTs", async () => { @@ -651,16 +654,12 @@ describe("handleSlackAction", () => { currentThreadTs: "1111111111.111111", replyToMode: "all", }); - expect(sendSlackMessage).toHaveBeenCalledWith( - "C123", - "Bare target", - expect.objectContaining({ - cfg, - mediaUrl: undefined, - threadTs: "1111111111.111111", - blocks: undefined, - }), - ); + expectSlackSendCall(0, "C123", "Bare target", { + cfg, + mediaUrl: undefined, + threadTs: "1111111111.111111", + blocks: undefined, + }); }); it("adds normalized timestamps to readMessages payloads", async () => { @@ -674,17 +673,13 @@ describe("handleSlackAction", () => { slackConfig(), ); - expect(result).toMatchObject({ - details: { - ok: true, - hasMore: false, - messages: [ - expect.objectContaining({ - ts: "1712345678.123456", - timestampMs: 1712345678123, - }), - ], - }, + const details = requireDetails(result); + expect(details.ok).toBe(true); + expect(details.hasMore).toBe(false); + const messages = requireArray(details.messages, "read messages"); + expectRecordFields(requireRecord(messages[0], "first message"), { + ts: "1712345678.123456", + timestampMs: 1712345678123, }); }); @@ -697,16 +692,14 @@ describe("handleSlackAction", () => { cfg, ); - expect(readSlackMessages).toHaveBeenCalledWith( - "C1", - expect.objectContaining({ - cfg, - threadId: "1712345678.123456", - limit: undefined, - before: undefined, - after: undefined, - }), - ); + expect(readSlackMessages.mock.calls[0]?.[0]).toBe("C1"); + expectRecordFields(requireRecord(readSlackMessages.mock.calls[0]?.[1], "read options"), { + cfg, + threadId: "1712345678.123456", + limit: undefined, + before: undefined, + after: undefined, + }); }); it("passes messageId through to readSlackMessages", async () => { @@ -723,14 +716,12 @@ describe("handleSlackAction", () => { cfg, ); - expect(readSlackMessages).toHaveBeenCalledWith( - "C1", - expect.objectContaining({ - cfg, - threadId: "1712345678.123456", - messageId: "1712345678.654321", - }), - ); + expect(readSlackMessages.mock.calls[0]?.[0]).toBe("C1"); + expectRecordFields(requireRecord(readSlackMessages.mock.calls[0]?.[1], "read options"), { + cfg, + threadId: "1712345678.123456", + messageId: "1712345678.654321", + }); }); it("adds normalized timestamps to pin payloads", async () => { @@ -738,18 +729,13 @@ describe("handleSlackAction", () => { const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, slackConfig()); - expect(result).toMatchObject({ - details: { - ok: true, - pins: [ - { - message: expect.objectContaining({ - ts: "1712345678.123456", - timestampMs: 1712345678123, - }), - }, - ], - }, + const details = requireDetails(result); + expect(details.ok).toBe(true); + const pins = requireArray(details.pins, "pins"); + const firstPin = requireRecord(pins[0], "first pin"); + expectRecordFields(requireRecord(firstPin.message, "first pin message"), { + ts: "1712345678.123456", + timestampMs: 1712345678123, }); }); @@ -819,13 +805,11 @@ describe("handleSlackAction", () => { const result = await handleSlackAction({ action: "emojiList" }, slackConfig()); - expect(result).toMatchObject({ - details: { - ok: true, - emojis: { - emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, - }, - }, + const details = requireDetails(result); + expect(details.ok).toBe(true); + expect(details.emojis).toEqual({ + ok: true, + emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" }, }); }); @@ -841,15 +825,13 @@ describe("handleSlackAction", () => { const result = await handleSlackAction({ action: "emojiList", limit: 2 }, slackConfig()); - expect(result).toMatchObject({ - details: { - ok: true, - emojis: { - emoji: { - party: "https://example.com/party.png", - tada: "https://example.com/tada.png", - }, - }, + const details = requireDetails(result); + expect(details.ok).toBe(true); + expect(details.emojis).toEqual({ + ok: true, + emoji: { + party: "https://example.com/party.png", + tada: "https://example.com/tada.png", }, }); });