test: collapse imessage test suites

This commit is contained in:
Peter Steinberger
2026-03-25 05:20:29 +00:00
parent bc8622c659
commit 33d31e2b0d
7 changed files with 314 additions and 299 deletions

View File

@@ -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<typeof sendMessageIMessage>[2] = {},
) {
return await sendMessageIMessage(to, text, {
account: defaultAccount,
config: {},
client: createClient(),
...opts,
});
}
function getSentParams() {
return requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
}
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("<media:image>");
});
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("<media:audio>");
});
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();
});
});

View File

@@ -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"],
});
});
});

View File

@@ -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" });
});
});

View File

@@ -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<ReturnType<typeof clientModule.createIMessageRpcClient>>);
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();
});
});

View File

@@ -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<typeof sendMessageIMessage>[2] = {},
) {
return await sendMessageIMessage(to, text, {
account: defaultAccount,
config: {},
client: createClient(),
...opts,
});
}
function getSentParams() {
return requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
}
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("<media:image>");
});
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("<media:audio>");
});
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();
});
});

View File

@@ -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",
});
});
});

View File

@@ -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<ReturnType<typeof clientModule.createIMessageRpcClient>>);
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();
});
});