test: speed extension and contract scenarios

This commit is contained in:
Peter Steinberger
2026-05-06 00:54:06 +01:00
parent cb42efb6e6
commit 093b2b9b5f
16 changed files with 270 additions and 89 deletions

View File

@@ -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);
});

View File

@@ -248,6 +248,7 @@ const toggleStoreLocks = new Map<string, AsyncLock>();
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<void> = Promise.resolve();
@@ -1906,7 +1907,7 @@ async function waitForSubagentPartialTimeoutData(
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<undefined>((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);

View File

@@ -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<RealtimeTranscriptionProviderPlugin["createSession"]>[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 };

View File

@@ -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");

View File

@@ -325,6 +325,7 @@ describe("qa-lab server", () => {
port: 0,
repoRoot,
embeddedGateway: "disabled",
selfCheckWaitTimeoutMs: 1,
});
cleanups.push(async () => {
await lab.stop();

View File

@@ -244,6 +244,7 @@ export async function startQaLabServer(
transportId: "qa-channel",
outputPath: params?.outputPath,
repoRoot,
waitTimeoutMs: params?.selfCheckWaitTimeoutMs,
});
latestScenarioRun = withQaLabRunCounts({
kind: "self-check",

View File

@@ -55,6 +55,7 @@ export type QaLabServerStartParams = {
autoKickoffTarget?: string;
embeddedGateway?: string;
sendKickoffOnStart?: boolean;
selfCheckWaitTimeoutMs?: number;
};
export type QaLabServerHandle = {

View File

@@ -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;
},

View File

@@ -29,6 +29,7 @@ export async function runQaSelfCheckAgainstState(params: {
outputPath?: string;
repoRoot?: string;
notes?: string[];
waitTimeoutMs?: number;
}): Promise<QaSelfCheckResult> {
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",

View File

@@ -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,

View File

@@ -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, /(?<!\.)\bloadConfig\s*\(/)) {
violations.push(
`${relPath}:${line} use a passed cfg, context.getRuntimeConfig(), or getRuntimeConfig() at an explicit process boundary`,
@@ -344,7 +355,7 @@ export function collectDeprecatedInternalConfigApiViolations({
for (const { filePath, relPath } of collectTypeScriptFiles(gatewayServerMethodsRoot)
.map((filePath) => ({ 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, /(?<!\.)\bloadConfig\s*\(/);
if (loadConfigLines.length === 0) {
continue;
@@ -444,7 +455,7 @@ export function collectRuntimeActionLoadConfigViolations({ repoRoot = DEFAULT_RE
.map((filePath) => ({ 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()}`]

View File

@@ -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(() => {

View File

@@ -107,11 +107,22 @@ export async function dispatchRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
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(

View File

@@ -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 });
}
});

View File

@@ -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<string, string[]>();
const exportNames = new Set<string>();
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<string, number>();
const selfReferenceCounts = new Map<string, Map<string, number>>();
for (const file of workspaceCodeFiles) {
const source = 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 = 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 });
}
}

View File

@@ -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<string, unknown>;
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<typeof loadRootAli
).toBe("function");
}
function collectRuntimeExports(filePath: string, seen = new Set<string>()): Set<string> {
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<string>();
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<string, unknown>;