fix(test): repair channel regression suites

This commit is contained in:
Vincent Koc 2026-03-22 12:45:21 -07:00
parent 0404c16217
commit 9bb5eb6c7f
4 changed files with 124 additions and 95 deletions

View File

@ -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<EnsureConfiguredBindingRouteReadyFn>(async () => ({
ok: true,
})),
);
const matchPluginCommandMockState = vi.hoisted(() => ({
current: null as null | ReturnType<typeof vi.fn<MatchPluginCommandFn>>,
const runtimeModuleMocks = vi.hoisted(() => ({
matchPluginCommand: vi.fn(),
executePluginCommand: vi.fn(),
dispatchReplyWithDispatcher: vi.fn(),
}));
const executePluginCommandMockState = vi.hoisted(() => ({
current: null as null | ReturnType<typeof vi.fn<ExecutePluginCommandFn>>,
}));
const dispatchReplyWithDispatcherMockState = vi.hoisted(() => ({
current: null as null | ReturnType<typeof vi.fn<DispatchReplyWithDispatcherFn>>,
}));
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
return {
...actual,
matchPluginCommand: (...args: Parameters<MatchPluginCommandFn>) =>
matchPluginCommandMockState.current
? matchPluginCommandMockState.current(...args)
: actual.matchPluginCommand(...args),
executePluginCommand: (...args: Parameters<ExecutePluginCommandFn>) =>
executePluginCommandMockState.current
? executePluginCommandMockState.current(...args)
: actual.executePluginCommand(...args),
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
dispatchReplyWithDispatcher: (...args: Parameters<DispatchReplyWithDispatcherFn>) =>
dispatchReplyWithDispatcherMockState.current
? dispatchReplyWithDispatcherMockState.current(...args)
: actual.dispatchReplyWithDispatcher(...args),
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
@ -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<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
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<typeof import("openclaw/plugin-sdk/reply-runtime")>();
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<DispatchReplyWithDispatcherFn>().mockResolvedValue({} as never);
dispatchReplyWithDispatcherMockState.current = dispatchSpy;
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher;
await (command as { run: (interaction: unknown) => Promise<void> }).run(
Object.assign(params.interaction, {
@ -189,15 +172,13 @@ async function createStatusCommand(cfg: OpenClawConfig) {
}
function createDispatchSpy() {
const dispatchSpy = vi.fn<DispatchReplyWithDispatcherFn>().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<MatchPluginCommandFn>().mockReturnValue(null);
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
const command = await createStatusCommand(params.cfg);
await (command as { run: (interaction: unknown) => Promise<void> }).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<ExecutePluginCommandFn>();
executePluginCommandMockState.current = executeSpy;
const dispatchSpy = createDispatchSpy();
const executeSpy = runtimeModuleMocks.executePluginCommand;
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue(
{} as never,
);
await (command as { run: (interaction: unknown) => Promise<void> }).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<MatchPluginCommandFn>()
.mockReturnValue(pluginMatch as ReturnType<MatchPluginCommandFn>);
const executeSpy = vi
.fn<ExecutePluginCommandFn>()
.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<void> }).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<MatchPluginCommandFn>().mockReturnValue(null);
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
const command = await createStatusCommand(cfg);
await (command as { run: (interaction: unknown) => Promise<void> }).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<MatchPluginCommandFn>().mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(dispatchSpy).toHaveBeenCalledTimes(1);

View File

@ -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 () => {

View File

@ -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",

View File

@ -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<typeof import("openclaw/plugin-sdk/media-runtime")>();
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<Parameters<typeof monitorWebInbox>[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({