From d462d1faf2aa0722683062c0ff522cfa7d130360 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 00:14:52 +0100 Subject: [PATCH] refactor: move plugin contracts onto SDK testing seams --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/plugins/sdk-subpaths.md | 1 + docs/plugins/sdk-testing.md | 57 +++++++------ src/plugin-sdk/setup.ts | 7 +- src/plugin-sdk/testing.ts | 58 ++++++++++++- src/plugins/provider-wizard.ts | 21 +++++ test/helpers/plugins/contracts-testkit.ts | 18 +++-- test/helpers/plugins/onboard-config.ts | 4 +- test/helpers/plugins/outbound-delivery.ts | 22 +++-- .../plugins/package-manifest-contract.ts | 7 +- .../plugins/plugin-registration-contract.ts | 6 +- test/helpers/plugins/plugin-runtime-mock.ts | 14 ++-- .../helpers/plugins/provider-auth-contract.ts | 16 ++-- test/helpers/plugins/provider-catalog.ts | 6 +- .../plugins/provider-contract-suites.ts | 10 +-- test/helpers/plugins/provider-contract.ts | 8 +- .../plugins/provider-discovery-contract.ts | 13 ++- test/helpers/plugins/provider-onboard.ts | 2 +- .../plugins/provider-runtime-contract.ts | 3 +- .../provider-wizard-contract-suites.ts | 23 ++++-- test/helpers/plugins/public-artifacts.ts | 5 +- test/helpers/plugins/public-surface-loader.ts | 81 +++++++++++++++++++ test/helpers/plugins/start-account-context.ts | 8 +- .../plugins/start-account-lifecycle.ts | 5 +- test/helpers/plugins/tts-contract-suites.ts | 79 ++++++++---------- .../plugins/web-fetch-provider-contract.ts | 8 +- .../plugins/web-search-provider-contract.ts | 6 +- 28 files changed, 326 insertions(+), 167 deletions(-) create mode 100644 test/helpers/plugins/public-surface-loader.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe7cfae27d..c6728bd1195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh. - 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: expose provider catalog, wizard, registry, manifest, public-artifact, outbound, and TTS contract helpers through documented SDK testing seams so bundled plugin tests no longer import repo `src/**` internals. Thanks @vincentkoc. - Matrix: attach versioned structured approval metadata to pending approval messages so capable Matrix clients can render richer approval UI while body text and reaction fallback keep working. (#72432) Thanks @kakahu2015. ### Fixes diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 4a41cbe41d8..e7ce9fedc45 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -af72813b59c6f35912bb5eacb01365d7686e3b2ca4c8813f7582de10c5da3c81 plugin-sdk-api-baseline.json -71603527acce8f5e1112a035dad6def83c0b02afd831b288fb655f83f8cb3bd1 plugin-sdk-api-baseline.jsonl +7a2039df8cfdcfea0fbc82bbe425c96631ab27b2210c932c6e3f17d380aff350 plugin-sdk-api-baseline.json +ea9f134bc2a1aa17e8abf8f1335daaa927b2272c972523b9d0bd05bf4ac8e149 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index fa5949ba721..c8edeb62877 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -22,6 +22,7 @@ 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/migration` | Migration provider item helpers such as `createMigrationItem`, reason constants, item status markers, redaction helpers, and `summarizeMigrationItems` | | `plugin-sdk/migration-runtime` | Runtime migration helpers such as `copyMigrationFileItem` and `writeMigrationReport` | diff --git a/docs/plugins/sdk-testing.md b/docs/plugins/sdk-testing.md index 6058aa97275..e79c958cc81 100644 --- a/docs/plugins/sdk-testing.md +++ b/docs/plugins/sdk-testing.md @@ -33,29 +33,40 @@ import { ### Available exports -| Export | Purpose | -| -------------------------------------- | ------------------------------------------------------ | -| `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 | -| `withFetchPreconnect` | Run fetch tests with preconnect hooks installed | -| `withEnv` / `withEnvAsync` | Temporarily patch environment variables | -| `createTempHomeEnv` / `withTempDir` | Create isolated filesystem test fixtures | -| `createMockServerResponse` | Create a minimal HTTP server response mock | -| `registerSingleProviderPlugin` | Register one provider plugin in loader smoke tests | -| `registerProviderPlugin` | Capture all provider kinds from one plugin | -| `requireRegisteredProvider` | Assert that a provider collection contains an id | -| `createProviderUsageFetch` | Build provider usage fetch fixtures | -| `useFrozenTime` / `useRealTime` | Freeze and restore timers for time-sensitive tests | -| `createRuntimeEnv` | Build a mocked CLI/plugin runtime environment | -| `createTestWizardPrompter` | Build a mocked setup wizard prompter | -| `createPluginSetupWizardStatus` | Build setup status helpers for channel plugins | -| `createRuntimeTaskFlow` | Create isolated runtime task-flow state | -| `typedCases` | Preserve literal types for table-driven tests | +| Export | Purpose | +| ------------------------------------------- | ------------------------------------------------------- | +| `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 | +| `withFetchPreconnect` | Run fetch tests with preconnect hooks installed | +| `withEnv` / `withEnvAsync` | Temporarily patch environment variables | +| `createTempHomeEnv` / `withTempDir` | Create isolated filesystem test fixtures | +| `createMockServerResponse` | Create a minimal HTTP server response mock | +| `registerSingleProviderPlugin` | Register one provider plugin in loader smoke tests | +| `registerProviderPlugin` | Capture all provider kinds from one plugin | +| `registerProviderPlugins` | Capture provider registrations across multiple plugins | +| `requireRegisteredProvider` | Assert that a provider collection contains an id | +| `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 | +| `createRuntimeEnv` | Build a mocked CLI/plugin runtime environment | +| `createTestWizardPrompter` | Build a mocked setup wizard prompter | +| `createPluginSetupWizardStatus` | Build setup status helpers for channel plugins | +| `createRuntimeTaskFlow` | Create isolated runtime task-flow state | +| `typedCases` | Preserve literal types for table-driven tests | + +Bundled-plugin contract suites also use this subpath for test-only registry, +manifest, public-artifact, and runtime fixture helpers. Keep new extension tests +on `openclaw/plugin-sdk/testing` or a narrower documented SDK subpath rather +than importing repo `src/**` files directly. ### Types diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index 91196d478d2..de694393d3f 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -3,7 +3,12 @@ export type { OpenClawConfig } from "../config/config.js"; export type { DmPolicy, GroupPolicy } from "../config/types.js"; export type { SecretInput } from "../config/types.secrets.js"; -export type { WizardPrompter } from "../wizard/prompts.js"; +export type { + WizardMultiSelectParams, + WizardProgress, + WizardPrompter, + WizardSelectParams, +} from "../wizard/prompts.js"; export { WizardCancelledError } from "../wizard/prompts.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export type { ChannelSetupInput } from "../channels/plugins/types.core.js"; diff --git a/src/plugin-sdk/testing.ts b/src/plugin-sdk/testing.ts index 87a27cd2513..ba687659547 100644 --- a/src/plugin-sdk/testing.ts +++ b/src/plugin-sdk/testing.ts @@ -1,7 +1,12 @@ // Narrow public testing surface for plugin authors. // Keep this list additive and limited to helpers we are willing to support. -export { removeAckReactionAfterReply, shouldAckReaction } from "../channels/ack-reactions.js"; +export { + createAckReactionHandle, + removeAckReactionAfterReply, + removeAckReactionHandleAfterReply, + shouldAckReaction, +} from "../channels/ack-reactions.js"; export { expectChannelInboundContextContract, primeChannelOutboundSendMock, @@ -23,10 +28,48 @@ export { setDefaultChannelPluginRegistryForTests } from "../commands/channel-tes export type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js"; export type { ChannelGatewayContext } from "../channels/plugins/types.adapters.js"; export type { OpenClawConfig } from "../config/config.js"; +export { isAtLeast, parseSemver } from "../infra/runtime-guard.js"; export { callGateway } from "../gateway/call.js"; -export { createEmptyPluginRegistry } from "../plugins/registry.js"; +export { deliverOutboundPayloads } from "../infra/outbound/deliver.js"; +export { + createEmptyPluginRegistry, + createPluginRegistry, + type PluginRecord, +} from "../plugins/registry.js"; +export { + providerContractLoadError, + pluginRegistrationContractRegistry, + resolveProviderContractProvidersForPluginIds, + resolveWebFetchProviderContractEntriesForPluginId, + resolveWebSearchProviderContractEntriesForPluginId, +} from "../plugins/contracts/registry.js"; +export { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "../plugins/contracts/inventory/bundled-capability-metadata.js"; +export { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; +export { parseMinHostVersionRequirement } from "../plugins/min-host-version.js"; +export { resolveBundledExplicitProviderContractsFromPublicArtifacts } from "../plugins/provider-contract-public-artifacts.js"; +export { + expectAugmentedCodexCatalog, + expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55, + expectCodexBuiltInSuppression, + expectCodexMissingAuthHint, +} from "../plugins/provider-runtime.test-support.js"; +export { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../plugins/hook-runner-global.js"; +export { addTestHook } from "../plugins/hooks.test-helpers.js"; +export { + assertUniqueValues, + BUNDLED_RUNTIME_SIDECAR_PATHS, +} from "../plugins/runtime-sidecar-paths.js"; +export { createPluginRecord } from "../plugins/status.test-helpers.js"; +export { + resolveBundledExplicitWebFetchProvidersFromPublicArtifacts, + resolveBundledExplicitWebSearchProvidersFromPublicArtifacts, +} from "../plugins/web-provider-public-artifacts.explicit.js"; export { getActivePluginRegistry, + releasePinnedPluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "../plugins/runtime.js"; @@ -35,8 +78,16 @@ export { resetFacadeRuntimeStateForTest, } from "./facade-runtime.js"; export { capturePluginRegistration } from "../plugins/captured-registration.js"; +export { runProviderCatalog } from "../plugins/provider-discovery.js"; +export { + buildProviderPluginMethodChoice, + resolveProviderModelPickerEntries, + resolveProviderWizardOptions, + setProviderWizardProvidersResolverForTest, +} from "../plugins/provider-wizard.js"; export { resolveProviderPluginChoice } from "../plugins/provider-auth-choice.runtime.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { PluginHookRegistration } from "../plugins/hook-types.js"; export type { RuntimeEnv } from "../runtime.js"; export type { MockFn } from "../test-utils/vitest-mock-fn.js"; export { @@ -105,7 +156,7 @@ export type { } from "../video-generation/types.js"; export { jsonResponse, requestBodyText, requestUrl } from "../test-helpers/http.js"; export { mockPinnedHostnameResolution } from "../test-helpers/ssrf.js"; -export { createTestRegistry } from "../test-utils/channel-plugins.js"; +export { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; export { createWindowsCmdShimFixture } from "../test-helpers/windows-cmd-shim.js"; export { installCommonResolveTargetErrorCases } from "../test-helpers/resolve-target-error-cases.js"; export { sanitizeTerminalText } from "../terminal/safe-text.js"; @@ -117,6 +168,7 @@ export { withFetchPreconnect, type FetchMock } from "../test-utils/fetch-mock.js export { createMockServerResponse } from "../test-utils/mock-http-response.js"; export { registerProviderPlugin, + registerProviderPlugins, registerSingleProviderPlugin, requireRegisteredProvider, type RegisteredProviderCollections, diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index 76ac80a5826..6301aac4dc3 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -35,6 +35,24 @@ export type ProviderModelPickerEntry = { hint?: string; }; +type ProviderWizardProvidersResolver = (params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}) => ProviderPlugin[]; + +let providerWizardProvidersResolverForTest: ProviderWizardProvidersResolver | undefined; + +export function setProviderWizardProvidersResolverForTest( + resolver: ProviderWizardProvidersResolver | undefined, +): () => void { + const previous = providerWizardProvidersResolverForTest; + providerWizardProvidersResolverForTest = resolver; + return () => { + providerWizardProvidersResolverForTest = previous; + }; +} + function resolveWizardSetupChoiceId( provider: ProviderPlugin, wizard: ProviderPluginWizardSetup, @@ -113,6 +131,9 @@ function resolveProviderWizardProviders(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin[] { + if (providerWizardProvidersResolverForTest) { + return providerWizardProvidersResolverForTest(params); + } return resolvePluginProviders({ config: params.config, workspaceDir: params.workspaceDir, diff --git a/test/helpers/plugins/contracts-testkit.ts b/test/helpers/plugins/contracts-testkit.ts index 91024d4d474..1f94dcf5d9a 100644 --- a/test/helpers/plugins/contracts-testkit.ts +++ b/test/helpers/plugins/contracts-testkit.ts @@ -1,13 +1,15 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { createPluginRegistry, type PluginRecord } from "../../../src/plugins/registry.js"; -import type { PluginRuntime } from "../../../src/plugins/runtime/types.js"; -import { createPluginRecord } from "../../../src/plugins/status.test-helpers.js"; -import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; - -export { +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { + createPluginRecord, + createPluginRegistry, registerProviderPlugins as registerProviders, requireRegisteredProvider as requireProvider, -} from "../../../src/test-utils/plugin-registration.js"; + type OpenClawConfig, + type PluginRecord, + type PluginRuntime, +} from "openclaw/plugin-sdk/testing"; + +export { registerProviders, requireProvider }; export function uniqueSortedStrings(values: readonly string[]) { return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); diff --git a/test/helpers/plugins/onboard-config.ts b/test/helpers/plugins/onboard-config.ts index 0934f1c6346..15516776e2d 100644 --- a/test/helpers/plugins/onboard-config.ts +++ b/test/helpers/plugins/onboard-config.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { ModelApi } from "../../../src/config/types.models.js"; +import type { ModelApi } from "openclaw/plugin-sdk/provider-model-shared"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/testing"; export const EXPECTED_FALLBACKS = ["anthropic/claude-opus-4-5"] as const; diff --git a/test/helpers/plugins/outbound-delivery.ts b/test/helpers/plugins/outbound-delivery.ts index e93562fba97..38fa4f1cfcd 100644 --- a/test/helpers/plugins/outbound-delivery.ts +++ b/test/helpers/plugins/outbound-delivery.ts @@ -1,16 +1,12 @@ -export { deliverOutboundPayloads } from "../../../src/infra/outbound/deliver.js"; -export { - initializeGlobalHookRunner, - resetGlobalHookRunner, -} from "../../../src/plugins/hook-runner-global.js"; -export { addTestHook } from "../../../src/plugins/hooks.test-helpers.js"; -export { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; -export { - releasePinnedPluginChannelRegistry, - setActivePluginRegistry, -} from "../../../src/plugins/runtime.js"; -export type { PluginHookRegistration } from "../../../src/plugins/types.js"; export { + addTestHook, + createEmptyPluginRegistry, createOutboundTestPlugin, createTestRegistry, -} from "../../../src/test-utils/channel-plugins.js"; + deliverOutboundPayloads, + initializeGlobalHookRunner, + releasePinnedPluginChannelRegistry, + resetGlobalHookRunner, + setActivePluginRegistry, + type PluginHookRegistration, +} from "openclaw/plugin-sdk/testing"; diff --git a/test/helpers/plugins/package-manifest-contract.ts b/test/helpers/plugins/package-manifest-contract.ts index 3687ba2f394..fd5d01eccc8 100644 --- a/test/helpers/plugins/package-manifest-contract.ts +++ b/test/helpers/plugins/package-manifest-contract.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; import path from "node:path"; +import { + isAtLeast, + parseMinHostVersionRequirement, + parseSemver, +} from "openclaw/plugin-sdk/testing"; import { describe, expect, it } from "vitest"; -import { isAtLeast, parseSemver } from "../../../src/infra/runtime-guard.js"; -import { parseMinHostVersionRequirement } from "../../../src/plugins/min-host-version.js"; import { bundledPluginFile } from "../bundled-plugin-paths.js"; type PackageManifest = { diff --git a/test/helpers/plugins/plugin-registration-contract.ts b/test/helpers/plugins/plugin-registration-contract.ts index c320aeeec25..6238d15de20 100644 --- a/test/helpers/plugins/plugin-registration-contract.ts +++ b/test/helpers/plugins/plugin-registration-contract.ts @@ -1,6 +1,8 @@ +import { + loadPluginManifestRegistry, + pluginRegistrationContractRegistry, +} from "openclaw/plugin-sdk/testing"; import { describe, expect, it } from "vitest"; -import { pluginRegistrationContractRegistry } from "../../../src/plugins/contracts/registry.js"; -import { loadPluginManifestRegistry } from "../../../src/plugins/manifest-registry.js"; type PluginRegistrationContractParams = { pluginId: string; diff --git a/test/helpers/plugins/plugin-runtime-mock.ts b/test/helpers/plugins/plugin-runtime-mock.ts index a276a29724a..660f3d27a7f 100644 --- a/test/helpers/plugins/plugin-runtime-mock.ts +++ b/test/helpers/plugins/plugin-runtime-mock.ts @@ -1,15 +1,15 @@ -import { vi } from "vitest"; +import { + implicitMentionKindWhen, + resolveInboundMentionDecision, +} from "openclaw/plugin-sdk/channel-mention-gating"; import { createAckReactionHandle, removeAckReactionAfterReply, removeAckReactionHandleAfterReply, shouldAckReaction, -} from "../../../src/channels/ack-reactions.js"; -import { - implicitMentionKindWhen, - resolveInboundMentionDecision, -} from "../../../src/channels/mention-gating.js"; -import type { PluginRuntime } from "../../../src/plugins/runtime/types.js"; +} from "openclaw/plugin-sdk/testing"; +import type { PluginRuntime } from "openclaw/plugin-sdk/testing"; +import { vi } from "vitest"; const DEFAULT_PROVIDER = "openai"; const DEFAULT_MODEL = "gpt-5.5"; diff --git a/test/helpers/plugins/provider-auth-contract.ts b/test/helpers/plugins/provider-auth-contract.ts index 666c9e7715c..e8e33499c6d 100644 --- a/test/helpers/plugins/provider-auth-contract.ts +++ b/test/helpers/plugins/provider-auth-contract.ts @@ -1,21 +1,21 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles/store.js"; -import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js"; -import { createNonExitingRuntime } from "../../../src/runtime.js"; +import { + clearRuntimeAuthProfileStoreSnapshots, + type AuthProfileStore, +} from "openclaw/plugin-sdk/agent-runtime"; +import { createNonExitingRuntime } from "openclaw/plugin-sdk/runtime"; import type { WizardMultiSelectParams, WizardPrompter, WizardProgress, WizardSelectParams, -} from "../../../src/wizard/prompts.js"; +} from "openclaw/plugin-sdk/setup"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { registerProviders, requireProvider } from "./contracts-testkit.js"; type LoginOpenAICodexOAuth = (typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"]; type GithubCopilotLoginCommand = (typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"]; -type CreateVpsAwareHandlers = - (typeof import("../../../src/plugins/provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; type EnsureAuthProfileStore = typeof import("openclaw/plugin-sdk/provider-auth").ensureAuthProfileStore; type ListProfilesForProvider = @@ -83,7 +83,7 @@ function buildAuthContext() { isRemote: false, openUrl: async () => {}, oauth: { - createVpsAwareHandlers: vi.fn(), + createVpsAwareHandlers: vi.fn(), }, }; } diff --git a/test/helpers/plugins/provider-catalog.ts b/test/helpers/plugins/provider-catalog.ts index 79569b4732b..16a1ef8bc86 100644 --- a/test/helpers/plugins/provider-catalog.ts +++ b/test/helpers/plugins/provider-catalog.ts @@ -3,12 +3,12 @@ export { expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55, expectCodexBuiltInSuppression, expectCodexMissingAuthHint, -} from "../../../src/plugins/provider-runtime.test-support.js"; -export type { ProviderPlugin } from "../../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/testing"; +export type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; export { loadBundledPluginPublicSurface, loadBundledPluginPublicSurfaceSync, -} from "../../../src/test-utils/bundled-plugin-public-surface.js"; +} from "./public-surface-loader.js"; type ProviderRuntimeCatalogModule = Pick< typeof import("openclaw/plugin-sdk/provider-catalog-runtime"), diff --git a/test/helpers/plugins/provider-contract-suites.ts b/test/helpers/plugins/provider-contract-suites.ts index 183c082e584..fc1785f6bd1 100644 --- a/test/helpers/plugins/provider-contract-suites.ts +++ b/test/helpers/plugins/provider-contract-suites.ts @@ -1,10 +1,8 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import type { WebFetchProviderPlugin } from "openclaw/plugin-sdk/provider-web-fetch-contract"; +import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/testing"; import { expect, it } from "vitest"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { - ProviderPlugin, - WebFetchProviderPlugin, - WebSearchProviderPlugin, -} from "../../../src/plugins/types.js"; type Lazy = T | (() => T); diff --git a/test/helpers/plugins/provider-contract.ts b/test/helpers/plugins/provider-contract.ts index 0cb2eb1d958..c01e2ec4043 100644 --- a/test/helpers/plugins/provider-contract.ts +++ b/test/helpers/plugins/provider-contract.ts @@ -1,10 +1,10 @@ -import { describe, expect, it } from "vitest"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { providerContractLoadError, + resolveBundledExplicitProviderContractsFromPublicArtifacts, resolveProviderContractProvidersForPluginIds, -} from "../../../src/plugins/contracts/registry.js"; -import { resolveBundledExplicitProviderContractsFromPublicArtifacts } from "../../../src/plugins/provider-contract-public-artifacts.js"; -import type { ProviderPlugin } from "../../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/testing"; +import { describe, expect, it } from "vitest"; import { installProviderPluginContractSuite } from "./provider-contract-suites.js"; type ProviderContractEntry = { diff --git a/test/helpers/plugins/provider-discovery-contract.ts b/test/helpers/plugins/provider-discovery-contract.ts index 32275065ea7..7fcca3c2230 100644 --- a/test/helpers/plugins/provider-discovery-contract.ts +++ b/test/helpers/plugins/provider-discovery-contract.ts @@ -1,10 +1,10 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { AuthProfileStore, OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; import { registerProviderPlugins as registerProviders, requireRegisteredProvider as requireProvider, -} from "../../../src/test-utils/plugin-registration.js"; + runProviderCatalog, +} from "openclaw/plugin-sdk/testing"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn()); const buildVllmProviderMock = vi.hoisted(() => vi.fn()); @@ -19,7 +19,7 @@ export type ProviderDiscoveryContractPluginLoader = () => Promise<{ type ProviderHandle = Awaited>[number]; type DiscoveryState = { - runProviderCatalog: typeof import("../../../src/plugins/provider-discovery.js").runProviderCatalog; + runProviderCatalog: typeof runProviderCatalog; githubCopilotProvider?: ProviderHandle; vllmProvider?: ProviderHandle; sglangProvider?: ProviderHandle; @@ -183,8 +183,7 @@ function installDiscoveryHooks(state: DiscoveryState, options: DiscoveryContract }; }); } - ({ runProviderCatalog: state.runProviderCatalog } = - await import("../../../src/plugins/provider-discovery.js")); + state.runProviderCatalog = runProviderCatalog; if (options.providerIds.includes("github-copilot")) { const { default: githubCopilotPlugin } = await options.loadGithubCopilot!(); diff --git a/test/helpers/plugins/provider-onboard.ts b/test/helpers/plugins/provider-onboard.ts index 894c972a2e5..d0309631d9e 100644 --- a/test/helpers/plugins/provider-onboard.ts +++ b/test/helpers/plugins/provider-onboard.ts @@ -3,8 +3,8 @@ import { resolveAgentModelPrimaryValue, } from "openclaw/plugin-sdk/provider-onboard"; import type { ModelApi } from "openclaw/plugin-sdk/provider-onboard"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/testing"; import { expect } from "vitest"; -import type { OpenClawConfig } from "../../../src/config/config.js"; import { createConfigWithFallbacks, createLegacyProviderConfig, diff --git a/test/helpers/plugins/provider-runtime-contract.ts b/test/helpers/plugins/provider-runtime-contract.ts index d3951b77cf4..66ba2caf428 100644 --- a/test/helpers/plugins/provider-runtime-contract.ts +++ b/test/helpers/plugins/provider-runtime-contract.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { createProviderUsageFetch, makeResponse, @@ -8,7 +10,6 @@ import { requireRegisteredProvider, } from "openclaw/plugin-sdk/testing"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ProviderPlugin, ProviderRuntimeModel } from "../../../src/plugins/types.js"; const CONTRACT_SETUP_TIMEOUT_MS = 300_000; diff --git a/test/helpers/plugins/provider-wizard-contract-suites.ts b/test/helpers/plugins/provider-wizard-contract-suites.ts index 74da758890d..8a231b43373 100644 --- a/test/helpers/plugins/provider-wizard-contract-suites.ts +++ b/test/helpers/plugins/provider-wizard-contract-suites.ts @@ -1,18 +1,16 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderPluginMethodChoice, resolveProviderModelPickerEntries, resolveProviderPluginChoice, resolveProviderWizardOptions, -} from "../../../src/plugins/provider-wizard.js"; -import type { ProviderAuthMethod, ProviderPlugin } from "../../../src/plugins/types.js"; + setProviderWizardProvidersResolverForTest, +} from "openclaw/plugin-sdk/testing"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const resolvePluginProvidersMock = vi.fn(); - -vi.mock("../../../src/plugins/providers.runtime.js", () => ({ - isPluginProvidersLoadInFlight: () => false, - resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args), -})); +let restoreProviderResolver: (() => void) | undefined; function createAuthMethod( params: Pick & @@ -175,6 +173,15 @@ function expectAllChoicesResolve( beforeEach(() => { resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue(TEST_PROVIDERS); + restoreProviderResolver?.(); + restoreProviderResolver = setProviderWizardProvidersResolverForTest((params) => + resolvePluginProvidersMock(params), + ); +}); + +afterEach(() => { + restoreProviderResolver?.(); + restoreProviderResolver = undefined; }); export function describeProviderWizardSetupOptionsContract() { diff --git a/test/helpers/plugins/public-artifacts.ts b/test/helpers/plugins/public-artifacts.ts index 4c4193cfa32..5337eff07dc 100644 --- a/test/helpers/plugins/public-artifacts.ts +++ b/test/helpers/plugins/public-artifacts.ts @@ -1,7 +1,4 @@ -import { - assertUniqueValues, - BUNDLED_RUNTIME_SIDECAR_PATHS, -} from "../../../src/plugins/runtime-sidecar-paths.js"; +import { assertUniqueValues, BUNDLED_RUNTIME_SIDECAR_PATHS } from "openclaw/plugin-sdk/testing"; export function getPublicArtifactBasename(relativePath: string): string { return relativePath.split("/").at(-1) ?? relativePath; diff --git a/test/helpers/plugins/public-surface-loader.ts b/test/helpers/plugins/public-surface-loader.ts new file mode 100644 index 00000000000..c5bc6617621 --- /dev/null +++ b/test/helpers/plugins/public-surface-loader.ts @@ -0,0 +1,81 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const repoRoot = process.cwd(); + +function readJson(filePath: string): T | undefined { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as T; + } catch { + return undefined; + } +} + +function normalizeArtifactBasename(artifactBasename: string): string { + return artifactBasename.replace(/^\.\/+/u, "").replace(/^\/+/u, ""); +} + +function resolveSourceArtifactPath(packageDir: string, artifactBasename: string): string { + const artifactPath = path.resolve(packageDir, normalizeArtifactBasename(artifactBasename)); + if (artifactPath.endsWith(".js")) { + const sourcePath = `${artifactPath.slice(0, -".js".length)}.ts`; + if (fs.existsSync(sourcePath)) { + return sourcePath; + } + } + return artifactPath; +} + +function resolveExtensionDirByManifestId(pluginId: string): string { + const pluginDir = path.resolve(repoRoot, "extensions", pluginId); + const manifest = readJson<{ id?: unknown }>(path.join(pluginDir, "openclaw.plugin.json")); + if (manifest?.id === pluginId) { + return pluginDir; + } + throw new Error(`Unknown bundled plugin id: ${pluginId}`); +} + +function resolveWorkspacePackageDir(packageName: string): string { + const extensionsDir = path.resolve(repoRoot, "extensions"); + for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const packageDir = path.join(extensionsDir, entry.name); + const manifest = readJson<{ name?: unknown }>(path.join(packageDir, "package.json")); + if (manifest?.name === packageName) { + return packageDir; + } + } + throw new Error(`Unknown workspace package: ${packageName}`); +} + +export async function loadBundledPluginPublicSurface(params: { + pluginId: string; + artifactBasename: string; +}): Promise { + const artifactPath = resolveSourceArtifactPath( + resolveExtensionDirByManifestId(params.pluginId), + params.artifactBasename, + ); + return (await import(pathToFileURL(artifactPath).href)) as T; +} + +export function loadBundledPluginPublicSurfaceSync(_params: { + pluginId: string; + artifactBasename: string; +}): T { + throw new Error("Synchronous bundled plugin public-surface loading is not available here"); +} + +export function resolveWorkspacePackagePublicModuleUrl(params: { + packageName: string; + artifactBasename: string; +}): string { + const artifactPath = resolveSourceArtifactPath( + resolveWorkspacePackageDir(params.packageName), + params.artifactBasename, + ); + return pathToFileURL(artifactPath).href; +} diff --git a/test/helpers/plugins/start-account-context.ts b/test/helpers/plugins/start-account-context.ts index eccfc01e7d4..37c7031ef42 100644 --- a/test/helpers/plugins/start-account-context.ts +++ b/test/helpers/plugins/start-account-context.ts @@ -1,11 +1,11 @@ import { createRuntimeEnv } from "openclaw/plugin-sdk/testing"; -import { vi } from "vitest"; import type { ChannelAccountSnapshot, ChannelGatewayContext, -} from "../../../src/channels/plugins/types.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; + OpenClawConfig, + RuntimeEnv, +} from "openclaw/plugin-sdk/testing"; +import { vi } from "vitest"; export function createStartAccountContext(params: { account: TAccount; diff --git a/test/helpers/plugins/start-account-lifecycle.ts b/test/helpers/plugins/start-account-lifecycle.ts index c9c39496b0c..d699e2614bd 100644 --- a/test/helpers/plugins/start-account-lifecycle.ts +++ b/test/helpers/plugins/start-account-lifecycle.ts @@ -1,8 +1,5 @@ +import type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/testing"; import { expect, vi } from "vitest"; -import type { - ChannelAccountSnapshot, - ChannelGatewayContext, -} from "../../../src/channels/plugins/types.js"; import { createStartAccountContext } from "./start-account-context.js"; export function startAccountAndTrackLifecycle(params: { diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index 6b36b5f7311..68e7ee3aa1c 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -1,16 +1,19 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { ResolvedTtsConfig, SpeechProviderPlugin } from "openclaw/plugin-sdk/speech-core"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/testing"; +import { + BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS, + createEmptyPluginRegistry, + setActivePluginRegistry, + withEnv, + withEnvAsync, +} from "openclaw/plugin-sdk/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "../../../src/plugins/contracts/inventory/bundled-capability-metadata.js"; -import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js"; -import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; -import type { SpeechProviderPlugin } from "../../../src/plugins/types.js"; -import { resolveWorkspacePackagePublicModuleUrl } from "../../../src/test-utils/bundled-plugin-public-surface.js"; -import { withEnv, withEnvAsync } from "../../../src/test-utils/env.js"; -import type { ResolvedTtsConfig } from "../../../src/tts/tts-types.js"; +import { resolveWorkspacePackagePublicModuleUrl } from "./public-surface-loader.js"; -type TtsRuntimeModule = typeof import("../../../src/tts/tts.js"); -type TtsCoreModule = typeof import("../../../src/tts/tts-core.js"); +type TtsRuntimeModule = typeof import("openclaw/plugin-sdk/tts-runtime"); +type TtsCoreModule = typeof import("openclaw/plugin-sdk/speech-core"); +type SummarizeTextDeps = NonNullable[1]>; const speechCoreRuntimeApiModuleId = resolveWorkspacePackagePublicModuleUrl({ packageName: "@openclaw/speech-core", @@ -22,11 +25,11 @@ let ttsRuntimePromise: Promise | null = null; let ttsRuntimeInitialized = false; let ttsCorePromise: Promise | null = null; let completeSimple: typeof import("@mariozechner/pi-ai").completeSimple; -let getApiKeyForModelMock: typeof import("../../../src/agents/model-auth.js").getApiKeyForModel; -let requireApiKeyMock: typeof import("../../../src/agents/model-auth.js").requireApiKey; -let resolveModelAsyncMock: typeof import("../../../src/agents/pi-embedded-runner/model.js").resolveModelAsync; -let ensureCustomApiRegisteredMock: typeof import("../../../src/agents/custom-api-registry.js").ensureCustomApiRegistered; -let prepareModelForSimpleCompletionMock: typeof import("../../../src/agents/simple-completion-transport.js").prepareModelForSimpleCompletion; +let getApiKeyForModelMock: SummarizeTextDeps["getApiKeyForModel"]; +let requireApiKeyMock: SummarizeTextDeps["requireApiKey"]; +let resolveModelAsyncMock: SummarizeTextDeps["resolveModelAsync"]; +let ensureCustomApiRegisteredMock: ReturnType; +let prepareModelForSimpleCompletionMock: SummarizeTextDeps["prepareModelForSimpleCompletion"]; let summarizeTextCore: TtsCoreModule["summarizeText"]; let resolveTtsConfig: TtsRuntimeModule["resolveTtsConfig"]; let maybeApplyTtsToPayload: TtsRuntimeModule["maybeApplyTtsToPayload"]; @@ -108,28 +111,6 @@ function createResolvedModel(provider: string, modelId: string, api = "openai-co }; } -vi.mock("../../../src/agents/pi-embedded-runner/model.js", () => ({ - resolveModel: vi.fn((provider: string, modelId: string) => - createResolvedModel(provider, modelId), - ), - resolveModelAsync: vi.fn(async (provider: string, modelId: string) => - createResolvedModel(provider, modelId), - ), -})); - -vi.mock("../../../src/agents/model-auth.js", () => ({ - getApiKeyForModel: vi.fn(async () => ({ - apiKey: "test-api-key", - source: "test", - mode: "api-key", - })), - requireApiKey: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""), -})); - -vi.mock("../../../src/agents/custom-api-registry.js", () => ({ - ensureCustomApiRegistered: vi.fn(), -})); - function asLegacyTtsConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } @@ -444,10 +425,16 @@ async function loadTtsRuntime(): Promise { } async function loadTtsCore(): Promise { - ttsCorePromise ??= import("../../../src/tts/tts-core.js"); + ttsCorePromise ??= import("openclaw/plugin-sdk/speech-core"); return await ttsCorePromise; } +function createPrepareModelForSimpleCompletionMock(): SummarizeTextDeps["prepareModelForSimpleCompletion"] { + return vi.fn( + ({ model }: Parameters[0]) => model, + ) as SummarizeTextDeps["prepareModelForSimpleCompletion"]; +} + async function setupTtsRuntime() { if (ttsRuntimeInitialized) { return; @@ -467,7 +454,7 @@ async function setupTtsRuntime() { } function setupTestSpeechProviderRegistry() { - prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model); + prepareModelForSimpleCompletionMock = createPrepareModelForSimpleCompletionMock(); const registry = createEmptyPluginRegistry(); registry.speechProviders = [ { pluginId: "openai", provider: buildTestOpenAISpeechProvider(), source: "test" }, @@ -510,14 +497,12 @@ function createResolvedSummarizationConfig(cfg: OpenClawConfig): ResolvedTtsConf async function setupSummarizationMocks() { ({ summarizeText: summarizeTextCore } = await loadTtsCore()); - prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model); ({ completeSimple } = await import("@mariozechner/pi-ai")); - ({ getApiKeyForModel: getApiKeyForModelMock, requireApiKey: requireApiKeyMock } = - await import("../../../src/agents/model-auth.js")); - ({ resolveModelAsync: resolveModelAsyncMock } = - await import("../../../src/agents/pi-embedded-runner/model.js")); - ({ ensureCustomApiRegistered: ensureCustomApiRegisteredMock } = - await import("../../../src/agents/custom-api-registry.js")); + getApiKeyForModelMock = vi.fn() as SummarizeTextDeps["getApiKeyForModel"]; + requireApiKeyMock = vi.fn() as SummarizeTextDeps["requireApiKey"]; + resolveModelAsyncMock = vi.fn() as SummarizeTextDeps["resolveModelAsync"]; + ensureCustomApiRegisteredMock = vi.fn(); + prepareModelForSimpleCompletionMock = createPrepareModelForSimpleCompletionMock(); vi.mocked(completeSimple).mockResolvedValue( mockAssistantMessage([{ type: "text", text: "Summary" }]), ); @@ -534,7 +519,7 @@ async function setupSummarizationMocks() { >, ); vi.mocked(ensureCustomApiRegisteredMock).mockReset(); - prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model); + prepareModelForSimpleCompletionMock = createPrepareModelForSimpleCompletionMock(); } async function setupTtsContractTest() { diff --git a/test/helpers/plugins/web-fetch-provider-contract.ts b/test/helpers/plugins/web-fetch-provider-contract.ts index d4c6a5c59ad..c48bc530950 100644 --- a/test/helpers/plugins/web-fetch-provider-contract.ts +++ b/test/helpers/plugins/web-fetch-provider-contract.ts @@ -1,10 +1,10 @@ -import { describe, expect, it } from "vitest"; +import type { WebFetchProviderPlugin } from "openclaw/plugin-sdk/provider-web-fetch-contract"; import { pluginRegistrationContractRegistry, + resolveBundledExplicitWebFetchProvidersFromPublicArtifacts, resolveWebFetchProviderContractEntriesForPluginId, -} from "../../../src/plugins/contracts/registry.js"; -import type { WebFetchProviderPlugin } from "../../../src/plugins/types.js"; -import { resolveBundledExplicitWebFetchProvidersFromPublicArtifacts } from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; +} from "openclaw/plugin-sdk/testing"; +import { describe, expect, it } from "vitest"; import { installWebFetchProviderContractSuite } from "./provider-contract-suites.js"; function resolveWebFetchCredentialValue(provider: WebFetchProviderPlugin): unknown { diff --git a/test/helpers/plugins/web-search-provider-contract.ts b/test/helpers/plugins/web-search-provider-contract.ts index 76b7b502ae0..5cf8562a35c 100644 --- a/test/helpers/plugins/web-search-provider-contract.ts +++ b/test/helpers/plugins/web-search-provider-contract.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from "vitest"; import { pluginRegistrationContractRegistry, + resolveBundledExplicitWebSearchProvidersFromPublicArtifacts, resolveWebSearchProviderContractEntriesForPluginId, -} from "../../../src/plugins/contracts/registry.js"; -import { resolveBundledExplicitWebSearchProvidersFromPublicArtifacts } from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; +} from "openclaw/plugin-sdk/testing"; +import { describe, expect, it } from "vitest"; import { installWebSearchProviderContractSuite } from "./provider-contract-suites.js"; type WebSearchContractEntry = ReturnType<