From 33d31e2b0d2cf2aecf3c010947f503295ed3a509 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Mar 2026 05:20:29 +0000 Subject: [PATCH] test: collapse imessage test suites --- .../imessage/src/channel.outbound.test.ts | 223 ++++++++++++++++++ extensions/imessage/src/group-policy.test.ts | 36 --- .../imessage/src/outbound-adapter.test.ts | 70 ------ extensions/imessage/src/probe.test.ts | 34 --- extensions/imessage/src/send.test.ts | 135 ----------- .../imessage/src/setup-allow-from.test.ts | 24 -- extensions/imessage/src/targets.test.ts | 91 +++++++ 7 files changed, 314 insertions(+), 299 deletions(-) delete mode 100644 extensions/imessage/src/group-policy.test.ts delete mode 100644 extensions/imessage/src/outbound-adapter.test.ts delete mode 100644 extensions/imessage/src/probe.test.ts delete mode 100644 extensions/imessage/src/send.test.ts delete mode 100644 extensions/imessage/src/setup-allow-from.test.ts diff --git a/extensions/imessage/src/channel.outbound.test.ts b/extensions/imessage/src/channel.outbound.test.ts index b516fda477a..1c7f1021743 100644 --- a/extensions/imessage/src/channel.outbound.test.ts +++ b/extensions/imessage/src/channel.outbound.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; +import type { ResolvedIMessageAccount } from "./accounts.js"; import { imessagePlugin } from "./channel.js"; +import type { IMessageRpcClient } from "./client.js"; +import { imessageOutbound } from "./outbound-adapter.js"; +import { sendMessageIMessage } from "./send.js"; function requireIMessageSendText() { const sendText = imessagePlugin.outbound?.sendText; @@ -17,6 +21,40 @@ function requireIMessageSendMedia() { return sendMedia; } +const requestMock = vi.fn(); +const stopMock = vi.fn(); + +const defaultAccount: ResolvedIMessageAccount = { + accountId: "default", + enabled: true, + configured: false, + config: {}, +}; + +function createClient(): IMessageRpcClient { + return { + request: (...args: unknown[]) => requestMock(...args), + stop: (...args: unknown[]) => stopMock(...args), + } as unknown as IMessageRpcClient; +} + +async function sendWithDefaults( + to: string, + text: string, + opts: Parameters[2] = {}, +) { + return await sendMessageIMessage(to, text, { + account: defaultAccount, + config: {}, + client: createClient(), + ...opts, + }); +} + +function getSentParams() { + return requestMock.mock.calls[0]?.[1] as Record; +} + describe("imessagePlugin outbound", () => { const cfg = { channels: { @@ -106,3 +144,188 @@ describe("imessagePlugin outbound", () => { expect(result).toEqual({ channel: "imessage", messageId: "m-media-local" }); }); }); + +describe("imessageOutbound", () => { + const cfg = { + channels: { + imessage: { + mediaMaxMb: 3, + }, + }, + }; + + it("forwards replyToId on direct text sends", async () => { + const sendIMessage = vi.fn().mockResolvedValueOnce({ messageId: "m-text" }); + + const result = await imessageOutbound.sendText!({ + cfg, + to: "chat_id:12", + text: "hello", + accountId: "default", + replyToId: "reply-1", + deps: { sendIMessage }, + }); + + expect(sendIMessage).toHaveBeenCalledWith( + "chat_id:12", + "hello", + expect.objectContaining({ + accountId: "default", + replyToId: "reply-1", + maxBytes: 3 * 1024 * 1024, + }), + ); + expect(result).toEqual({ channel: "imessage", messageId: "m-text" }); + }); + + it("forwards mediaLocalRoots on direct media sends", async () => { + const sendIMessage = vi.fn().mockResolvedValueOnce({ messageId: "m-media-local" }); + + const result = await imessageOutbound.sendMedia!({ + cfg, + to: "chat_id:88", + text: "caption", + mediaUrl: "/tmp/workspace/pic.png", + mediaLocalRoots: ["/tmp/workspace"], + accountId: "acct-1", + replyToId: "reply-2", + deps: { sendIMessage }, + }); + + expect(sendIMessage).toHaveBeenCalledWith( + "chat_id:88", + "caption", + expect.objectContaining({ + mediaUrl: "/tmp/workspace/pic.png", + mediaLocalRoots: ["/tmp/workspace"], + accountId: "acct-1", + replyToId: "reply-2", + maxBytes: 3 * 1024 * 1024, + }), + ); + expect(result).toEqual({ channel: "imessage", messageId: "m-media-local" }); + }); +}); + +describe("sendMessageIMessage", () => { + it("sends to chat_id targets", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("chat_id:123", "hi"); + const params = getSentParams(); + expect(requestMock).toHaveBeenCalledWith("send", expect.any(Object), expect.any(Object)); + expect(params.chat_id).toBe(123); + expect(params.text).toBe("hi"); + }); + + it("applies sms service prefix", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("sms:+1555", "hello"); + const params = getSentParams(); + expect(params.service).toBe("sms"); + expect(params.to).toBe("+1555"); + }); + + it("adds file attachment with placeholder text", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("chat_id:7", "", { + mediaUrl: "http://x/y.jpg", + resolveAttachmentImpl: async () => ({ + path: "/tmp/imessage-media.jpg", + contentType: "image/jpeg", + }), + }); + const params = getSentParams(); + expect(params.file).toBe("/tmp/imessage-media.jpg"); + expect(params.text).toBe(""); + }); + + it("normalizes mixed-case parameterized MIME for attachment placeholder text", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("chat_id:7", "", { + mediaUrl: "http://x/voice", + resolveAttachmentImpl: async () => ({ + path: "/tmp/imessage-media.ogg", + contentType: " Audio/Ogg; codecs=opus ", + }), + }); + const params = getSentParams(); + expect(params.file).toBe("/tmp/imessage-media.ogg"); + expect(params.text).toBe(""); + }); + + it("returns message id when rpc provides one", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true, id: 123 }); + stopMock.mockClear().mockResolvedValue(undefined); + + const result = await sendWithDefaults("chat_id:7", "hello"); + expect(result.messageId).toBe("123"); + }); + + it("prepends reply tag as the first token when replyToId is provided", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("chat_id:123", " hello\nworld", { + replyToId: "abc-123", + }); + const params = getSentParams(); + expect(params.text).toBe("[[reply_to:abc-123]] hello\nworld"); + }); + + it("rewrites an existing leading reply tag to keep the requested id first", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("chat_id:123", " [[reply_to:old-id]] hello", { + replyToId: "new-id", + }); + const params = getSentParams(); + expect(params.text).toBe("[[reply_to:new-id]] hello"); + }); + + it("sanitizes replyToId before writing the leading reply tag", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("chat_id:123", "hello", { + replyToId: " [ab]\n\u0000c\td ] ", + }); + const params = getSentParams(); + expect(params.text).toBe("[[reply_to:abcd]] hello"); + }); + + it("skips reply tagging when sanitized replyToId is empty", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("chat_id:123", "hello", { + replyToId: "[]\u0000\n\r", + }); + const params = getSentParams(); + expect(params.text).toBe("hello"); + }); + + it("normalizes string message_id values from rpc result", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true, message_id: " guid-1 " }); + stopMock.mockClear().mockResolvedValue(undefined); + + const result = await sendWithDefaults("chat_id:7", "hello"); + expect(result.messageId).toBe("guid-1"); + }); + + it("does not stop an injected client", async () => { + requestMock.mockClear().mockResolvedValue({ ok: true }); + stopMock.mockClear().mockResolvedValue(undefined); + + await sendWithDefaults("chat_id:123", "hello"); + expect(stopMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/imessage/src/group-policy.test.ts b/extensions/imessage/src/group-policy.test.ts deleted file mode 100644 index c94d76bfd27..00000000000 --- a/extensions/imessage/src/group-policy.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - resolveIMessageGroupRequireMention, - resolveIMessageGroupToolPolicy, -} from "./group-policy.js"; - -describe("imessage group policy", () => { - it("uses generic channel group policy helpers", () => { - const cfg = { - channels: { - imessage: { - groups: { - "chat:family": { - requireMention: false, - tools: { deny: ["exec"] }, - }, - "*": { - requireMention: true, - tools: { allow: ["message.send"] }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:family" })).toBe(false); - expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true); - expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:family" })).toEqual({ - deny: ["exec"], - }); - expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({ - allow: ["message.send"], - }); - }); -}); diff --git a/extensions/imessage/src/outbound-adapter.test.ts b/extensions/imessage/src/outbound-adapter.test.ts deleted file mode 100644 index 8c2ff831b68..00000000000 --- a/extensions/imessage/src/outbound-adapter.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { imessageOutbound } from "./outbound-adapter.js"; - -describe("imessageOutbound", () => { - const cfg = { - channels: { - imessage: { - mediaMaxMb: 3, - }, - }, - }; - - const sendIMessage = vi.fn(); - - beforeEach(() => { - sendIMessage.mockReset(); - }); - - it("forwards replyToId on direct text sends", async () => { - sendIMessage.mockResolvedValueOnce({ messageId: "m-text" }); - - const result = await imessageOutbound.sendText!({ - cfg, - to: "chat_id:12", - text: "hello", - accountId: "default", - replyToId: "reply-1", - deps: { sendIMessage }, - }); - - expect(sendIMessage).toHaveBeenCalledWith( - "chat_id:12", - "hello", - expect.objectContaining({ - accountId: "default", - replyToId: "reply-1", - maxBytes: 3 * 1024 * 1024, - }), - ); - expect(result).toEqual({ channel: "imessage", messageId: "m-text" }); - }); - - it("forwards mediaLocalRoots on direct media sends", async () => { - sendIMessage.mockResolvedValueOnce({ messageId: "m-media-local" }); - - const result = await imessageOutbound.sendMedia!({ - cfg, - to: "chat_id:88", - text: "caption", - mediaUrl: "/tmp/workspace/pic.png", - mediaLocalRoots: ["/tmp/workspace"], - accountId: "acct-1", - replyToId: "reply-2", - deps: { sendIMessage }, - }); - - expect(sendIMessage).toHaveBeenCalledWith( - "chat_id:88", - "caption", - expect.objectContaining({ - mediaUrl: "/tmp/workspace/pic.png", - mediaLocalRoots: ["/tmp/workspace"], - accountId: "acct-1", - replyToId: "reply-2", - maxBytes: 3 * 1024 * 1024, - }), - ); - expect(result).toEqual({ channel: "imessage", messageId: "m-media-local" }); - }); -}); diff --git a/extensions/imessage/src/probe.test.ts b/extensions/imessage/src/probe.test.ts deleted file mode 100644 index fad23896170..00000000000 --- a/extensions/imessage/src/probe.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as processRuntime from "../../../src/plugin-sdk/process-runtime.js"; -import * as setupRuntime from "../../../src/plugin-sdk/setup.js"; -import * as clientModule from "./client.js"; -import { probeIMessage } from "./probe.js"; - -beforeEach(() => { - vi.restoreAllMocks(); - vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true); - vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({ - stdout: "", - stderr: 'unknown command "rpc" for "imsg"', - code: 1, - signal: null, - killed: false, - termination: "exit", - }); -}); - -describe("probeIMessage", () => { - it("marks unknown rpc subcommand as fatal", async () => { - const createIMessageRpcClientMock = vi - .spyOn(clientModule, "createIMessageRpcClient") - .mockResolvedValue({ - request: vi.fn(), - stop: vi.fn(), - } as unknown as Awaited>); - const result = await probeIMessage(1000, { cliPath: "imsg-test-rpc" }); - expect(result.ok).toBe(false); - expect(result.fatal).toBe(true); - expect(result.error).toMatch(/rpc/i); - expect(createIMessageRpcClientMock).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts deleted file mode 100644 index 5d0987e6010..00000000000 --- a/extensions/imessage/src/send.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ResolvedIMessageAccount } from "./accounts.js"; -import type { IMessageRpcClient } from "./client.js"; -import { sendMessageIMessage } from "./send.js"; - -const requestMock = vi.fn(); -const stopMock = vi.fn(); - -const defaultAccount: ResolvedIMessageAccount = { - accountId: "default", - enabled: true, - configured: false, - config: {}, -}; - -function createClient(): IMessageRpcClient { - return { - request: (...args: unknown[]) => requestMock(...args), - stop: (...args: unknown[]) => stopMock(...args), - } as unknown as IMessageRpcClient; -} - -async function sendWithDefaults( - to: string, - text: string, - opts: Parameters[2] = {}, -) { - return await sendMessageIMessage(to, text, { - account: defaultAccount, - config: {}, - client: createClient(), - ...opts, - }); -} - -function getSentParams() { - return requestMock.mock.calls[0]?.[1] as Record; -} - -describe("sendMessageIMessage", () => { - beforeEach(() => { - requestMock.mockClear().mockResolvedValue({ ok: true }); - stopMock.mockClear().mockResolvedValue(undefined); - }); - - it("sends to chat_id targets", async () => { - await sendWithDefaults("chat_id:123", "hi"); - const params = getSentParams(); - expect(requestMock).toHaveBeenCalledWith("send", expect.any(Object), expect.any(Object)); - expect(params.chat_id).toBe(123); - expect(params.text).toBe("hi"); - }); - - it("applies sms service prefix", async () => { - await sendWithDefaults("sms:+1555", "hello"); - const params = getSentParams(); - expect(params.service).toBe("sms"); - expect(params.to).toBe("+1555"); - }); - - it("adds file attachment with placeholder text", async () => { - await sendWithDefaults("chat_id:7", "", { - mediaUrl: "http://x/y.jpg", - resolveAttachmentImpl: async () => ({ - path: "/tmp/imessage-media.jpg", - contentType: "image/jpeg", - }), - }); - const params = getSentParams(); - expect(params.file).toBe("/tmp/imessage-media.jpg"); - expect(params.text).toBe(""); - }); - - it("normalizes mixed-case parameterized MIME for attachment placeholder text", async () => { - await sendWithDefaults("chat_id:7", "", { - mediaUrl: "http://x/voice", - resolveAttachmentImpl: async () => ({ - path: "/tmp/imessage-media.ogg", - contentType: " Audio/Ogg; codecs=opus ", - }), - }); - const params = getSentParams(); - expect(params.file).toBe("/tmp/imessage-media.ogg"); - expect(params.text).toBe(""); - }); - - it("returns message id when rpc provides one", async () => { - requestMock.mockResolvedValue({ ok: true, id: 123 }); - const result = await sendWithDefaults("chat_id:7", "hello"); - expect(result.messageId).toBe("123"); - }); - - it("prepends reply tag as the first token when replyToId is provided", async () => { - await sendWithDefaults("chat_id:123", " hello\nworld", { - replyToId: "abc-123", - }); - const params = getSentParams(); - expect(params.text).toBe("[[reply_to:abc-123]] hello\nworld"); - }); - - it("rewrites an existing leading reply tag to keep the requested id first", async () => { - await sendWithDefaults("chat_id:123", " [[reply_to:old-id]] hello", { - replyToId: "new-id", - }); - const params = getSentParams(); - expect(params.text).toBe("[[reply_to:new-id]] hello"); - }); - - it("sanitizes replyToId before writing the leading reply tag", async () => { - await sendWithDefaults("chat_id:123", "hello", { - replyToId: " [ab]\n\u0000c\td ] ", - }); - const params = getSentParams(); - expect(params.text).toBe("[[reply_to:abcd]] hello"); - }); - - it("skips reply tagging when sanitized replyToId is empty", async () => { - await sendWithDefaults("chat_id:123", "hello", { - replyToId: "[]\u0000\n\r", - }); - const params = getSentParams(); - expect(params.text).toBe("hello"); - }); - - it("normalizes string message_id values from rpc result", async () => { - requestMock.mockResolvedValue({ ok: true, message_id: " guid-1 " }); - const result = await sendWithDefaults("chat_id:7", "hello"); - expect(result.messageId).toBe("guid-1"); - }); - - it("does not stop an injected client", async () => { - await sendWithDefaults("chat_id:123", "hello"); - expect(stopMock).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/imessage/src/setup-allow-from.test.ts b/extensions/imessage/src/setup-allow-from.test.ts deleted file mode 100644 index 24082342e68..00000000000 --- a/extensions/imessage/src/setup-allow-from.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseIMessageAllowFromEntries } from "./setup-surface.js"; - -describe("parseIMessageAllowFromEntries", () => { - it("parses handles and chat targets", () => { - expect(parseIMessageAllowFromEntries("+15555550123, chat_id:123, chat_guid:abc")).toEqual({ - entries: ["+15555550123", "chat_id:123", "chat_guid:abc"], - }); - }); - - it("returns validation errors for invalid chat_id", () => { - expect(parseIMessageAllowFromEntries("chat_id:abc")).toEqual({ - entries: [], - error: "Invalid chat_id: chat_id:abc", - }); - }); - - it("returns validation errors for invalid chat_identifier entries", () => { - expect(parseIMessageAllowFromEntries("chat_identifier:")).toEqual({ - entries: [], - error: "Invalid chat_identifier entry", - }); - }); -}); diff --git a/extensions/imessage/src/targets.test.ts b/extensions/imessage/src/targets.test.ts index ec5360a50b0..d13c9142527 100644 --- a/extensions/imessage/src/targets.test.ts +++ b/extensions/imessage/src/targets.test.ts @@ -1,4 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as processRuntime from "../../../src/plugin-sdk/process-runtime.js"; +import * as setupRuntime from "../../../src/plugin-sdk/setup.js"; +import * as clientModule from "./client.js"; +import { + resolveIMessageGroupRequireMention, + resolveIMessageGroupToolPolicy, +} from "./group-policy.js"; +import { probeIMessage } from "./probe.js"; +import { parseIMessageAllowFromEntries } from "./setup-surface.js"; import { formatIMessageChatTarget, inferIMessageTargetChatType, @@ -117,3 +126,85 @@ describe("createIMessageRpcClient", () => { expect(spawnMock).not.toHaveBeenCalled(); }); }); + +describe("imessage group policy", () => { + it("uses generic channel group policy helpers", () => { + const cfg = { + channels: { + imessage: { + groups: { + "chat:family": { + requireMention: false, + tools: { deny: ["exec"] }, + }, + "*": { + requireMention: true, + tools: { allow: ["message.send"] }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:family" })).toBe(false); + expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true); + expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:family" })).toEqual({ + deny: ["exec"], + }); + expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({ + allow: ["message.send"], + }); + }); +}); + +describe("parseIMessageAllowFromEntries", () => { + it("parses handles and chat targets", () => { + expect(parseIMessageAllowFromEntries("+15555550123, chat_id:123, chat_guid:abc")).toEqual({ + entries: ["+15555550123", "chat_id:123", "chat_guid:abc"], + }); + }); + + it("returns validation errors for invalid chat_id", () => { + expect(parseIMessageAllowFromEntries("chat_id:abc")).toEqual({ + entries: [], + error: "Invalid chat_id: chat_id:abc", + }); + }); + + it("returns validation errors for invalid chat_identifier entries", () => { + expect(parseIMessageAllowFromEntries("chat_identifier:")).toEqual({ + entries: [], + error: "Invalid chat_identifier entry", + }); + }); +}); + +describe("probeIMessage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true); + vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({ + stdout: "", + stderr: 'unknown command "rpc" for "imsg"', + code: 1, + signal: null, + killed: false, + termination: "exit", + }); + }); + + it("marks unknown rpc subcommand as fatal", async () => { + const createIMessageRpcClientMock = vi + .spyOn(clientModule, "createIMessageRpcClient") + .mockResolvedValue({ + request: vi.fn(), + stop: vi.fn(), + } as unknown as Awaited>); + const result = await probeIMessage(1000, { cliPath: "imsg-test-rpc" }); + expect(result.ok).toBe(false); + expect(result.fatal).toBe(true); + expect(result.error).toMatch(/rpc/i); + expect(createIMessageRpcClientMock).not.toHaveBeenCalled(); + }); +});