refactor: tighten extension test support boundaries

This commit is contained in:
Peter Steinberger
2026-04-28 03:52:16 +01:00
parent e5452a9c57
commit 129b996a4e
16 changed files with 223 additions and 70 deletions

View File

@@ -1,2 +1,2 @@
6c33fafd66396c2c769b8f8d1f5d0792beb8b4b2d23b5f0323ff2bb2d0a79805 plugin-sdk-api-baseline.json
27336f313c029843be409d22d79caf6f45884cf37458d9f52c0a88c4e8b268fd plugin-sdk-api-baseline.jsonl
343a555f212dd5ebf26dccbefff1cb4b56a08e4dcc2c801ac7ab5fb98973192a plugin-sdk-api-baseline.json
02aaccbe13f261de2d41fcb4270fc9ae70b931966089e56d21a4ebc8e80c8821 plugin-sdk-api-baseline.jsonl

View File

@@ -45,6 +45,9 @@ plugins.
Prefer the focused subpaths below for new plugin tests. The broad
`openclaw/plugin-sdk/testing` barrel is legacy compatibility only.
Repo guardrails reject new real imports from `plugin-sdk/testing` and
`plugin-sdk/test-utils`; those names remain only as deprecated compatibility
surfaces for external plugins and compatibility-record tests.
```typescript
import {

View File

@@ -1,2 +1,41 @@
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common";
import { resolveTelegramAccount, type ResolvedTelegramAccount } from "./src/accounts.js";
import { telegramApprovalCapability } from "./src/approval-native.js";
import { telegramConfigAdapter } from "./src/shared.js";
export { sendMessageTelegram, sendPollTelegram, type TelegramApiOverride } from "./src/send.js";
export { resetTelegramThreadBindingsForTests } from "./src/thread-bindings.js";
export const telegramCommandTestPlugin = {
id: "telegram",
meta: getChatChannelMeta("telegram"),
capabilities: {
chatTypes: ["direct", "group", "channel", "thread"],
reactions: true,
threads: true,
media: true,
polls: true,
nativeCommands: true,
blockStreaming: true,
},
config: telegramConfigAdapter,
approvalCapability: telegramApprovalCapability,
pairing: {
idLabel: "telegramUserId",
},
allowlist: buildDmGroupAccountAllowlistAdapter<ResolvedTelegramAccount>({
channelId: "telegram",
resolveAccount: resolveTelegramAccount,
normalize: ({ cfg, accountId, values }) =>
telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupAllowFrom: (account) => account.config.groupAllowFrom,
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveGroupPolicy: (account) => account.config.groupPolicy,
}),
} satisfies Pick<
ChannelPlugin<ResolvedTelegramAccount>,
"id" | "meta" | "capabilities" | "config" | "approvalCapability" | "pairing" | "allowlist"
>;

View File

@@ -1,39 +0,0 @@
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common";
import type { ResolvedTelegramAccount } from "./src/accounts.js";
import { resolveTelegramAccount } from "./src/accounts.js";
import { telegramApprovalCapability } from "./src/approval-native.js";
import { telegramConfigAdapter } from "./src/shared.js";
export const telegramCommandTestPlugin = {
id: "telegram",
meta: getChatChannelMeta("telegram"),
capabilities: {
chatTypes: ["direct", "group", "channel", "thread"],
reactions: true,
threads: true,
media: true,
polls: true,
nativeCommands: true,
blockStreaming: true,
},
config: telegramConfigAdapter,
approvalCapability: telegramApprovalCapability,
pairing: {
idLabel: "telegramUserId",
},
allowlist: buildDmGroupAccountAllowlistAdapter<ResolvedTelegramAccount>({
channelId: "telegram",
resolveAccount: resolveTelegramAccount,
normalize: ({ cfg, accountId, values }) =>
telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupAllowFrom: (account) => account.config.groupAllowFrom,
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveGroupPolicy: (account) => account.config.groupPolicy,
}),
} satisfies Pick<
ChannelPlugin<ResolvedTelegramAccount>,
"id" | "meta" | "capabilities" | "config" | "approvalCapability" | "pairing" | "allowlist"
>;

View File

@@ -6,14 +6,14 @@ import {
createLifecycleMonitorSetup,
expectImageLifecycleDelivery,
settleAsyncWork,
} from "../test-support/lifecycle-test-support.js";
} from "./test-support/lifecycle-test-support.js";
import {
getUpdatesMock,
getZaloRuntimeMock,
loadCachedLifecycleMonitorModule,
resetLifecycleTestState,
sendMessageMock,
} from "../test-support/monitor-mocks-test-support.js";
} from "./test-support/monitor-mocks-test-support.js";
describe("Zalo polling image handling", () => {
const {

View File

@@ -5,13 +5,13 @@ import {
createTextUpdate,
postWebhookReplay,
settleAsyncWork,
} from "../test-support/lifecycle-test-support.js";
} from "./test-support/lifecycle-test-support.js";
import {
resetLifecycleTestState,
sendMessageMock,
setLifecycleRuntimeCore,
startWebhookLifecycleMonitor,
} from "../test-support/monitor-mocks-test-support.js";
} from "./test-support/monitor-mocks-test-support.js";
describe("Zalo pairing lifecycle", () => {
const readAllowFromStoreMock = vi.fn(async () => [] as string[]);

View File

@@ -9,14 +9,14 @@ import {
createLifecycleMonitorSetup,
createTextUpdate,
settleAsyncWork,
} from "../test-support/lifecycle-test-support.js";
} from "./test-support/lifecycle-test-support.js";
import {
getUpdatesMock,
loadCachedLifecycleMonitorModule,
resetLifecycleTestState,
sendPhotoMock,
setLifecycleRuntimeCore,
} from "../test-support/monitor-mocks-test-support.js";
} from "./test-support/monitor-mocks-test-support.js";
const prepareHostedZaloMediaUrlMock = vi.fn();

View File

@@ -6,13 +6,13 @@ import {
createTextUpdate,
postWebhookReplay,
settleAsyncWork,
} from "../test-support/lifecycle-test-support.js";
} from "./test-support/lifecycle-test-support.js";
import {
resetLifecycleTestState,
sendMessageMock,
setLifecycleRuntimeCore,
startWebhookLifecycleMonitor,
} from "../test-support/monitor-mocks-test-support.js";
} from "./test-support/monitor-mocks-test-support.js";
describe("Zalo reply-once lifecycle", () => {
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);

View File

@@ -6,13 +6,6 @@ import {
import { withServer } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import {
createImageLifecycleCore,
createImageUpdate,
createTextUpdate,
expectImageLifecycleDelivery,
postWebhookReplay,
} from "../test-support/lifecycle-test-support.js";
import { handleZaloWebhookRequest } from "./monitor.js";
import type { ZaloRuntimeEnv } from "./monitor.types.js";
import {
@@ -24,6 +17,13 @@ import {
type ZaloWebhookProcessUpdate,
ZaloRetryableWebhookError,
} from "./monitor.webhook.js";
import {
createImageLifecycleCore,
createImageUpdate,
createTextUpdate,
expectImageLifecycleDelivery,
postWebhookReplay,
} from "./test-support/lifecycle-test-support.js";
import type { ResolvedZaloAccount } from "./types.js";
const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
accountId: "default",

View File

@@ -1,7 +1,7 @@
import { request as httpRequest } from "node:http";
import { expect, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "../src/types.js";
import type { ResolvedZaloAccount } from "../types.js";
export function createLifecycleConfig(params: {
accountId: string;

View File

@@ -6,17 +6,17 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { vi, type Mock } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import type { ResolvedZaloAccount } from "../src/types.js";
import type { ResolvedZaloAccount } from "../types.js";
type MonitorModule = typeof import("../src/monitor.js");
type SecretInputModule = typeof import("../src/secret-input.js");
type WebhookModule = typeof import("../src/monitor.webhook.js");
type MonitorModule = typeof import("../monitor.js");
type SecretInputModule = typeof import("../secret-input.js");
type WebhookModule = typeof import("../monitor.webhook.js");
const monitorModuleUrl = new URL("../src/monitor.ts", import.meta.url).href;
const secretInputModuleUrl = new URL("../src/secret-input.ts", import.meta.url).href;
const webhookModuleUrl = new URL("../src/monitor.webhook.ts", import.meta.url).href;
const apiModuleId = new URL("../src/api.js", import.meta.url).pathname;
const runtimeModuleId = new URL("../src/runtime.js", import.meta.url).pathname;
const monitorModuleUrl = new URL("../monitor.ts", import.meta.url).href;
const secretInputModuleUrl = new URL("../secret-input.ts", import.meta.url).href;
const webhookModuleUrl = new URL("../monitor.webhook.ts", import.meta.url).href;
const apiModuleId = new URL("../api.js", import.meta.url).pathname;
const runtimeModuleId = new URL("../runtime.js", import.meta.url).pathname;
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;

View File

@@ -86,6 +86,8 @@ const MOCK_RELATIVE_MODULE_PATTERN =
const RELATIVE_CORE_HINT =
"Use a focused plugin-sdk test/runtime subpath instead of core internals.";
const ROOT_TEST_SUPPORT_LOCAL_SRC_HINT =
"Move this helper under the extension's src/test-support tree or expose a narrow test-api/runtime-api surface instead of reaching into private src from package-root test-support.";
// Tombstones for retired repo-only plugin helper bridge files. Keep this list so
// deleted bridges fail loudly if they are recreated instead of using SDK subpaths.
@@ -203,6 +205,30 @@ function resolvesToRepoSrc(filePath: string, specifier: string): boolean {
return repoRelative === "src" || repoRelative.startsWith("src/");
}
function getExtensionRootForFile(filePath: string): string | undefined {
const relativePath = path.relative(process.cwd(), filePath).replaceAll(path.sep, "/");
const match = /^extensions\/[^/]+(?:\/|$)/u.exec(relativePath);
return match ? path.resolve(process.cwd(), match[0]) : undefined;
}
function isRootExtensionTestSupportFile(filePath: string): boolean {
const relativePath = path.relative(process.cwd(), filePath).replaceAll(path.sep, "/");
return /^extensions\/[^/]+\/test-support(?:\.[cm]?[jt]sx?|\/)/u.test(relativePath);
}
function resolvesToExtensionLocalSrc(filePath: string, specifier: string): boolean {
if (!specifier.startsWith(".")) {
return false;
}
const extensionRoot = getExtensionRootForFile(filePath);
if (!extensionRoot) {
return false;
}
const resolved = path.resolve(path.dirname(filePath), specifier);
const localSrc = path.join(extensionRoot, "src");
return resolved === localSrc || resolved.startsWith(`${localSrc}${path.sep}`);
}
function collectRelativeCoreImportOffenders(
filePath: string,
content: string,
@@ -229,6 +255,34 @@ function collectRelativeCoreImportOffenders(
return offenders;
}
function collectRootTestSupportLocalSrcImportOffenders(
filePath: string,
content: string,
): Offender[] {
if (!isRootExtensionTestSupportFile(filePath)) {
return [];
}
const offenders: Offender[] = [];
const matches = [
...content.matchAll(STATIC_RELATIVE_MODULE_PATTERN),
...content.matchAll(DYNAMIC_RELATIVE_MODULE_PATTERN),
...content.matchAll(MOCK_RELATIVE_MODULE_PATTERN),
];
for (const match of matches) {
const specifier = match[1];
if (!specifier || !resolvesToExtensionLocalSrc(filePath, specifier)) {
continue;
}
offenders.push({
file: filePath,
hint: ROOT_TEST_SUPPORT_LOCAL_SRC_HINT,
line: lineNumberForOffset(content, match.index ?? 0),
specifier,
});
}
return offenders;
}
function main() {
const extensionsDir = path.join(process.cwd(), "extensions");
const pluginHelpersDir = path.join(process.cwd(), "test/helpers/plugins");
@@ -272,6 +326,7 @@ function main() {
includeDynamic: true,
}),
);
offenders.push(...collectRootTestSupportLocalSrcImportOffenders(file, content));
}
for (const file of pluginHelperFiles) {

View File

@@ -1,4 +1,7 @@
// Deprecated compatibility alias.
// Prefer focused openclaw/plugin-sdk/* test subpaths for public test helpers.
/**
* @deprecated Compatibility alias for the legacy `plugin-sdk/testing` barrel.
*
* Prefer focused `openclaw/plugin-sdk/*` test subpaths for public test helpers.
*/
export * from "./testing.js";

View File

@@ -1,5 +1,9 @@
// Broad legacy compatibility barrel for older plugin tests.
// New tests should import focused plugin-sdk/* test subpaths.
/**
* @deprecated Broad compatibility barrel for older plugin tests.
*
* New tests should import focused `openclaw/plugin-sdk/*` test subpaths such as
* `plugin-test-runtime`, `channel-test-helpers`, `test-env`, or `test-fixtures`.
*/
export {
createAckReactionHandle,

View File

@@ -31,6 +31,17 @@ const DEPRECATED_EXTENSION_SDK_SPECIFIERS = new Set([
"openclaw/plugin-sdk/testing",
"openclaw/plugin-sdk/test-utils",
]);
const DEPRECATED_TEST_BARREL_SPECIFIERS = new Set([
"openclaw/plugin-sdk/testing",
"openclaw/plugin-sdk/test-utils",
]);
const DEPRECATED_TEST_BARREL_ALLOWED_REFERENCE_FILES = new Set([
"src/plugin-sdk/testing.ts",
"src/plugin-sdk/test-utils.ts",
"src/plugins/compat/registry.ts",
"src/plugins/contracts/plugin-entry-guardrails.test.ts",
"src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts",
]);
function collectPluginSdkPackageExports(): string[] {
const packageJson = JSON.parse(readFileSync(resolve(REPO_ROOT, "package.json"), "utf8")) as {
@@ -300,6 +311,56 @@ function collectDeprecatedExtensionSdkImports(): Array<{ file: string; specifier
return leaks;
}
function collectCodeFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "dist" || entry.name === "node_modules" || entry.name === ".git") {
continue;
}
const nextPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectCodeFiles(nextPath));
continue;
}
if (!entry.isFile() || !/\.(?:[cm]?ts|tsx|mts|cts)$/.test(entry.name)) {
continue;
}
files.push(nextPath);
}
return files;
}
function collectDeprecatedTestBarrelImports(): Array<{ file: string; specifier: string }> {
const leaks: Array<{ file: string; specifier: string }> = [];
const importPatterns = [
/\b(?:import|export)\b[\s\S]*?\bfrom\s*["'](openclaw\/plugin-sdk\/(?:testing|test-utils))["']/g,
/\bimport\s*\(\s*["'](openclaw\/plugin-sdk\/(?:testing|test-utils))["']\s*\)/g,
/\bvi\.(?:mock|doMock)\s*\(\s*["'](openclaw\/plugin-sdk\/(?:testing|test-utils))["']/g,
];
for (const root of ["src", "test", "extensions", "packages"]) {
for (const file of collectCodeFiles(resolve(REPO_ROOT, root))) {
const repoRelativePath = relative(REPO_ROOT, file).replaceAll("\\", "/");
if (DEPRECATED_TEST_BARREL_ALLOWED_REFERENCE_FILES.has(repoRelativePath)) {
continue;
}
const source = readFileSync(file, "utf8");
for (const importPattern of importPatterns) {
for (const match of source.matchAll(importPattern)) {
const specifier = match[1];
if (!specifier || !DEPRECATED_TEST_BARREL_SPECIFIERS.has(specifier)) {
continue;
}
leaks.push({
file: repoRelativePath,
specifier,
});
}
}
}
}
return leaks;
}
function collectCrossOwnerReservedSdkImports(): Array<{
file: string;
specifier: string;
@@ -467,6 +528,10 @@ describe("plugin-sdk package contract guardrails", () => {
expect(collectDeprecatedExtensionSdkImports()).toEqual([]);
});
it("keeps real tests off deprecated plugin-sdk testing barrels", () => {
expect(collectDeprecatedTestBarrelImports()).toEqual([]);
});
it("keeps reserved SDK compatibility subpaths inside their owning bundled plugins", () => {
expect(collectCrossOwnerReservedSdkImports()).toEqual([]);
});

View File

@@ -81,6 +81,14 @@ function findBundledPluginPublicSurfaceImports(source: string): string[] {
].map((match) => match[0]);
}
function findRelativeSrcImports(source: string): string[] {
return [
...source.matchAll(/from\s+["']((?:\.\.?\/)+src\/[^"']+)["']/g),
...source.matchAll(/import\(\s*["']((?:\.\.?\/)+src\/[^"']+)["']\s*\)/g),
...source.matchAll(/vi\.(?:mock|doMock)\s*\(\s*["']((?:\.\.?\/)+src\/[^"']+)["']/g),
].map((match) => match[1]);
}
function getImportBasename(importPath: string): string {
return importPath.split("/").at(-1) ?? importPath;
}
@@ -229,6 +237,21 @@ describe("non-extension test boundaries", () => {
expect(offenders).toEqual([]);
});
it("keeps extension root test-support helpers from reaching into private src trees", () => {
const files = walkCode(path.join(repoRoot, "extensions")).filter((file) =>
/^extensions\/[^/]+\/test-support(?:\.ts|\/)/u.test(file),
);
const offenders = files
.map((file) => {
const imports = findRelativeSrcImports(fs.readFileSync(path.join(repoRoot, file), "utf8"));
return imports.length === 0 ? null : { file, imports };
})
.filter((entry): entry is { file: string; imports: string[] } => entry !== null);
expect(offenders).toEqual([]);
});
it("keeps bundled extension sources off deprecated channel config schema aliases", () => {
const files = walkCode(path.join(repoRoot, "extensions"));