Tests: narrow session binding contract setup

This commit is contained in:
Gustavo Madeira Santana
2026-04-17 03:22:14 -04:00
parent 00951dc9f9
commit 54e4e16844
6 changed files with 169 additions and 198 deletions

View File

@@ -2,3 +2,7 @@ export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export {
__testing as blueBubblesConversationBindingTesting,
createBlueBubblesConversationBindingManager,
} from "./src/conversation-bindings.js";

View File

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

View File

@@ -7,3 +7,7 @@ export {
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./src/media-contract.js";
export {
__testing as imessageConversationBindingTesting,
createIMessageConversationBindingManager,
} from "./src/conversation-bindings.js";

View File

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

View File

@@ -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<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
beforeEach?: () => Promise<void> | void;
};
const contractApiPromises = new Map<string, Promise<Record<string, unknown>>>();
@@ -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<unknown>;
}>("matrix");
const { setMatrixRuntime, createMatrixThreadBindingManager } =
await getContractApi<MatrixContractApi>("matrix");
setMatrixRuntime({
state: {
resolveStateDir: () => matrixSessionBindingStateDir,
@@ -126,11 +121,145 @@ const baseSessionBindingCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
type ChannelConversationBindingManagerFactory = NonNullable<
NonNullable<ChannelPlugin["conversationBindings"]>["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<unknown>;
resetMatrixThreadBindingsForTests: () => void;
setMatrixRuntime: (runtime: unknown) => void;
};
type TelegramContractApi = {
createTelegramThreadBindingManager: (params: {
accountId: string;
persist: boolean;
enableSweeper: boolean;
}) => unknown;
resetTelegramThreadBindingsForTests: () => Promise<void>;
};
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<BlueBubblesContractApi>("bluebubbles");
api.blueBubblesConversationBindingTesting.resetBlueBubblesConversationBindingsForTests();
setRegistryBackedConversationBindingPlugin({
id: "bluebubbles",
createManager: api.createBlueBubblesConversationBindingManager,
});
}
async function prepareDiscordSessionBindingContract() {
const api = await getContractApi<DiscordContractApi>("discord");
api.discordThreadBindingTesting.resetThreadBindingsForTests();
}
async function prepareFeishuSessionBindingContract() {
const api = await getContractApi<FeishuContractApi>("feishu");
api.feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
}
async function prepareIMessageSessionBindingContract() {
const api = await getContractApi<IMessageContractApi>("imessage");
api.imessageConversationBindingTesting.resetIMessageConversationBindingsForTests();
setRegistryBackedConversationBindingPlugin({
id: "imessage",
createManager: api.createIMessageConversationBindingManager,
});
}
async function prepareMatrixSessionBindingContract() {
const api = await getContractApi<MatrixContractApi>("matrix");
api.resetMatrixThreadBindingsForTests();
}
async function prepareTelegramSessionBindingContract() {
const api = await getContractApi<TelegramContractApi>("telegram");
await api.resetTelegramThreadBindingsForTests();
}
const sessionBindingContractEntries: Record<
SessionBindingContractChannelId,
Omit<SessionBindingContractEntry, "id">
> = {
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<DiscordContractApi>("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<DiscordContractApi>("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<FeishuContractApi>("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<FeishuContractApi>("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<TelegramContractApi>("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<TelegramContractApi>("telegram");
createTelegramThreadBindingManager({
accountId: "default",
persist: false,

View File

@@ -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<void>;
function getBluebubblesPlugin(): ChannelPlugin {
bluebubblesApi ??= loadBundledPluginApiSync<BluebubblesApiSurface>("bluebubbles");
return bluebubblesApi.bluebubblesPlugin as unknown as ChannelPlugin;
}
function getDiscordPlugin(): ChannelPlugin {
discordTestApi ??= loadBundledPluginTestApiSync<DiscordTestApiSurface>("discord");
return discordTestApi.discordPlugin as unknown as ChannelPlugin;
}
function getFeishuPlugin(): ChannelPlugin {
feishuApi ??= loadBundledPluginApiSync<FeishuApiSurface>("feishu");
return feishuApi.feishuPlugin as unknown as ChannelPlugin;
}
function getIMessagePlugin(): ChannelPlugin {
imessageApi ??= loadBundledPluginApiSync<IMessageApiSurface>("imessage");
return imessageApi.imessagePlugin as unknown as ChannelPlugin;
}
function getMatrixPlugin(): ChannelPlugin {
matrixTestApi ??= loadBundledPluginTestApiSync<MatrixTestApiSurface>("matrix");
return matrixTestApi.matrixPlugin as unknown as ChannelPlugin;
}
function getSetMatrixRuntime(): (runtime: PluginRuntime) => void {
matrixTestApi ??= loadBundledPluginTestApiSync<MatrixTestApiSurface>("matrix");
return matrixTestApi.setMatrixRuntime;
}
function getTelegramPlugin(): ChannelPlugin {
telegramApi ??= loadBundledPluginApiSync<TelegramApiSurface>("telegram");
return telegramApi.telegramPlugin as unknown as ChannelPlugin;
}
function getDiscordThreadBindingTesting(): DiscordThreadBindingTesting {
discordTestApi ??= loadBundledPluginTestApiSync<DiscordTestApiSurface>("discord");
return discordTestApi.discordThreadBindingTesting;
}
function getResetTelegramThreadBindingsForTests(): ResetTelegramThreadBindingsForTests {
telegramTestApi ??= loadBundledPluginTestApiSync<TelegramTestApiSurface>("telegram");
return telegramTestApi.resetTelegramThreadBindingsForTests;
}
async function getFeishuThreadBindingTesting() {
feishuApi ??= loadBundledPluginApiSync<FeishuApiSurface>("feishu");
return feishuApi.feishuThreadBindingTesting;
}
async function getResetMatrixThreadBindingsForTests() {
matrixApi ??= loadBundledPluginApiSync<MatrixApiSurface>("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<void> {
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<typeof createTestRegistry>[0];
setActivePluginRegistry(createTestRegistry(channels));
}
function installSessionBindingContractSuite(params: {
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
@@ -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();