refactor: expose channel contract test helpers

This commit is contained in:
Peter Steinberger
2026-04-28 01:45:58 +01:00
parent 8d9a2f82a4
commit 45a84b5f95
20 changed files with 389 additions and 359 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Gateway/runtime: reuse the current plugin metadata snapshot for provider discovery so repeated model-provider discovery avoids rebuilding plugin manifest metadata. Thanks @shakkernerd.
- Gateway/startup: pass the plugin metadata snapshot from config validation into plugin bootstrap so startup reuses one manifest product instead of rebuilding plugin metadata. Thanks @shakkernerd.
- Plugin SDK/testing: promote bundled plugin/provider/channel contract helpers to focused SDK test subpaths and retire the repo-only `test/helpers/plugins` TypeScript bridge. Thanks @vincentkoc.
- Plugin SDK/testing: expose generic channel action, setup, status, and directory contract helpers through `plugin-sdk/channel-test-helpers` so bundled extension tests no longer import repo-only channel helper bridges. Thanks @vincentkoc.
- Plugin SDK/testing: add a focused generic fixture subpath for CLI capture, sandbox, skill, agent-message, system-event, terminal, chunking, auth-token, and typed-case helpers. Thanks @vincentkoc.
- Plugin SDK/testing: add focused plugin runtime and environment fixture subpaths so plugin tests can avoid the broad `plugin-sdk/testing` barrel for common setup helpers. Thanks @vincentkoc.
- Plugin SDK/testing: add a focused `plugin-sdk/plugin-test-api` helper subpath and move bundled plugin registration tests off the repo-only plugin API bridge. Thanks @vincentkoc.

View File

@@ -1,2 +1,2 @@
5c810137267a9ddaeab9abc40219e4a02bafec32767533df06bcca7109ee1608 plugin-sdk-api-baseline.json
ec3183fad4412002a07d61359e7f62a7b9ca6b2ddf70ddb1d1afb713cf5d053a plugin-sdk-api-baseline.jsonl
f9c6ac5f5a7f49963a1dde734066293ab847fbb611f941403bed3096e19c4808 plugin-sdk-api-baseline.json
d0c54afbd40ef44a40c27de0f0138178e5389c965a0f5794962c6f84226adaa8 plugin-sdk-api-baseline.jsonl

View File

@@ -22,9 +22,9 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/core` | `defineChannelPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `defineSetupPluginEntry`, `buildChannelConfigSchema` |
| `plugin-sdk/config-schema` | `OpenClawSchema` |
| `plugin-sdk/provider-entry` | `defineSingleProviderPluginEntry` |
| `plugin-sdk/testing` | Public plugin test fixtures, provider registration/catalog helpers, wizard contract hooks, and bundled-plugin contract maintenance helpers |
| `plugin-sdk/testing` | Broad compatibility barrel for legacy plugin tests; prefer focused test subpaths for new extension tests |
| `plugin-sdk/plugin-test-api` | Minimal `OpenClawPluginApi` mock builder for direct plugin registration unit tests |
| `plugin-sdk/channel-test-helpers` | Channel account lifecycle, directory, send-config, runtime mock, and hook test helpers |
| `plugin-sdk/channel-test-helpers` | Channel account lifecycle, directory, send-config, runtime mock, hook, and generic channel contract test helpers |
| `plugin-sdk/plugin-test-contracts` | Plugin registration, package manifest, public artifact, runtime API, import side-effect, and direct import contract helpers |
| `plugin-sdk/plugin-test-runtime` | Plugin runtime, registry, provider-registration, setup-wizard, and runtime task-flow fixtures for tests |
| `plugin-sdk/provider-test-contracts` | Provider runtime, auth, discovery, onboard, catalog, web-search/fetch, and wizard contract helpers |
@@ -267,9 +267,9 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/webhook-path` | Webhook path normalization helpers |
| `plugin-sdk/web-media` | Shared remote/local media loading helpers |
| `plugin-sdk/zod` | Re-exported `zod` for plugin SDK consumers |
| `plugin-sdk/testing` | Public extension test helpers including plugin registry/runtime mocks, provider registration capture, setup-wizard helpers, fetch/env/temp/time fixtures, schema/media/live-test helpers, `installCommonResolveTargetErrorCases`, `writeSkill`, `createTestRegistry`, and live generation env loading. Extension `*.test-support.ts` helpers stay on this or focused SDK subpaths, not core internals |
| `plugin-sdk/testing` | Broad compatibility barrel for legacy plugin tests. New extension tests should import focused SDK subpaths such as `plugin-sdk/plugin-test-runtime`, `plugin-sdk/channel-test-helpers`, `plugin-sdk/test-env`, or `plugin-sdk/test-fixtures` instead |
| `plugin-sdk/plugin-test-api` | Minimal `createTestPluginApi` helper for direct plugin registration unit tests without importing repo test helper bridges |
| `plugin-sdk/channel-test-helpers` | Channel-oriented test helpers for account startup lifecycle, directory assertions, send-config threading, runtime mocks, status issues, outbound delivery, and hook registration |
| `plugin-sdk/channel-test-helpers` | Channel-oriented test helpers for generic actions/setup/status contracts, directory assertions, account startup lifecycle, send-config threading, runtime mocks, status issues, outbound delivery, and hook registration |
| `plugin-sdk/plugin-test-contracts` | Plugin package, registration, public artifact, direct import, runtime API, and import side-effect contract helpers |
| `plugin-sdk/provider-test-contracts` | Provider runtime, auth, discovery, onboard, catalog, wizard, web-search/fetch, and stream contract helpers |
| `plugin-sdk/test-fixtures` | Generic CLI runtime capture, sandbox context, skill writer, agent-message, system-event, terminal-text, chunking, auth-token, and typed-case fixtures |

View File

@@ -19,7 +19,7 @@ plugins.
## Test utilities
**General import:** `openclaw/plugin-sdk/testing`
**Compatibility import:** `openclaw/plugin-sdk/testing`
**Plugin API mock import:** `openclaw/plugin-sdk/plugin-test-api`
@@ -37,14 +37,12 @@ plugins.
**Generic fixture import:** `openclaw/plugin-sdk/test-fixtures`
The testing subpath exports a narrow set of helpers for plugin authors:
Prefer the focused subpaths below for new plugin tests. The broad
`openclaw/plugin-sdk/testing` barrel remains for compatibility with older tests
and helpers that have not moved to a narrower documented surface yet.
```typescript
import {
installCommonResolveTargetErrorCases,
shouldAckReaction,
removeAckReactionAfterReply,
} from "openclaw/plugin-sdk/testing";
import { installCommonResolveTargetErrorCases } from "openclaw/plugin-sdk/testing";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { expectChannelInboundContextContract } from "openclaw/plugin-sdk/channel-contract-testing";
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
@@ -57,59 +55,63 @@ import { createCliRuntimeCapture, typedCases } from "openclaw/plugin-sdk/test-fi
### Available exports
| Export | Purpose |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `createTestPluginApi` | Build a minimal plugin API mock for direct registration unit tests. Import from `plugin-sdk/plugin-test-api` |
| `expectChannelInboundContextContract` | Assert channel inbound context shape. Import from `plugin-sdk/channel-contract-testing` |
| `installChannelOutboundPayloadContractSuite` | Install channel outbound payload contract cases. Import from `plugin-sdk/channel-contract-testing` |
| `createStartAccountContext` | Build channel account lifecycle contexts. Import from `plugin-sdk/channel-test-helpers` |
| `describePluginRegistrationContract` | Install plugin registration contract checks. Import from `plugin-sdk/plugin-test-contracts` |
| `registerSingleProviderPlugin` | Register one provider plugin in loader smoke tests. Import from `plugin-sdk/plugin-test-runtime` |
| `registerProviderPlugin` | Capture all provider kinds from one plugin. Import from `plugin-sdk/plugin-test-runtime` |
| `registerProviderPlugins` | Capture provider registrations across multiple plugins. Import from `plugin-sdk/plugin-test-runtime` |
| `requireRegisteredProvider` | Assert that a provider collection contains an id. Import from `plugin-sdk/plugin-test-runtime` |
| `createRuntimeEnv` | Build a mocked CLI/plugin runtime environment. Import from `plugin-sdk/plugin-test-runtime` |
| `createPluginSetupWizardStatus` | Build setup status helpers for channel plugins. Import from `plugin-sdk/plugin-test-runtime` |
| `describeOpenAIProviderRuntimeContract` | Install provider-family runtime contract checks. Import from `plugin-sdk/provider-test-contracts` |
| `installCommonResolveTargetErrorCases` | Shared test cases for target resolution error handling |
| `shouldAckReaction` | Check whether a channel should add an ack reaction |
| `removeAckReactionAfterReply` | Remove ack reaction after reply delivery |
| `createTestRegistry` | Build a channel plugin registry fixture |
| `createEmptyPluginRegistry` | Build an empty plugin registry fixture |
| `setActivePluginRegistry` | Install a registry fixture for plugin runtime tests |
| `createRequestCaptureJsonFetch` | Capture JSON fetch requests in media helper tests. Import from `plugin-sdk/test-env` |
| `withFetchPreconnect` | Run fetch tests with preconnect hooks installed. Import from `plugin-sdk/test-env` |
| `withEnv` / `withEnvAsync` | Temporarily patch environment variables. Import from `plugin-sdk/test-env` |
| `createTempHomeEnv` / `withTempDir` | Create isolated filesystem test fixtures. Import from `plugin-sdk/test-env` |
| `createMockServerResponse` | Create a minimal HTTP server response mock. Import from `plugin-sdk/test-env` |
| `createCliRuntimeCapture` | Capture CLI runtime output in tests. Import from `plugin-sdk/test-fixtures` |
| `createSandboxTestContext` | Build sandbox test contexts. Import from `plugin-sdk/test-fixtures` |
| `writeSkill` | Write skill fixtures. Import from `plugin-sdk/test-fixtures` |
| `makeAgentAssistantMessage` | Build agent transcript message fixtures. Import from `plugin-sdk/test-fixtures` |
| `peekSystemEvents` / `resetSystemEventsForTest` | Inspect and reset system event fixtures. Import from `plugin-sdk/test-fixtures` |
| `sanitizeTerminalText` | Sanitize terminal output for assertions. Import from `plugin-sdk/test-fixtures` |
| `countLines` / `hasBalancedFences` | Assert chunking output shape. Import from `plugin-sdk/test-fixtures` |
| `runProviderCatalog` | Execute a provider catalog hook with test dependencies |
| `resolveProviderWizardOptions` | Resolve provider setup wizard choices in contract tests |
| `resolveProviderModelPickerEntries` | Resolve provider model-picker entries in contract tests |
| `buildProviderPluginMethodChoice` | Build provider wizard choice ids for assertions |
| `setProviderWizardProvidersResolverForTest` | Inject provider wizard providers for isolated tests |
| `createProviderUsageFetch` | Build provider usage fetch fixtures |
| `useFrozenTime` / `useRealTime` | Freeze and restore timers for time-sensitive tests. Import from `plugin-sdk/test-env` |
| `createTestWizardPrompter` | Build a mocked setup wizard prompter |
| `createRuntimeTaskFlow` | Create isolated runtime task-flow state |
| `typedCases` | Preserve literal types for table-driven tests. Import from `plugin-sdk/test-fixtures` |
| Export | Purpose |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `createTestPluginApi` | Build a minimal plugin API mock for direct registration unit tests. Import from `plugin-sdk/plugin-test-api` |
| `expectChannelInboundContextContract` | Assert channel inbound context shape. Import from `plugin-sdk/channel-contract-testing` |
| `installChannelOutboundPayloadContractSuite` | Install channel outbound payload contract cases. Import from `plugin-sdk/channel-contract-testing` |
| `createStartAccountContext` | Build channel account lifecycle contexts. Import from `plugin-sdk/channel-test-helpers` |
| `installChannelActionsContractSuite` | Install generic channel message-action contract cases. Import from `plugin-sdk/channel-test-helpers` |
| `installChannelSetupContractSuite` | Install generic channel setup contract cases. Import from `plugin-sdk/channel-test-helpers` |
| `installChannelStatusContractSuite` | Install generic channel status contract cases. Import from `plugin-sdk/channel-test-helpers` |
| `expectDirectoryIds` | Assert channel directory ids from a directory-list function. Import from `plugin-sdk/channel-test-helpers` |
| `describePluginRegistrationContract` | Install plugin registration contract checks. Import from `plugin-sdk/plugin-test-contracts` |
| `registerSingleProviderPlugin` | Register one provider plugin in loader smoke tests. Import from `plugin-sdk/plugin-test-runtime` |
| `registerProviderPlugin` | Capture all provider kinds from one plugin. Import from `plugin-sdk/plugin-test-runtime` |
| `registerProviderPlugins` | Capture provider registrations across multiple plugins. Import from `plugin-sdk/plugin-test-runtime` |
| `requireRegisteredProvider` | Assert that a provider collection contains an id. Import from `plugin-sdk/plugin-test-runtime` |
| `createRuntimeEnv` | Build a mocked CLI/plugin runtime environment. Import from `plugin-sdk/plugin-test-runtime` |
| `createPluginSetupWizardStatus` | Build setup status helpers for channel plugins. Import from `plugin-sdk/plugin-test-runtime` |
| `describeOpenAIProviderRuntimeContract` | Install provider-family runtime contract checks. Import from `plugin-sdk/provider-test-contracts` |
| `installCommonResolveTargetErrorCases` | Shared test cases for target resolution error handling. Import from `plugin-sdk/testing` until a narrower target-resolution test subpath exists |
| `shouldAckReaction` | Check whether a channel should add an ack reaction. Import from `plugin-sdk/testing` until a narrower reaction test subpath exists |
| `removeAckReactionAfterReply` | Remove ack reaction after reply delivery. Import from `plugin-sdk/testing` until a narrower reaction test subpath exists |
| `createTestRegistry` | Build a channel plugin registry fixture. Import from `plugin-sdk/plugin-test-runtime` or `plugin-sdk/channel-test-helpers` |
| `createEmptyPluginRegistry` | Build an empty plugin registry fixture. Import from `plugin-sdk/plugin-test-runtime` or `plugin-sdk/channel-test-helpers` |
| `setActivePluginRegistry` | Install a registry fixture for plugin runtime tests. Import from `plugin-sdk/plugin-test-runtime` or `plugin-sdk/channel-test-helpers` |
| `createRequestCaptureJsonFetch` | Capture JSON fetch requests in media helper tests. Import from `plugin-sdk/test-env` |
| `withFetchPreconnect` | Run fetch tests with preconnect hooks installed. Import from `plugin-sdk/test-env` |
| `withEnv` / `withEnvAsync` | Temporarily patch environment variables. Import from `plugin-sdk/test-env` |
| `createTempHomeEnv` / `withTempDir` | Create isolated filesystem test fixtures. Import from `plugin-sdk/test-env` |
| `createMockServerResponse` | Create a minimal HTTP server response mock. Import from `plugin-sdk/test-env` |
| `createCliRuntimeCapture` | Capture CLI runtime output in tests. Import from `plugin-sdk/test-fixtures` |
| `createSandboxTestContext` | Build sandbox test contexts. Import from `plugin-sdk/test-fixtures` |
| `writeSkill` | Write skill fixtures. Import from `plugin-sdk/test-fixtures` |
| `makeAgentAssistantMessage` | Build agent transcript message fixtures. Import from `plugin-sdk/test-fixtures` |
| `peekSystemEvents` / `resetSystemEventsForTest` | Inspect and reset system event fixtures. Import from `plugin-sdk/test-fixtures` |
| `sanitizeTerminalText` | Sanitize terminal output for assertions. Import from `plugin-sdk/test-fixtures` |
| `countLines` / `hasBalancedFences` | Assert chunking output shape. Import from `plugin-sdk/test-fixtures` |
| `runProviderCatalog` | Execute a provider catalog hook with test dependencies |
| `resolveProviderWizardOptions` | Resolve provider setup wizard choices in contract tests |
| `resolveProviderModelPickerEntries` | Resolve provider model-picker entries in contract tests |
| `buildProviderPluginMethodChoice` | Build provider wizard choice ids for assertions |
| `setProviderWizardProvidersResolverForTest` | Inject provider wizard providers for isolated tests |
| `createProviderUsageFetch` | Build provider usage fetch fixtures |
| `useFrozenTime` / `useRealTime` | Freeze and restore timers for time-sensitive tests. Import from `plugin-sdk/test-env` |
| `createTestWizardPrompter` | Build a mocked setup wizard prompter |
| `createRuntimeTaskFlow` | Create isolated runtime task-flow state |
| `typedCases` | Preserve literal types for table-driven tests. Import from `plugin-sdk/test-fixtures` |
Bundled-plugin contract suites also use SDK testing subpaths for test-only
registry, manifest, public-artifact, and runtime fixture helpers. Core-only
suites that depend on bundled OpenClaw inventory stay under `src/plugins/contracts`.
Keep new extension tests on `openclaw/plugin-sdk/testing` or a narrower
documented SDK subpath such as `plugin-sdk/plugin-test-api` or
`plugin-sdk/channel-contract-testing`, `plugin-sdk/channel-test-helpers`,
`plugin-sdk/plugin-test-contracts`, `plugin-sdk/plugin-test-runtime`,
`plugin-sdk/provider-test-contracts`, `plugin-sdk/test-env`, or
`plugin-sdk/test-fixtures` rather than
importing repo `src/**` files or repo `test/helpers/plugins/*` bridges directly.
Keep new extension tests on a documented focused SDK subpath such as
`plugin-sdk/plugin-test-api`, `plugin-sdk/channel-contract-testing`,
`plugin-sdk/channel-test-helpers`, `plugin-sdk/plugin-test-contracts`,
`plugin-sdk/plugin-test-runtime`, `plugin-sdk/provider-test-contracts`,
`plugin-sdk/test-env`, or `plugin-sdk/test-fixtures` rather than importing the
broad `plugin-sdk/testing` compatibility barrel, repo `src/**` files, or repo
`test/helpers/plugins/*` bridges directly.
### Types

View File

@@ -21,7 +21,7 @@ vi.mock("./cli-auth-seam.js", async (importActual) => {
const { buildAnthropicCliMigrationResult, hasClaudeCliAuth } = await import("./cli-migration.js");
const { createTestWizardPrompter, registerSingleProviderPlugin } =
await import("openclaw/plugin-sdk/testing");
await import("openclaw/plugin-sdk/plugin-test-runtime");
const { default: anthropicPlugin } = await import("./index.js");
async function resolveAnthropicCliAuthMethod() {

View File

@@ -1,6 +1,6 @@
import { installChannelActionsContractSuite } from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe } from "vitest";
import { installChannelActionsContractSuite } from "../../../test/helpers/channels/registry-contract-suites.js";
import { discordPlugin } from "../api.js";
describe("discord actions contract", () => {

View File

@@ -1,7 +1,7 @@
import type { BaseProbeResult, BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
import { expectDirectoryIds } from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, expectTypeOf, it } from "vitest";
import { expectDirectoryIds } from "../../../test/helpers/channels/directory-ids.js";
import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,

View File

@@ -1,9 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect } from "vitest";
import {
installChannelSetupContractSuite,
installChannelStatusContractSuite,
} from "../../../test/helpers/channels/registry-contract-suites.js";
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect } from "vitest";
import { linePlugin, lineSetupPlugin } from "../api.js";
describe("line setup contract", () => {

View File

@@ -1,10 +1,10 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect } from "vitest";
import {
installChannelActionsContractSuite,
installChannelSetupContractSuite,
installChannelStatusContractSuite,
} from "../../../test/helpers/channels/registry-contract-suites.js";
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect } from "vitest";
import { mattermostPlugin, mattermostSetupPlugin } from "../channel-plugin-api.js";
describe("mattermost actions contract", () => {

View File

@@ -1,10 +1,10 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect } from "vitest";
import {
installChannelActionsContractSuite,
installChannelSetupContractSuite,
installChannelStatusContractSuite,
} from "../../../test/helpers/channels/registry-contract-suites.js";
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect } from "vitest";
import { slackPlugin } from "../api.js";
import { slackSetupPlugin } from "../setup-plugin-api.js";

View File

@@ -1,7 +1,7 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { expectDirectoryIds } from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe, expect, expectTypeOf, it } from "vitest";
import { expectDirectoryIds } from "../../../test/helpers/channels/directory-ids.js";
import {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,

View File

@@ -3,7 +3,7 @@ import { clearPluginCommands, registerPluginCommand } from "openclaw/plugin-sdk/
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
let setActivePluginRegistry: typeof import("openclaw/plugin-sdk/testing").setActivePluginRegistry;
let setActivePluginRegistry: typeof import("openclaw/plugin-sdk/plugin-test-runtime").setActivePluginRegistry;
let createCommandBot: typeof import("./bot-native-commands.menu-test-support.js").createCommandBot;
let createNativeCommandTestParams: typeof import("./bot-native-commands.menu-test-support.js").createNativeCommandTestParams;
let createPrivateCommandContext: typeof import("./bot-native-commands.menu-test-support.js").createPrivateCommandContext;
@@ -112,7 +112,7 @@ async function registerPairMenu(params: {
describe("registerTelegramNativeCommands real plugin registry", () => {
beforeAll(async () => {
({ setActivePluginRegistry } = await import("openclaw/plugin-sdk/testing"));
({ setActivePluginRegistry } = await import("openclaw/plugin-sdk/plugin-test-runtime"));
({ registerTelegramNativeCommands } = await import("./bot-native-commands.js"));
({
createCommandBot,

View File

@@ -1,6 +1,6 @@
import { installChannelActionsContractSuite } from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { describe } from "vitest";
import { installChannelActionsContractSuite } from "../../../test/helpers/channels/registry-contract-suites.js";
import { telegramPlugin } from "../api.js";
describe("telegram actions contract", () => {

View File

@@ -1,8 +1,8 @@
import type { BaseProbeResult, BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
import { expectDirectoryIds } from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { describe, expect, expectTypeOf, it } from "vitest";
import { expectDirectoryIds } from "../../../test/helpers/channels/directory-ids.js";
import {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,

View File

@@ -11,7 +11,11 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [
},
{
pattern: /["']openclaw\/plugin-sdk\/test-utils["']/,
hint: "Use openclaw/plugin-sdk/testing or a focused plugin-sdk test subpath for the public extension test surface.",
hint: "Use a focused plugin-sdk test subpath for the public extension test surface.",
},
{
pattern: /["']openclaw\/plugin-sdk\/testing["']/,
hint: "Use a focused plugin-sdk test subpath instead of the broad compatibility testing barrel.",
},
{
pattern: /["']openclaw\/plugin-sdk\/compat["']/,
@@ -25,6 +29,10 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [
pattern: /["'](?:\.\.\/)+(?:test\/helpers\/plugins\/)[^"']+["']/,
hint: "Use a documented openclaw/plugin-sdk test subpath instead of repo-only plugin helper bridges.",
},
{
pattern: /["'](?:\.\.\/)+(?:test\/helpers\/channels\/)[^"']+["']/,
hint: "Use openclaw/plugin-sdk/channel-test-helpers or another focused SDK test subpath instead of repo-only channel helper bridges.",
},
{
pattern: /["'](?:\.\.\/)+(?:src\/test-utils\/)[^"']+["']/,
hint: "Use a documented openclaw/plugin-sdk test subpath for public surfaces.",
@@ -45,7 +53,7 @@ const MOCK_RELATIVE_MODULE_PATTERN =
/\bvi\.(?:mock|doMock|unmock|doUnmock)\s*\(\s*["']([^"']+)["']/g;
const RELATIVE_CORE_HINT =
"Use openclaw/plugin-sdk/testing or a focused plugin-sdk test/runtime subpath instead of core internals.";
"Use a focused plugin-sdk test/runtime subpath instead of core internals.";
// Tombstones for retired repo-only plugin helper bridge files. Keep this list so
// deleted bridges fail loudly if they are recreated instead of using SDK subpaths.

View File

@@ -1,4 +1,12 @@
export { createDirectoryTestRuntime, expectDirectorySurface } from "./test-helpers/directory.js";
export { expectDirectoryIds, type DirectoryListFn } from "./test-helpers/directory-ids.js";
export {
expectChannelPluginContract,
installChannelActionsContractSuite,
installChannelPluginContractSuite,
installChannelSetupContractSuite,
installChannelStatusContractSuite,
} from "./test-helpers/channel-contract-suites.js";
export {
addTestHook,
createEmptyPluginRegistry,

View File

@@ -0,0 +1,242 @@
import { expect, it } from "vitest";
import type {
ChannelAccountSnapshot,
ChannelAccountState,
ChannelSetupInput,
} from "../../channels/plugins/types.core.js";
import type {
ChannelMessageActionName,
ChannelMessageCapability,
ChannelPlugin,
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
function sortStrings(values: readonly string[]) {
return [...values].toSorted((left, right) => left.localeCompare(right));
}
function resolveContractMessageDiscovery(params: {
plugin: Pick<ChannelPlugin, "actions">;
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<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
}) {
it("satisfies the base channel plugin contract", () => {
expectChannelPluginContract(params.plugin);
});
}
export function expectChannelPluginContract(
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">,
) {
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<ChannelPlugin, "id" | "actions">;
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<ResolvedAccount> = {
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<ResolvedAccount>(params: {
plugin: Pick<ChannelPlugin<ResolvedAccount>, "id" | "config" | "setup">;
cases: readonly ChannelSetupContractCase<ResolvedAccount>[];
}) {
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<Probe> = {
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<string, unknown>) => void;
};
export function installChannelStatusContractSuite<ResolvedAccount, Probe = unknown>(params: {
plugin: Pick<ChannelPlugin<ResolvedAccount, Probe>, "id" | "config" | "status">;
cases: readonly ChannelStatusContractCase<Probe>[];
}) {
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);
}
});
}
}

View File

@@ -0,0 +1,36 @@
import { expect } from "vitest";
import type { ChannelDirectoryEntry } from "../channel-contract.js";
import type { OpenClawConfig } from "../config-types.js";
export type DirectoryListFn = (params: {
cfg: OpenClawConfig;
accountId?: string;
query?: string | null;
limit?: number | null;
}) => Promise<ChannelDirectoryEntry[]>;
export async function expectDirectoryIds(
listFn: DirectoryListFn,
cfg: OpenClawConfig,
expected: string[],
options?: { sorted?: boolean },
) {
const entries = await listFn({
cfg,
accountId: "default",
query: null,
limit: null,
});
const ids = entries.map((entry) => entry.id);
expect(options?.sorted ? sortDirectoryIds(ids) : ids).toEqual(
options?.sorted ? sortDirectoryIds(expected) : expected,
);
}
function compareDirectoryIds(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}
function sortDirectoryIds(values: string[]) {
return values.toSorted(compareDirectoryIds);
}

View File

@@ -1,36 +1,4 @@
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { expect } from "vitest";
export type DirectoryListFn = (params: {
cfg: OpenClawConfig;
accountId?: string;
query?: string | null;
limit?: number | null;
}) => Promise<ChannelDirectoryEntry[]>;
export async function expectDirectoryIds(
listFn: DirectoryListFn,
cfg: OpenClawConfig,
expected: string[],
options?: { sorted?: boolean },
) {
const entries = await listFn({
cfg,
accountId: "default",
query: null,
limit: null,
});
const ids = entries.map((entry) => entry.id);
expect(options?.sorted ? sortDirectoryIds(ids) : ids).toEqual(
options?.sorted ? sortDirectoryIds(expected) : expected,
);
}
function compareDirectoryIds(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}
function sortDirectoryIds(values: string[]) {
return values.toSorted(compareDirectoryIds);
}
export {
expectDirectoryIds,
type DirectoryListFn,
} from "../../../src/plugin-sdk/test-helpers/directory-ids.js";

View File

@@ -1,242 +1,7 @@
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<ChannelPlugin, "actions">;
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<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
}) {
it("satisfies the base channel plugin contract", () => {
expectChannelPluginContract(params.plugin);
});
}
export function expectChannelPluginContract(
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">,
) {
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<ChannelPlugin, "id" | "actions">;
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<ResolvedAccount> = {
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<ResolvedAccount>(params: {
plugin: Pick<ChannelPlugin<ResolvedAccount>, "id" | "config" | "setup">;
cases: readonly ChannelSetupContractCase<ResolvedAccount>[];
}) {
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<Probe> = {
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<string, unknown>) => void;
};
export function installChannelStatusContractSuite<ResolvedAccount, Probe = unknown>(params: {
plugin: Pick<ChannelPlugin<ResolvedAccount, Probe>, "id" | "config" | "status">;
cases: readonly ChannelStatusContractCase<Probe>[];
}) {
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);
}
});
}
}
export {
expectChannelPluginContract,
installChannelActionsContractSuite,
installChannelPluginContractSuite,
installChannelSetupContractSuite,
installChannelStatusContractSuite,
} from "../../../src/plugin-sdk/test-helpers/channel-contract-suites.js";