From c5a45eb2746b99b015d143ea8ddc1886b40eb72e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 4 Apr 2026 02:08:39 +0900 Subject: [PATCH] test(contracts): localize registry-backed contract helpers --- .../actions.registry-backed.contract.test.ts | 2 +- .../plugin.registry-backed.contract.test.ts | 2 +- .../setup.registry-backed.contract.test.ts | 2 +- .../status.registry-backed.contract.test.ts | 2 +- .../channels/registry-contract-suites.ts | 238 ++++++++++++++++++ 5 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 test/helpers/channels/registry-contract-suites.ts diff --git a/src/channels/plugins/contracts/actions.registry-backed.contract.test.ts b/src/channels/plugins/contracts/actions.registry-backed.contract.test.ts index 472bae639fa..7fe39d3d5ca 100644 --- a/src/channels/plugins/contracts/actions.registry-backed.contract.test.ts +++ b/src/channels/plugins/contracts/actions.registry-backed.contract.test.ts @@ -1,6 +1,6 @@ import { describe } from "vitest"; +import { installChannelActionsContractSuite } from "../../../../test/helpers/channels/registry-contract-suites.js"; import { getActionContractRegistry } from "./registry-actions.js"; -import { installChannelActionsContractSuite } from "./suites.js"; for (const entry of getActionContractRegistry()) { describe(`${entry.id} actions contract`, () => { diff --git a/src/channels/plugins/contracts/plugin.registry-backed.contract.test.ts b/src/channels/plugins/contracts/plugin.registry-backed.contract.test.ts index fc40e7c42e3..56579aa21ec 100644 --- a/src/channels/plugins/contracts/plugin.registry-backed.contract.test.ts +++ b/src/channels/plugins/contracts/plugin.registry-backed.contract.test.ts @@ -1,6 +1,6 @@ import { describe } from "vitest"; +import { installChannelPluginContractSuite } from "../../../../test/helpers/channels/registry-contract-suites.js"; import { getPluginContractRegistry } from "./registry-plugin.js"; -import { installChannelPluginContractSuite } from "./suites.js"; for (const entry of getPluginContractRegistry()) { describe(`${entry.id} plugin contract`, () => { diff --git a/src/channels/plugins/contracts/setup.registry-backed.contract.test.ts b/src/channels/plugins/contracts/setup.registry-backed.contract.test.ts index 2717853981b..4c4be13eed1 100644 --- a/src/channels/plugins/contracts/setup.registry-backed.contract.test.ts +++ b/src/channels/plugins/contracts/setup.registry-backed.contract.test.ts @@ -1,6 +1,6 @@ import { describe } from "vitest"; +import { installChannelSetupContractSuite } from "../../../../test/helpers/channels/registry-contract-suites.js"; import { getSetupContractRegistry } from "./registry-setup-status.js"; -import { installChannelSetupContractSuite } from "./suites.js"; for (const entry of getSetupContractRegistry()) { describe(`${entry.id} setup contract`, () => { diff --git a/src/channels/plugins/contracts/status.registry-backed.contract.test.ts b/src/channels/plugins/contracts/status.registry-backed.contract.test.ts index 8a518497d03..7fc7d834834 100644 --- a/src/channels/plugins/contracts/status.registry-backed.contract.test.ts +++ b/src/channels/plugins/contracts/status.registry-backed.contract.test.ts @@ -1,6 +1,6 @@ import { describe } from "vitest"; +import { installChannelStatusContractSuite } from "../../../../test/helpers/channels/registry-contract-suites.js"; import { getStatusContractRegistry } from "./registry-setup-status.js"; -import { installChannelStatusContractSuite } from "./suites.js"; for (const entry of getStatusContractRegistry()) { describe(`${entry.id} status contract`, () => { diff --git a/test/helpers/channels/registry-contract-suites.ts b/test/helpers/channels/registry-contract-suites.ts new file mode 100644 index 00000000000..1c9ae0f683c --- /dev/null +++ b/test/helpers/channels/registry-contract-suites.ts @@ -0,0 +1,238 @@ +import { expect, it } from "vitest"; +import type { + ChannelAccountSnapshot, + ChannelAccountState, + ChannelSetupInput, +} from "../../../src/channels/plugins/types.core.js"; +import type { + ChannelMessageActionName, + ChannelMessageCapability, + ChannelPlugin, +} from "../../../src/channels/plugins/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; + +function sortStrings(values: readonly string[]) { + return [...values].toSorted((left, right) => left.localeCompare(right)); +} + +function resolveContractMessageDiscovery(params: { + plugin: Pick; + cfg: OpenClawConfig; +}) { + const actions = params.plugin.actions; + if (!actions) { + return { + actions: [] as ChannelMessageActionName[], + capabilities: [] as readonly ChannelMessageCapability[], + }; + } + const discovery = actions.describeMessageTool({ cfg: params.cfg }) ?? null; + return { + actions: Array.isArray(discovery?.actions) ? [...discovery.actions] : [], + capabilities: Array.isArray(discovery?.capabilities) ? discovery.capabilities : [], + }; +} + +export function installChannelPluginContractSuite(params: { + plugin: Pick; +}) { + it("satisfies the base channel plugin contract", () => { + const { plugin } = params; + + expect(typeof plugin.id).toBe("string"); + expect(plugin.id.trim()).not.toBe(""); + + expect(plugin.meta.id).toBe(plugin.id); + expect(plugin.meta.label.trim()).not.toBe(""); + expect(plugin.meta.selectionLabel.trim()).not.toBe(""); + expect(plugin.meta.docsPath).toMatch(/^\/channels\//); + expect(plugin.meta.blurb.trim()).not.toBe(""); + + expect(plugin.capabilities.chatTypes.length).toBeGreaterThan(0); + + expect(typeof plugin.config.listAccountIds).toBe("function"); + expect(typeof plugin.config.resolveAccount).toBe("function"); + }); +} + +type ChannelActionsContractCase = { + name: string; + cfg: OpenClawConfig; + expectedActions: readonly ChannelMessageActionName[]; + expectedCapabilities?: readonly ChannelMessageCapability[]; + beforeTest?: () => void; +}; + +export function installChannelActionsContractSuite(params: { + plugin: Pick; + cases: readonly ChannelActionsContractCase[]; + unsupportedAction?: ChannelMessageActionName; +}) { + it("exposes the base message actions contract", () => { + expect(params.plugin.actions).toBeDefined(); + expect(typeof params.plugin.actions?.describeMessageTool).toBe("function"); + }); + + for (const testCase of params.cases) { + it(`actions contract: ${testCase.name}`, () => { + testCase.beforeTest?.(); + + const discovery = resolveContractMessageDiscovery({ + plugin: params.plugin, + cfg: testCase.cfg, + }); + const actions = discovery.actions; + const capabilities = discovery.capabilities; + + expect(actions).toEqual([...new Set(actions)]); + expect(capabilities).toEqual([...new Set(capabilities)]); + expect(sortStrings(actions)).toEqual(sortStrings(testCase.expectedActions)); + expect(sortStrings(capabilities)).toEqual(sortStrings(testCase.expectedCapabilities ?? [])); + + if (params.plugin.actions?.supportsAction) { + for (const action of testCase.expectedActions) { + expect(params.plugin.actions.supportsAction({ action })).toBe(true); + } + if ( + params.unsupportedAction && + !testCase.expectedActions.includes(params.unsupportedAction) + ) { + expect(params.plugin.actions.supportsAction({ action: params.unsupportedAction })).toBe( + false, + ); + } + } + }); + } +} + +type ChannelSetupContractCase = { + name: string; + cfg: OpenClawConfig; + accountId?: string; + input: ChannelSetupInput; + expectedAccountId?: string; + expectedValidation?: string | null; + beforeTest?: () => void; + assertPatchedConfig?: (cfg: OpenClawConfig) => void; + assertResolvedAccount?: (account: ResolvedAccount, cfg: OpenClawConfig) => void; +}; + +export function installChannelSetupContractSuite(params: { + plugin: Pick, "id" | "config" | "setup">; + cases: readonly ChannelSetupContractCase[]; +}) { + it("exposes the base setup contract", () => { + expect(params.plugin.setup).toBeDefined(); + expect(typeof params.plugin.setup?.applyAccountConfig).toBe("function"); + }); + + for (const testCase of params.cases) { + it(`setup contract: ${testCase.name}`, () => { + testCase.beforeTest?.(); + + const resolvedAccountId = + params.plugin.setup?.resolveAccountId?.({ + cfg: testCase.cfg, + accountId: testCase.accountId, + input: testCase.input, + }) ?? + testCase.accountId ?? + "default"; + + expect(resolvedAccountId).toBe(testCase.expectedAccountId ?? resolvedAccountId); + + const validation = + params.plugin.setup?.validateInput?.({ + cfg: testCase.cfg, + accountId: resolvedAccountId, + input: testCase.input, + }) ?? null; + expect(validation).toBe(testCase.expectedValidation ?? null); + + const nextCfg = params.plugin.setup?.applyAccountConfig({ + cfg: testCase.cfg, + accountId: resolvedAccountId, + input: testCase.input, + }); + expect(nextCfg).toBeDefined(); + + const account = params.plugin.config.resolveAccount(nextCfg!, resolvedAccountId); + testCase.assertPatchedConfig?.(nextCfg!); + testCase.assertResolvedAccount?.(account, nextCfg!); + }); + } +} + +type ChannelStatusContractCase = { + name: string; + cfg: OpenClawConfig; + accountId?: string; + runtime?: ChannelAccountSnapshot; + probe?: Probe; + beforeTest?: () => void; + expectedState?: ChannelAccountState; + resolveStateInput?: { + configured: boolean; + enabled: boolean; + }; + assertSnapshot?: (snapshot: ChannelAccountSnapshot) => void; + assertSummary?: (summary: Record) => void; +}; + +export function installChannelStatusContractSuite(params: { + plugin: Pick, "id" | "config" | "status">; + cases: readonly ChannelStatusContractCase[]; +}) { + it("exposes the base status contract", () => { + expect(params.plugin.status).toBeDefined(); + expect(typeof params.plugin.status?.buildAccountSnapshot).toBe("function"); + }); + + if (params.plugin.status?.defaultRuntime) { + it("status contract: default runtime is shaped like an account snapshot", () => { + expect(typeof params.plugin.status?.defaultRuntime?.accountId).toBe("string"); + }); + } + + for (const testCase of params.cases) { + it(`status contract: ${testCase.name}`, async () => { + testCase.beforeTest?.(); + + const account = params.plugin.config.resolveAccount(testCase.cfg, testCase.accountId); + const snapshot = await params.plugin.status!.buildAccountSnapshot!({ + account, + cfg: testCase.cfg, + runtime: testCase.runtime, + probe: testCase.probe, + }); + + expect(typeof snapshot.accountId).toBe("string"); + expect(snapshot.accountId.trim()).not.toBe(""); + testCase.assertSnapshot?.(snapshot); + + if (params.plugin.status?.buildChannelSummary) { + const defaultAccountId = + params.plugin.config.defaultAccountId?.(testCase.cfg) ?? testCase.accountId ?? "default"; + const summary = await params.plugin.status.buildChannelSummary({ + account, + cfg: testCase.cfg, + defaultAccountId, + snapshot, + }); + expect(summary).toEqual(expect.any(Object)); + testCase.assertSummary?.(summary); + } + + if (testCase.expectedState && params.plugin.status?.resolveAccountState) { + const state = params.plugin.status.resolveAccountState({ + account, + cfg: testCase.cfg, + configured: testCase.resolveStateInput?.configured ?? true, + enabled: testCase.resolveStateInput?.enabled ?? true, + }); + expect(state).toBe(testCase.expectedState); + } + }); + } +}