test: extract message action plugin dispatch coverage

This commit is contained in:
Peter Steinberger 2026-03-13 21:04:02 +00:00
parent a9fd34058f
commit 9044a10c5f
2 changed files with 440 additions and 439 deletions

View File

@ -0,0 +1,439 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResult } from "../../agents/tools/common.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { runMessageAction } from "./message-action-runner.js";
function createAlwaysConfiguredPluginConfig(account: Record<string, unknown> = { enabled: true }) {
return {
listAccountIds: () => ["default"],
resolveAccount: () => account,
isConfigured: () => true,
};
}
describe("runMessageAction plugin dispatch", () => {
describe("media caption behavior", () => {
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
it("promotes caption to message for media sends when message is empty", async () => {
const sendMedia = vi.fn().mockResolvedValue({
channel: "testchat",
messageId: "m1",
chatId: "c1",
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "testchat",
source: "test",
plugin: createOutboundTestPlugin({
id: "testchat",
outbound: {
deliveryMode: "direct",
sendText: vi.fn().mockResolvedValue({
channel: "testchat",
messageId: "t1",
chatId: "c1",
}),
sendMedia,
},
}),
},
]),
);
const cfg = {
channels: {
testchat: {
enabled: true,
},
},
} as OpenClawConfig;
const result = await runMessageAction({
cfg,
action: "send",
params: {
channel: "testchat",
target: "channel:abc",
media: "https://example.com/cat.png",
caption: "caption-only text",
},
dryRun: false,
});
expect(result.kind).toBe("send");
expect(sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
text: "caption-only text",
mediaUrl: "https://example.com/cat.png",
}),
);
});
});
describe("card-only send behavior", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({
ok: true,
card: params.card ?? null,
message: params.message ?? null,
}),
);
const cardPlugin: ChannelPlugin = {
id: "cardchat",
meta: {
id: "cardchat",
label: "Card Chat",
selectionLabel: "Card Chat",
docsPath: "/channels/cardchat",
blurb: "Card-only send test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
actions: {
listActions: () => ["send"],
supportsAction: ({ action }) => action === "send",
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "cardchat",
source: "test",
plugin: cardPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("allows card-only sends without text or media", async () => {
const cfg = {
channels: {
cardchat: {
enabled: true,
},
},
} as OpenClawConfig;
const card = {
type: "AdaptiveCard",
version: "1.4",
body: [{ type: "TextBlock", text: "Card-only payload" }],
};
const result = await runMessageAction({
cfg,
action: "send",
params: {
channel: "cardchat",
target: "channel:test-card",
card,
},
dryRun: false,
});
expect(result.kind).toBe("send");
expect(result.handledBy).toBe("plugin");
expect(handleAction).toHaveBeenCalled();
expect(result.payload).toMatchObject({
ok: true,
card,
});
});
});
describe("telegram plugin poll forwarding", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({
ok: true,
forwarded: {
to: params.to ?? null,
pollQuestion: params.pollQuestion ?? null,
pollOption: params.pollOption ?? null,
pollDurationSeconds: params.pollDurationSeconds ?? null,
pollPublic: params.pollPublic ?? null,
threadId: params.threadId ?? null,
},
}),
);
const telegramPollPlugin: ChannelPlugin = {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram poll forwarding test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
messaging: {
targetResolver: {
looksLikeId: () => true,
},
},
actions: {
listActions: () => ["poll"],
supportsAction: ({ action }) => action === "poll",
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: telegramPollPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("forwards telegram poll params through plugin dispatch", async () => {
const result = await runMessageAction({
cfg: {
channels: {
telegram: {
botToken: "tok",
},
},
} as OpenClawConfig,
action: "poll",
params: {
channel: "telegram",
target: "telegram:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
threadId: "42",
},
dryRun: false,
});
expect(result.kind).toBe("poll");
expect(result.handledBy).toBe("plugin");
expect(handleAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "poll",
channel: "telegram",
params: expect.objectContaining({
to: "telegram:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
threadId: "42",
}),
}),
);
expect(result.payload).toMatchObject({
ok: true,
forwarded: {
to: "telegram:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
threadId: "42",
},
});
});
});
describe("components parsing", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({
ok: true,
components: params.components ?? null,
}),
);
const componentsPlugin: ChannelPlugin = {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "Discord components send test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig({}),
actions: {
listActions: () => ["send"],
supportsAction: ({ action }) => action === "send",
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
source: "test",
plugin: componentsPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("parses components JSON strings before plugin dispatch", async () => {
const components = {
text: "hello",
buttons: [{ label: "A", customId: "a" }],
};
const result = await runMessageAction({
cfg: {} as OpenClawConfig,
action: "send",
params: {
channel: "discord",
target: "channel:123",
message: "hi",
components: JSON.stringify(components),
},
dryRun: false,
});
expect(result.kind).toBe("send");
expect(handleAction).toHaveBeenCalled();
expect(result.payload).toMatchObject({ ok: true, components });
});
it("throws on invalid components JSON strings", async () => {
await expect(
runMessageAction({
cfg: {} as OpenClawConfig,
action: "send",
params: {
channel: "discord",
target: "channel:123",
message: "hi",
components: "{not-json}",
},
dryRun: false,
}),
).rejects.toThrow(/--components must be valid JSON/);
expect(handleAction).not.toHaveBeenCalled();
});
});
describe("accountId defaults", () => {
const handleAction = vi.fn(async () => jsonResult({ ok: true }));
const accountPlugin: ChannelPlugin = {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "Discord test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
actions: {
listActions: () => ["send"],
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
source: "test",
plugin: accountPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it.each([
{
name: "uses defaultAccountId override",
args: {
cfg: {} as OpenClawConfig,
defaultAccountId: "ops",
},
expectedAccountId: "ops",
},
{
name: "falls back to agent binding account",
args: {
cfg: {
bindings: [
{ agentId: "agent-b", match: { channel: "discord", accountId: "account-b" } },
],
} as OpenClawConfig,
agentId: "agent-b",
},
expectedAccountId: "account-b",
},
])("$name", async ({ args, expectedAccountId }) => {
await runMessageAction({
...args,
action: "send",
params: {
channel: "discord",
target: "channel:123",
message: "hi",
},
});
expect(handleAction).toHaveBeenCalled();
const ctx = (handleAction.mock.calls as unknown as Array<[unknown]>)[0]?.[0] as
| {
accountId?: string | null;
params: Record<string, unknown>;
}
| undefined;
if (!ctx) {
throw new Error("expected action context");
}
expect(ctx.accountId).toBe(expectedAccountId);
expect(ctx.params.accountId).toBe(expectedAccountId);
});
});
});

View File

@ -9,7 +9,7 @@ import { jsonResult } from "../../agents/tools/common.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { loadWebMedia } from "../../web/media.js"; import { loadWebMedia } from "../../web/media.js";
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
@ -105,14 +105,6 @@ async function expectSandboxMediaRewrite(params: {
); );
} }
function createAlwaysConfiguredPluginConfig(account: Record<string, unknown> = { enabled: true }) {
return {
listAccountIds: () => ["default"],
resolveAccount: () => account,
isConfigured: () => true,
};
}
let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime; let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime;
let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime; let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime;
let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime; let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime;
@ -825,433 +817,3 @@ describe("runMessageAction sandboxed media validation", () => {
} }
}); });
}); });
describe("runMessageAction media caption behavior", () => {
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
it("promotes caption to message for media sends when message is empty", async () => {
const sendMedia = vi.fn().mockResolvedValue({
channel: "testchat",
messageId: "m1",
chatId: "c1",
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "testchat",
source: "test",
plugin: createOutboundTestPlugin({
id: "testchat",
outbound: {
deliveryMode: "direct",
sendText: vi.fn().mockResolvedValue({
channel: "testchat",
messageId: "t1",
chatId: "c1",
}),
sendMedia,
},
}),
},
]),
);
const cfg = {
channels: {
testchat: {
enabled: true,
},
},
} as OpenClawConfig;
const result = await runMessageAction({
cfg,
action: "send",
params: {
channel: "testchat",
target: "channel:abc",
media: "https://example.com/cat.png",
caption: "caption-only text",
},
dryRun: false,
});
expect(result.kind).toBe("send");
expect(sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
text: "caption-only text",
mediaUrl: "https://example.com/cat.png",
}),
);
});
});
describe("runMessageAction card-only send behavior", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({
ok: true,
card: params.card ?? null,
message: params.message ?? null,
}),
);
const cardPlugin: ChannelPlugin = {
id: "cardchat",
meta: {
id: "cardchat",
label: "Card Chat",
selectionLabel: "Card Chat",
docsPath: "/channels/cardchat",
blurb: "Card-only send test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
actions: {
listActions: () => ["send"],
supportsAction: ({ action }) => action === "send",
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "cardchat",
source: "test",
plugin: cardPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("allows card-only sends without text or media", async () => {
const cfg = {
channels: {
cardchat: {
enabled: true,
},
},
} as OpenClawConfig;
const card = {
type: "AdaptiveCard",
version: "1.4",
body: [{ type: "TextBlock", text: "Card-only payload" }],
};
const result = await runMessageAction({
cfg,
action: "send",
params: {
channel: "cardchat",
target: "channel:test-card",
card,
},
dryRun: false,
});
expect(result.kind).toBe("send");
expect(result.handledBy).toBe("plugin");
expect(handleAction).toHaveBeenCalled();
expect(result.payload).toMatchObject({
ok: true,
card,
});
});
});
describe("runMessageAction telegram plugin poll forwarding", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({
ok: true,
forwarded: {
to: params.to ?? null,
pollQuestion: params.pollQuestion ?? null,
pollOption: params.pollOption ?? null,
pollDurationSeconds: params.pollDurationSeconds ?? null,
pollPublic: params.pollPublic ?? null,
threadId: params.threadId ?? null,
},
}),
);
const telegramPollPlugin: ChannelPlugin = {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram poll forwarding test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
messaging: {
targetResolver: {
looksLikeId: () => true,
},
},
actions: {
listActions: () => ["poll"],
supportsAction: ({ action }) => action === "poll",
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: telegramPollPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("forwards telegram poll params through plugin dispatch", async () => {
const result = await runMessageAction({
cfg: {
channels: {
telegram: {
botToken: "tok",
},
},
} as OpenClawConfig,
action: "poll",
params: {
channel: "telegram",
target: "telegram:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
threadId: "42",
},
dryRun: false,
});
expect(result.kind).toBe("poll");
expect(result.handledBy).toBe("plugin");
expect(handleAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "poll",
channel: "telegram",
params: expect.objectContaining({
to: "telegram:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
threadId: "42",
}),
}),
);
expect(result.payload).toMatchObject({
ok: true,
forwarded: {
to: "telegram:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
threadId: "42",
},
});
});
});
describe("runMessageAction components parsing", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({
ok: true,
components: params.components ?? null,
}),
);
const componentsPlugin: ChannelPlugin = {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "Discord components send test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig({}),
actions: {
listActions: () => ["send"],
supportsAction: ({ action }) => action === "send",
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
source: "test",
plugin: componentsPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("parses components JSON strings before plugin dispatch", async () => {
const components = {
text: "hello",
buttons: [{ label: "A", customId: "a" }],
};
const result = await runMessageAction({
cfg: {} as OpenClawConfig,
action: "send",
params: {
channel: "discord",
target: "channel:123",
message: "hi",
components: JSON.stringify(components),
},
dryRun: false,
});
expect(result.kind).toBe("send");
expect(handleAction).toHaveBeenCalled();
expect(result.payload).toMatchObject({ ok: true, components });
});
it("throws on invalid components JSON strings", async () => {
await expect(
runMessageAction({
cfg: {} as OpenClawConfig,
action: "send",
params: {
channel: "discord",
target: "channel:123",
message: "hi",
components: "{not-json}",
},
dryRun: false,
}),
).rejects.toThrow(/--components must be valid JSON/);
expect(handleAction).not.toHaveBeenCalled();
});
});
describe("runMessageAction accountId defaults", () => {
const handleAction = vi.fn(async () => jsonResult({ ok: true }));
const accountPlugin: ChannelPlugin = {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "Discord test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
actions: {
listActions: () => ["send"],
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "discord",
source: "test",
plugin: accountPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("propagates defaultAccountId into params", async () => {
await runMessageAction({
cfg: {} as OpenClawConfig,
action: "send",
params: {
channel: "discord",
target: "channel:123",
message: "hi",
},
defaultAccountId: "ops",
});
expect(handleAction).toHaveBeenCalled();
const ctx = (handleAction.mock.calls as unknown as Array<[unknown]>)[0]?.[0] as
| {
accountId?: string | null;
params: Record<string, unknown>;
}
| undefined;
if (!ctx) {
throw new Error("expected action context");
}
expect(ctx.accountId).toBe("ops");
expect(ctx.params.accountId).toBe("ops");
});
it("falls back to the agent's bound account when accountId is omitted", async () => {
await runMessageAction({
cfg: {
bindings: [{ agentId: "agent-b", match: { channel: "discord", accountId: "account-b" } }],
} as OpenClawConfig,
action: "send",
params: {
channel: "discord",
target: "channel:123",
message: "hi",
},
agentId: "agent-b",
});
expect(handleAction).toHaveBeenCalled();
const ctx = (handleAction.mock.calls as unknown as Array<[unknown]>)[0]?.[0] as
| {
accountId?: string | null;
params: Record<string, unknown>;
}
| undefined;
if (!ctx) {
throw new Error("expected action context");
}
expect(ctx.accountId).toBe("account-b");
expect(ctx.params.accountId).toBe("account-b");
});
});