From 3105a1284a872f1d80c36b489c550f2e323e0898 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 23:45:06 -0700 Subject: [PATCH] Tests: add plugin contract suites --- src/channels/plugins/contracts/suites.ts | 214 +++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 src/channels/plugins/contracts/suites.ts diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts new file mode 100644 index 00000000000..48a0f886208 --- /dev/null +++ b/src/channels/plugins/contracts/suites.ts @@ -0,0 +1,214 @@ +import { expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; +import type { + ChannelAccountSnapshot, + ChannelAccountState, + ChannelMessageCapability, + ChannelSetupInput, +} from "../types.core.js"; +import type { ChannelPlugin } from "../types.js"; +import type { ChannelMessageActionName } from "../types.js"; + +function sortStrings(values: readonly string[]) { + return [...values].toSorted((left, right) => left.localeCompare(right)); +} + +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?.listActions).toBe("function"); + }); + + for (const testCase of params.cases) { + it(`actions contract: ${testCase.name}`, () => { + testCase.beforeTest?.(); + + const actions = params.plugin.actions?.listActions?.({ cfg: testCase.cfg }) ?? []; + const capabilities = params.plugin.actions?.getCapabilities?.({ cfg: testCase.cfg }) ?? []; + + 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); + } + }); + } +}