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
This commit is contained in:
Peter Steinberger
2026-08-10 16:42:34 -07:00
committed by GitHub
parent 59492c51a5
commit fd1b965f2b
30 changed files with 206 additions and 470 deletions
-7
View File
@@ -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";
-3
View File
@@ -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";
@@ -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<typeof vi.fn>): [string, RequestInit] {
const [call] = fetchMock.mock.calls;
@@ -38,7 +35,7 @@ describe("elevenLabsMediaUnderstandingProvider", () => {
.fn<typeof fetch>()
.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<typeof fetch>().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<typeof fetch>().mockResolvedValue(new Response(JSON.stringify([])));
await expect(
transcribeElevenLabsAudio({
elevenLabsMediaUnderstandingProvider.transcribeAudio!({
buffer: Buffer.from("audio"),
fileName: "voice.mp3",
mime: "audio/mpeg",
@@ -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<AudioTranscriptionResult> {
const fetchFn = req.fetchFn ?? fetch;
-7
View File
@@ -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";
-4
View File
@@ -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";
-3
View File
@@ -1,3 +0,0 @@
// Googlechat API module exposes the plugin public contract.
export { googlechatPlugin } from "./src/channel.js";
export { setGoogleChatRuntime } from "./src/runtime.js";
-2
View File
@@ -1,2 +0,0 @@
// Groq API module exposes the plugin public contract.
export { groqMediaUnderstandingProvider } from "./media-understanding-provider.js";
@@ -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<string, unknown>,
) => 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<string, unknown>,
) => 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<ChannelMessageActionName>([
"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: "<handle|chat_id:ID>",
},
normalizeTarget: (raw) => normalizeIMessageTestHandle(raw),
},
});
+4 -53
View File
@@ -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<ReturnType<typeof createIMessageTestPlugin>["outbound"]>;
type IMessageOutbound = NonNullable<typeof imessagePlugin.outbound>;
type IMessageMessageAdapter = NonNullable<typeof imessagePlugin.message>;
type IMessageMessageSender = NonNullable<IMessageMessageAdapter["send"]>;
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",
]);
});
});
-2
View File
@@ -1,2 +0,0 @@
// Imessage API module exposes the plugin public contract.
export { createIMessageTestPlugin } from "./src/imessage.test-plugin.js";
-2
View File
@@ -1,2 +0,0 @@
// Microsoft API module exposes the plugin public contract.
export { buildMicrosoftSpeechProvider } from "./speech-provider.js";
-3
View File
@@ -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";
-2
View File
@@ -1,2 +0,0 @@
// Msteams API module exposes the plugin public contract.
export { msteamsPlugin } from "./src/channel.js";
-2
View File
@@ -1,2 +0,0 @@
// Nostr API module exposes the plugin public contract.
export { nostrPlugin } from "./src/channel.js";
-7
View File
@@ -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";
-5
View File
@@ -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";
-3
View File
@@ -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";
-2
View File
@@ -1,2 +0,0 @@
// Senseaudio API module exposes the plugin public contract.
export { senseaudioMediaUnderstandingProvider } from "./media-understanding-provider.js";
-3
View File
@@ -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";
-2
View File
@@ -1,2 +0,0 @@
// Tlon API module exposes the plugin public contract.
export { tlonPlugin } from "./src/channel.js";
-3
View File
@@ -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";
-2
View File
@@ -1,2 +0,0 @@
// Zai API module exposes the plugin public contract.
export { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js";
-2
View File
@@ -1,2 +0,0 @@
// Zalo API module exposes the plugin public contract.
export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";
@@ -7,7 +7,6 @@ vi.mock("./accounts.js", () => {
listZalouserAccountIds: () => ["default"],
resolveDefaultZalouserAccountId: () => "default",
resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(),
getZcaUserInfo: async () => null,
checkZcaAuthenticated: async () => false,
};
});
+1 -25
View File
@@ -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();
});
});
-13
View File
@@ -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" },
-22
View File
@@ -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";
@@ -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<string, unknown>;
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<string>();
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,
);
});
@@ -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<string>();
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<string, string[]>();
const exportNames = new Set<string>();
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<string, number>();
const selfReferenceCounts = new Map<string, Map<string, number>>();
for (const file of workspaceCodeFiles) {
const source = fs.readFileSync(file, "utf8");
const selfCounts = testApiExports.has(file) ? new Map<string, number>() : 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([]);
});