refactor(test): share iMessage monitor test harness

This commit is contained in:
Peter Steinberger
2026-02-14 18:04:08 +00:00
parent 5faba6a48c
commit b4e406b6c4
3 changed files with 251 additions and 243 deletions

View File

@@ -1,111 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { monitorIMessageProvider } from "./monitor.js";
import {
flush,
getCloseResolve,
getConfigMock,
getNotificationHandler,
getReplyMock,
getSendMock,
getUpsertPairingRequestMock,
installMonitorIMessageProviderTestHooks,
setConfigMock,
waitForSubscribe,
} from "./monitor.test-harness.js";
const requestMock = vi.fn();
const stopMock = vi.fn();
const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
installMonitorIMessageProviderTestHooks();
let config: Record<string, unknown> = {};
let notificationHandler: ((msg: { method: string; params?: unknown }) => void) | undefined;
let closeResolve: (() => void) | undefined;
const replyMock = getReplyMock();
const sendMock = getSendMock();
const upsertPairingRequestMock = getUpsertPairingRequestMock();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => config,
};
});
type TestConfig = {
channels: Record<string, unknown> & { imessage: Record<string, unknown> };
messages: Record<string, unknown>;
session: Record<string, unknown>;
[k: string]: unknown;
};
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
}));
vi.mock("./send.js", () => ({
sendMessageIMessage: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./client.js", () => ({
createIMessageRpcClient: vi.fn(async (opts: { onNotification?: typeof notificationHandler }) => {
notificationHandler = opts.onNotification;
return {
request: (...args: unknown[]) => requestMock(...args),
waitForClose: () =>
new Promise<void>((resolve) => {
closeResolve = resolve;
}),
stop: (...args: unknown[]) => stopMock(...args),
};
}),
}));
vi.mock("./probe.js", () => ({
probeIMessage: vi.fn(async () => ({ ok: true })),
}));
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForSubscribe() {
for (let i = 0; i < 5; i += 1) {
if (requestMock.mock.calls.some((call) => call[0] === "watch.subscribe")) {
return;
}
await flush();
}
function getConfig(): TestConfig {
return getConfigMock() as unknown as TestConfig;
}
beforeEach(() => {
config = {
channels: {
imessage: {
dmPolicy: "open",
allowFrom: ["*"],
groups: { "*": { requireMention: true } },
},
},
session: { mainKey: "main" },
messages: {
groupChat: { mentionPatterns: ["@openclaw"] },
},
};
requestMock.mockReset().mockImplementation((method: string) => {
if (method === "watch.subscribe") {
return Promise.resolve({ subscription: 1 });
}
return Promise.resolve({});
});
stopMock.mockReset().mockResolvedValue(undefined);
sendMock.mockReset().mockResolvedValue({ messageId: "ok" });
replyMock.mockReset().mockResolvedValue({ text: "ok" });
updateLastRouteMock.mockReset();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
notificationHandler = undefined;
closeResolve = undefined;
});
describe("monitorIMessageProvider", () => {
it("skips group messages without a mention by default", async () => {
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -120,7 +50,7 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).not.toHaveBeenCalled();
@@ -128,21 +58,22 @@ describe("monitorIMessageProvider", () => {
});
it("allows group messages when imessage groups default disables mention gating", async () => {
config = {
const config = getConfig();
setConfigMock({
...config,
channels: {
...config.channels,
imessage: {
...config.channels?.imessage,
...config.channels.imessage,
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
};
});
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -157,29 +88,30 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).toHaveBeenCalled();
});
it("allows group messages when requireMention is true but no mentionPatterns exist", async () => {
config = {
const config = getConfig();
setConfigMock({
...config,
messages: { groupChat: { mentionPatterns: [] } },
channels: {
...config.channels,
imessage: {
...config.channels?.imessage,
...config.channels.imessage,
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
};
});
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -194,27 +126,28 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).toHaveBeenCalled();
});
it("blocks group messages when imessage.groups is set without a wildcard", async () => {
config = {
const config = getConfig();
setConfigMock({
...config,
channels: {
...config.channels,
imessage: {
...config.channels?.imessage,
...config.channels.imessage,
groups: { "99": { requireMention: false } },
},
},
};
});
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -229,7 +162,7 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).not.toHaveBeenCalled();
@@ -237,23 +170,24 @@ describe("monitorIMessageProvider", () => {
});
it("treats configured chat_id as a group session even when is_group is false", async () => {
config = {
const config = getConfig();
setConfigMock({
...config,
channels: {
...config.channels,
imessage: {
...config.channels?.imessage,
...config.channels.imessage,
dmPolicy: "open",
allowFrom: ["*"],
groups: { "2": { requireMention: false } },
},
},
};
});
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -268,7 +202,7 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).toHaveBeenCalled();
@@ -281,15 +215,16 @@ describe("monitorIMessageProvider", () => {
});
it("prefixes final replies with responsePrefix", async () => {
config = {
const config = getConfig();
setConfigMock({
...config,
messages: { responsePrefix: "PFX" },
};
});
replyMock.mockResolvedValue({ text: "final reply" });
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -304,7 +239,7 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(sendMock).toHaveBeenCalledTimes(1);
@@ -312,22 +247,23 @@ describe("monitorIMessageProvider", () => {
});
it("defaults to dmPolicy=pairing behavior when allowFrom is empty", async () => {
config = {
const config = getConfig();
setConfigMock({
...config,
channels: {
...config.channels,
imessage: {
...config.channels?.imessage,
...config.channels.imessage,
dmPolicy: "pairing",
allowFrom: [],
groups: { "*": { requireMention: true } },
},
},
};
});
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -342,7 +278,7 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).not.toHaveBeenCalled();
@@ -359,7 +295,7 @@ describe("monitorIMessageProvider", () => {
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -376,7 +312,7 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).toHaveBeenCalledOnce();
@@ -394,21 +330,22 @@ describe("monitorIMessageProvider", () => {
});
it("honors group allowlist when groupPolicy is allowlist", async () => {
config = {
const config = getConfig();
setConfigMock({
...config,
channels: {
...config.channels,
imessage: {
...config.channels?.imessage,
...config.channels.imessage,
groupPolicy: "allowlist",
groupAllowFrom: ["chat_id:101"],
},
},
};
});
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -423,27 +360,28 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).not.toHaveBeenCalled();
});
it("blocks group messages when groupPolicy is disabled", async () => {
config = {
const config = getConfig();
setConfigMock({
...config,
channels: {
...config.channels,
imessage: {
...config.channels?.imessage,
...config.channels.imessage,
groupPolicy: "disabled",
},
},
};
});
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -458,7 +396,7 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).not.toHaveBeenCalled();
@@ -468,7 +406,7 @@ describe("monitorIMessageProvider", () => {
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -485,7 +423,7 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).toHaveBeenCalled();
@@ -499,7 +437,7 @@ describe("monitorIMessageProvider", () => {
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -517,7 +455,7 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(replyMock).toHaveBeenCalled();

View File

@@ -0,0 +1,151 @@
import { beforeEach, vi } from "vitest";
type NotificationHandler = (msg: { method: string; params?: unknown }) => void;
const state = vi.hoisted(() => ({
requestMock: vi.fn(),
stopMock: vi.fn(),
sendMock: vi.fn(),
replyMock: vi.fn(),
updateLastRouteMock: vi.fn(),
readAllowFromStoreMock: vi.fn(),
upsertPairingRequestMock: vi.fn(),
config: {} as Record<string, unknown>,
notificationHandler: undefined as NotificationHandler | undefined,
closeResolve: undefined as (() => void) | undefined,
}));
export function getRequestMock() {
return state.requestMock;
}
export function getStopMock() {
return state.stopMock;
}
export function getSendMock() {
return state.sendMock;
}
export function getReplyMock() {
return state.replyMock;
}
export function getUpdateLastRouteMock() {
return state.updateLastRouteMock;
}
export function getReadAllowFromStoreMock() {
return state.readAllowFromStoreMock;
}
export function getUpsertPairingRequestMock() {
return state.upsertPairingRequestMock;
}
export function getNotificationHandler() {
return state.notificationHandler;
}
export function getCloseResolve() {
return state.closeResolve;
}
export function setConfigMock(next: Record<string, unknown>) {
state.config = next;
}
export function getConfigMock() {
return state.config;
}
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => state.config,
};
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: unknown[]) => state.replyMock(...args),
}));
vi.mock("./send.js", () => ({
sendMessageIMessage: (...args: unknown[]) => state.sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => state.readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => state.upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => state.updateLastRouteMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./client.js", () => ({
createIMessageRpcClient: vi.fn(async (opts: { onNotification?: NotificationHandler }) => {
state.notificationHandler = opts.onNotification;
return {
request: (...args: unknown[]) => state.requestMock(...args),
waitForClose: () =>
new Promise<void>((resolve) => {
state.closeResolve = resolve;
}),
stop: (...args: unknown[]) => state.stopMock(...args),
};
}),
}));
vi.mock("./probe.js", () => ({
probeIMessage: vi.fn(async () => ({ ok: true })),
}));
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
export async function waitForSubscribe() {
for (let i = 0; i < 5; i += 1) {
if (state.requestMock.mock.calls.some((call) => call[0] === "watch.subscribe")) {
return;
}
await flush();
}
}
export function installMonitorIMessageProviderTestHooks() {
beforeEach(() => {
state.config = {
channels: {
imessage: {
dmPolicy: "open",
allowFrom: ["*"],
groups: { "*": { requireMention: true } },
},
},
session: { mainKey: "main" },
messages: {
groupChat: { mentionPatterns: ["@openclaw"] },
},
};
state.requestMock.mockReset().mockImplementation((method: string) => {
if (method === "watch.subscribe") {
return Promise.resolve({ subscription: 1 });
}
return Promise.resolve({});
});
state.stopMock.mockReset().mockResolvedValue(undefined);
state.sendMock.mockReset().mockResolvedValue({ messageId: "ok" });
state.replyMock.mockReset().mockResolvedValue({ text: "ok" });
state.updateLastRouteMock.mockReset();
state.readAllowFromStoreMock.mockReset().mockResolvedValue([]);
state.upsertPairingRequestMock
.mockReset()
.mockResolvedValue({ code: "PAIRCODE", created: true });
state.notificationHandler = undefined;
state.closeResolve = undefined;
});
}

View File

@@ -1,104 +1,23 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { monitorIMessageProvider } from "./monitor.js";
import {
flush,
getCloseResolve,
getNotificationHandler,
getReplyMock,
getRequestMock,
getStopMock,
getUpdateLastRouteMock,
installMonitorIMessageProviderTestHooks,
waitForSubscribe,
} from "./monitor.test-harness.js";
const requestMock = vi.fn();
const stopMock = vi.fn();
const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
installMonitorIMessageProviderTestHooks();
let config: Record<string, unknown> = {};
let notificationHandler: ((msg: { method: string; params?: unknown }) => void) | undefined;
let closeResolve: (() => void) | undefined;
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => config,
};
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
}));
vi.mock("./send.js", () => ({
sendMessageIMessage: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./client.js", () => ({
createIMessageRpcClient: vi.fn(async (opts: { onNotification?: typeof notificationHandler }) => {
notificationHandler = opts.onNotification;
return {
request: (...args: unknown[]) => requestMock(...args),
waitForClose: () =>
new Promise<void>((resolve) => {
closeResolve = resolve;
}),
stop: (...args: unknown[]) => stopMock(...args),
};
}),
}));
vi.mock("./probe.js", () => ({
probeIMessage: vi.fn(async () => ({ ok: true })),
}));
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitForSubscribe() {
for (let i = 0; i < 5; i += 1) {
if (requestMock.mock.calls.some((call) => call[0] === "watch.subscribe")) {
return;
}
await flush();
}
}
beforeEach(() => {
config = {
channels: {
imessage: {
dmPolicy: "open",
allowFrom: ["*"],
groups: { "*": { requireMention: true } },
},
},
session: { mainKey: "main" },
messages: {
groupChat: { mentionPatterns: ["@openclaw"] },
},
};
requestMock.mockReset().mockImplementation((method: string) => {
if (method === "watch.subscribe") {
return Promise.resolve({ subscription: 1 });
}
return Promise.resolve({});
});
stopMock.mockReset().mockResolvedValue(undefined);
sendMock.mockReset().mockResolvedValue({ messageId: "ok" });
replyMock.mockReset().mockResolvedValue({ text: "ok" });
updateLastRouteMock.mockReset();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
notificationHandler = undefined;
closeResolve = undefined;
});
const replyMock = getReplyMock();
const requestMock = getRequestMock();
const stopMock = getStopMock();
const updateLastRouteMock = getUpdateLastRouteMock();
describe("monitorIMessageProvider", () => {
it("updates last route with sender handle for direct messages", async () => {
@@ -106,7 +25,7 @@ describe("monitorIMessageProvider", () => {
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
getNotificationHandler()?.({
method: "message",
params: {
message: {
@@ -121,7 +40,7 @@ describe("monitorIMessageProvider", () => {
});
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
expect(updateLastRouteMock).toHaveBeenCalledWith(
@@ -162,7 +81,7 @@ describe("monitorIMessageProvider", () => {
abortController.abort();
await flush();
closeResolve?.();
getCloseResolve()?.();
await run;
} finally {
process.off("unhandledRejection", onUnhandled);