test: use synthetic outbound core fixtures

This commit is contained in:
Peter Steinberger
2026-04-20 23:20:33 +01:00
parent f1a544ef6d
commit c561e4c11b
5 changed files with 128 additions and 120 deletions

View File

@@ -15,15 +15,15 @@ class TestSeparator {
constructor(readonly options: { divider: boolean; spacing: string }) {}
}
class TestDiscordUiContainer {
class TestRichUiContainer {
constructor(readonly components: Array<TestTextDisplay | TestSeparator>) {}
}
const discordCrossContextPlugin: Pick<
const richCrossContextPlugin: Pick<
ChannelPlugin,
"id" | "meta" | "capabilities" | "config" | "messaging"
> = {
...createChannelTestPluginBase({ id: "discord" }),
...createChannelTestPluginBase({ id: "rich-chat" }),
messaging: {
buildCrossContextComponents: ({ originLabel, message, cfg, accountId }) => {
const trimmed = message.trim();
@@ -35,7 +35,7 @@ const discordCrossContextPlugin: Pick<
components.push(new TestTextDisplay(`*From ${originLabel}*`));
void cfg;
void accountId;
return [new TestDiscordUiContainer(components)];
return [new TestRichUiContainer(components)];
},
},
};
@@ -44,38 +44,38 @@ describe("getChannelMessageAdapter", () => {
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" },
{ pluginId: "rich-chat", plugin: richCrossContextPlugin, source: "test" },
{
pluginId: "telegram",
plugin: createChannelTestPluginBase({ id: "telegram" }),
pluginId: "plain-chat",
plugin: createChannelTestPluginBase({ id: "plain-chat" }),
source: "test",
},
]),
);
});
it("returns the default adapter for non-discord channels", () => {
expect(getChannelMessageAdapter("telegram")).toEqual({
it("returns the default adapter for channels without structured component support", () => {
expect(getChannelMessageAdapter("plain-chat")).toEqual({
supportsComponentsV2: false,
});
});
it("returns the discord adapter with a cross-context component builder", () => {
const adapter = getChannelMessageAdapter("discord");
it("returns an adapter with a cross-context component builder", () => {
const adapter = getChannelMessageAdapter("rich-chat");
expect(adapter.supportsComponentsV2).toBe(true);
expect(adapter.buildCrossContextComponents).toBeTypeOf("function");
const components = adapter.buildCrossContextComponents?.({
originLabel: "Telegram",
originLabel: "Forum",
message: "Hello from chat",
cfg: {} as never,
accountId: "primary",
});
const container = components?.[0] as TestDiscordUiContainer | undefined;
const container = components?.[0] as TestRichUiContainer | undefined;
expect(components).toHaveLength(1);
expect(container).toBeInstanceOf(TestDiscordUiContainer);
expect(container).toBeInstanceOf(TestRichUiContainer);
expect(container?.components).toEqual([
expect.any(TestTextDisplay),
expect.any(TestSeparator),
@@ -86,7 +86,7 @@ describe("getChannelMessageAdapter", () => {
it.each([
{
message: "Hello from chat",
originLabel: "Telegram",
originLabel: "Forum",
accountId: "primary",
expectedComponents: [
expect.any(TestTextDisplay),
@@ -96,20 +96,20 @@ describe("getChannelMessageAdapter", () => {
},
{
message: " ",
originLabel: "Signal",
originLabel: "Pager",
expectedComponents: [expect.any(TestTextDisplay)],
},
])(
"builds cross-context components for %j",
({ message, originLabel, accountId, expectedComponents }) => {
const adapter = getChannelMessageAdapter("discord");
const adapter = getChannelMessageAdapter("rich-chat");
const components = adapter.buildCrossContextComponents?.({
originLabel,
message,
cfg: {} as never,
...(accountId ? { accountId } : {}),
});
const container = components?.[0] as TestDiscordUiContainer | undefined;
const container = components?.[0] as TestRichUiContainer | undefined;
expect(components).toHaveLength(1);
expect(container?.components).toEqual(expectedComponents);

View File

@@ -83,7 +83,7 @@ describe("outbound channel resolution", () => {
typeof value === "string" ? value.trim().toLowerCase() : undefined,
);
isDeliverableMessageChannelMock.mockImplementation((value?: string) =>
["telegram", "discord", "slack"].includes(String(value)),
["alpha", "beta", "gamma"].includes(String(value)),
);
getActivePluginRegistryMock.mockReturnValue({ channels: [] });
getActivePluginChannelRegistryMock.mockReturnValue({ channels: [] });
@@ -100,7 +100,7 @@ describe("outbound channel resolution", () => {
});
it.each([
{ input: " Telegram ", expected: "telegram" },
{ input: " Alpha ", expected: "alpha" },
{ input: "unknown", expected: undefined },
{ input: null, expected: undefined },
])("normalizes deliverable outbound channel for %j", async ({ input, expected }) => {
@@ -109,13 +109,13 @@ describe("outbound channel resolution", () => {
});
it("returns the already-registered plugin without bootstrapping", async () => {
const plugin = { id: "telegram" };
const plugin = { id: "alpha" };
getLoadedChannelPluginMock.mockReturnValueOnce(plugin);
const channelResolution = await importChannelResolution("existing-plugin");
expect(
channelResolution.resolveOutboundChannelPlugin({
channel: "telegram",
channel: "alpha",
cfg: {} as never,
}),
).toBe(plugin);
@@ -123,7 +123,7 @@ describe("outbound channel resolution", () => {
});
it("falls back to the active registry when getChannelPlugin misses", async () => {
const plugin = { id: "telegram" };
const plugin = { id: "alpha" };
getChannelPluginMock.mockReturnValue(undefined);
getActivePluginRegistryMock.mockReturnValue({
channels: [{ plugin }],
@@ -135,20 +135,20 @@ describe("outbound channel resolution", () => {
expect(
channelResolution.resolveOutboundChannelPlugin({
channel: "telegram",
channel: "alpha",
cfg: {} as never,
}),
).toBe(plugin);
});
it("bootstraps plugins once per registry key and returns the newly loaded plugin", async () => {
const plugin = { id: "telegram" };
const plugin = { id: "alpha" };
getLoadedChannelPluginMock.mockReturnValueOnce(undefined).mockReturnValueOnce(plugin);
const channelResolution = await importChannelResolution("bootstrap-success");
expect(
channelResolution.resolveOutboundChannelPlugin({
channel: "telegram",
channel: "alpha",
cfg: { channels: {} } as never,
}),
).toBe(plugin);
@@ -156,7 +156,7 @@ describe("outbound channel resolution", () => {
getChannelPluginMock.mockReturnValue(undefined);
channelResolution.resolveOutboundChannelPlugin({
channel: "telegram",
channel: "alpha",
cfg: { channels: {} } as never,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
@@ -164,19 +164,19 @@ describe("outbound channel resolution", () => {
});
it("bootstraps when the active registry has other channels but not the requested one", async () => {
const plugin = { id: "telegram" };
const plugin = { id: "alpha" };
getLoadedChannelPluginMock.mockReturnValueOnce(undefined).mockReturnValueOnce(plugin);
getActivePluginRegistryMock.mockReturnValue({
channels: [{ plugin: { id: "discord" } }],
channels: [{ plugin: { id: "beta" } }],
});
getActivePluginChannelRegistryMock.mockReturnValue({
channels: [{ plugin: { id: "discord" } }],
channels: [{ plugin: { id: "beta" } }],
});
const channelResolution = await importChannelResolution("bootstrap-missing-target");
expect(
channelResolution.resolveOutboundChannelPlugin({
channel: "telegram",
channel: "alpha",
cfg: { channels: {} } as never,
}),
).toBe(plugin);
@@ -192,13 +192,13 @@ describe("outbound channel resolution", () => {
expect(
channelResolution.resolveOutboundChannelPlugin({
channel: "telegram",
channel: "alpha",
cfg: { channels: {} } as never,
}),
).toBeUndefined();
channelResolution.resolveOutboundChannelPlugin({
channel: "telegram",
channel: "alpha",
cfg: { channels: {} } as never,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(2);
@@ -209,14 +209,14 @@ describe("outbound channel resolution", () => {
const channelResolution = await importChannelResolution("channel-version-change");
channelResolution.resolveOutboundChannelPlugin({
channel: "telegram",
channel: "alpha",
cfg: { channels: {} } as never,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
getActivePluginChannelRegistryVersionMock.mockReturnValue(2);
channelResolution.resolveOutboundChannelPlugin({
channel: "telegram",
channel: "alpha",
cfg: { channels: {} } as never,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(2);

View File

@@ -5,11 +5,20 @@ const mocks = vi.hoisted(() => ({
resolveOutboundChannelPlugin: vi.fn(),
}));
const deliverableChannelIds = vi.hoisted(() => ["alpha", "beta", "gamma", "delta", "muted"]);
vi.mock("../../channels/plugins/index.js", () => ({
getLoadedChannelPlugin: vi.fn(),
listChannelPlugins: mocks.listChannelPlugins,
}));
vi.mock("../../utils/message-channel.js", () => ({
listDeliverableMessageChannels: () => deliverableChannelIds,
isDeliverableMessageChannel: (value: string) => deliverableChannelIds.includes(value),
normalizeMessageChannel: (value?: string | null) =>
typeof value === "string" ? value.trim().toLowerCase() : undefined,
}));
vi.mock("./channel-resolution.js", () => ({
resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin,
}));
@@ -70,37 +79,37 @@ describe("listConfiguredMessageChannels", () => {
it.each([
{
plugins: [makePlugin({ id: "not-a-channel" }), makePlugin({ id: "slack", accountIds: [] })],
plugins: [makePlugin({ id: "not-a-channel" }), makePlugin({ id: "alpha", accountIds: [] })],
expected: [],
expectedErrors: 0,
},
{
plugins: [
makePlugin({
id: "discord",
id: "beta",
resolveAccount: () => ({ enabled: true }),
}),
],
expected: ["discord"],
expected: ["beta"],
expectedErrors: 0,
},
{
plugins: [
makePlugin({
id: "telegram",
id: "gamma",
accountIds: ["disabled", "enabled"],
resolveAccount: (accountId) =>
accountId === "disabled" ? { enabled: false } : { enabled: true },
isConfigured: (account) => (account as { enabled?: boolean }).enabled === true,
}),
],
expected: ["telegram"],
expected: ["gamma"],
expectedErrors: 0,
},
{
plugins: [
makePlugin({
id: "signal",
id: "muted",
resolveAccount: () => ({ token: "x" }),
isEnabled: () => false,
isConfigured: () => true,
@@ -112,7 +121,7 @@ describe("listConfiguredMessageChannels", () => {
{
plugins: [
makePlugin({
id: "discord",
id: "beta",
resolveAccount: () => {
throw new Error("boom");
},
@@ -136,9 +145,9 @@ describe("resolveMessageChannelSelection", () => {
it.each([
{
params: { cfg: {} as never, channel: "telegram" },
params: { cfg: {} as never, channel: "alpha" },
expected: {
channel: "telegram",
channel: "alpha",
configured: [],
source: "explicit",
},
@@ -146,12 +155,12 @@ describe("resolveMessageChannelSelection", () => {
{
setup: () => {
const isConfigured = vi.fn(async () => true);
mocks.listChannelPlugins.mockReturnValue([makePlugin({ id: "slack", isConfigured })]);
mocks.listChannelPlugins.mockReturnValue([makePlugin({ id: "beta", isConfigured })]);
return { isConfigured };
},
params: { cfg: {} as never, channel: "slack" },
params: { cfg: {} as never, channel: "beta" },
expected: {
channel: "slack",
channel: "beta",
configured: [],
source: "explicit",
},
@@ -160,17 +169,17 @@ describe("resolveMessageChannelSelection", () => {
},
},
{
params: { cfg: {} as never, channel: "channel:C123", fallbackChannel: "slack" },
params: { cfg: {} as never, channel: "channel:C123", fallbackChannel: "beta" },
expected: {
channel: "slack",
channel: "beta",
configured: [],
source: "tool-context-fallback",
},
},
{
params: { cfg: {} as never, fallbackChannel: "signal" },
params: { cfg: {} as never, fallbackChannel: "gamma" },
expected: {
channel: "signal",
channel: "gamma",
configured: [],
source: "tool-context-fallback",
},
@@ -178,25 +187,25 @@ describe("resolveMessageChannelSelection", () => {
{
setup: () => {
mocks.listChannelPlugins.mockReturnValue([
makePlugin({ id: "discord", isConfigured: async () => true }),
makePlugin({ id: "delta", isConfigured: async () => true }),
]);
},
params: { cfg: {} as never },
expected: {
channel: "discord",
configured: ["discord"],
channel: "delta",
configured: ["delta"],
source: "single-configured",
},
},
{
setup: () => {
mocks.resolveOutboundChannelPlugin.mockImplementation(({ channel }: { channel: string }) =>
channel === "slack" ? { id: "slack" } : undefined,
channel === "beta" ? { id: "beta" } : undefined,
);
},
params: { cfg: {} as never, channel: "discord", fallbackChannel: "slack" },
params: { cfg: {} as never, channel: "alpha", fallbackChannel: "beta" },
expected: {
channel: "slack",
channel: "beta",
configured: [],
source: "tool-context-fallback",
},
@@ -216,8 +225,8 @@ describe("resolveMessageChannelSelection", () => {
setup: () => {
mocks.resolveOutboundChannelPlugin.mockReturnValue(undefined);
},
params: { cfg: {} as never, channel: "discord" },
expectedMessage: "Channel is unavailable: discord",
params: { cfg: {} as never, channel: "alpha" },
expectedMessage: "Channel is unavailable: alpha",
},
{
params: { cfg: {} as never },
@@ -226,13 +235,12 @@ describe("resolveMessageChannelSelection", () => {
{
setup: () => {
mocks.listChannelPlugins.mockReturnValue([
makePlugin({ id: "discord", isConfigured: async () => true }),
makePlugin({ id: "telegram", isConfigured: async () => true }),
makePlugin({ id: "beta", isConfigured: async () => true }),
makePlugin({ id: "gamma", isConfigured: async () => true }),
]);
},
params: { cfg: {} as never },
expectedMessage:
"Channel is required when multiple channels are configured: discord, telegram",
expectedMessage: "Channel is required when multiple channels are configured: beta, gamma",
},
])("rejects invalid channel selection for %j", async ({ setup, params, expectedMessage }) => {
setup?.();

View File

@@ -32,27 +32,27 @@ vi.mock("./message-action-threading.js", async () => {
await import("./message-action-threading.test-helpers.js");
return createOutboundThreadingMock();
});
const telegramConfig = {
const pollerConfig = {
channels: {
telegram: {
botToken: "telegram-test",
poller: {
botToken: "poller-test",
},
},
} as OpenClawConfig;
const telegramPollTestPlugin: ChannelPlugin = {
id: "telegram",
const pollerTestPlugin: ChannelPlugin = {
id: "poller",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram poll test plugin.",
id: "poller",
label: "Poller",
selectionLabel: "Poller",
docsPath: "/channels/poller",
blurb: "Poller test plugin.",
},
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ botToken: "telegram-test" }),
resolveAccount: () => ({ botToken: "poller-test" }),
isConfigured: () => true,
},
outbound: {
@@ -119,9 +119,9 @@ describe("runMessageAction poll handling", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
pluginId: "poller",
source: "test",
plugin: telegramPollTestPlugin,
plugin: pollerTestPlugin,
},
]),
);
@@ -146,10 +146,10 @@ describe("runMessageAction poll handling", () => {
it("requires at least two poll options", async () => {
await expect(
runPollAction({
cfg: telegramConfig,
cfg: pollerConfig,
actionParams: {
channel: "telegram",
target: "telegram:123",
channel: "poller",
target: "poller:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza"],
},
@@ -160,16 +160,16 @@ describe("runMessageAction poll handling", () => {
it("passes shared poll fields and auto threadId to executePollAction", async () => {
const call = await runPollAction({
cfg: telegramConfig,
cfg: pollerConfig,
actionParams: {
channel: "telegram",
target: "telegram:123",
channel: "poller",
target: "poller:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationHours: 2,
},
toolContext: {
currentChannelId: "telegram:123",
currentChannelId: "poller:123",
currentThreadTs: "42",
},
});
@@ -181,10 +181,10 @@ describe("runMessageAction poll handling", () => {
it("expands maxSelections when pollMulti is enabled", async () => {
const call = await runPollAction({
cfg: telegramConfig,
cfg: pollerConfig,
actionParams: {
channel: "telegram",
target: "telegram:123",
channel: "poller",
target: "poller:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi", "Soup"],
pollMulti: true,
@@ -196,10 +196,10 @@ describe("runMessageAction poll handling", () => {
it("defaults maxSelections to one choice when pollMulti is omitted", async () => {
const call = await runPollAction({
cfg: telegramConfig,
cfg: pollerConfig,
actionParams: {
channel: "telegram",
target: "telegram:123",
channel: "poller",
target: "poller:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi", "Soup"],
},

View File

@@ -8,24 +8,24 @@ import {
const ensureOutboundSessionEntry = vi.fn(async () => undefined);
const resolveOutboundSessionRoute = vi.fn();
const slackConfig = {
const workspaceConfig = {
channels: {
slack: {
workspace: {
botToken: "xoxb-test",
},
},
} as OpenClawConfig;
const telegramConfig = {
const forumConfig = {
channels: {
telegram: {
botToken: "telegram-test",
forum: {
botToken: "forum-test",
},
},
} as OpenClawConfig;
const defaultTelegramToolContext = {
currentChannelId: "telegram:123",
const defaultForumToolContext = {
currentChannelId: "forum:123",
currentThreadTs: "42",
} as const;
@@ -40,17 +40,17 @@ describe("message action threading helpers", () => {
name: "exact channel id",
target: "channel:C123",
threadTs: "111.222",
expectedSessionKey: "agent:main:slack:channel:c123:thread:111.222",
expectedSessionKey: "agent:main:workspace:channel:c123:thread:111.222",
},
{
name: "case-insensitive channel id",
target: "channel:c123",
threadTs: "333.444",
expectedSessionKey: "agent:main:slack:channel:c123:thread:333.444",
expectedSessionKey: "agent:main:workspace:channel:c123:thread:333.444",
},
] as const)("prepares outbound routes for slack using $name", async (testCase) => {
] as const)("prepares outbound routes for workspace using $name", async (testCase) => {
const actionParams: Record<string, unknown> = {
channel: "slack",
channel: "workspace",
target: testCase.target,
message: "hi",
};
@@ -65,8 +65,8 @@ describe("message action threading helpers", () => {
});
const result = await prepareOutboundMirrorRoute({
cfg: slackConfig,
channel: "slack",
cfg: workspaceConfig,
channel: "workspace",
to: testCase.target,
actionParams,
toolContext: {
@@ -89,30 +89,30 @@ describe("message action threading helpers", () => {
it.each([
{
name: "injects threadId for matching target",
target: "telegram:123",
target: "forum:123",
expectedThreadId: "42",
},
{
name: "injects threadId for prefixed group target",
target: "telegram:group:123",
target: "forum:group:123",
expectedThreadId: "42",
},
{
name: "skips threadId when target chat differs",
target: "telegram:999",
target: "forum:999",
expectedThreadId: undefined,
},
] as const)("telegram auto-threading: $name", (testCase) => {
] as const)("forum auto-threading: $name", (testCase) => {
const actionParams: Record<string, unknown> = {
channel: "telegram",
channel: "forum",
target: testCase.target,
message: "hi",
};
const resolved = resolveAndApplyOutboundThreadId(actionParams, {
cfg: telegramConfig,
cfg: forumConfig,
to: testCase.target,
toolContext: defaultTelegramToolContext,
toolContext: defaultForumToolContext,
resolveAutoThreadId: ({ to, toolContext }) =>
to.includes("123") ? toolContext?.currentThreadTs : undefined,
});
@@ -121,18 +121,18 @@ describe("message action threading helpers", () => {
expect(resolved).toBe(testCase.expectedThreadId);
});
it("uses explicit telegram threadId when provided", () => {
it("uses explicit forum threadId when provided", () => {
const actionParams: Record<string, unknown> = {
channel: "telegram",
target: "telegram:123",
channel: "forum",
target: "forum:123",
message: "hi",
threadId: "999",
};
const resolved = resolveAndApplyOutboundThreadId(actionParams, {
cfg: telegramConfig,
to: "telegram:123",
toolContext: defaultTelegramToolContext,
cfg: forumConfig,
to: "forum:123",
toolContext: defaultForumToolContext,
resolveAutoThreadId: () => "42",
});
@@ -143,16 +143,16 @@ describe("message action threading helpers", () => {
it("passes explicit replyTo into auto-thread resolution", () => {
const resolveAutoThreadId = vi.fn(() => "thread-777");
const actionParams: Record<string, unknown> = {
channel: "telegram",
target: "telegram:123",
channel: "forum",
target: "forum:123",
message: "hi",
replyTo: "777",
};
const resolved = resolveAndApplyOutboundThreadId(actionParams, {
cfg: telegramConfig,
to: "telegram:123",
toolContext: defaultTelegramToolContext,
cfg: forumConfig,
to: "forum:123",
toolContext: defaultForumToolContext,
resolveAutoThreadId,
});