From 9bb5eb6c7f08bbb97abfae8ce014ad2976c10eaa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 22 Mar 2026 12:45:21 -0700 Subject: [PATCH] fix(test): repair channel regression suites --- .../native-command.plugin-dispatch.test.ts | 139 +++++++++--------- extensions/matrix/src/matrix/send.test.ts | 45 ++++-- ...tor-inbox.streams-inbound-messages.test.ts | 26 +++- .../src/monitor-inbox.test-harness.ts | 9 +- 4 files changed, 124 insertions(+), 95 deletions(-) diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 5a024f1cae4..7d1c5f471c8 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -12,52 +12,17 @@ import { createNoopThreadBindingManager } from "./thread-bindings.js"; type EnsureConfiguredBindingRouteReadyFn = typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady; -type MatchPluginCommandFn = typeof import("openclaw/plugin-sdk/plugin-runtime").matchPluginCommand; -type ExecutePluginCommandFn = - typeof import("openclaw/plugin-sdk/plugin-runtime").executePluginCommand; -type DispatchReplyWithDispatcherFn = - typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher; const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, })), ); -const matchPluginCommandMockState = vi.hoisted(() => ({ - current: null as null | ReturnType>, +const runtimeModuleMocks = vi.hoisted(() => ({ + matchPluginCommand: vi.fn(), + executePluginCommand: vi.fn(), + dispatchReplyWithDispatcher: vi.fn(), })); -const executePluginCommandMockState = vi.hoisted(() => ({ - current: null as null | ReturnType>, -})); -const dispatchReplyWithDispatcherMockState = vi.hoisted(() => ({ - current: null as null | ReturnType>, -})); - -vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - matchPluginCommand: (...args: Parameters) => - matchPluginCommandMockState.current - ? matchPluginCommandMockState.current(...args) - : actual.matchPluginCommand(...args), - executePluginCommand: (...args: Parameters) => - executePluginCommandMockState.current - ? executePluginCommandMockState.current(...args) - : actual.executePluginCommand(...args), - }; -}); - -vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchReplyWithDispatcher: (...args: Parameters) => - dispatchReplyWithDispatcherMockState.current - ? dispatchReplyWithDispatcherMockState.current(...args) - : actual.dispatchReplyWithDispatcher(...args), - }; -}); vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { const actual = await importOriginal(); @@ -70,6 +35,24 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { }; }); +vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + matchPluginCommand: (...args: unknown[]) => runtimeModuleMocks.matchPluginCommand(...args), + executePluginCommand: (...args: unknown[]) => runtimeModuleMocks.executePluginCommand(...args), + }; +}); + +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchReplyWithDispatcher: (...args: unknown[]) => + runtimeModuleMocks.dispatchReplyWithDispatcher(...args), + }; +}); + function createInteraction(params?: { channelType?: ChannelType; channelId?: string; @@ -99,6 +82,7 @@ function createConfig(): OpenClawConfig { } async function loadCreateDiscordNativeCommand() { + vi.resetModules(); return (await import("./native-command.js")).createDiscordNativeCommand; } @@ -161,8 +145,7 @@ async function expectPairCommandReply(params: { cfg: params.cfg, name: params.commandName, }); - const dispatchSpy = vi.fn().mockResolvedValue({} as never); - dispatchReplyWithDispatcherMockState.current = dispatchSpy; + const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher; await (command as { run: (interaction: unknown) => Promise }).run( Object.assign(params.interaction, { @@ -189,15 +172,13 @@ async function createStatusCommand(cfg: OpenClawConfig) { } function createDispatchSpy() { - const dispatchSpy = vi.fn().mockResolvedValue({ + return runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({ counts: { final: 1, block: 0, tool: 0, }, } as never); - dispatchReplyWithDispatcherMockState.current = dispatchSpy; - return dispatchSpy; } function expectBoundSessionDispatch( @@ -221,10 +202,9 @@ async function expectBoundStatusCommandDispatch(params: { interaction: MockCommandInteraction; expectedPattern: RegExp; }) { - const command = await createStatusCommand(params.cfg); - - matchPluginCommandMockState.current = vi.fn().mockReturnValue(null); + runtimeModuleMocks.matchPluginCommand.mockReturnValue(null); const dispatchSpy = createDispatchSpy(); + const command = await createStatusCommand(params.cfg); await (command as { run: (interaction: unknown) => Promise }).run( params.interaction as unknown, @@ -234,17 +214,33 @@ async function expectBoundStatusCommandDispatch(params: { } describe("Discord native plugin command dispatch", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); clearPluginCommands(); setDefaultChannelPluginRegistryForTests(); - matchPluginCommandMockState.current = null; - executePluginCommandMockState.current = null; - dispatchReplyWithDispatcherMockState.current = null; ensureConfiguredBindingRouteReadyMock.mockReset(); ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true, }); + const actualPluginRuntime = await vi.importActual< + typeof import("openclaw/plugin-sdk/plugin-runtime") + >("openclaw/plugin-sdk/plugin-runtime"); + runtimeModuleMocks.matchPluginCommand.mockReset(); + runtimeModuleMocks.matchPluginCommand.mockImplementation( + actualPluginRuntime.matchPluginCommand, + ); + runtimeModuleMocks.executePluginCommand.mockReset(); + runtimeModuleMocks.executePluginCommand.mockImplementation( + actualPluginRuntime.executePluginCommand, + ); + runtimeModuleMocks.dispatchReplyWithDispatcher.mockReset(); + runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); }); it("executes plugin commands from the real registry through the native Discord command path", async () => { @@ -319,9 +315,10 @@ describe("Discord native plugin command dispatch", () => { }), ).toEqual({ ok: true }); - const executeSpy = vi.fn(); - executePluginCommandMockState.current = executeSpy; - const dispatchSpy = createDispatchSpy(); + const executeSpy = runtimeModuleMocks.executePluginCommand; + const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue( + {} as never, + ); await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); @@ -342,7 +339,6 @@ describe("Discord native plugin command dispatch", () => { description: "List cron jobs", acceptsArgs: false, }; - const command = await createNativeCommand(cfg, commandSpec); const interaction = createInteraction(); const pluginMatch = { command: { @@ -355,14 +351,14 @@ describe("Discord native plugin command dispatch", () => { args: undefined, }; - matchPluginCommandMockState.current = vi - .fn() - .mockReturnValue(pluginMatch as ReturnType); - const executeSpy = vi - .fn() - .mockResolvedValue({ text: "direct plugin output" }); - executePluginCommandMockState.current = executeSpy; - const dispatchSpy = createDispatchSpy(); + runtimeModuleMocks.matchPluginCommand.mockReturnValue(pluginMatch as never); + const executeSpy = runtimeModuleMocks.executePluginCommand.mockResolvedValue({ + text: "direct plugin output", + }); + const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue( + {} as never, + ); + const command = await createNativeCommand(cfg, commandSpec); await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); @@ -450,7 +446,6 @@ describe("Discord native plugin command dispatch", () => { }, }, } as OpenClawConfig; - const command = await createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.GuildText, channelId, @@ -458,8 +453,9 @@ describe("Discord native plugin command dispatch", () => { guildName: "Ops", }); - matchPluginCommandMockState.current = vi.fn().mockReturnValue(null); + runtimeModuleMocks.matchPluginCommand.mockReturnValue(null); const dispatchSpy = createDispatchSpy(); + const command = await createStatusCommand(cfg); await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); @@ -540,19 +536,18 @@ describe("Discord native plugin command dispatch", () => { guildId, guildName: "Ops", }); + ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ + ok: false, + error: "acpx exited with code 1", + }); + runtimeModuleMocks.matchPluginCommand.mockReturnValue(null); + const dispatchSpy = createDispatchSpy(); const command = await createNativeCommand(cfg, { name: "new", description: "Start a new session.", acceptsArgs: true, }); - ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ - ok: false, - error: "acpx exited with code 1", - }); - matchPluginCommandMockState.current = vi.fn().mockReturnValue(null); - const dispatchSpy = createDispatchSpy(); - await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); expect(dispatchSpy).toHaveBeenCalledTimes(1); diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index e50510a90d1..a5add3fee47 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -1,8 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "../../runtime-api.js"; -import { setMatrixRuntime } from "../runtime.js"; -import { voteMatrixPoll } from "./actions/polls.js"; -import { sendMessageMatrix, sendTypingMatrix } from "./send.js"; const loadWebMediaMock = vi.fn().mockResolvedValue({ buffer: Buffer.from("media"), @@ -46,6 +43,18 @@ const runtimeStub = { }, } as unknown as PluginRuntime; +let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; +let sendTypingMatrix: typeof import("./send.js").sendTypingMatrix; +let voteMatrixPoll: typeof import("./actions/polls.js").voteMatrixPoll; + +async function loadMatrixSendModules() { + vi.resetModules(); + const runtimeModule = await import("../runtime.js"); + runtimeModule.setMatrixRuntime(runtimeStub); + ({ sendMessageMatrix } = await import("./send.js")); + ({ sendTypingMatrix } = await import("./send.js")); + ({ voteMatrixPoll } = await import("./actions/polls.js")); +} const makeClient = () => { const sendMessage = vi.fn().mockResolvedValue("evt1"); const sendEvent = vi.fn().mockResolvedValue("evt-poll-vote"); @@ -66,7 +75,11 @@ const makeClient = () => { }; describe("sendMessageMatrix media", () => { - beforeEach(() => { + beforeAll(async () => { + await loadMatrixSendModules(); + }); + + beforeEach(async () => { loadWebMediaMock.mockReset().mockResolvedValue({ buffer: Buffer.from("media"), fileName: "photo.png", @@ -79,7 +92,7 @@ describe("sendMessageMatrix media", () => { mediaKindFromMimeMock.mockReset().mockReturnValue("image"); isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false); resolveTextChunkLimitMock.mockReset().mockReturnValue(4000); - setMatrixRuntime(runtimeStub); + await loadMatrixSendModules(); }); it("uploads media with url payloads", async () => { @@ -317,12 +330,12 @@ describe("sendMessageMatrix media", () => { }); describe("sendMessageMatrix threads", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); loadConfigMock.mockReset().mockReturnValue({}); mediaKindFromMimeMock.mockReset().mockReturnValue("image"); isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false); - setMatrixRuntime(runtimeStub); + await loadMatrixSendModules(); }); it("includes thread relation metadata when threadId is set", async () => { @@ -361,12 +374,16 @@ describe("sendMessageMatrix threads", () => { }); describe("voteMatrixPoll", () => { - beforeEach(() => { + beforeAll(async () => { + await loadMatrixSendModules(); + }); + + beforeEach(async () => { vi.clearAllMocks(); loadConfigMock.mockReset().mockReturnValue({}); mediaKindFromMimeMock.mockReset().mockReturnValue("image"); isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false); - setMatrixRuntime(runtimeStub); + await loadMatrixSendModules(); }); it("maps 1-based option indexes to Matrix poll answer ids", async () => { @@ -502,12 +519,16 @@ describe("voteMatrixPoll", () => { }); describe("sendTypingMatrix", () => { - beforeEach(() => { + beforeAll(async () => { + await loadMatrixSendModules(); + }); + + beforeEach(async () => { vi.clearAllMocks(); loadConfigMock.mockReset().mockReturnValue({}); mediaKindFromMimeMock.mockReset().mockReturnValue("image"); isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false); - setMatrixRuntime(runtimeStub); + await loadMatrixSendModules(); }); it("normalizes room-prefixed targets before sending typing state", async () => { diff --git a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts index de57ecdae55..28ea04f319a 100644 --- a/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test.ts @@ -11,6 +11,13 @@ import { waitForMessageCalls, } from "./monitor-inbox.test-harness.js"; +let nextMessageSequence = 0; + +function nextMessageId(label: string): string { + nextMessageSequence += 1; + return `${label}-${nextMessageSequence}`; +} + describe("web monitor inbox", () => { installWebMonitorInboxUnitTestHooks(); @@ -24,7 +31,11 @@ describe("web monitor inbox", () => { type: "notify", messages: [ { - key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + key: { + id: nextMessageId("quoted"), + fromMe: false, + remoteJid: "999@s.whatsapp.net", + }, message: { extendedTextMessage: { text: "reply", @@ -66,8 +77,9 @@ describe("web monitor inbox", () => { const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage); expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); + const messageId = nextMessageId("stream"); const upsert = buildNotifyMessageUpsert({ - id: "abc", + id: messageId, remoteJid: "999@s.whatsapp.net", text: "ping", timestamp: 1_700_000_000, @@ -83,7 +95,7 @@ describe("web monitor inbox", () => { expect(sock.readMessages).toHaveBeenCalledWith([ { remoteJid: "999@s.whatsapp.net", - id: "abc", + id: messageId, participant: undefined, fromMe: false, }, @@ -104,7 +116,7 @@ describe("web monitor inbox", () => { const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage); const upsert = buildNotifyMessageUpsert({ - id: "abc", + id: nextMessageId("dedupe"), remoteJid: "999@s.whatsapp.net", text: "ping", timestamp: 1_700_000_000, @@ -129,7 +141,7 @@ describe("web monitor inbox", () => { const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID"); sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce("999:0@s.whatsapp.net"); const upsert = buildNotifyMessageUpsert({ - id: "abc", + id: nextMessageId("lid-store"), remoteJid: "999@lid", text: "ping", timestamp: 1_700_000_000, @@ -159,7 +171,7 @@ describe("web monitor inbox", () => { const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage); const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID"); const upsert = buildNotifyMessageUpsert({ - id: "abc", + id: nextMessageId("lid-authdir"), remoteJid: "555@lid", text: "ping", timestamp: 1_700_000_000, @@ -186,7 +198,7 @@ describe("web monitor inbox", () => { const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID"); sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce("444:0@s.whatsapp.net"); const upsert = buildNotifyMessageUpsert({ - id: "abc", + id: nextMessageId("group-lid"), remoteJid: "123@g.us", participant: "444@lid", text: "ping", diff --git a/extensions/whatsapp/src/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts index ea7ed502d57..6a8dda625fb 100644 --- a/extensions/whatsapp/src/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -75,9 +75,6 @@ function createMockSock(): MockSock { }; } -const sock: MockSock = createMockSock(); -sessionState.sock = sock; - vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { const actual = await importOriginal(); return { @@ -134,7 +131,10 @@ vi.mock("./session.js", () => ({ })); export function getSock(): MockSock { - return sock; + if (!sessionState.sock) { + throw new Error("mock WhatsApp socket not initialized"); + } + return sessionState.sock; } export type InboxOnMessage = NonNullable[0]["onMessage"]>; @@ -212,6 +212,7 @@ export function installWebMonitorInboxUnitTestHooks(opts?: { authDir?: boolean } beforeEach(async () => { vi.clearAllMocks(); + sessionState.sock = createMockSock(); mockLoadConfig.mockReturnValue(DEFAULT_WEB_INBOX_CONFIG); readAllowFromStoreMock.mockResolvedValue([]); upsertPairingRequestMock.mockResolvedValue({