diff --git a/extensions/bluebubbles/contract-api.ts b/extensions/bluebubbles/contract-api.ts index bc8f64f050f..a701f3c5e47 100644 --- a/extensions/bluebubbles/contract-api.ts +++ b/extensions/bluebubbles/contract-api.ts @@ -2,3 +2,7 @@ export { collectRuntimeConfigAssignments, secretTargetRegistryEntries, } from "./src/secret-contract.js"; +export { + __testing as blueBubblesConversationBindingTesting, + createBlueBubblesConversationBindingManager, +} from "./src/conversation-bindings.js"; diff --git a/extensions/discord/contract-api.ts b/extensions/discord/contract-api.ts index 84c1af73bb0..8659ae168fd 100644 --- a/extensions/discord/contract-api.ts +++ b/extensions/discord/contract-api.ts @@ -1,4 +1,5 @@ export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js"; +export { __testing as discordThreadBindingTesting } from "./src/monitor/thread-bindings.manager.js"; export { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, diff --git a/extensions/imessage/contract-api.ts b/extensions/imessage/contract-api.ts index f6a80c0dd53..25a36a96735 100644 --- a/extensions/imessage/contract-api.ts +++ b/extensions/imessage/contract-api.ts @@ -7,3 +7,7 @@ export { resolveIMessageAttachmentRoots, resolveIMessageRemoteAttachmentRoots, } from "./src/media-contract.js"; +export { + __testing as imessageConversationBindingTesting, + createIMessageConversationBindingManager, +} from "./src/conversation-bindings.js"; diff --git a/extensions/telegram/contract-api.ts b/extensions/telegram/contract-api.ts index 6b6b454ad74..d392c27aece 100644 --- a/extensions/telegram/contract-api.ts +++ b/extensions/telegram/contract-api.ts @@ -20,7 +20,10 @@ export { buildCommandsPaginationKeyboard, buildTelegramModelsProviderChannelData, } from "./src/command-ui.js"; -export { createTelegramThreadBindingManager } from "./src/thread-bindings.js"; +export { + createTelegramThreadBindingManager, + resetTelegramThreadBindingsForTests, +} from "./src/thread-bindings.js"; export type { TelegramInteractiveHandlerContext, TelegramInteractiveHandlerRegistration, diff --git a/test/helpers/channels/registry-session-binding.ts b/test/helpers/channels/registry-session-binding.ts index f9baf6b222f..d126c30f89f 100644 --- a/test/helpers/channels/registry-session-binding.ts +++ b/test/helpers/channels/registry-session-binding.ts @@ -3,12 +3,15 @@ import os from "node:os"; import path from "node:path"; import { expect } from "vitest"; import { createChannelConversationBindingManager } from "../../../src/channels/plugins/conversation-bindings.js"; +import type { ChannelPlugin } from "../../../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { getSessionBindingService, type SessionBindingCapabilities, type SessionBindingRecord, } from "../../../src/infra/outbound/session-binding-service.js"; +import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; +import { createTestRegistry } from "../../../src/test-utils/channel-plugins.js"; import { sessionBindingContractChannelIds, type SessionBindingContractChannelId, @@ -23,6 +26,7 @@ type SessionBindingContractEntry = { bindAndResolve: () => Promise; unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | void; + beforeEach?: () => Promise | void; }; const contractApiPromises = new Map>>(); @@ -96,17 +100,8 @@ function resetMatrixSessionBindingStateDir() { async function createContractMatrixThreadBindingManager() { resetMatrixSessionBindingStateDir(); - const { setMatrixRuntime, createMatrixThreadBindingManager } = await getContractApi<{ - setMatrixRuntime: (runtime: unknown) => void; - createMatrixThreadBindingManager: (params: { - accountId: string; - auth: typeof matrixSessionBindingAuth; - client: unknown; - idleTimeoutMs: number; - maxAgeMs: number; - enableSweeper: boolean; - }) => Promise; - }>("matrix"); + const { setMatrixRuntime, createMatrixThreadBindingManager } = + await getContractApi("matrix"); setMatrixRuntime({ state: { resolveStateDir: () => matrixSessionBindingStateDir, @@ -126,11 +121,145 @@ const baseSessionBindingCfg = { session: { mainKey: "main", scope: "per-sender" }, } satisfies OpenClawConfig; +type ChannelConversationBindingManagerFactory = NonNullable< + NonNullable["createManager"] +>; + +type BlueBubblesContractApi = { + blueBubblesConversationBindingTesting: { + resetBlueBubblesConversationBindingsForTests: () => void; + }; + createBlueBubblesConversationBindingManager: ChannelConversationBindingManagerFactory; +}; + +type DiscordContractApi = { + createThreadBindingManager: (params: { + accountId: string; + cfg?: OpenClawConfig; + persist: boolean; + enableSweeper: boolean; + }) => unknown; + discordThreadBindingTesting: { + resetThreadBindingsForTests: () => void; + }; +}; + +type FeishuContractApi = { + createFeishuThreadBindingManager: (params: { + accountId?: string; + cfg: OpenClawConfig; + }) => unknown; + feishuThreadBindingTesting: { + resetFeishuThreadBindingsForTests: () => void; + }; +}; + +type IMessageContractApi = { + createIMessageConversationBindingManager: ChannelConversationBindingManagerFactory; + imessageConversationBindingTesting: { + resetIMessageConversationBindingsForTests: () => void; + }; +}; + +type MatrixContractApi = { + createMatrixThreadBindingManager: (params: { + accountId: string; + auth: typeof matrixSessionBindingAuth; + client: unknown; + idleTimeoutMs: number; + maxAgeMs: number; + enableSweeper: boolean; + }) => Promise; + resetMatrixThreadBindingsForTests: () => void; + setMatrixRuntime: (runtime: unknown) => void; +}; + +type TelegramContractApi = { + createTelegramThreadBindingManager: (params: { + accountId: string; + persist: boolean; + enableSweeper: boolean; + }) => unknown; + resetTelegramThreadBindingsForTests: () => Promise; +}; + +function setRegistryBackedConversationBindingPlugin(params: { + id: SessionBindingContractChannelId; + createManager: ChannelConversationBindingManagerFactory; +}) { + const plugin = { + id: params.id, + meta: { + id: params.id, + label: params.id, + selectionLabel: params.id, + blurb: "session binding contract fixture", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + conversationBindings: { + supportsCurrentConversationBinding: true, + createManager: params.createManager, + }, + } as unknown as ChannelPlugin; + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: params.id, + plugin, + source: "test", + }, + ]), + ); +} + +async function prepareBlueBubblesSessionBindingContract() { + const api = await getContractApi("bluebubbles"); + api.blueBubblesConversationBindingTesting.resetBlueBubblesConversationBindingsForTests(); + setRegistryBackedConversationBindingPlugin({ + id: "bluebubbles", + createManager: api.createBlueBubblesConversationBindingManager, + }); +} + +async function prepareDiscordSessionBindingContract() { + const api = await getContractApi("discord"); + api.discordThreadBindingTesting.resetThreadBindingsForTests(); +} + +async function prepareFeishuSessionBindingContract() { + const api = await getContractApi("feishu"); + api.feishuThreadBindingTesting.resetFeishuThreadBindingsForTests(); +} + +async function prepareIMessageSessionBindingContract() { + const api = await getContractApi("imessage"); + api.imessageConversationBindingTesting.resetIMessageConversationBindingsForTests(); + setRegistryBackedConversationBindingPlugin({ + id: "imessage", + createManager: api.createIMessageConversationBindingManager, + }); +} + +async function prepareMatrixSessionBindingContract() { + const api = await getContractApi("matrix"); + api.resetMatrixThreadBindingsForTests(); +} + +async function prepareTelegramSessionBindingContract() { + const api = await getContractApi("telegram"); + await api.resetTelegramThreadBindingsForTests(); +} + const sessionBindingContractEntries: Record< SessionBindingContractChannelId, Omit > = { bluebubbles: { + beforeEach: prepareBlueBubblesSessionBindingContract, expectedCapabilities: { adapterAvailable: true, bindSupported: true, @@ -193,6 +322,7 @@ const sessionBindingContractEntries: Record< }, }, discord: { + beforeEach: prepareDiscordSessionBindingContract, expectedCapabilities: { adapterAvailable: true, bindSupported: true, @@ -200,14 +330,7 @@ const sessionBindingContractEntries: Record< placements: ["current", "child"], }, getCapabilities: async () => { - const { createThreadBindingManager } = await getContractApi<{ - createThreadBindingManager: (params: { - accountId: string; - cfg?: OpenClawConfig; - persist: boolean; - enableSweeper: boolean; - }) => unknown; - }>("discord"); + const { createThreadBindingManager } = await getContractApi("discord"); createThreadBindingManager({ accountId: "default", cfg: baseSessionBindingCfg, @@ -220,14 +343,7 @@ const sessionBindingContractEntries: Record< }); }, bindAndResolve: async () => { - const { createThreadBindingManager } = await getContractApi<{ - createThreadBindingManager: (params: { - accountId: string; - cfg?: OpenClawConfig; - persist: boolean; - enableSweeper: boolean; - }) => unknown; - }>("discord"); + const { createThreadBindingManager } = await getContractApi("discord"); createThreadBindingManager({ accountId: "default", cfg: baseSessionBindingCfg, @@ -267,6 +383,7 @@ const sessionBindingContractEntries: Record< }, }, feishu: { + beforeEach: prepareFeishuSessionBindingContract, expectedCapabilities: { adapterAvailable: true, bindSupported: true, @@ -274,12 +391,8 @@ const sessionBindingContractEntries: Record< placements: ["current"], }, getCapabilities: async () => { - const { createFeishuThreadBindingManager } = await getContractApi<{ - createFeishuThreadBindingManager: (params: { - accountId?: string; - cfg: OpenClawConfig; - }) => unknown; - }>("feishu"); + const { createFeishuThreadBindingManager } = + await getContractApi("feishu"); createFeishuThreadBindingManager({ accountId: "default", cfg: baseSessionBindingCfg, @@ -290,12 +403,8 @@ const sessionBindingContractEntries: Record< }); }, bindAndResolve: async () => { - const { createFeishuThreadBindingManager } = await getContractApi<{ - createFeishuThreadBindingManager: (params: { - accountId?: string; - cfg: OpenClawConfig; - }) => unknown; - }>("feishu"); + const { createFeishuThreadBindingManager } = + await getContractApi("feishu"); createFeishuThreadBindingManager({ accountId: "default", cfg: baseSessionBindingCfg, @@ -335,6 +444,7 @@ const sessionBindingContractEntries: Record< }, }, imessage: { + beforeEach: prepareIMessageSessionBindingContract, expectedCapabilities: { adapterAvailable: true, bindSupported: true, @@ -397,6 +507,7 @@ const sessionBindingContractEntries: Record< }, }, matrix: { + beforeEach: prepareMatrixSessionBindingContract, expectedCapabilities: { adapterAvailable: true, bindSupported: true, @@ -447,6 +558,7 @@ const sessionBindingContractEntries: Record< }, }, telegram: { + beforeEach: prepareTelegramSessionBindingContract, expectedCapabilities: { adapterAvailable: true, bindSupported: true, @@ -454,13 +566,8 @@ const sessionBindingContractEntries: Record< placements: ["current", "child"], }, getCapabilities: async () => { - const { createTelegramThreadBindingManager } = await getContractApi<{ - createTelegramThreadBindingManager: (params: { - accountId: string; - persist: boolean; - enableSweeper: boolean; - }) => unknown; - }>("telegram"); + const { createTelegramThreadBindingManager } = + await getContractApi("telegram"); createTelegramThreadBindingManager({ accountId: "default", persist: false, @@ -472,13 +579,8 @@ const sessionBindingContractEntries: Record< }); }, bindAndResolve: async () => { - const { createTelegramThreadBindingManager } = await getContractApi<{ - createTelegramThreadBindingManager: (params: { - accountId: string; - persist: boolean; - enableSweeper: boolean; - }) => unknown; - }>("telegram"); + const { createTelegramThreadBindingManager } = + await getContractApi("telegram"); createTelegramThreadBindingManager({ accountId: "default", persist: false, diff --git a/test/helpers/channels/session-binding-registry-backed-contract.ts b/test/helpers/channels/session-binding-registry-backed-contract.ts index 1d70e035dab..f5d0222e1da 100644 --- a/test/helpers/channels/session-binding-registry-backed-contract.ts +++ b/test/helpers/channels/session-binding-registry-backed-contract.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.js"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot, @@ -10,94 +9,8 @@ import { type SessionBindingRecord, } from "../../../src/infra/outbound/session-binding-service.js"; import { resetPluginRuntimeStateForTest } from "../../../src/plugins/runtime.js"; -import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; -import type { PluginRuntime } from "../../../src/plugins/runtime/index.js"; -import { - loadBundledPluginApiSync, - loadBundledPluginTestApiSync, -} from "../../../src/test-utils/bundled-plugin-public-surface.js"; -import { createTestRegistry } from "../../../src/test-utils/channel-plugins.js"; import { getSessionBindingContractRegistry } from "./registry-session-binding.js"; -type BluebubblesApiSurface = typeof import("@openclaw/bluebubbles/api.js"); -type DiscordTestApiSurface = typeof import("@openclaw/discord/test-api.js"); -type FeishuApiSurface = typeof import("@openclaw/feishu/api.js"); -type IMessageApiSurface = typeof import("@openclaw/imessage/api.js"); -type MatrixApiSurface = typeof import("@openclaw/matrix/api.js"); -type MatrixTestApiSurface = typeof import("@openclaw/matrix/test-api.js"); -type TelegramApiSurface = typeof import("@openclaw/telegram/api.js"); -type TelegramTestApiSurface = typeof import("@openclaw/telegram/test-api.js"); - -let bluebubblesApi: BluebubblesApiSurface | undefined; -let discordTestApi: DiscordTestApiSurface | undefined; -let feishuApi: FeishuApiSurface | undefined; -let imessageApi: IMessageApiSurface | undefined; -let matrixApi: MatrixApiSurface | undefined; -let matrixTestApi: MatrixTestApiSurface | undefined; -let telegramApi: TelegramApiSurface | undefined; -let telegramTestApi: TelegramTestApiSurface | undefined; - -type DiscordThreadBindingTesting = { - resetThreadBindingsForTests: () => void; -}; - -type ResetTelegramThreadBindingsForTests = () => Promise; - -function getBluebubblesPlugin(): ChannelPlugin { - bluebubblesApi ??= loadBundledPluginApiSync("bluebubbles"); - return bluebubblesApi.bluebubblesPlugin as unknown as ChannelPlugin; -} - -function getDiscordPlugin(): ChannelPlugin { - discordTestApi ??= loadBundledPluginTestApiSync("discord"); - return discordTestApi.discordPlugin as unknown as ChannelPlugin; -} - -function getFeishuPlugin(): ChannelPlugin { - feishuApi ??= loadBundledPluginApiSync("feishu"); - return feishuApi.feishuPlugin as unknown as ChannelPlugin; -} - -function getIMessagePlugin(): ChannelPlugin { - imessageApi ??= loadBundledPluginApiSync("imessage"); - return imessageApi.imessagePlugin as unknown as ChannelPlugin; -} - -function getMatrixPlugin(): ChannelPlugin { - matrixTestApi ??= loadBundledPluginTestApiSync("matrix"); - return matrixTestApi.matrixPlugin as unknown as ChannelPlugin; -} - -function getSetMatrixRuntime(): (runtime: PluginRuntime) => void { - matrixTestApi ??= loadBundledPluginTestApiSync("matrix"); - return matrixTestApi.setMatrixRuntime; -} - -function getTelegramPlugin(): ChannelPlugin { - telegramApi ??= loadBundledPluginApiSync("telegram"); - return telegramApi.telegramPlugin as unknown as ChannelPlugin; -} - -function getDiscordThreadBindingTesting(): DiscordThreadBindingTesting { - discordTestApi ??= loadBundledPluginTestApiSync("discord"); - return discordTestApi.discordThreadBindingTesting; -} - -function getResetTelegramThreadBindingsForTests(): ResetTelegramThreadBindingsForTests { - telegramTestApi ??= loadBundledPluginTestApiSync("telegram"); - return telegramTestApi.resetTelegramThreadBindingsForTests; -} - -async function getFeishuThreadBindingTesting() { - feishuApi ??= loadBundledPluginApiSync("feishu"); - return feishuApi.feishuThreadBindingTesting; -} - -async function getResetMatrixThreadBindingsForTests() { - matrixApi ??= loadBundledPluginApiSync("matrix"); - return matrixApi.resetMatrixThreadBindingsForTests; -} - function resolveSessionBindingContractRuntimeConfig(id: string) { if (id !== "discord" && id !== "matrix") { return {}; @@ -113,59 +26,6 @@ function resolveSessionBindingContractRuntimeConfig(id: string) { }; } -function getSessionBindingPlugin(id: string): ChannelPlugin { - switch (id) { - case "bluebubbles": - return getBluebubblesPlugin(); - case "discord": - return getDiscordPlugin(); - case "feishu": - return getFeishuPlugin(); - case "imessage": - return getIMessagePlugin(); - case "matrix": - getSetMatrixRuntime()({ - state: { - resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(), - }, - } as PluginRuntime); - return getMatrixPlugin(); - case "telegram": - return getTelegramPlugin(); - default: - throw new Error(`missing session binding plugin fixture for ${id}`); - } -} - -async function resetSessionBindingPluginFixtureForTests(id: string): Promise { - switch (id) { - case "discord": - getDiscordThreadBindingTesting().resetThreadBindingsForTests(); - return; - case "feishu": - (await getFeishuThreadBindingTesting()).resetFeishuThreadBindingsForTests(); - return; - case "matrix": - (await getResetMatrixThreadBindingsForTests())(); - return; - case "telegram": - await getResetTelegramThreadBindingsForTests()(); - return; - default: - return; - } -} - -function setSessionBindingPluginRegistryForTests(id: string): void { - const channels = [getSessionBindingPlugin(id)].map((plugin) => ({ - pluginId: plugin.id, - plugin, - source: "test" as const, - })) as Parameters[0]; - - setActivePluginRegistry(createTestRegistry(channels)); -} - function installSessionBindingContractSuite(params: { getCapabilities: () => SessionBindingCapabilities | Promise; bindAndResolve: () => Promise; @@ -217,11 +77,8 @@ export function describeSessionBindingRegistryBackedContract(id: string) { // These registry-backed contract suites intentionally exercise bundled runtime facades. // Opt the bundled-runtime cases in so the activation boundary behaves like real runtime usage. setRuntimeConfigSnapshot(runtimeConfig); - // These suites only exercise the session-binding channels, so avoid the broader - // default registry helper and seed only the six plugins this contract lane needs. - setSessionBindingPluginRegistryForTests(entry.id); sessionBindingTesting.resetSessionBindingAdaptersForTests(); - await resetSessionBindingPluginFixtureForTests(entry.id); + await entry.beforeEach?.(); }); afterEach(() => { clearRuntimeConfigSnapshot();