diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index fb8a1eb554b..c5ae0b93234 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -199,6 +199,64 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }; } + function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + + function requireRecord(value: unknown, label: string): Record { + expect(isRecord(value), `${label} must be an object`).toBe(true); + return value as Record; + } + + function expectRecordFields( + value: unknown, + label: string, + expected: Record, + ): Record { + const record = requireRecord(value, label); + for (const [key, expectedValue] of Object.entries(expected)) { + expect(record[key], `${label}.${key}`).toEqual(expectedValue); + } + return record; + } + + function expectMockArgFields( + mock: ReturnType, + label: string, + expected: Record, + callIndex = 0, + argIndex = 0, + ): Record { + return expectRecordFields(mock.mock.calls[callIndex]?.[argIndex], label, expected); + } + + function expectLastMockArgFields( + mock: ReturnType, + label: string, + expected: Record, + argIndex = 0, + ): Record { + const callIndex = mock.mock.calls.length - 1; + return expectMockArgFields(mock, label, expected, callIndex, argIndex); + } + + function expectStreamingStartOptions( + instanceIndex: number, + expected: Record, + ): Record { + const start = streamingInstances[instanceIndex]?.start; + expect(start, "streaming instance must exist").toBeDefined(); + expect(start.mock.calls[0]?.[0]).toBe("oc_chat"); + expect(start.mock.calls[0]?.[1]).toBe("chat_id"); + return expectRecordFields(start.mock.calls[0]?.[2], "streaming start options", expected); + } + + function streamingUpdateTexts(instanceIndex = 0): string[] { + return streamingInstances[instanceIndex].update.mock.calls.map((call: unknown[]) => + typeof call[0] === "string" ? call[0] : "", + ); + } + it("skips typing indicator when account typingIndicator is disabled", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", @@ -272,11 +330,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { await options.onReplyStart?.(); expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1); - expect(addTypingIndicatorMock).toHaveBeenCalledWith( - expect.objectContaining({ - messageId: "om_parent", - }), - ); + expectMockArgFields(addTypingIndicatorMock, "typing indicator params", { + messageId: "om_parent", + }); }); it("keeps auto mode plain text on non-streaming send path", async () => { @@ -413,17 +469,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); - expect(streamingInstances[0].start).toHaveBeenCalledWith( - "oc_chat", - "chat_id", - expect.objectContaining({ - replyToMessageId: undefined, - replyInThread: undefined, - rootId: "om_root_topic", - header: { title: "agent", template: "blue" }, - note: "Agent: agent", - }), - ); + expectStreamingStartOptions(0, { + replyToMessageId: undefined, + replyInThread: undefined, + rootId: "om_root_topic", + header: { title: "agent", template: "blue" }, + note: "Agent: agent", + }); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); @@ -545,11 +597,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "https://example.com/a.png", - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + mediaUrl: "https://example.com/a.png", + }); }); it("suppresses duplicate final text while still sending media", async () => { @@ -561,17 +611,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { ); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMessageFeishuMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - text: "plain final", - }), - ); + expectLastMockArgFields(sendMessageFeishuMock, "message send params", { + text: "plain final", + }); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "https://example.com/a.png", - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + mediaUrl: "https://example.com/a.png", + }); }); it("keeps distinct non-streaming final payloads", async () => { @@ -580,13 +626,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { await options.deliver({ text: "actual answer body" }, { kind: "final" }); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2); - expect(sendMessageFeishuMock).toHaveBeenNthCalledWith( + expectMockArgFields(sendMessageFeishuMock, "first message send params", { + text: "notice header", + }); + expectMockArgFields( + sendMessageFeishuMock, + "second message send params", + { + text: "actual answer body", + }, 1, - expect.objectContaining({ text: "notice header" }), - ); - expect(sendMessageFeishuMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ text: "actual answer body" }), ); }); @@ -707,12 +756,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" }); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - to: "oc_chat", - mediaUrl: "https://example.com/a.png", - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + to: "oc_chat", + mediaUrl: "https://example.com/a.png", + }); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); @@ -724,12 +771,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { { kind: "final" }, ); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "https://example.com/reply.mp3", - audioAsVoice: true, - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + mediaUrl: "https://example.com/reply.mp3", + audioAsVoice: true, + }); }); it("suppresses duplicate text when final replies send voice media", async () => { @@ -746,12 +791,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "https://example.com/reply.mp3", - audioAsVoice: true, - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + mediaUrl: "https://example.com/reply.mp3", + audioAsVoice: true, + }); }); it("sends skipped voice text when final voice media degrades to a file attachment", async () => { @@ -771,18 +814,14 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { ); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "https://example.com/reply.mp3", - audioAsVoice: true, - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + mediaUrl: "https://example.com/reply.mp3", + audioAsVoice: true, + }); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMessageFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - text: "spoken reply", - }), - ); + expectMockArgFields(sendMessageFeishuMock, "message send params", { + text: "spoken reply", + }); }); it("suppresses duplicate text for native voice media without audioAsVoice", async () => { @@ -797,11 +836,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "https://example.com/reply.opus?download=1", - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + mediaUrl: "https://example.com/reply.opus?download=1", + }); }); it("preserves captions for regular audio attachments", async () => { @@ -815,17 +852,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { ); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMessageFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - text: "caption text", - }), - ); + expectMockArgFields(sendMessageFeishuMock, "message send params", { + text: "caption text", + }); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "https://example.com/song.mp3", - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + mediaUrl: "https://example.com/song.mp3", + }); }); it("keeps skipped voice text in the upload failure fallback", async () => { @@ -842,11 +875,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { ); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMessageFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - text: "spoken reply\n\nšŸ“Ž https://example.com/reply.mp3", - }), - ); + expectMockArgFields(sendMessageFeishuMock, "message send params", { + text: "spoken reply\n\nšŸ“Ž https://example.com/reply.mp3", + }); }); it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => { @@ -858,11 +889,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "https://example.com/a.png", - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + mediaUrl: "https://example.com/a.png", + }); }); it("sends attachments after streaming final markdown replies", async () => { @@ -879,11 +908,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "https://example.com/a.png", - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + mediaUrl: "https://example.com/a.png", + }); }); it("passes replyInThread to sendMessageFeishu for plain text", async () => { @@ -893,12 +920,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await options.deliver({ text: "plain text" }, { kind: "final" }); - expect(sendMessageFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - replyToMessageId: "om_msg", - replyInThread: true, - }), - ); + expectMockArgFields(sendMessageFeishuMock, "message send params", { + replyToMessageId: "om_msg", + replyInThread: true, + }); }); it("passes replyInThread to sendStructuredCardFeishu for card text", async () => { @@ -919,12 +944,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await options.deliver({ text: "card text" }, { kind: "final" }); - expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - replyToMessageId: "om_msg", - replyInThread: true, - }), - ); + expectMockArgFields(sendStructuredCardFeishuMock, "structured card params", { + replyToMessageId: "om_msg", + replyInThread: true, + }); }); it("streams reasoning content as blockquote before answer", async () => { @@ -1074,16 +1097,12 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(1); - expect(streamingInstances[0].start).toHaveBeenCalledWith( - "oc_chat", - "chat_id", - expect.objectContaining({ - replyToMessageId: "om_msg", - replyInThread: true, - header: { title: "agent", template: "blue" }, - note: "Agent: agent", - }), - ); + expectStreamingStartOptions(0, { + replyToMessageId: "om_msg", + replyInThread: true, + header: { title: "agent", template: "blue" }, + note: "Agent: agent", + }); }); it("uses streaming cards for thread replies and keeps topic metadata", async () => { @@ -1097,15 +1116,11 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(1); - expect(streamingInstances[0].start).toHaveBeenCalledWith( - "oc_chat", - "chat_id", - expect.objectContaining({ - replyToMessageId: "om_msg", - replyInThread: true, - rootId: "om_root_topic", - }), - ); + expectStreamingStartOptions(0, { + replyToMessageId: "om_msg", + replyInThread: true, + rootId: "om_root_topic", + }); expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); }); @@ -1128,13 +1143,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { await options.deliver({ text: "streamed card" }, { kind: "final" }); await options.onIdle?.(); - expect(streamingInstances[0].start).toHaveBeenCalledWith( - "oc_chat", - "chat_id", - expect.objectContaining({ - header: undefined, - }), - ); + expectStreamingStartOptions(0, { + header: undefined, + }); resolveFeishuAccountMock.mockReturnValue({ accountId: "main", @@ -1153,11 +1164,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await staticOptions.deliver({ text: "static card" }, { kind: "final" }); - expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - header: undefined, - }), - ); + expectLastMockArgFields(sendStructuredCardFeishuMock, "structured card params", { + header: undefined, + }); }); it("shows shared transient tool status on streaming cards but omits it from the final close", async () => { @@ -1180,10 +1189,8 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { result.replyOptions.onPartialReply?.({ text: "final answer" }); await options.onIdle?.(); - const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) => - typeof call[0] === "string" ? call[0] : "", - ); - expect(updateTexts).toEqual(expect.arrayContaining([expect.stringContaining("šŸ”Ž Web Search")])); + const updateTexts = streamingUpdateTexts(); + expect(updateTexts.some((text) => text.includes("šŸ”Ž Web Search"))).toBe(true); expect(streamingInstances[0].close).toHaveBeenCalledWith("final answer", { note: "Agent: agent", }); @@ -1213,14 +1220,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { result.replyOptions.onPartialReply?.({ text: "final answer" }); await options.onIdle?.(); - const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) => - typeof call[0] === "string" ? call[0] : "", - ); - expect(updateTexts).toEqual( - expect.arrayContaining([ - expect.stringContaining("šŸ› ļø run tests, `pnpm test -- --watch=false`"), - ]), - ); + const updateTexts = streamingUpdateTexts(); + expect( + updateTexts.some((text) => text.includes("šŸ› ļø run tests, `pnpm test -- --watch=false`")), + ).toBe(true); }); it("omits message-like tools from streaming card status", async () => { @@ -1243,10 +1246,8 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { result.replyOptions.onPartialReply?.({ text: "final answer" }); await options.onIdle?.(); - const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) => - typeof call[0] === "string" ? call[0] : "", - ); - expect(updateTexts).not.toEqual(expect.arrayContaining([expect.stringContaining("Message")])); + const updateTexts = streamingUpdateTexts(); + expect(updateTexts.some((text) => text.includes("Message"))).toBe(false); }); it("does not suppress a later final after error closeout", async () => { @@ -1366,12 +1367,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" }); - expect(sendMediaFeishuMock).toHaveBeenCalledWith( - expect.objectContaining({ - replyToMessageId: "om_msg", - replyInThread: true, - }), - ); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + replyToMessageId: "om_msg", + replyInThread: true, + }); }); it("backs off streaming retries after start() throws (HTTP 400)", async () => { @@ -1406,7 +1405,12 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { // Wait for the async error to propagate await vi.waitFor(() => { - expect(errorMock).toHaveBeenCalledWith(expect.stringContaining("streaming start failed")); + expect( + errorMock.mock.calls.some( + ([message]) => + typeof message === "string" && message.includes("streaming start failed"), + ), + ).toBe(true); }); expect(streamingInstances).toHaveLength(1); expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(1);