From 093b2b9b5f9704bc5645969d11aeb421fb43739b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 6 May 2026 00:54:06 +0100 Subject: [PATCH] test: speed extension and contract scenarios --- extensions/active-memory/index.test.ts | 1 + extensions/active-memory/index.ts | 7 +- extensions/google-meet/index.test.ts | 29 +++--- .../matrix/src/matrix/thread-bindings.test.ts | 8 ++ extensions/qa-lab/src/lab-server.test.ts | 1 + extensions/qa-lab/src/lab-server.ts | 1 + extensions/qa-lab/src/lab-server.types.ts | 1 + extensions/qa-lab/src/self-check-scenario.ts | 9 +- extensions/qa-lab/src/self-check.ts | 24 +++-- .../runners/contract/scenario-runtime-cli.ts | 45 +++++---- scripts/lib/config-boundary-guard.mjs | 31 ++++-- src/config/plugin-auto-enable.core.test.ts | 19 ++++ src/gateway/server-http.test-harness.ts | 19 +++- .../server.chat.gateway-server-chat.test.ts | 2 +- ...in-sdk-package-contract-guardrails.test.ts | 65 +++++++++---- .../contracts/plugin-sdk-root-alias.test.ts | 97 ++++++++++++++++--- 16 files changed, 270 insertions(+), 89 deletions(-) diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index fcb711eb33b..55b94a678f9 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -188,6 +188,7 @@ describe("active-memory plugin", () => { payloads: [{ text: "- lemon pepper wings\n- blue cheese" }], }); __testing.resetActiveRecallCacheForTests(); + __testing.setTimeoutPartialDataGraceMsForTests(5); plugin.register(api as unknown as OpenClawPluginApi); }); diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 10d891405be..7e7c9bcd047 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -248,6 +248,7 @@ const toggleStoreLocks = new Map(); let lastActiveRecallCacheSweepAt = 0; let minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS; let setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS; +let timeoutPartialDataGraceMs = TIMEOUT_PARTIAL_DATA_GRACE_MS; function createAsyncLock(): AsyncLock { let lock: Promise = Promise.resolve(); @@ -1906,7 +1907,7 @@ async function waitForSubagentPartialTimeoutData( } let timeoutId: ReturnType | undefined; const timeoutPromise = new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(undefined), TIMEOUT_PARTIAL_DATA_GRACE_MS); + timeoutId = setTimeout(() => resolve(undefined), timeoutPartialDataGraceMs); timeoutId.unref?.(); }); try { @@ -3009,6 +3010,7 @@ const testing = { lastActiveRecallCacheSweepAt = 0; minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS; setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS; + timeoutPartialDataGraceMs = TIMEOUT_PARTIAL_DATA_GRACE_MS; }, setMinimumTimeoutMsForTests(value: number) { minimumTimeoutMs = value; @@ -3016,6 +3018,9 @@ const testing = { setSetupGraceTimeoutMsForTests(value: number) { setupGraceTimeoutMs = Math.max(0, Math.floor(value)); }, + setTimeoutPartialDataGraceMsForTests(value: number) { + timeoutPartialDataGraceMs = Math.max(0, Math.floor(value)); + }, setCachedResult, getCircuitBreakerEntry(key: string) { return timeoutCircuitBreaker.get(key); diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 1a0b1337484..2e3b3b70df7 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -30,6 +30,7 @@ import { convertGoogleMeetTtsAudioForBridge, extendGoogleMeetOutputEchoSuppression, isGoogleMeetLikelyAssistantEchoTranscript, + GOOGLE_MEET_AGENT_TRANSCRIPT_DEBOUNCE_MS, resolveGoogleMeetRealtimeProvider, resolveGoogleMeetRealtimeTranscriptionProvider, startCommandAgentAudioBridge, @@ -315,6 +316,7 @@ describe("google-meet plugin", () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); chromeTransportTesting.setDepsForTest(null); googleMeetPluginTesting.setCallGatewayFromCliForTests(); @@ -1250,14 +1252,16 @@ describe("google-meet plugin", () => { introSent: true, }, }); - expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith({ - config: expect.objectContaining({ defaultTransport: "twilio" }), - dialInNumber: "+15551234567", - dtmfSequence: "123456#", - logger: expect.objectContaining({ info: expect.any(Function) }), - message: "Say exactly: I'm here and listening.", - sessionKey: expect.stringMatching(/^voice:google-meet:meet_/), - }); + expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ defaultTransport: "twilio" }), + dialInNumber: "+15551234567", + dtmfSequence: "123456#", + logger: expect.objectContaining({ info: expect.any(Function) }), + message: "Say exactly: I'm here and listening.", + sessionKey: expect.stringMatching(/^voice:google-meet:meet_/), + }), + ); }); it("passes the caller session key through tool joins for agent context forking", async () => { @@ -2762,6 +2766,7 @@ describe("google-meet plugin", () => { url: "https://meet.google.com/abc-defg-hij", }); const { methods } = setup({ + realtime: { introMessage: "" }, chrome: { audioBridgeCommand: ["bridge", "start"], waitForInCallMs: 1, @@ -3781,6 +3786,7 @@ describe("google-meet plugin", () => { const { methods, runCommandWithTimeout } = setup({ defaultMode: "bidi", chrome: { + waitForInCallMs: 1, audioBridgeHealthCommand: ["bridge", "status"], audioBridgeCommand: ["bridge", "start"], }, @@ -3822,6 +3828,7 @@ describe("google-meet plugin", () => { }); it("uses realtime transcription plus regular TTS in Chrome agent mode", async () => { + vi.useFakeTimers(); let callbacks: Parameters[0] | undefined; const sendAudio = vi.fn(); const sttSession = { @@ -3919,7 +3926,7 @@ describe("google-meet plugin", () => { ); inputStdout.write(Buffer.from([1, 0, 2, 0, 3, 0, 4, 0])); callbacks?.onTranscript?.("Please summarize the launch."); - await new Promise((resolve) => setTimeout(resolve, 1100)); + await vi.advanceTimersByTimeAsync(GOOGLE_MEET_AGENT_TRANSCRIPT_DEBOUNCE_MS); expect(sendAudio).toHaveBeenCalledWith(expect.any(Buffer)); expect(runtime.agent.runEmbeddedPiAgent).toHaveBeenCalled(); @@ -4497,7 +4504,7 @@ describe("google-meet plugin", () => { if (pullCount === 1) { return { bridgeId: "bridge-1", base64: Buffer.from([9, 8, 7]).toString("base64") }; } - await new Promise((resolve) => setTimeout(resolve, 1_000)); + await new Promise((resolve) => setTimeout(resolve, 10)); return { bridgeId: "bridge-1" }; } return { ok: true }; @@ -4683,7 +4690,7 @@ describe("google-meet plugin", () => { if (pullCount === 2) { return { bridgeId: "bridge-1", base64: Buffer.from([5, 4, 3]).toString("base64") }; } - await new Promise((resolve) => setTimeout(resolve, 1_000)); + await new Promise((resolve) => setTimeout(resolve, 10)); return { bridgeId: "bridge-1" }; } return { ok: true }; diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts index 362aabf29c8..3a2cc743c5f 100644 --- a/extensions/matrix/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -318,6 +318,14 @@ describe("matrix thread bindings", () => { await vi.advanceTimersByTimeAsync(61_000); + await vi.waitFor( + () => expect(sendMessageMatrixMock.mock.calls.length).toBeGreaterThanOrEqual(2), + { + interval: 1, + timeout: 1_000, + }, + ); + await vi.waitFor( async () => { const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8"); diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index 5486ef16015..7dea2c896a3 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -325,6 +325,7 @@ describe("qa-lab server", () => { port: 0, repoRoot, embeddedGateway: "disabled", + selfCheckWaitTimeoutMs: 1, }); cleanups.push(async () => { await lab.stop(); diff --git a/extensions/qa-lab/src/lab-server.ts b/extensions/qa-lab/src/lab-server.ts index 1ad9a86d344..6a5554463ee 100644 --- a/extensions/qa-lab/src/lab-server.ts +++ b/extensions/qa-lab/src/lab-server.ts @@ -244,6 +244,7 @@ export async function startQaLabServer( transportId: "qa-channel", outputPath: params?.outputPath, repoRoot, + waitTimeoutMs: params?.selfCheckWaitTimeoutMs, }); latestScenarioRun = withQaLabRunCounts({ kind: "self-check", diff --git a/extensions/qa-lab/src/lab-server.types.ts b/extensions/qa-lab/src/lab-server.types.ts index c023415f66f..db468e951f2 100644 --- a/extensions/qa-lab/src/lab-server.types.ts +++ b/extensions/qa-lab/src/lab-server.types.ts @@ -55,6 +55,7 @@ export type QaLabServerStartParams = { autoKickoffTarget?: string; embeddedGateway?: string; sendKickoffOnStart?: boolean; + selfCheckWaitTimeoutMs?: number; }; export type QaLabServerHandle = { diff --git a/extensions/qa-lab/src/self-check-scenario.ts b/extensions/qa-lab/src/self-check-scenario.ts index 8bf72e6525a..45c2e0761cc 100644 --- a/extensions/qa-lab/src/self-check-scenario.ts +++ b/extensions/qa-lab/src/self-check-scenario.ts @@ -1,7 +1,10 @@ import { extractQaToolPayload } from "./extract-tool-payload.js"; import type { QaScenarioDefinition } from "./scenario.js"; -export function createQaSelfCheckScenario(): QaScenarioDefinition { +export function createQaSelfCheckScenario(options?: { + waitTimeoutMs?: number; +}): QaScenarioDefinition { + const waitTimeoutMs = options?.waitTimeoutMs ?? 5_000; return { name: "Synthetic Slack-class roundtrip", steps: [ @@ -18,7 +21,7 @@ export function createQaSelfCheckScenario(): QaScenarioDefinition { kind: "message-text", textIncludes: "qa-echo: hello from qa", direction: "outbound", - timeoutMs: 5_000, + timeoutMs: waitTimeoutMs, }); }, }, @@ -52,7 +55,7 @@ export function createQaSelfCheckScenario(): QaScenarioDefinition { kind: "message-text", textIncludes: "qa-echo: inside thread", direction: "outbound", - timeoutMs: 5_000, + timeoutMs: waitTimeoutMs, }); return threadId; }, diff --git a/extensions/qa-lab/src/self-check.ts b/extensions/qa-lab/src/self-check.ts index f8f182a8eea..6ecf20af48c 100644 --- a/extensions/qa-lab/src/self-check.ts +++ b/extensions/qa-lab/src/self-check.ts @@ -29,6 +29,7 @@ export async function runQaSelfCheckAgainstState(params: { outputPath?: string; repoRoot?: string; notes?: string[]; + waitTimeoutMs?: number; }): Promise { const startedAt = new Date(); const transport = createQaTransportAdapter({ @@ -36,16 +37,19 @@ export async function runQaSelfCheckAgainstState(params: { state: params.state, }); params.state.reset(); - const scenarioResult = await runQaScenario(createQaSelfCheckScenario(), { - state: params.state, - performAction: async (action, args) => - await transport.handleAction({ - action, - args, - cfg: params.cfg, - accountId: transport.accountId, - }), - }); + const scenarioResult = await runQaScenario( + createQaSelfCheckScenario({ waitTimeoutMs: params.waitTimeoutMs }), + { + state: params.state, + performAction: async (action, args) => + await transport.handleAction({ + action, + args, + cfg: params.cfg, + accountId: transport.accountId, + }), + }, + ); const checks = [ { name: "QA self-check scenario", diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts index 817d0e4639e..1e47a19b04c 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts @@ -105,6 +105,7 @@ export function startMatrixQaOpenClawCli(params: { const stderr: Buffer[] = []; let closed = false; let closeResult: MatrixQaCliRunResult | undefined; + let timedOut = false; let settleWait: | { reject: (error: Error) => void; @@ -138,24 +139,31 @@ export function startMatrixQaOpenClawCli(params: { }; const timeout = setTimeout(() => { - const result = buildMatrixQaCliResult({ - args: params.args, - exitCode: 1, - output: readOutput(), - }); + timedOut = true; child.kill("SIGTERM"); - finish( - result, - new Error( - [ - `${formatMatrixQaCliCommand(params.args)} timed out after ${params.timeoutMs}ms`, - result.stderr.trim() ? `stderr:\n${redactMatrixQaCliOutput(result.stderr.trim())}` : null, - result.stdout.trim() ? `stdout:\n${redactMatrixQaCliOutput(result.stdout.trim())}` : null, - ] - .filter(Boolean) - .join("\n"), - ), - ); + setTimeout(() => { + const result = buildMatrixQaCliResult({ + args: params.args, + exitCode: 1, + output: readOutput(), + }); + finish( + result, + new Error( + [ + `${formatMatrixQaCliCommand(params.args)} timed out after ${params.timeoutMs}ms`, + result.stderr.trim() + ? `stderr:\n${redactMatrixQaCliOutput(result.stderr.trim())}` + : null, + result.stdout.trim() + ? `stdout:\n${redactMatrixQaCliOutput(result.stdout.trim())}` + : null, + ] + .filter(Boolean) + .join("\n"), + ), + ); + }, 25); }, params.timeoutMs); child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk))); @@ -176,6 +184,9 @@ export function startMatrixQaOpenClawCli(params: { }); child.on("close", (exitCode) => { clearTimeout(timeout); + if (timedOut) { + return; + } const result = buildMatrixQaCliResult({ args: params.args, exitCode: exitCode ?? 1, diff --git a/scripts/lib/config-boundary-guard.mjs b/scripts/lib/config-boundary-guard.mjs index d4e4988e5ec..4dfeee2172b 100644 --- a/scripts/lib/config-boundary-guard.mjs +++ b/scripts/lib/config-boundary-guard.mjs @@ -3,6 +3,7 @@ import { dirname, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const DEFAULT_REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const sourceCache = new Map(); const COMPAT_CONFIG_API_FILES = new Set([ "src/config/config.ts", @@ -62,6 +63,16 @@ function repoRelative(repoRoot, filePath) { return relative(repoRoot, filePath).split(sep).join("/"); } +function readTypeScriptSource(filePath) { + const cached = sourceCache.get(filePath); + if (cached !== undefined) { + return cached; + } + const source = readFileSync(filePath, "utf8"); + sourceCache.set(filePath, source); + return source; +} + function isProductionExtensionFile(relPath) { if ( relPath.includes("/test-support/") || @@ -151,7 +162,7 @@ function pushDeprecatedRuntimeApiViolations(violations, files) { ]; for (const { filePath, relPath } of files) { - const source = readFileSync(filePath, "utf8"); + const source = readTypeScriptSource(filePath); for (const guard of guards) { for (const line of findMatchLineNumbers(source, guard.pattern)) { violations.push(`${relPath}:${line} ${guard.replacement}`); @@ -169,7 +180,7 @@ function pushBroadConfigRuntimeBarrelViolations(violations, files) { /\b(?:typeof\s+)?import\(["']openclaw\/plugin-sdk\/config-runtime["']\)\.[A-Za-z_$][\w$]*/g; for (const { filePath, relPath } of files) { - const source = readFileSync(filePath, "utf8"); + const source = readTypeScriptSource(filePath); for (const pattern of [staticImportPattern, dynamicImportPattern, typeQueryPattern]) { for (const line of findMatchLineNumbers(source, pattern)) { violations.push( @@ -184,7 +195,7 @@ function pushBroadConfigRuntimeSpecifierViolations(violations, files) { const moduleSpecifierPattern = /["']openclaw\/plugin-sdk\/config-runtime["']/g; for (const { filePath, relPath } of files) { - const source = readFileSync(filePath, "utf8"); + const source = readTypeScriptSource(filePath); for (const line of findMatchLineNumbers(source, moduleSpecifierPattern)) { violations.push( `${relPath}:${line} use narrow plugin-sdk config subpaths instead of openclaw/plugin-sdk/config-runtime`, @@ -218,7 +229,7 @@ export function collectDeprecatedInternalConfigApiViolations({ pushBroadConfigRuntimeBarrelViolations(violations, productionExtensionFiles); for (const { filePath, relPath } of productionExtensionFiles) { - const source = readFileSync(filePath, "utf8"); + const source = readTypeScriptSource(filePath); const guards = [ { pattern: @@ -274,7 +285,7 @@ export function collectDeprecatedInternalConfigApiViolations({ for (const { filePath, relPath } of repoFiles.filter( ({ relPath }) => !isCompatConfigApiFile(relPath), )) { - const source = readFileSync(filePath, "utf8"); + const source = readTypeScriptSource(filePath); const guards = [ { pattern: @@ -301,7 +312,7 @@ export function collectDeprecatedInternalConfigApiViolations({ !isCompatConfigApiFile(relPath) && !relPath.startsWith("test/"), )) { - const source = readFileSync(filePath, "utf8"); + const source = readTypeScriptSource(filePath); const importPattern = /\bimport\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/; const dynamicImportPattern = @@ -328,7 +339,7 @@ export function collectDeprecatedInternalConfigApiViolations({ !PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES.has(relPath) && !relPath.startsWith("test/"), )) { - const source = readFileSync(filePath, "utf8"); + const source = readTypeScriptSource(filePath); for (const line of findNonCommentLineNumbers(source, /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })) .filter(({ relPath }) => !isTestOrHarnessFile(relPath))) { - const source = readFileSync(filePath, "utf8"); + const source = readTypeScriptSource(filePath); const importPattern = /\bimport\s+\{[\s\S]*?\bloadConfig\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/; for (const line of findMatchLineNumbers(source, importPattern)) { @@ -368,7 +379,7 @@ export function collectDeprecatedInternalConfigApiViolations({ !isCompatConfigApiFile(relPath) && !isAmbientRuntimeConfigCompatFile(relPath), )) { - const source = readFileSync(filePath, "utf8"); + const source = readTypeScriptSource(filePath); const loadConfigLines = findNonCommentLineNumbers(source, /(? ({ filePath, relPath: repoRelative(repoRoot, filePath) })) .filter(({ relPath }) => isRuntimeActionLoadConfigCandidate(relPath)) .flatMap(({ filePath, relPath }) => { - const lines = readFileSync(filePath, "utf8").split(/\r?\n/); + const lines = readTypeScriptSource(filePath).split(/\r?\n/); return lines.flatMap((line, index) => RUNTIME_ACTION_FORBIDDEN_CONFIG_LOAD_PATTERNS.some((pattern) => pattern.test(line)) ? [`${relPath}:${index + 1}: ${line.trim()}`] diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index e6398e70363..05c04d9acd3 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -37,6 +37,25 @@ vi.mock("../channels/plugins/configured-state.js", async (importOriginal) => { }; }); +const setupRegistryMock = vi.hoisted(() => ({ + resolvePluginSetupAutoEnableReasons: vi.fn( + (params: { config?: OpenClawConfig; pluginIds?: readonly string[] }) => { + const pluginIds = new Set(params.pluginIds ?? []); + const browserEntry = params.config?.plugins?.entries?.browser; + const hasBrowserEntry = + browserEntry && typeof browserEntry === "object" && browserEntry.enabled !== false; + return pluginIds.has("browser") && hasBrowserEntry + ? [{ pluginId: "browser", reason: "browser plugin configured" }] + : []; + }, + ), +})); + +vi.mock("../plugins/setup-registry.js", () => ({ + clearPluginSetupRegistryCache: vi.fn(), + resolvePluginSetupAutoEnableReasons: setupRegistryMock.resolvePluginSetupAutoEnableReasons, +})); + const env = makeIsolatedEnv(); afterAll(() => { diff --git a/src/gateway/server-http.test-harness.ts b/src/gateway/server-http.test-harness.ts index fbd7006db01..41fc6654a7a 100644 --- a/src/gateway/server-http.test-harness.ts +++ b/src/gateway/server-http.test-harness.ts @@ -107,11 +107,22 @@ export async function dispatchRequest( req: IncomingMessage, res: ServerResponse, ): Promise { + let timeout: NodeJS.Timeout | undefined; server.emit("request", req, res); - await Promise.race([ - responseEndPromises.get(res) ?? new Promise((resolve) => setImmediate(resolve)), - new Promise((resolve) => setTimeout(resolve, 2_000)), - ]); + try { + await Promise.race([ + responseEndPromises.get(res) ?? new Promise((resolve) => setImmediate(resolve)), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`gateway test request timed out: ${req.method ?? "GET"} ${req.url}`)); + }, 15_000); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } } export async function withGatewayTempConfig( diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index aab3d3cf7d7..021d15d26ca 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -223,7 +223,7 @@ describe("gateway server chat", () => { expect(res.payload?.messageSeq).toBe(1); } finally { testState.sessionStorePath = undefined; - await fs.rm(dir, { recursive: true, force: true }); + await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); } }); 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 c2952d56f1a..40182429325 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -416,21 +416,8 @@ function collectWorkspaceCodeFiles(): string[] { return files; } -function countIdentifierReferences( - files: readonly string[], - excludedFile: string, - name: string, -): number { - let count = 0; - const pattern = new RegExp(`\\b${name}\\b`, "g"); - for (const file of files) { - if (file === excludedFile) { - continue; - } - const source = readFileSync(file, "utf8"); - count += [...source.matchAll(pattern)].length; - } - return count; +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function collectUnusedExtensionTestApiExports(): Array<{ file: string; exportName: string }> { @@ -439,12 +426,54 @@ function collectUnusedExtensionTestApiExports(): Array<{ file: string; exportNam 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 repoRelativePath = relative(REPO_ROOT, file).replaceAll("\\", "/"); const source = readFileSync(file, "utf8"); - for (const exportName of parseTestApiNamedExports(source)) { - if (countIdentifierReferences(workspaceCodeFiles, file, exportName) === 0) { + 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 = 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 = relative(REPO_ROOT, file).replaceAll("\\", "/"); + 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 }); } } diff --git a/src/plugins/contracts/plugin-sdk-root-alias.test.ts b/src/plugins/contracts/plugin-sdk-root-alias.test.ts index 6f5fde93fe2..1b7d9d3a8a7 100644 --- a/src/plugins/contracts/plugin-sdk-root-alias.test.ts +++ b/src/plugins/contracts/plugin-sdk-root-alias.test.ts @@ -9,7 +9,23 @@ const require = createRequire(import.meta.url); const rootAliasPath = fileURLToPath(new URL("../../plugin-sdk/root-alias.cjs", import.meta.url)); const rootSdk = require(rootAliasPath) as Record; const rootAliasSource = fs.readFileSync(rootAliasPath, "utf-8"); +const compatPath = fileURLToPath(new URL("../../plugin-sdk/compat.ts", import.meta.url)); const packageJsonPath = fileURLToPath(new URL("../../../package.json", import.meta.url)); +const legacyRootExportNames = [ + "registerContextEngine", + "buildMemorySystemPromptAddition", + "delegateCompactionToRuntime", + "optionalStringEnum", + "stringEnum", + "buildChannelConfigSchema", + "normalizeAccountId", + "createReplyPrefixContext", + "createReplyPrefixOptions", + "createTypingCallbacks", + "createChannelReplyPipeline", + "resolveChannelSourceReplyDeliveryMode", + "resolvePreferredOpenClawTmpDir", +] as const; type EmptySchema = { safeParse: (value: unknown) => @@ -153,6 +169,52 @@ function expectDiagnosticEventAccessor(lazyModule: ReturnType()): Set { + const normalizedPath = path.resolve(filePath); + if (seen.has(normalizedPath)) { + return new Set(); + } + seen.add(normalizedPath); + const source = fs.readFileSync(normalizedPath, "utf-8"); + const exportNames = new Set(); + + for (const match of source.matchAll(/export\s+(?:const|function|class)\s+([A-Za-z_$][\w$]*)/g)) { + exportNames.add(match[1]); + } + for (const match of source.matchAll(/export\s+(?!type\b)\{([\s\S]*?)\}\s+from\s+"([^"]+)";/g)) { + const names = match[1] + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0 && !part.startsWith("type ")) + .map( + (part) => + part + .split(/\s+as\s+/u) + .at(-1) + ?.trim() ?? part, + ); + for (const name of names) { + exportNames.add(name); + } + } + for (const match of source.matchAll(/export\s+\*\s+from\s+"([^"]+)";/g)) { + const specifier = match[1]; + if (!specifier.startsWith(".")) { + continue; + } + const nestedPath = path.resolve( + path.dirname(normalizedPath), + specifier.replace(/\.(?:mjs|js)$/u, ".ts"), + ); + const nestedExports = collectRuntimeExports(nestedPath, seen); + for (const name of nestedExports) { + exportNames.add(name); + } + } + + return exportNames; +} + describe("plugin-sdk root alias", () => { it("exposes the fast empty config schema helper", () => { const factory = rootSdk.emptyPluginConfigSchema as (() => EmptySchema) | undefined; @@ -457,28 +519,35 @@ describe("plugin-sdk root alias", () => { expect(exportName in lazyModule.moduleExports).toBe(true); }); - it("loads legacy root exports through the merged root wrapper", { timeout: 240_000 }, () => { + it("forwards legacy root exports through the merged root wrapper", () => { + const monolithicExports = Object.fromEntries( + legacyRootExportNames.map((name) => [name, () => name]), + ); + const lazyModule = loadRootAliasWithStubs({ monolithicExports }); + expect(typeof rootSdk.emptyPluginConfigSchema).toBe("function"); - expect(typeof rootSdk.registerContextEngine).toBe("function"); - expect(typeof rootSdk.buildMemorySystemPromptAddition).toBe("function"); - expect(typeof rootSdk.delegateCompactionToRuntime).toBe("function"); expect(typeof rootSdk.resolveControlCommandGate).toBe("function"); expect(typeof rootSdk.onDiagnosticEvent).toBe("function"); - expect(typeof rootSdk.optionalStringEnum).toBe("function"); - expect(typeof rootSdk.stringEnum).toBe("function"); - expect(typeof rootSdk.buildChannelConfigSchema).toBe("function"); - expect(typeof rootSdk.normalizeAccountId).toBe("function"); - expect(typeof rootSdk.createReplyPrefixContext).toBe("function"); - expect(typeof rootSdk.createReplyPrefixOptions).toBe("function"); - expect(typeof rootSdk.createTypingCallbacks).toBe("function"); - expect(typeof rootSdk.createChannelReplyPipeline).toBe("function"); - expect(typeof rootSdk.resolveChannelSourceReplyDeliveryMode).toBe("function"); - expect(typeof rootSdk.resolvePreferredOpenClawTmpDir).toBe("function"); + + for (const name of legacyRootExportNames) { + expect(typeof lazyModule.moduleExports[name]).toBe("function"); + } + expect(lazyModule.jitiLoadCalls).toBe(1); + expect(Object.keys(lazyModule.moduleExports)).toEqual( + expect.arrayContaining([...legacyRootExportNames]), + ); expect(typeof rootSdk.default).toBe("object"); expect(rootSdk.default).toBe(rootSdk); expect(rootSdk.__esModule).toBe(true); }); + it("keeps legacy root export names present in the compat source", () => { + const compatExports = collectRuntimeExports(compatPath); + for (const name of legacyRootExportNames) { + expect(compatExports.has(name)).toBe(true); + } + }); + it("does not publish private local-only plugin-sdk subpaths", () => { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { exports?: Record;