From fd1b965f2b7a2d42104cdefa2351d243981101ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 16:42:34 -0700 Subject: [PATCH] refactor(plugins): remove orphan test API barrels (#121761) * test(plugins): remove orphan test API barrels * refactor(plugins): remove orphan test-only exports * refactor(imessage): remove orphan test plugin * test(plugins): remove stale package guard timeout --- extensions/anthropic/test-api.ts | 7 - extensions/deepgram/test-api.ts | 3 - .../media-understanding-provider.test.ts | 11 +- .../media-understanding-provider.ts | 2 +- extensions/elevenlabs/test-api.ts | 7 - extensions/fal/test-api.ts | 4 - extensions/googlechat/test-api.ts | 3 - extensions/groq/test-api.ts | 2 - .../imessage/src/imessage.test-plugin.ts | 173 ---------------- extensions/imessage/src/test-plugin.test.ts | 57 +---- extensions/imessage/test-api.ts | 2 - extensions/microsoft/test-api.ts | 2 - extensions/mistral/test-api.ts | 3 - extensions/msteams/test-api.ts | 2 - extensions/nostr/test-api.ts | 2 - extensions/openai/test-api.ts | 7 - extensions/openrouter/test-api.ts | 5 - extensions/qwen/test-api.ts | 3 - extensions/senseaudio/test-api.ts | 2 - extensions/telegram/test-api.ts | 3 - extensions/tlon/test-api.ts | 2 - extensions/whatsapp/test-api.ts | 3 - extensions/zai/test-api.ts | 2 - extensions/zalo/test-api.ts | 2 - .../zalouser/src/accounts.test-mocks.ts | 1 - extensions/zalouser/src/accounts.test.ts | 26 +-- extensions/zalouser/src/accounts.ts | 13 -- extensions/zalouser/test-api.ts | 22 -- .../extension-test-api-consumption.test.ts | 196 ++++++++++++++++++ ...in-sdk-package-contract-guardrails.test.ts | 109 ---------- 30 files changed, 206 insertions(+), 470 deletions(-) delete mode 100644 extensions/anthropic/test-api.ts delete mode 100644 extensions/deepgram/test-api.ts delete mode 100644 extensions/elevenlabs/test-api.ts delete mode 100644 extensions/fal/test-api.ts delete mode 100644 extensions/googlechat/test-api.ts delete mode 100644 extensions/groq/test-api.ts delete mode 100644 extensions/imessage/src/imessage.test-plugin.ts delete mode 100644 extensions/imessage/test-api.ts delete mode 100644 extensions/microsoft/test-api.ts delete mode 100644 extensions/mistral/test-api.ts delete mode 100644 extensions/msteams/test-api.ts delete mode 100644 extensions/nostr/test-api.ts delete mode 100644 extensions/openai/test-api.ts delete mode 100644 extensions/openrouter/test-api.ts delete mode 100644 extensions/qwen/test-api.ts delete mode 100644 extensions/senseaudio/test-api.ts delete mode 100644 extensions/telegram/test-api.ts delete mode 100644 extensions/tlon/test-api.ts delete mode 100644 extensions/whatsapp/test-api.ts delete mode 100644 extensions/zai/test-api.ts delete mode 100644 extensions/zalo/test-api.ts delete mode 100644 extensions/zalouser/test-api.ts create mode 100644 src/plugins/contracts/extension-test-api-consumption.test.ts diff --git a/extensions/anthropic/test-api.ts b/extensions/anthropic/test-api.ts deleted file mode 100644 index 714f3e18d2b4..000000000000 --- a/extensions/anthropic/test-api.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Test API barrel for Anthropic plugin internals. Tests import this path to - * avoid reaching into unrelated runtime modules. - */ -export { buildAnthropicCliBackend } from "./cli-backend.js"; -export { normalizeClaudeBackendConfig } from "./cli-shared.js"; -export { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; diff --git a/extensions/deepgram/test-api.ts b/extensions/deepgram/test-api.ts deleted file mode 100644 index 1414debf70c2..000000000000 --- a/extensions/deepgram/test-api.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Deepgram API module exposes the plugin public contract. -export { deepgramMediaUnderstandingProvider } from "./media-understanding-provider.js"; -export { buildDeepgramRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; diff --git a/extensions/elevenlabs/media-understanding-provider.test.ts b/extensions/elevenlabs/media-understanding-provider.test.ts index 5f0f501ac37a..8f09d64cfa73 100644 --- a/extensions/elevenlabs/media-understanding-provider.test.ts +++ b/extensions/elevenlabs/media-understanding-provider.test.ts @@ -1,10 +1,7 @@ // Elevenlabs tests cover media understanding provider plugin behavior. import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - elevenLabsMediaUnderstandingProvider, - transcribeElevenLabsAudio, -} from "./media-understanding-provider.js"; +import { elevenLabsMediaUnderstandingProvider } from "./media-understanding-provider.js"; function requireFirstFetchCall(fetchMock: ReturnType): [string, RequestInit] { const [call] = fetchMock.mock.calls; @@ -38,7 +35,7 @@ describe("elevenLabsMediaUnderstandingProvider", () => { .fn() .mockResolvedValue(new Response(JSON.stringify({ text: "hello" }))); - const result = await transcribeElevenLabsAudio({ + const result = await elevenLabsMediaUnderstandingProvider.transcribeAudio!({ buffer: Buffer.from("audio"), fileName: "voice.mp3", mime: "audio/mpeg", @@ -66,7 +63,7 @@ describe("elevenLabsMediaUnderstandingProvider", () => { const fetchMock = vi.fn().mockResolvedValue(new Response("{ nope")); await expect( - transcribeElevenLabsAudio({ + elevenLabsMediaUnderstandingProvider.transcribeAudio!({ buffer: Buffer.from("audio"), fileName: "voice.mp3", mime: "audio/mpeg", @@ -82,7 +79,7 @@ describe("elevenLabsMediaUnderstandingProvider", () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify([]))); await expect( - transcribeElevenLabsAudio({ + elevenLabsMediaUnderstandingProvider.transcribeAudio!({ buffer: Buffer.from("audio"), fileName: "voice.mp3", mime: "audio/mpeg", diff --git a/extensions/elevenlabs/media-understanding-provider.ts b/extensions/elevenlabs/media-understanding-provider.ts index 4c13b671db00..68a72f07b9a9 100644 --- a/extensions/elevenlabs/media-understanding-provider.ts +++ b/extensions/elevenlabs/media-understanding-provider.ts @@ -16,7 +16,7 @@ import { DEFAULT_ELEVENLABS_BASE_URL, normalizeElevenLabsBaseUrl } from "./share const DEFAULT_ELEVENLABS_STT_MODEL = "scribe_v2"; -export async function transcribeElevenLabsAudio( +async function transcribeElevenLabsAudio( req: AudioTranscriptionRequest, ): Promise { const fetchFn = req.fetchFn ?? fetch; diff --git a/extensions/elevenlabs/test-api.ts b/extensions/elevenlabs/test-api.ts deleted file mode 100644 index 717faaf0084f..000000000000 --- a/extensions/elevenlabs/test-api.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Elevenlabs API module exposes the plugin public contract. -export { - elevenLabsMediaUnderstandingProvider, - transcribeElevenLabsAudio, -} from "./media-understanding-provider.js"; -export { buildElevenLabsRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; -export { buildElevenLabsSpeechProvider } from "./speech-provider.js"; diff --git a/extensions/fal/test-api.ts b/extensions/fal/test-api.ts deleted file mode 100644 index 8ac3da164498..000000000000 --- a/extensions/fal/test-api.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Fal API module exposes the plugin public contract. -export { buildFalImageGenerationProvider } from "./image-generation-provider.js"; -export { buildFalMusicGenerationProvider } from "./music-generation-provider.js"; -export { buildFalVideoGenerationProvider } from "./video-generation-provider.js"; diff --git a/extensions/googlechat/test-api.ts b/extensions/googlechat/test-api.ts deleted file mode 100644 index 168ec2b2813a..000000000000 --- a/extensions/googlechat/test-api.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Googlechat API module exposes the plugin public contract. -export { googlechatPlugin } from "./src/channel.js"; -export { setGoogleChatRuntime } from "./src/runtime.js"; diff --git a/extensions/groq/test-api.ts b/extensions/groq/test-api.ts deleted file mode 100644 index 4f07afeb1f59..000000000000 --- a/extensions/groq/test-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Groq API module exposes the plugin public contract. -export { groqMediaUnderstandingProvider } from "./media-understanding-provider.js"; diff --git a/extensions/imessage/src/imessage.test-plugin.ts b/extensions/imessage/src/imessage.test-plugin.ts deleted file mode 100644 index b023dcf83d90..000000000000 --- a/extensions/imessage/src/imessage.test-plugin.ts +++ /dev/null @@ -1,173 +0,0 @@ -// Imessage plugin module implements imessage plugin behavior. -import type { - ChannelMessageActionAdapter, - ChannelMessageActionName, - ChannelOutboundAdapter, -} from "openclaw/plugin-sdk/channel-contract"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-outbound"; -import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; -import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-helpers"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; - -function normalizeIMessageTestHandle(raw: string): string { - let trimmed = raw.trim(); - if (!trimmed) { - return ""; - } - - while (trimmed) { - const lowered = normalizeLowercaseStringOrEmpty(trimmed); - if (lowered.startsWith("imessage:")) { - trimmed = trimmed.slice("imessage:".length).trim(); - continue; - } - if (lowered.startsWith("sms:")) { - trimmed = trimmed.slice("sms:".length).trim(); - continue; - } - if (lowered.startsWith("auto:")) { - trimmed = trimmed.slice("auto:".length).trim(); - continue; - } - break; - } - - if (!trimmed) { - return ""; - } - - if (/^(chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) { - return trimmed.replace(/^(chat_id:|chat_guid:|chat_identifier:)/i, (match) => - normalizeLowercaseStringOrEmpty(match), - ); - } - if (trimmed.includes("@")) { - return normalizeLowercaseStringOrEmpty(trimmed); - } - const digits = trimmed.replace(/[^\d+]/g, ""); - if (digits) { - return digits.startsWith("+") ? `+${digits.slice(1)}` : `+${digits}`; - } - return trimmed.replace(/\s+/g, ""); -} - -const defaultIMessageOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - deliveryCapabilities: { - durableFinal: { - text: true, - media: true, - replyTo: true, - messageSendingHooks: true, - }, - }, - sendText: async ({ to, text, accountId, replyToId, deps, cfg }) => { - const sendIMessage = resolveOutboundSendDep< - ( - target: string, - content: string, - opts?: Record, - ) => Promise<{ messageId: string }> - >(deps, "imessage"); - const result = await sendIMessage?.(to, text, { - config: cfg, - accountId: accountId ?? undefined, - replyToId: replyToId ?? undefined, - }); - return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" }; - }, - sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, deps, cfg, mediaLocalRoots }) => { - const sendIMessage = resolveOutboundSendDep< - ( - target: string, - content: string, - opts?: Record, - ) => Promise<{ messageId: string }> - >(deps, "imessage"); - const result = await sendIMessage?.(to, text, { - config: cfg, - mediaUrl, - accountId: accountId ?? undefined, - replyToId: replyToId ?? undefined, - mediaLocalRoots, - }); - return { channel: "imessage", messageId: result?.messageId ?? "imessage-test-stub" }; - }, -}; - -const defaultIMessageActions: ChannelMessageActionAdapter = { - describeMessageTool: () => ({ - actions: [ - "react", - "edit", - "unsend", - "reply", - "sendWithEffect", - "upload-file", - "renameGroup", - "setGroupIcon", - "addParticipant", - "removeParticipant", - "leaveGroup", - ], - }), - supportsAction: ({ action }) => - new Set([ - "react", - "edit", - "unsend", - "reply", - "sendWithEffect", - "upload-file", - "sendAttachment", - "renameGroup", - "setGroupIcon", - "addParticipant", - "removeParticipant", - "leaveGroup", - ]).has(action), -}; - -export const createIMessageTestPlugin = (params?: { - outbound?: ChannelOutboundAdapter; - actions?: ChannelMessageActionAdapter; -}): ChannelPlugin => ({ - id: "imessage", - meta: { - id: "imessage", - label: "iMessage", - selectionLabel: "iMessage (imsg)", - docsPath: "/channels/imessage", - blurb: "iMessage test stub.", - aliases: ["imsg"], - }, - capabilities: { chatTypes: ["direct", "group"], media: true }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - status: { - collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts), - }, - actions: params?.actions ?? defaultIMessageActions, - outbound: params?.outbound ?? defaultIMessageOutbound, - messaging: { - targetResolver: { - looksLikeId: (raw) => { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - if (/^(imessage:|sms:|auto:|chat_id:|chat_guid:|chat_identifier:)/i.test(trimmed)) { - return true; - } - if (trimmed.includes("@")) { - return true; - } - return /^\+?\d{3,}$/.test(trimmed); - }, - hint: "", - }, - normalizeTarget: (raw) => normalizeIMessageTestHandle(raw), - }, -}); diff --git a/extensions/imessage/src/test-plugin.test.ts b/extensions/imessage/src/test-plugin.test.ts index de062c6e846b..9c23df1649c9 100644 --- a/extensions/imessage/src/test-plugin.test.ts +++ b/extensions/imessage/src/test-plugin.test.ts @@ -15,32 +15,22 @@ import { } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { drainPendingDeliveries } from "openclaw/plugin-sdk/delivery-queue-runtime"; -import { - listImportedBundledPluginFacadeIds, - resetFacadeRuntimeStateForTest, -} from "openclaw/plugin-sdk/plugin-test-runtime"; import { withStateDirEnv } from "openclaw/plugin-sdk/test-env"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearIMessageApprovalReactionTargetsForTest, resolveIMessageApprovalReactionTargetWithPersistence, } from "./approval-reactions.js"; import { imessagePlugin } from "./channel.js"; import type { IMessageRpcClient } from "./client.js"; -import { createIMessageTestPlugin } from "./imessage.test-plugin.js"; import { extractMarkdownFormatRuns } from "./markdown-format.js"; import { sendMessageIMessage } from "./send.js"; beforeEach(() => { - resetFacadeRuntimeStateForTest(); clearIMessageApprovalReactionTargetsForTest(); }); -afterEach(() => { - resetFacadeRuntimeStateForTest(); -}); - -type IMessageOutbound = NonNullable["outbound"]>; +type IMessageOutbound = NonNullable; type IMessageMessageAdapter = NonNullable; type IMessageMessageSender = NonNullable; const IMESSAGE_WORKSPACE_PNG = Buffer.from( @@ -49,7 +39,7 @@ const IMESSAGE_WORKSPACE_PNG = Buffer.from( ); function requireOutbound(): IMessageOutbound { - const outbound = createIMessageTestPlugin().outbound; + const outbound = imessagePlugin.outbound; if (!outbound) { throw new Error("Expected iMessage test plugin outbound adapter"); } @@ -104,22 +94,7 @@ function requireMessageSendMedia( return media; } -describe("createIMessageTestPlugin", () => { - it("does not load the bundled iMessage facade by default", () => { - expect(listImportedBundledPluginFacadeIds()).toStrictEqual([]); - - createIMessageTestPlugin(); - - expect(listImportedBundledPluginFacadeIds()).toStrictEqual([]); - }); - - it("normalizes repeated transport prefixes without recursive stack growth", () => { - const plugin = createIMessageTestPlugin(); - const prefixedHandle = `${"imessage:".repeat(5000)}+44 20 7946 0958`; - - expect(plugin.messaging?.normalizeTarget?.(prefixedHandle)).toBe("+442079460958"); - }); - +describe("imessagePlugin contracts", () => { it("declares durable final delivery capabilities", () => { expect(imessagePlugin.outbound?.deliveryCapabilities?.durableFinal).toStrictEqual({ text: true, @@ -127,12 +102,6 @@ describe("createIMessageTestPlugin", () => { replyTo: true, messageSendingHooks: true, }); - expect(createIMessageTestPlugin().outbound?.deliveryCapabilities?.durableFinal).toStrictEqual({ - text: true, - media: true, - replyTo: true, - messageSendingHooks: true, - }); }); it("preserves sanitized HTML formatting as native ranges", () => { @@ -705,22 +674,4 @@ describe("createIMessageTestPlugin", () => { resetPluginRuntimeStateForTest(); } }); - - it("exposes seeded private API actions for binding contract tests", () => { - const plugin = createIMessageTestPlugin(); - - expect(plugin.actions?.describeMessageTool({} as never)?.actions).toStrictEqual([ - "react", - "edit", - "unsend", - "reply", - "sendWithEffect", - "upload-file", - "renameGroup", - "setGroupIcon", - "addParticipant", - "removeParticipant", - "leaveGroup", - ]); - }); }); diff --git a/extensions/imessage/test-api.ts b/extensions/imessage/test-api.ts deleted file mode 100644 index 0335891bc32b..000000000000 --- a/extensions/imessage/test-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Imessage API module exposes the plugin public contract. -export { createIMessageTestPlugin } from "./src/imessage.test-plugin.js"; diff --git a/extensions/microsoft/test-api.ts b/extensions/microsoft/test-api.ts deleted file mode 100644 index c9c584ca6551..000000000000 --- a/extensions/microsoft/test-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Microsoft API module exposes the plugin public contract. -export { buildMicrosoftSpeechProvider } from "./speech-provider.js"; diff --git a/extensions/mistral/test-api.ts b/extensions/mistral/test-api.ts deleted file mode 100644 index 2b08d78c1f23..000000000000 --- a/extensions/mistral/test-api.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Mistral API module exposes the plugin public contract. -export { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js"; -export { buildMistralRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; diff --git a/extensions/msteams/test-api.ts b/extensions/msteams/test-api.ts deleted file mode 100644 index 761777f53ced..000000000000 --- a/extensions/msteams/test-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Msteams API module exposes the plugin public contract. -export { msteamsPlugin } from "./src/channel.js"; diff --git a/extensions/nostr/test-api.ts b/extensions/nostr/test-api.ts deleted file mode 100644 index 603b98228536..000000000000 --- a/extensions/nostr/test-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Nostr API module exposes the plugin public contract. -export { nostrPlugin } from "./src/channel.js"; diff --git a/extensions/openai/test-api.ts b/extensions/openai/test-api.ts deleted file mode 100644 index 9471b5333ee4..000000000000 --- a/extensions/openai/test-api.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Openai API module exposes the plugin public contract. -export { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js"; -export { openaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; -export { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; -export { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; -export { buildOpenAISpeechProvider } from "./speech-provider.js"; -export { buildOpenAIVideoGenerationProvider } from "./video-generation-provider.js"; diff --git a/extensions/openrouter/test-api.ts b/extensions/openrouter/test-api.ts deleted file mode 100644 index 513c9935cb05..000000000000 --- a/extensions/openrouter/test-api.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Openrouter API module exposes the plugin public contract. -export { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js"; -export { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js"; -export { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js"; -export { buildOpenRouterSpeechProvider } from "./speech-provider.js"; diff --git a/extensions/qwen/test-api.ts b/extensions/qwen/test-api.ts deleted file mode 100644 index 5e72d5c3de31..000000000000 --- a/extensions/qwen/test-api.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Qwen API module exposes the plugin public contract. -export { buildQwenMediaUnderstandingProvider } from "./media-understanding-provider.js"; -export { qwenVideoGenerationProvider } from "./video-generation-provider.js"; diff --git a/extensions/senseaudio/test-api.ts b/extensions/senseaudio/test-api.ts deleted file mode 100644 index c69c3560c999..000000000000 --- a/extensions/senseaudio/test-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Senseaudio API module exposes the plugin public contract. -export { senseaudioMediaUnderstandingProvider } from "./media-understanding-provider.js"; diff --git a/extensions/telegram/test-api.ts b/extensions/telegram/test-api.ts deleted file mode 100644 index 6af11e21e832..000000000000 --- a/extensions/telegram/test-api.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Telegram API module exposes the plugin public contract. -export { sendMessageTelegram, sendPollTelegram, type TelegramApiOverride } from "./src/send.js"; -export { resetTelegramThreadBindingsForTests } from "./src/thread-bindings.js"; diff --git a/extensions/tlon/test-api.ts b/extensions/tlon/test-api.ts deleted file mode 100644 index 0a17cdd45ef7..000000000000 --- a/extensions/tlon/test-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Tlon API module exposes the plugin public contract. -export { tlonPlugin } from "./src/channel.js"; diff --git a/extensions/whatsapp/test-api.ts b/extensions/whatsapp/test-api.ts deleted file mode 100644 index d70be9de47c1..000000000000 --- a/extensions/whatsapp/test-api.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Whatsapp API module exposes the plugin public contract. -export { whatsappOutbound } from "./src/outbound-adapter.js"; -export { resolveWhatsAppRuntimeGroupPolicy } from "./src/runtime-group-policy.js"; diff --git a/extensions/zai/test-api.ts b/extensions/zai/test-api.ts deleted file mode 100644 index 50b5cfa712f2..000000000000 --- a/extensions/zai/test-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Zai API module exposes the plugin public contract. -export { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; diff --git a/extensions/zalo/test-api.ts b/extensions/zalo/test-api.ts deleted file mode 100644 index 3d09c2db05d0..000000000000 --- a/extensions/zalo/test-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Zalo API module exposes the plugin public contract. -export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js"; diff --git a/extensions/zalouser/src/accounts.test-mocks.ts b/extensions/zalouser/src/accounts.test-mocks.ts index cd0485e1fc50..0f7db0364067 100644 --- a/extensions/zalouser/src/accounts.test-mocks.ts +++ b/extensions/zalouser/src/accounts.test-mocks.ts @@ -7,7 +7,6 @@ vi.mock("./accounts.js", () => { listZalouserAccountIds: () => ["default"], resolveDefaultZalouserAccountId: () => "default", resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(), - getZcaUserInfo: async () => null, checkZcaAuthenticated: async () => false, }; }); diff --git a/extensions/zalouser/src/accounts.test.ts b/extensions/zalouser/src/accounts.test.ts index 423afc67ab7c..b409bc7bde6f 100644 --- a/extensions/zalouser/src/accounts.test.ts +++ b/extensions/zalouser/src/accounts.test.ts @@ -1,20 +1,12 @@ // Zalouser tests cover accounts plugin behavior. import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../runtime-api.js"; import { - getZcaUserInfo, listZalouserAccountIds, resolveDefaultZalouserAccountId, resolveZalouserAccountSync, } from "./accounts.js"; -import { getZaloUserInfo } from "./zalo-js.js"; - -vi.mock("./zalo-js.js", () => ({ - getZaloUserInfo: vi.fn(), -})); - -const mockGetUserInfo = vi.mocked(getZaloUserInfo); const originalZalouserProfile = process.env.ZALOUSER_PROFILE; const originalZcaProfile = process.env.ZCA_PROFILE; @@ -24,7 +16,6 @@ function asConfig(value: unknown): OpenClawConfig { describe("zalouser account resolution", () => { beforeEach(() => { - mockGetUserInfo.mockReset(); delete process.env.ZALOUSER_PROFILE; delete process.env.ZCA_PROFILE; }); @@ -242,19 +233,4 @@ describe("zalouser account resolution", () => { expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("explicit-profile"); }); - - it("maps account info helper from zalo-js", async () => { - mockGetUserInfo.mockResolvedValueOnce({ - userId: "123", - displayName: "Alice", - avatar: "https://example.com/avatar.png", - }); - expect(await getZcaUserInfo("default")).toEqual({ - userId: "123", - displayName: "Alice", - }); - - mockGetUserInfo.mockResolvedValueOnce(null); - expect(await getZcaUserInfo("default")).toBeNull(); - }); }); diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index daa72ad968a2..e704b0917c3f 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -77,19 +77,6 @@ export function resolveZalouserAccountSync(params: { }; } -export async function getZcaUserInfo( - profile: string, -): Promise<{ userId?: string; displayName?: string } | null> { - const info = await (await loadZalouserAccountsRuntime()).getZaloUserInfo(profile); - if (!info) { - return null; - } - return { - userId: info.userId, - displayName: info.displayName, - }; -} - export async function checkZcaAuthenticated( profile: string, options?: { credentialPersistence?: "persist" | "read-only" }, diff --git a/extensions/zalouser/test-api.ts b/extensions/zalouser/test-api.ts deleted file mode 100644 index f60e0095350d..000000000000 --- a/extensions/zalouser/test-api.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Zalouser API module exposes the plugin public contract. -export { sendMessageZalouser } from "./src/send.js"; -export { parseZalouserOutboundTarget } from "./src/session-route.js"; -export { - checkZcaAuthenticated, - getZcaUserInfo, - listZalouserAccountIds, - resolveDefaultZalouserAccountId, - resolveZalouserAccountSync, -} from "./src/accounts.js"; -export { - checkZaloAuthenticated, - getZaloUserInfo, - listZaloFriendsMatching, - listZaloGroupMembers, - listZaloGroupsMatching, - logoutZaloProfile, - resolveZaloAllowFromEntries, - resolveZaloGroupsByEntries, - startZaloQrLogin, - waitForZaloQrLogin, -} from "./src/zalo-js.js"; diff --git a/src/plugins/contracts/extension-test-api-consumption.test.ts b/src/plugins/contracts/extension-test-api-consumption.test.ts new file mode 100644 index 000000000000..59f3a88b30a0 --- /dev/null +++ b/src/plugins/contracts/extension-test-api-consumption.test.ts @@ -0,0 +1,196 @@ +import fs from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { listGitTrackedFiles } from "../../test-utils/repo-files.js"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const THIS_TEST_FILE = "src/plugins/contracts/extension-test-api-consumption.test.ts"; + +type ExtensionTestApi = { + absoluteStem: string; + packageName?: string; + packageExportsTestApi: boolean; + pluginId: string; + repoPath: string; +}; + +function listTrackedFiles(pathspecs: string | readonly string[]): string[] { + const files = listGitTrackedFiles({ pathspecs, repoRoot: REPO_ROOT }); + if (!files) { + throw new Error(`failed to list tracked files for ${JSON.stringify(pathspecs)}`); + } + return files; +} + +function stripModuleExtension(value: string): string { + return value.replace(/\.(?:[cm]?[jt]sx?)$/u, ""); +} + +function listExtensionTestApis(): ExtensionTestApi[] { + return listTrackedFiles("extensions/*/test-api.ts").map((repoPath) => { + const pluginId = repoPath.split("/")[1]; + if (!pluginId) { + throw new Error(`invalid extension test API path: ${repoPath}`); + } + const packageJsonPath = resolve(REPO_ROOT, "extensions", pluginId, "package.json"); + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + exports?: Record; + name?: unknown; + }; + const packageName = typeof parsed.name === "string" ? parsed.name : undefined; + const packageExportsTestApi = Object.keys(parsed.exports ?? {}).some( + (key) => stripModuleExtension(key) === "./test-api", + ); + return { + absoluteStem: stripModuleExtension(resolve(REPO_ROOT, repoPath)), + packageName, + packageExportsTestApi, + pluginId, + repoPath, + }; + }); +} + +function objectStringProperty(node: ts.ObjectLiteralExpression, name: string): string | undefined { + for (const property of node.properties) { + if (!ts.isPropertyAssignment(property)) { + continue; + } + const propertyName = ts.isIdentifier(property.name) + ? property.name.text + : ts.isStringLiteralLike(property.name) + ? property.name.text + : undefined; + if (propertyName === name && ts.isStringLiteralLike(property.initializer)) { + return property.initializer.text; + } + } + return undefined; +} + +function collectTestApiSourceReferences(source: string, fileName = "source.ts") { + const moduleSpecifiers = ts + .preProcessFile(source, true, true) + .importedFiles.map((entry) => entry.fileName) + .toSorted(); + const pluginIds = new Set(); + const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); + + function visit(node: ts.Node): void { + if (ts.isCallExpression(node)) { + const name = ts.isIdentifier(node.expression) + ? node.expression.text + : ts.isPropertyAccessExpression(node.expression) + ? node.expression.name.text + : undefined; + const pluginId = node.arguments[0]; + if ( + name === "loadQaRunnerBundledPluginTestApi" && + pluginId && + ts.isStringLiteralLike(pluginId) + ) { + pluginIds.add(pluginId.text); + } + } else if (ts.isObjectLiteralExpression(node)) { + const pluginId = objectStringProperty(node, "pluginId"); + if (pluginId && objectStringProperty(node, "artifactBasename") === "test-api.js") { + pluginIds.add(pluginId); + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return { moduleSpecifiers, pluginIds: [...pluginIds].toSorted() }; +} + +function resolveTestApiPluginId( + specifier: string, + importerFile: string, + testApis: readonly ExtensionTestApi[], +): string | undefined { + if (specifier.startsWith(".") || specifier.startsWith("/")) { + const resolvedStem = stripModuleExtension(resolve(dirname(importerFile), specifier)); + return testApis.find((testApi) => testApi.absoluteStem === resolvedStem)?.pluginId; + } + const bareSpecifier = stripModuleExtension(specifier); + return testApis.find( + (testApi) => testApi.packageName && `${testApi.packageName}/test-api` === bareSpecifier, + )?.pluginId; +} + +function collectOrphanExtensionTestApiFiles(): string[] { + const testApis = listExtensionTestApis(); + const consumed = new Set( + testApis.filter((testApi) => testApi.packageExportsTestApi).map((testApi) => testApi.pluginId), + ); + const testApiFiles = new Set(testApis.map((testApi) => testApi.repoPath)); + + for (const repoPath of listTrackedFiles(["src", "test", "extensions", "packages", "scripts"])) { + if ( + repoPath === THIS_TEST_FILE || + testApiFiles.has(repoPath) || + !/\.(?:[cm]?[jt]sx?)$/u.test(repoPath) + ) { + continue; + } + const absolutePath = resolve(REPO_ROOT, repoPath); + const source = fs.readFileSync(absolutePath, "utf8"); + if (!source.includes("test-api") && !source.includes("loadQaRunnerBundledPluginTestApi")) { + continue; + } + const references = collectTestApiSourceReferences(source, absolutePath); + for (const pluginId of references.pluginIds) { + if (testApis.some((testApi) => testApi.pluginId === pluginId)) { + consumed.add(pluginId); + } + } + for (const specifier of references.moduleSpecifiers) { + const pluginId = resolveTestApiPluginId(specifier, absolutePath, testApis); + if (pluginId) { + consumed.add(pluginId); + } + } + } + + return testApis + .filter((testApi) => !consumed.has(testApi.pluginId)) + .map((testApi) => testApi.repoPath) + .toSorted(); +} + +describe("extension test API consumption", () => { + it("ignores identifier text and generic loader implementations", () => { + expect( + collectTestApiSourceReferences(` + const testing = {}; + function loadQaRunnerBundledPluginTestApi(pluginId: string) { + return load({ pluginId, artifactBasename: "test-api.js" }); + } + `), + ).toStrictEqual({ moduleSpecifiers: [], pluginIds: [] }); + }); + + it("collects real module edges and literal plugin loaders", () => { + expect( + collectTestApiSourceReferences(` + import { testing } from "@openclaw/example/test-api.js"; + export { helper } from "../test-api.js"; + type TestApi = typeof import("./test-api.js"); + loadQaRunnerBundledPluginTestApi("matrix"); + loadBundledPluginPublicSurface({ pluginId: "codex", artifactBasename: "test-api.js" }); + `), + ).toStrictEqual({ + moduleSpecifiers: ["../test-api.js", "./test-api.js", "@openclaw/example/test-api.js"], + pluginIds: ["codex", "matrix"], + }); + }); + + it( + "keeps only consumed extension test APIs", + () => expect(collectOrphanExtensionTestApiFiles()).toStrictEqual([]), + 240_000, + ); +}); diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index fe600df1c353..e58138f4d655 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -36,7 +36,6 @@ const PRIVATE_BUNDLED_SDK_SURFACE_PATTERN = const GENERIC_CORE_HELPER_FILES = ["src/polls.ts", "src/poll-params.ts"] as const; const GENERIC_CORE_PLUGIN_OWNER_NAME_PATTERN = /\b(?:imessage|discord|feishu|googlechat|matrix|mattermost|msteams|slack|telegram|whatsapp|zalo|zalouser)\b/gi; -const PACKAGE_CONTRACT_SCAN_TIMEOUT_MS = 240_000; const DEPRECATED_EXTENSION_SDK_SPECIFIERS = new Set([ "openclaw/plugin-sdk", // Bundled code uses the canonical channel-config-schema subpath; the @@ -539,38 +538,6 @@ function collectDeprecatedTestAliasImports(): string[] { return leaks.map((entry) => `${entry.file}: ${entry.specifier}`).toSorted(); } -function parseTestApiNamedExports(source: string): string[] { - const exports = new Set(); - const declarationPattern = - /\bexport\s+(?:const|function|class|async\s+function|type|interface)\s+([A-Za-z_$][\w$]*)/g; - const exportListPattern = /\bexport\s*\{([^}]+)\}/g; - - for (const match of source.matchAll(declarationPattern)) { - const exportName = match[1]; - if (exportName) { - exports.add(exportName); - } - } - - for (const match of source.matchAll(exportListPattern)) { - const exportList = match[1]; - if (!exportList) { - continue; - } - for (const part of exportList.split(",")) { - const item = part.trim().replace(/^type\s+/, ""); - const aliasMatch = /\bas\s+([A-Za-z_$][\w$]*)$/u.exec(item); - const nameMatch = /^([A-Za-z_$][\w$]*)/u.exec(item); - const exportName = aliasMatch?.[1] ?? nameMatch?.[1]; - if (exportName && exportName !== "default") { - exports.add(exportName); - } - } - } - - return [...exports].toSorted(); -} - function collectWorkspaceCodeFiles(): string[] { const files: string[] = []; for (const root of ["src", "test", "extensions", "packages", "scripts"]) { @@ -582,74 +549,6 @@ function collectWorkspaceCodeFiles(): string[] { return files; } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function collectUnusedExtensionTestApiExports(): Array<{ file: string; exportName: string }> { - const leaks: Array<{ file: string; exportName: string }> = []; - const workspaceCodeFiles = collectWorkspaceCodeFiles(); - const testApiFiles = collectCodeFiles(resolve(REPO_ROOT, "extensions")).filter((file) => - file.endsWith("/test-api.ts"), - ); - const testApiExports = new Map(); - const exportNames = new Set(); - - for (const file of testApiFiles) { - const source = fs.readFileSync(file, "utf8"); - const namedExports = parseTestApiNamedExports(source); - testApiExports.set(file, namedExports); - for (const exportName of namedExports) { - exportNames.add(exportName); - } - } - - if (exportNames.size === 0) { - return []; - } - - const identifierPattern = new RegExp( - `\\b(${[...exportNames].map(escapeRegExp).join("|")})\\b`, - "g", - ); - const referenceCounts = new Map(); - const selfReferenceCounts = new Map>(); - - for (const file of workspaceCodeFiles) { - const source = fs.readFileSync(file, "utf8"); - const selfCounts = testApiExports.has(file) ? new Map() : undefined; - for (const match of source.matchAll(identifierPattern)) { - const exportName = match[1]; - if (!exportName) { - continue; - } - referenceCounts.set(exportName, (referenceCounts.get(exportName) ?? 0) + 1); - if (selfCounts) { - selfCounts.set(exportName, (selfCounts.get(exportName) ?? 0) + 1); - } - } - if (selfCounts) { - selfReferenceCounts.set(file, selfCounts); - } - } - - for (const [file, namedExports] of testApiExports) { - const repoRelativePath = toRepoRelativePath(file); - for (const exportName of namedExports) { - const referenceCount = - (referenceCounts.get(exportName) ?? 0) - - (selfReferenceCounts.get(file)?.get(exportName) ?? 0); - if (referenceCount === 0) { - leaks.push({ file: repoRelativePath, exportName }); - } - } - } - - return leaks.toSorted( - (a, b) => a.file.localeCompare(b.file) || a.exportName.localeCompare(b.exportName), - ); -} - function collectCrossOwnerReservedSdkImports(): Array<{ file: string; specifier: string; @@ -999,14 +898,6 @@ describe("plugin-sdk package contract guardrails", () => { expect(deprecatedTestAliasImports).toStrictEqual([]); }); - it( - "keeps extension test-api exports consumed", - () => { - expect(collectUnusedExtensionTestApiExports()).toStrictEqual([]); - }, - PACKAGE_CONTRACT_SCAN_TIMEOUT_MS, - ); - it("keeps reserved SDK compatibility subpaths inside their owning bundled plugins", () => { expect(collectCrossOwnerReservedSdkImports()).toStrictEqual([]); });