mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
test: speed extension and contract scenarios
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -325,6 +325,7 @@ describe("qa-lab server", () => {
|
||||
port: 0,
|
||||
repoRoot,
|
||||
embeddedGateway: "disabled",
|
||||
selfCheckWaitTimeoutMs: 1,
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await lab.stop();
|
||||
|
||||
@@ -244,6 +244,7 @@ export async function startQaLabServer(
|
||||
transportId: "qa-channel",
|
||||
outputPath: params?.outputPath,
|
||||
repoRoot,
|
||||
waitTimeoutMs: params?.selfCheckWaitTimeoutMs,
|
||||
});
|
||||
latestScenarioRun = withQaLabRunCounts({
|
||||
kind: "self-check",
|
||||
|
||||
@@ -55,6 +55,7 @@ export type QaLabServerStartParams = {
|
||||
autoKickoffTarget?: string;
|
||||
embeddedGateway?: string;
|
||||
sendKickoffOnStart?: boolean;
|
||||
selfCheckWaitTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export type QaLabServerHandle = {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}`]
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user