From 3cea11d3b681fe2ea4106d9dde3ce11c82d0149c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 10 Apr 2026 08:03:42 +0100 Subject: [PATCH] test(boundary): route helper imports through bundled plugin surfaces --- ...-test-helper-extension-import-boundary.mjs | 134 ++++++++++++++++++ .../provider-capabilities.contract.test.ts | 8 +- .../bundled-plugin-public-surface.ts | 46 ++++-- .../channels/channel-media-roots-contract.ts | 12 +- test/helpers/channels/command-contract.ts | 22 ++- test/helpers/channels/dm-policy-contract.ts | 17 ++- .../helpers/channels/group-policy-contract.ts | 14 +- .../channels/inbound-contract.slack.ts | 3 +- test/helpers/channels/interactive-contract.ts | 6 +- .../helpers/channels/matrix-setup-contract.ts | 9 +- .../plugins-core-extension-contract.ts | 45 +++--- ...ession-binding-registry-backed-contract.ts | 39 +++-- .../bundled-provider-builders.ts | 106 ++++++++------ .../helpers/plugins/provider-auth-contract.ts | 20 ++- .../plugins/provider-discovery-contract.ts | 87 ++++++++---- .../plugins/provider-runtime-contract.ts | 58 ++++++-- test/helpers/providers/anthropic-contract.ts | 15 +- ...t-helper-extension-import-boundary.test.ts | 28 ++++ 18 files changed, 516 insertions(+), 153 deletions(-) create mode 100644 scripts/check-test-helper-extension-import-boundary.mjs create mode 100644 test/test-helper-extension-import-boundary.test.ts diff --git a/scripts/check-test-helper-extension-import-boundary.mjs b/scripts/check-test-helper-extension-import-boundary.mjs new file mode 100644 index 00000000000..4abac264f1d --- /dev/null +++ b/scripts/check-test-helper-extension-import-boundary.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs"; +import { + collectTypeScriptInventory, + normalizeRepoPath, + resolveRepoSpecifier, + visitModuleSpecifiers, + writeLine, +} from "./lib/guard-inventory-utils.mjs"; +import { + collectTypeScriptFilesFromRoots, + resolveSourceRoots, + runAsScript, + toLine, +} from "./lib/ts-guard-utils.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const scanRoots = resolveSourceRoots(repoRoot, ["test/helpers"]); +let cachedInventoryPromise = null; + +function compareEntries(left, right) { + return ( + left.file.localeCompare(right.file) || + left.line - right.line || + left.kind.localeCompare(right.kind) || + left.specifier.localeCompare(right.specifier) || + left.reason.localeCompare(right.reason) + ); +} + +function classifyResolvedExtensionReason(kind) { + const verb = + kind === "export" + ? "re-exports" + : kind === "dynamic-import" + ? "dynamically imports" + : "imports"; + return `${verb} bundled plugin file from test helper boundary`; +} + +function scanImportBoundaryViolations(sourceFile, filePath) { + const entries = []; + const relativeFile = normalizeRepoPath(repoRoot, filePath); + + visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => { + const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath); + if (!resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) { + return; + } + entries.push({ + file: relativeFile, + line: toLine(sourceFile, specifierNode), + kind, + specifier, + resolvedPath, + reason: classifyResolvedExtensionReason(kind), + }); + }); + + return entries; +} + +export async function collectTestHelperExtensionImportBoundaryInventory() { + if (cachedInventoryPromise) { + return cachedInventoryPromise; + } + + cachedInventoryPromise = (async () => { + const files = (await collectTypeScriptFilesFromRoots(scanRoots)).toSorted((left, right) => + normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)), + ); + return await collectTypeScriptInventory({ + ts, + files, + compareEntries, + collectEntries(sourceFile, filePath) { + return scanImportBoundaryViolations(sourceFile, filePath); + }, + }); + })(); + + try { + return await cachedInventoryPromise; + } catch (error) { + cachedInventoryPromise = null; + throw error; + } +} + +function formatInventoryHuman(inventory) { + if (inventory.length === 0) { + return "Rule: test/helpers/** must not import bundled plugin files directly\nNo test-helper import boundary violations found."; + } + + const lines = [ + "Rule: test/helpers/** must not import bundled plugin files directly", + "Test-helper extension import boundary inventory:", + ]; + let activeFile = ""; + for (const entry of inventory) { + if (entry.file !== activeFile) { + activeFile = entry.file; + lines.push(activeFile); + } + lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`); + lines.push(` specifier: ${entry.specifier}`); + lines.push(` resolved: ${entry.resolvedPath}`); + } + return lines.join("\n"); +} + +export async function main(argv = process.argv.slice(2), io) { + const streams = io ?? { stdout: process.stdout, stderr: process.stderr }; + const json = argv.includes("--json"); + const inventory = await collectTestHelperExtensionImportBoundaryInventory(); + + if (json) { + writeLine(streams.stdout, JSON.stringify(inventory, null, 2)); + } else { + writeLine(streams.stdout, formatInventoryHuman(inventory)); + writeLine( + streams.stdout, + inventory.length === 0 ? "Boundary is clean." : "Boundary has violations.", + ); + } + + return inventory.length === 0 ? 0 : 1; +} + +runAsScript(import.meta.url, main); diff --git a/src/media-generation/provider-capabilities.contract.test.ts b/src/media-generation/provider-capabilities.contract.test.ts index cbf2c6e9753..4ae6704b07f 100644 --- a/src/media-generation/provider-capabilities.contract.test.ts +++ b/src/media-generation/provider-capabilities.contract.test.ts @@ -24,8 +24,8 @@ function expectedBundledMusicProviderPluginIds(): string[] { } describe("bundled media-generation provider capabilities", () => { - it("declares explicit mode support for every bundled video-generation provider", () => { - const entries = loadBundledVideoGenerationProviders(); + it("declares explicit mode support for every bundled video-generation provider", async () => { + const entries = await loadBundledVideoGenerationProviders(); expect(entries.map((entry) => entry.pluginId).toSorted()).toEqual( expectedBundledVideoProviderPluginIds(), ); @@ -66,8 +66,8 @@ describe("bundled media-generation provider capabilities", () => { } }); - it("declares explicit generate/edit support for every bundled music-generation provider", () => { - const entries = loadBundledMusicGenerationProviders(); + it("declares explicit generate/edit support for every bundled music-generation provider", async () => { + const entries = await loadBundledMusicGenerationProviders(); expect(entries.map((entry) => entry.pluginId).toSorted()).toEqual( expectedBundledMusicProviderPluginIds(), ); diff --git a/src/test-utils/bundled-plugin-public-surface.ts b/src/test-utils/bundled-plugin-public-surface.ts index 3acdbe99526..e7ce1ed68a2 100644 --- a/src/test-utils/bundled-plugin-public-surface.ts +++ b/src/test-utils/bundled-plugin-public-surface.ts @@ -33,6 +33,27 @@ export function loadBundledPluginPublicSurfaceSync(params: { }); } +export function loadBundledPluginApiSync(pluginId: string): T { + return loadBundledPluginPublicSurfaceSync({ + pluginId, + artifactBasename: "api.js", + }); +} + +export function loadBundledPluginContractApiSync(pluginId: string): T { + return loadBundledPluginPublicSurfaceSync({ + pluginId, + artifactBasename: "contract-api.js", + }); +} + +export function loadBundledPluginRuntimeApiSync(pluginId: string): T { + return loadBundledPluginPublicSurfaceSync({ + pluginId, + artifactBasename: "runtime-api.js", + }); +} + export function loadBundledPluginTestApiSync(pluginId: string): T { return loadBundledPluginPublicSurfaceSync({ pluginId, @@ -40,20 +61,29 @@ export function loadBundledPluginTestApiSync(pluginId: string) }); } +export function resolveBundledPluginPublicModulePath(params: { + pluginId: string; + artifactBasename: string; +}): string { + const metadata = findBundledPluginMetadata(params.pluginId); + return path.resolve( + OPENCLAW_PACKAGE_ROOT, + "extensions", + metadata.dirName, + normalizeBundledPluginArtifactSubpath(params.artifactBasename), + ); +} + export function resolveRelativeBundledPluginPublicModuleId(params: { fromModuleUrl: string; pluginId: string; artifactBasename: string; }): string { - const metadata = findBundledPluginMetadata(params.pluginId); const fromFilePath = fileURLToPath(params.fromModuleUrl); - const artifactBasename = normalizeBundledPluginArtifactSubpath(params.artifactBasename); - const targetPath = path.resolve( - OPENCLAW_PACKAGE_ROOT, - "extensions", - metadata.dirName, - artifactBasename, - ); + const targetPath = resolveBundledPluginPublicModulePath({ + pluginId: params.pluginId, + artifactBasename: params.artifactBasename, + }); const relativePath = path .relative(path.dirname(fromFilePath), targetPath) .replaceAll(path.sep, "/"); diff --git a/test/helpers/channels/channel-media-roots-contract.ts b/test/helpers/channels/channel-media-roots-contract.ts index 61a86c14393..a2be8aa717d 100644 --- a/test/helpers/channels/channel-media-roots-contract.ts +++ b/test/helpers/channels/channel-media-roots-contract.ts @@ -1,5 +1,15 @@ +import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; + +type IMessageContractSurface = typeof import("@openclaw/imessage/contract-api.js"); + +const { + DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + resolveIMessageAttachmentRoots, + resolveIMessageRemoteAttachmentRoots, +} = loadBundledPluginContractApiSync("imessage"); + export { DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, resolveIMessageAttachmentRoots, resolveIMessageRemoteAttachmentRoots, -} from "../../../extensions/imessage/contract-api.js"; +}; diff --git a/test/helpers/channels/command-contract.ts b/test/helpers/channels/command-contract.ts index 1000ad9239a..9cbf78a4209 100644 --- a/test/helpers/channels/command-contract.ts +++ b/test/helpers/channels/command-contract.ts @@ -1,6 +1,22 @@ -export { buildTelegramModelsProviderChannelData } from "../../../extensions/telegram/contract-api.js"; -export { whatsappCommandPolicy } from "../../../extensions/whatsapp/contract-api.js"; +import { + loadBundledPluginApiSync, + loadBundledPluginContractApiSync, +} from "../../../src/test-utils/bundled-plugin-public-surface.js"; + +type TelegramContractSurface = typeof import("@openclaw/telegram/contract-api.js"); +type WhatsAppApiSurface = Pick< + typeof import("@openclaw/whatsapp/api.js"), + "isWhatsAppGroupJid" | "normalizeWhatsAppTarget" | "whatsappCommandPolicy" +>; + +const { buildTelegramModelsProviderChannelData } = + loadBundledPluginContractApiSync("telegram"); +const { isWhatsAppGroupJid, normalizeWhatsAppTarget, whatsappCommandPolicy } = + loadBundledPluginApiSync("whatsapp"); + export { + buildTelegramModelsProviderChannelData, isWhatsAppGroupJid, normalizeWhatsAppTarget, -} from "../../../extensions/whatsapp/contract-api.js"; + whatsappCommandPolicy, +}; diff --git a/test/helpers/channels/dm-policy-contract.ts b/test/helpers/channels/dm-policy-contract.ts index 413bb244f09..39ff6b6b01f 100644 --- a/test/helpers/channels/dm-policy-contract.ts +++ b/test/helpers/channels/dm-policy-contract.ts @@ -1,4 +1,13 @@ -export { - isSignalSenderAllowed, - type SignalSender, -} from "../../../extensions/signal/contract-api.js"; +import type { SignalSender } from "@openclaw/signal/contract-api.js"; +import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; + +type SignalContractApiSurface = Pick< + typeof import("@openclaw/signal/contract-api.js"), + "isSignalSenderAllowed" +>; + +const { isSignalSenderAllowed } = + loadBundledPluginContractApiSync("signal"); + +export { isSignalSenderAllowed }; +export type { SignalSender }; diff --git a/test/helpers/channels/group-policy-contract.ts b/test/helpers/channels/group-policy-contract.ts index e2dee13ce94..6f72549c8fd 100644 --- a/test/helpers/channels/group-policy-contract.ts +++ b/test/helpers/channels/group-policy-contract.ts @@ -1,5 +1,15 @@ -export { resolveWhatsAppRuntimeGroupPolicy } from "../../../extensions/whatsapp/test-api.js"; +import { loadBundledPluginTestApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; + +type WhatsAppTestSurface = typeof import("@openclaw/whatsapp/test-api.js"); +type ZaloTestSurface = typeof import("@openclaw/zalo/test-api.js"); + +const { resolveWhatsAppRuntimeGroupPolicy } = + loadBundledPluginTestApiSync("whatsapp"); +const { evaluateZaloGroupAccess, resolveZaloRuntimeGroupPolicy } = + loadBundledPluginTestApiSync("zalo"); + export { evaluateZaloGroupAccess, + resolveWhatsAppRuntimeGroupPolicy, resolveZaloRuntimeGroupPolicy, -} from "../../../extensions/zalo/test-api.js"; +}; diff --git a/test/helpers/channels/inbound-contract.slack.ts b/test/helpers/channels/inbound-contract.slack.ts index 13555a915f5..4dbd9cb6e40 100644 --- a/test/helpers/channels/inbound-contract.slack.ts +++ b/test/helpers/channels/inbound-contract.slack.ts @@ -1,11 +1,12 @@ import { expect, it } from "vitest"; -import type { ResolvedSlackAccount } from "../../../extensions/slack/api.js"; import type { MsgContext } from "../../../src/auto-reply/templating.js"; import { expectChannelInboundContextContract } from "../../../src/channels/plugins/contracts/test-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { withTempHome } from "../temp-home.js"; +type ResolvedSlackAccount = import("@openclaw/slack/api.js").ResolvedSlackAccount; + type SlackMessageEvent = { channel: string; channel_type?: string; diff --git a/test/helpers/channels/interactive-contract.ts b/test/helpers/channels/interactive-contract.ts index 581f069df9e..414c75f8728 100644 --- a/test/helpers/channels/interactive-contract.ts +++ b/test/helpers/channels/interactive-contract.ts @@ -1,12 +1,12 @@ export type { DiscordInteractiveHandlerContext, DiscordInteractiveHandlerRegistration, -} from "../../../extensions/discord/contract-api.js"; +} from "@openclaw/discord/contract-api.js"; export type { SlackInteractiveHandlerContext, SlackInteractiveHandlerRegistration, -} from "../../../extensions/slack/contract-api.js"; +} from "@openclaw/slack/contract-api.js"; export type { TelegramInteractiveHandlerContext, TelegramInteractiveHandlerRegistration, -} from "../../../extensions/telegram/contract-api.js"; +} from "@openclaw/telegram/contract-api.js"; diff --git a/test/helpers/channels/matrix-setup-contract.ts b/test/helpers/channels/matrix-setup-contract.ts index af72716c5d8..f5315d3a616 100644 --- a/test/helpers/channels/matrix-setup-contract.ts +++ b/test/helpers/channels/matrix-setup-contract.ts @@ -1 +1,8 @@ -export { matrixSetupAdapter, matrixSetupWizard } from "../../../extensions/matrix/contract-api.js"; +import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; + +type MatrixContractSurface = typeof import("@openclaw/matrix/contract-api.js"); + +const { matrixSetupAdapter, matrixSetupWizard } = + loadBundledPluginContractApiSync("matrix"); + +export { matrixSetupAdapter, matrixSetupWizard }; diff --git a/test/helpers/channels/plugins-core-extension-contract.ts b/test/helpers/channels/plugins-core-extension-contract.ts index 5532562047c..0492d3c0300 100644 --- a/test/helpers/channels/plugins-core-extension-contract.ts +++ b/test/helpers/channels/plugins-core-extension-contract.ts @@ -1,27 +1,4 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import { - listDiscordDirectoryGroupsFromConfig, - listDiscordDirectoryPeersFromConfig, - type DiscordProbe, - type DiscordTokenResolution, -} from "../../../extensions/discord/api.js"; -import type { IMessageProbe } from "../../../extensions/imessage/runtime-api.js"; -import type { SignalProbe } from "../../../extensions/signal/api.js"; -import { - listSlackDirectoryGroupsFromConfig, - listSlackDirectoryPeersFromConfig, - type SlackProbe, -} from "../../../extensions/slack/api.js"; -import { - listTelegramDirectoryGroupsFromConfig, - listTelegramDirectoryPeersFromConfig, - type TelegramProbe, - type TelegramTokenResolution, -} from "../../../extensions/telegram/api.js"; -import { - listWhatsAppDirectoryGroupsFromConfig, - listWhatsAppDirectoryPeersFromConfig, -} from "../../../extensions/whatsapp/api.js"; import type { BaseProbeResult, BaseTokenResolution, @@ -29,8 +6,30 @@ import type { } from "../../../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { LineProbeResult } from "../../../src/plugin-sdk/line.js"; +import { loadBundledPluginApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { withEnvAsync } from "../../../src/test-utils/env.js"; +type DiscordApiSurface = typeof import("@openclaw/discord/api.js"); +type DiscordProbe = import("@openclaw/discord/api.js").DiscordProbe; +type DiscordTokenResolution = import("@openclaw/discord/api.js").DiscordTokenResolution; +type IMessageProbe = import("@openclaw/imessage/runtime-api.js").IMessageProbe; +type SignalProbe = import("@openclaw/signal/api.js").SignalProbe; +type SlackApiSurface = typeof import("@openclaw/slack/api.js"); +type SlackProbe = import("@openclaw/slack/api.js").SlackProbe; +type TelegramApiSurface = typeof import("@openclaw/telegram/api.js"); +type TelegramProbe = import("@openclaw/telegram/api.js").TelegramProbe; +type TelegramTokenResolution = import("@openclaw/telegram/api.js").TelegramTokenResolution; +type WhatsAppApiSurface = typeof import("@openclaw/whatsapp/api.js"); + +const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } = + loadBundledPluginApiSync("discord"); +const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } = + loadBundledPluginApiSync("slack"); +const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } = + loadBundledPluginApiSync("telegram"); +const { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig } = + loadBundledPluginApiSync("whatsapp"); + type DirectoryListFn = (params: { cfg: OpenClawConfig; accountId?: string; diff --git a/test/helpers/channels/session-binding-registry-backed-contract.ts b/test/helpers/channels/session-binding-registry-backed-contract.ts index 0b6cf9f7037..64af080f586 100644 --- a/test/helpers/channels/session-binding-registry-backed-contract.ts +++ b/test/helpers/channels/session-binding-registry-backed-contract.ts @@ -1,14 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { bluebubblesPlugin } from "../../../extensions/bluebubbles/api.js"; -import { - discordPlugin, - discordThreadBindingTesting, -} from "../../../extensions/discord/test-api.js"; -import { feishuPlugin, feishuThreadBindingTesting } from "../../../extensions/feishu/api.js"; -import { imessagePlugin } from "../../../extensions/imessage/api.js"; -import { matrixPlugin, setMatrixRuntime } from "../../../extensions/matrix/test-api.js"; -import { telegramPlugin } from "../../../extensions/telegram/api.js"; -import { resetTelegramThreadBindingsForTests } from "../../../extensions/telegram/test-api.js"; import type { ChannelPlugin } from "../../../src/channels/plugins/types.js"; import { clearRuntimeConfigSnapshot, @@ -22,9 +12,35 @@ import { import { resetPluginRuntimeStateForTest } from "../../../src/plugins/runtime.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { PluginRuntime } from "../../../src/plugins/runtime/index.js"; +import { + loadBundledPluginApiSync, + loadBundledPluginTestApiSync, +} from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { createTestRegistry } from "../../../src/test-utils/channel-plugins.js"; import { getSessionBindingContractRegistry } from "./registry-session-binding.js"; +type BluebubblesApiSurface = typeof import("@openclaw/bluebubbles/api.js"); +type DiscordTestApiSurface = typeof import("@openclaw/discord/test-api.js"); +type FeishuApiSurface = typeof import("@openclaw/feishu/api.js"); +type IMessageApiSurface = typeof import("@openclaw/imessage/api.js"); +type MatrixApiSurface = typeof import("@openclaw/matrix/api.js"); +type MatrixTestApiSurface = typeof import("@openclaw/matrix/test-api.js"); +type TelegramApiSurface = typeof import("@openclaw/telegram/api.js"); +type TelegramTestApiSurface = typeof import("@openclaw/telegram/test-api.js"); + +const { bluebubblesPlugin } = loadBundledPluginApiSync("bluebubbles"); +const { discordPlugin, discordThreadBindingTesting } = + loadBundledPluginTestApiSync("discord"); +const { feishuPlugin, feishuThreadBindingTesting } = + loadBundledPluginApiSync("feishu"); +const { imessagePlugin } = loadBundledPluginApiSync("imessage"); +const { resetMatrixThreadBindingsForTests } = loadBundledPluginApiSync("matrix"); +const { matrixPlugin, setMatrixRuntime } = + loadBundledPluginTestApiSync("matrix"); +const { telegramPlugin } = loadBundledPluginApiSync("telegram"); +const { resetTelegramThreadBindingsForTests } = + loadBundledPluginTestApiSync("telegram"); + type DiscordThreadBindingTesting = { resetThreadBindingsForTests: () => void; }; @@ -72,8 +88,7 @@ async function getFeishuThreadBindingTesting() { } async function getResetMatrixThreadBindingsForTests() { - const matrixApi = await import("../../../extensions/matrix/api.js"); - return matrixApi.resetMatrixThreadBindingsForTests; + return resetMatrixThreadBindingsForTests; } function resolveSessionBindingContractRuntimeConfig(id: string) { diff --git a/test/helpers/media-generation/bundled-provider-builders.ts b/test/helpers/media-generation/bundled-provider-builders.ts index 88acbb38548..65b388de7ab 100644 --- a/test/helpers/media-generation/bundled-provider-builders.ts +++ b/test/helpers/media-generation/bundled-provider-builders.ts @@ -1,52 +1,78 @@ -import { buildAlibabaVideoGenerationProvider } from "../../../extensions/alibaba/video-generation-provider.js"; -import { buildBytePlusVideoGenerationProvider } from "../../../extensions/byteplus/video-generation-provider.js"; -import { buildComfyMusicGenerationProvider } from "../../../extensions/comfy/music-generation-provider.js"; -import { buildComfyVideoGenerationProvider } from "../../../extensions/comfy/video-generation-provider.js"; -import { buildFalVideoGenerationProvider } from "../../../extensions/fal/video-generation-provider.js"; -import { buildGoogleMusicGenerationProvider } from "../../../extensions/google/music-generation-provider.js"; -import { buildGoogleVideoGenerationProvider } from "../../../extensions/google/video-generation-provider.js"; -import { buildMinimaxMusicGenerationProvider } from "../../../extensions/minimax/music-generation-provider.js"; -import { buildMinimaxVideoGenerationProvider } from "../../../extensions/minimax/video-generation-provider.js"; -import { buildOpenAIVideoGenerationProvider } from "../../../extensions/openai/video-generation-provider.js"; -import { buildQwenVideoGenerationProvider } from "../../../extensions/qwen/video-generation-provider.js"; -import { buildRunwayVideoGenerationProvider } from "../../../extensions/runway/video-generation-provider.js"; -import { buildTogetherVideoGenerationProvider } from "../../../extensions/together/video-generation-provider.js"; -import { buildVydraVideoGenerationProvider } from "../../../extensions/vydra/video-generation-provider.js"; -import { buildXaiVideoGenerationProvider } from "../../../extensions/xai/video-generation-provider.js"; -import type { MusicGenerationProvider } from "../../../src/music-generation/types.js"; -import type { VideoGenerationProvider } from "../../../src/video-generation/types.js"; +import type { + MusicGenerationProviderPlugin, + OpenClawPluginApi, + VideoGenerationProviderPlugin, +} from "../../../src/plugins/types.js"; +import { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; +import { registerProviderPlugin } from "../plugins/provider-registration.js"; + +type BundledPluginEntryModule = { + default: { + register(api: OpenClawPluginApi): void | Promise; + }; +}; export type BundledVideoProviderEntry = { pluginId: string; - provider: VideoGenerationProvider; + provider: VideoGenerationProviderPlugin; }; export type BundledMusicProviderEntry = { pluginId: string; - provider: MusicGenerationProvider; + provider: MusicGenerationProviderPlugin; }; -export function loadBundledVideoGenerationProviders(): BundledVideoProviderEntry[] { - return [ - { pluginId: "alibaba", provider: buildAlibabaVideoGenerationProvider() }, - { pluginId: "byteplus", provider: buildBytePlusVideoGenerationProvider() }, - { pluginId: "comfy", provider: buildComfyVideoGenerationProvider() }, - { pluginId: "fal", provider: buildFalVideoGenerationProvider() }, - { pluginId: "google", provider: buildGoogleVideoGenerationProvider() }, - { pluginId: "minimax", provider: buildMinimaxVideoGenerationProvider() }, - { pluginId: "openai", provider: buildOpenAIVideoGenerationProvider() }, - { pluginId: "qwen", provider: buildQwenVideoGenerationProvider() }, - { pluginId: "runway", provider: buildRunwayVideoGenerationProvider() }, - { pluginId: "together", provider: buildTogetherVideoGenerationProvider() }, - { pluginId: "vydra", provider: buildVydraVideoGenerationProvider() }, - { pluginId: "xai", provider: buildXaiVideoGenerationProvider() }, - ]; +const BUNDLED_VIDEO_PROVIDER_PLUGIN_IDS = [ + "alibaba", + "byteplus", + "comfy", + "fal", + "google", + "minimax", + "openai", + "qwen", + "runway", + "together", + "vydra", + "xai", +] as const; + +const BUNDLED_MUSIC_PROVIDER_PLUGIN_IDS = ["comfy", "google", "minimax"] as const; + +function loadBundledPluginEntry(pluginId: string): BundledPluginEntryModule { + return loadBundledPluginPublicSurfaceSync({ + pluginId, + artifactBasename: "index.js", + }); } -export function loadBundledMusicGenerationProviders(): BundledMusicProviderEntry[] { - return [ - { pluginId: "comfy", provider: buildComfyMusicGenerationProvider() }, - { pluginId: "google", provider: buildGoogleMusicGenerationProvider() }, - { pluginId: "minimax", provider: buildMinimaxMusicGenerationProvider() }, - ]; +async function registerBundledMediaPlugin(pluginId: string) { + const { default: plugin } = loadBundledPluginEntry(pluginId); + return await registerProviderPlugin({ + plugin, + id: pluginId, + name: pluginId, + }); +} + +export async function loadBundledVideoGenerationProviders(): Promise { + return ( + await Promise.all( + BUNDLED_VIDEO_PROVIDER_PLUGIN_IDS.map(async (pluginId) => { + const { videoProviders } = await registerBundledMediaPlugin(pluginId); + return videoProviders.map((provider) => ({ pluginId, provider })); + }), + ) + ).flat(); +} + +export async function loadBundledMusicGenerationProviders(): Promise { + return ( + await Promise.all( + BUNDLED_MUSIC_PROVIDER_PLUGIN_IDS.map(async (pluginId) => { + const { musicProviders } = await registerBundledMediaPlugin(pluginId); + return musicProviders.map((provider) => ({ pluginId, provider })); + }), + ) + ).flat(); } diff --git a/test/helpers/plugins/provider-auth-contract.ts b/test/helpers/plugins/provider-auth-contract.ts index a34d4a9536a..46d0503198c 100644 --- a/test/helpers/plugins/provider-auth-contract.ts +++ b/test/helpers/plugins/provider-auth-contract.ts @@ -2,6 +2,7 @@ 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 { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import type { WizardMultiSelectParams, WizardPrompter, @@ -25,13 +26,18 @@ const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn( const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); const listProfilesForProviderMock = vi.hoisted(() => vi.fn()); -const providerAuthContractModules = vi.hoisted(() => ({ - githubCopilotIndexModuleUrl: new URL( - "../../../extensions/github-copilot/index.ts", - import.meta.url, - ).href, - openAIIndexModuleUrl: new URL("../../../extensions/openai/index.ts", import.meta.url).href, -})); +const providerAuthContractModules = { + githubCopilotIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "github-copilot", + artifactBasename: "index.js", + }), + openAIIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "openai", + artifactBasename: "index.js", + }), +}; vi.mock("openclaw/plugin-sdk/provider-auth-login", async () => { const actual = await vi.importActual( diff --git a/test/helpers/plugins/provider-discovery-contract.ts b/test/helpers/plugins/provider-discovery-contract.ts index 7c7142e58d5..8783701fed7 100644 --- a/test/helpers/plugins/provider-discovery-contract.ts +++ b/test/helpers/plugins/provider-discovery-contract.ts @@ -2,6 +2,10 @@ import { afterEach, 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 { ModelDefinitionConfig } from "../../../src/config/types.models.js"; +import { + resolveBundledPluginPublicModulePath, + resolveRelativeBundledPluginPublicModuleId, +} from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { registerProviders, requireProvider } from "./contracts-testkit.js"; const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn()); @@ -10,32 +14,59 @@ const buildVllmProviderMock = vi.hoisted(() => vi.fn()); const buildSglangProviderMock = vi.hoisted(() => vi.fn()); const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); const listProfilesForProviderMock = vi.hoisted(() => vi.fn()); -const bundledProviderModules = vi.hoisted(() => ({ - cloudflareAiGatewayIndexModuleUrl: new URL( - "../../../extensions/cloudflare-ai-gateway/index.ts", - import.meta.url, - ).href, - cloudflareAiGatewayIndexModuleId: new URL( - "../../../extensions/cloudflare-ai-gateway/index.js", - import.meta.url, - ).pathname, - githubCopilotIndexModuleUrl: new URL( - "../../../extensions/github-copilot/index.ts", - import.meta.url, - ).href, - githubCopilotTokenModuleId: new URL( - "../../../extensions/github-copilot/token.js", - import.meta.url, - ).pathname, - minimaxIndexModuleUrl: new URL("../../../extensions/minimax/index.ts", import.meta.url).href, - qwenIndexModuleUrl: new URL("../../../extensions/qwen/index.ts", import.meta.url).href, - ollamaApiModuleId: new URL("../../../extensions/ollama/api.js", import.meta.url).pathname, - ollamaIndexModuleUrl: new URL("../../../extensions/ollama/index.ts", import.meta.url).href, - sglangApiModuleId: new URL("../../../extensions/sglang/api.js", import.meta.url).pathname, - sglangIndexModuleUrl: new URL("../../../extensions/sglang/index.ts", import.meta.url).href, - vllmApiModuleId: new URL("../../../extensions/vllm/api.js", import.meta.url).pathname, - vllmIndexModuleUrl: new URL("../../../extensions/vllm/index.ts", import.meta.url).href, -})); +const bundledProviderModules = { + cloudflareAiGatewayIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "cloudflare-ai-gateway", + artifactBasename: "index.js", + }), + githubCopilotIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "github-copilot", + artifactBasename: "index.js", + }), + githubCopilotRegisterRuntimeModuleId: resolveBundledPluginPublicModulePath({ + pluginId: "github-copilot", + artifactBasename: "register.runtime.js", + }), + minimaxIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "minimax", + artifactBasename: "index.js", + }), + qwenIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "qwen", + artifactBasename: "index.js", + }), + ollamaApiModuleId: resolveBundledPluginPublicModulePath({ + pluginId: "ollama", + artifactBasename: "api.js", + }), + ollamaIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "ollama", + artifactBasename: "index.js", + }), + sglangApiModuleId: resolveBundledPluginPublicModulePath({ + pluginId: "sglang", + artifactBasename: "api.js", + }), + sglangIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "sglang", + artifactBasename: "index.js", + }), + vllmApiModuleId: resolveBundledPluginPublicModulePath({ + pluginId: "vllm", + artifactBasename: "api.js", + }), + vllmIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "vllm", + artifactBasename: "index.js", + }), +}; type ProviderHandle = Awaited>; @@ -186,9 +217,9 @@ function installDiscoveryHooks( validateApiKeyInput: () => undefined, }; }); - vi.doMock(bundledProviderModules.githubCopilotTokenModuleId, async () => { + vi.doMock(bundledProviderModules.githubCopilotRegisterRuntimeModuleId, async () => { const actual = await vi.importActual( - bundledProviderModules.githubCopilotTokenModuleId, + bundledProviderModules.githubCopilotRegisterRuntimeModuleId, ); return { ...actual, diff --git a/test/helpers/plugins/provider-runtime-contract.ts b/test/helpers/plugins/provider-runtime-contract.ts index 2675ed3892a..6f8c23db4e4 100644 --- a/test/helpers/plugins/provider-runtime-contract.ts +++ b/test/helpers/plugins/provider-runtime-contract.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { StreamFn } from "@mariozechner/pi-agent-core"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin, ProviderRuntimeModel } from "../../../src/plugins/types.js"; +import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { createProviderUsageFetch, makeResponse, @@ -20,17 +21,48 @@ const getOAuthProvidersMock = vi.hoisted(() => { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, ]), ); -const providerRuntimeContractModules = vi.hoisted(() => ({ - anthropicIndexModuleId: "../../../extensions/anthropic/index.ts", - githubCopilotIndexModuleId: "../../../extensions/github-copilot/index.ts", - googleIndexModuleId: "../../../extensions/google/index.ts", - openAIIndexModuleId: "../../../extensions/openai/index.ts", - openAICodexProviderRuntimeModuleId: "../../../extensions/openai/openai-codex-provider.runtime.js", - openRouterIndexModuleId: "../../../extensions/openrouter/index.ts", - veniceIndexModuleId: "../../../extensions/venice/index.ts", - xAIIndexModuleId: "../../../extensions/xai/index.ts", - zaiIndexModuleId: "../../../extensions/zai/index.ts", -})); +const providerRuntimeContractModules = { + anthropicIndexModuleId: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "anthropic", + artifactBasename: "index.js", + }), + githubCopilotIndexModuleId: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "github-copilot", + artifactBasename: "index.js", + }), + googleIndexModuleId: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "google", + artifactBasename: "index.js", + }), + openAIIndexModuleId: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "openai", + artifactBasename: "index.js", + }), + openRouterIndexModuleId: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "openrouter", + artifactBasename: "index.js", + }), + veniceIndexModuleId: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "venice", + artifactBasename: "index.js", + }), + xAIIndexModuleId: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "xai", + artifactBasename: "index.js", + }), + zaiIndexModuleId: resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "zai", + artifactBasename: "index.js", + }), +}; vi.mock("@mariozechner/pi-ai/oauth", async () => { const actual = await vi.importActual( @@ -43,10 +75,6 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => { }; }); -vi.mock(providerRuntimeContractModules.openAICodexProviderRuntimeModuleId, () => ({ - refreshOpenAICodexToken: refreshOpenAICodexTokenMock, -})); - async function importBundledProviderPlugin(moduleUrl: string): Promise { return (await import(moduleUrl)) as T; } diff --git a/test/helpers/providers/anthropic-contract.ts b/test/helpers/providers/anthropic-contract.ts index f84d0592c0a..923dbb04992 100644 --- a/test/helpers/providers/anthropic-contract.ts +++ b/test/helpers/providers/anthropic-contract.ts @@ -1,3 +1,16 @@ +import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; + +type AnthropicContractSurface = typeof import("@openclaw/anthropic/contract-api.js"); + +const { + createAnthropicBetaHeadersWrapper, + createAnthropicFastModeWrapper, + createAnthropicServiceTierWrapper, + resolveAnthropicBetas, + resolveAnthropicFastMode, + resolveAnthropicServiceTier, +} = loadBundledPluginContractApiSync("anthropic"); + export { createAnthropicBetaHeadersWrapper, createAnthropicFastModeWrapper, @@ -5,4 +18,4 @@ export { resolveAnthropicBetas, resolveAnthropicFastMode, resolveAnthropicServiceTier, -} from "../../../extensions/anthropic/contract-api.js"; +}; diff --git a/test/test-helper-extension-import-boundary.test.ts b/test/test-helper-extension-import-boundary.test.ts new file mode 100644 index 00000000000..1e0a3d73f0c --- /dev/null +++ b/test/test-helper-extension-import-boundary.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + collectTestHelperExtensionImportBoundaryInventory, + main, +} from "../scripts/check-test-helper-extension-import-boundary.mjs"; +import { createCapturedIo } from "./helpers/captured-io.js"; + +describe("test-helper extension import boundary inventory", () => { + it("stays empty", async () => { + expect(await collectTestHelperExtensionImportBoundaryInventory()).toEqual([]); + }); + + it("produces stable sorted output", async () => { + const first = await collectTestHelperExtensionImportBoundaryInventory(); + const second = await collectTestHelperExtensionImportBoundaryInventory(); + + expect(second).toEqual(first); + }); + + it("script json output stays empty", async () => { + const captured = createCapturedIo(); + const exitCode = await main(["--json"], captured.io); + + expect(exitCode).toBe(0); + expect(captured.readStderr()).toBe(""); + expect(JSON.parse(captured.readStdout())).toEqual([]); + }); +});