From 2223da19166a3ec485d98a0d005113a9d0358f37 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 21:08:09 +0000 Subject: [PATCH] test: reduce test suite runtime --- package.json | 1 + scripts/build-cache.mjs | 103 +++++++++++++ scripts/generate-package-metadata.mjs | 18 ++- scripts/record-build-cache.mjs | 7 + tests/artifactProvider.test.ts | 24 ++-- tests/bridgeRegex.test.ts | 200 ++++++++++++++++++++++++-- tests/browserCli.test.ts | 196 +++++++++++++------------ tests/cliOutput.test.ts | 118 ++++++++------- tests/contractSnapshot.test.ts | 39 ++--- tests/enhancedTools.test.ts | 9 +- tests/fixtures/bridgeRegexProbe.py | 10 +- tests/providerArchitecture.test.ts | 32 +++-- tsconfig.build.json | 2 + vitest.config.ts | 30 +++- 14 files changed, 582 insertions(+), 207 deletions(-) create mode 100644 scripts/build-cache.mjs create mode 100644 scripts/record-build-cache.mjs diff --git a/package.json b/package.json index e3a3148a4..4e2f33542 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "scripts": { "prebuild": "node scripts/generate-package-metadata.mjs", "build": "tsc -p tsconfig.build.json", + "postbuild": "node scripts/record-build-cache.mjs", "check": "npm run typecheck && npm run lint && npm run format:check && npm test", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/scripts/build-cache.mjs b/scripts/build-cache.mjs new file mode 100644 index 000000000..9ab4bfc9d --- /dev/null +++ b/scripts/build-cache.mjs @@ -0,0 +1,103 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const CACHE_DIRECTORY = ["node_modules", ".cache"]; +const BUILD_INFO_NAME = "rea-tsconfig-build.tsbuildinfo"; +const MANIFEST_NAME = "rea-tsconfig-build-outputs.json"; + +const isMissing = (cause) => + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ENOENT"; + +const outputFiles = async (root) => { + const sourceRoot = join(root, "src"); + const entries = await readdir(sourceRoot, { recursive: true }); + return entries + .filter((entry) => entry.endsWith(".ts") && !entry.endsWith(".d.ts")) + .sort() + .map((entry) => { + const output = `${entry.slice(0, -3)}.js`; + return { output, path: join(root, "dist", output) }; + }); +}; + +const digest = async (path) => + createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + +const cachePath = (root, name) => join(root, ...CACHE_DIRECTORY, name); + +const invalidate = async (root) => { + await Promise.all([ + rm(cachePath(root, BUILD_INFO_NAME), { force: true }), + rm(cachePath(root, MANIFEST_NAME), { force: true }), + ]); +}; + +const readManifest = async (root) => { + try { + const parsed = JSON.parse( + await readFile(cachePath(root, MANIFEST_NAME), "utf8"), + ); + if ( + typeof parsed !== "object" || + parsed === null || + parsed.version !== 1 || + typeof parsed.outputs !== "object" || + parsed.outputs === null || + Array.isArray(parsed.outputs) + ) + return undefined; + return parsed.outputs; + } catch (cause) { + if (isMissing(cause) || cause instanceof SyntaxError) return undefined; + throw cause; + } +}; + +/** Invalidates incremental state when generated JavaScript was removed or changed. */ +export const validateBuildCache = async (root) => { + const [files, manifest] = await Promise.all([ + outputFiles(root), + readManifest(root), + ]); + if (manifest === undefined || Object.keys(manifest).length !== files.length) { + await invalidate(root); + return; + } + const matches = await Promise.all( + files.map(async ({ output, path }) => { + if (typeof manifest[output] !== "string") return false; + try { + return (await digest(path)) === manifest[output]; + } catch (cause) { + if (isMissing(cause)) return false; + throw cause; + } + }), + ); + if (!matches.every(Boolean)) await invalidate(root); +}; + +/** Records generated JavaScript so a later incremental build can verify its cache. */ +export const recordBuildCache = async (root) => { + const files = await outputFiles(root); + const entries = await Promise.all( + files.map(async ({ output, path }) => [output, await digest(path)]), + ); + const source = `${JSON.stringify({ version: 1, outputs: Object.fromEntries(entries) })}\n`; + const directory = join(root, ...CACHE_DIRECTORY); + await mkdir(directory, { recursive: true }); + const path = cachePath(root, MANIFEST_NAME); + let existing; + try { + existing = await readFile(path, "utf8"); + } catch (cause) { + if (!isMissing(cause)) throw cause; + } + if (existing !== source) await writeFile(path, source, "utf8"); +}; diff --git a/scripts/generate-package-metadata.mjs b/scripts/generate-package-metadata.mjs index aa15cbe54..43ced3f84 100644 --- a/scripts/generate-package-metadata.mjs +++ b/scripts/generate-package-metadata.mjs @@ -2,6 +2,8 @@ import { readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { validateBuildCache } from "./build-cache.mjs"; + const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const packageJson = JSON.parse( await readFile(join(root, "package.json"), "utf8"), @@ -32,4 +34,18 @@ export const PACKAGE_METADATA = { skillVersion: ${JSON.stringify(skillVersion)}, } as const; `; -await writeFile(join(root, "src/generatedPackageMetadata.ts"), source, "utf8"); +const outputPath = join(root, "src/generatedPackageMetadata.ts"); +let existingSource; +try { + existingSource = await readFile(outputPath, "utf8"); +} catch (cause) { + if ( + typeof cause !== "object" || + cause === null || + !("code" in cause) || + cause.code !== "ENOENT" + ) + throw cause; +} +if (existingSource !== source) await writeFile(outputPath, source, "utf8"); +await validateBuildCache(root); diff --git a/scripts/record-build-cache.mjs b/scripts/record-build-cache.mjs new file mode 100644 index 000000000..06b0ed8ad --- /dev/null +++ b/scripts/record-build-cache.mjs @@ -0,0 +1,7 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { recordBuildCache } from "./build-cache.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +await recordBuildCache(root); diff --git a/tests/artifactProvider.test.ts b/tests/artifactProvider.test.ts index b83495aa4..eb88b50e9 100644 --- a/tests/artifactProvider.test.ts +++ b/tests/artifactProvider.test.ts @@ -314,17 +314,19 @@ describe("artifact graph provider", () => { const client = new ArtifactProvider().createClient( target(root, "directory"), ); - const first = await client.execute( - "inventory_artifact", - artifactInventoryInputSchema.parse({ occurrence_limit: 500 }), - ); - const secondPage = await client.execute( - "inventory_artifact", - artifactInventoryInputSchema.parse({ - occurrence_offset: 500, - occurrence_limit: 500, - }), - ); + const [first, secondPage] = await Promise.all([ + client.execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ occurrence_limit: 500 }), + ), + client.execute( + "inventory_artifact", + artifactInventoryInputSchema.parse({ + occurrence_offset: 500, + occurrence_limit: 500, + }), + ), + ]); expect(first.ok && secondPage.ok).toBe(true); if (!first.ok || !secondPage.ok) return; const left = artifactInventoryResultSchema.parse(first.value.result); diff --git a/tests/bridgeRegex.test.ts b/tests/bridgeRegex.test.ts index 1d70a59c0..b882a6d59 100644 --- a/tests/bridgeRegex.test.ts +++ b/tests/bridgeRegex.test.ts @@ -1,11 +1,9 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; -const execFileAsync = promisify(execFile); const bridgePath = fileURLToPath( new URL("../bridge/hopper_bridge.py", import.meta.url), ); @@ -49,6 +47,7 @@ const probeResultSchema = z.union([ matchResultSchema, searchResultSchema, ]); +type ProbeResult = z.infer; type ProbeInput = | { @@ -63,15 +62,194 @@ type ProbeInput = readonly params: Readonly>; }; -const probe = async (input: ProbeInput) => { - const result = await execFileAsync( - "python3", - [probePath, bridgePath, JSON.stringify(input)], - { timeout: 3_000, maxBuffer: 1024 * 1024 }, - ); - return probeResultSchema.parse(JSON.parse(result.stdout)); +interface PendingProbe { + readonly resolve: (result: ProbeResult) => void; + readonly reject: (error: Error) => void; + timeout: NodeJS.Timeout | undefined; +} + +const PROBE_TIMEOUT_MS = 3_000; +const PROBE_BUFFER_LIMIT = 1_024 * 1_024; +const pendingProbes: PendingProbe[] = []; +let probeProcess: ChildProcessWithoutNullStreams | undefined; +let probeStdout = ""; +let probeStderr = ""; +let probeFailure: Error | undefined; +let stoppingProbe = false; + +const failureWithStderr = (message: string): Error => { + const stderr = probeStderr.trim(); + return new Error(stderr.length === 0 ? message : `${message}: ${stderr}`); }; +const rejectPendingProbes = (error: Error): void => { + for (const pending of pendingProbes.splice(0)) { + if (pending.timeout !== undefined) clearTimeout(pending.timeout); + pending.reject(error); + } +}; + +const failProbeProcess = (error: Error): void => { + probeFailure ??= error; + rejectPendingProbes(probeFailure); + if ( + !stoppingProbe && + probeProcess?.exitCode === null && + probeProcess.signalCode === null + ) + probeProcess.kill("SIGKILL"); +}; + +const acceptProbeLine = (line: string): void => { + const pending = pendingProbes.shift(); + if (pending === undefined) { + failProbeProcess( + failureWithStderr("Python probe returned an extra result"), + ); + return; + } + if (pending.timeout !== undefined) clearTimeout(pending.timeout); + try { + const decoded: unknown = JSON.parse(line); + pending.resolve(probeResultSchema.parse(decoded)); + } catch (cause: unknown) { + const error = + cause instanceof Error + ? cause + : failureWithStderr("Python probe returned an invalid result"); + pending.reject(error); + failProbeProcess(error); + } +}; + +const acceptProbeOutput = (chunk: string): void => { + probeStdout += chunk; + if (Buffer.byteLength(probeStdout) > PROBE_BUFFER_LIMIT) { + failProbeProcess(failureWithStderr("Python probe output exceeded 1 MiB")); + return; + } + let newline = probeStdout.indexOf("\n"); + while (newline >= 0) { + const line = probeStdout.slice(0, newline).replace(/\r$/u, ""); + probeStdout = probeStdout.slice(newline + 1); + acceptProbeLine(line); + newline = probeStdout.indexOf("\n"); + } +}; + +const startProbeProcess = async (): Promise => { + stoppingProbe = false; + probeFailure = undefined; + probeStdout = ""; + probeStderr = ""; + const child = spawn("python3", [probePath, bridgePath], { + stdio: ["pipe", "pipe", "pipe"], + }); + probeProcess = child; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", acceptProbeOutput); + child.stderr.on("data", (chunk: string) => { + probeStderr += chunk; + if (Buffer.byteLength(probeStderr) > PROBE_BUFFER_LIMIT) + failProbeProcess(new Error("Python probe stderr exceeded 1 MiB")); + }); + child.stdin.on("error", (error) => { + if (!stoppingProbe) failProbeProcess(error); + }); + child.on("error", (error) => { + if (!stoppingProbe) failProbeProcess(error); + }); + child.on("exit", (code, signal) => { + if (!stoppingProbe) + failProbeProcess( + failureWithStderr( + `Python probe exited before responding (code ${String(code)}, signal ${String(signal)})`, + ), + ); + }); + await new Promise((resolve, reject) => { + const onSpawn = (): void => { + child.off("error", onError); + resolve(); + }; + const onError = (error: Error): void => { + child.off("spawn", onSpawn); + reject(error); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); +}; + +const hasExited = (child: ChildProcessWithoutNullStreams): boolean => + child.exitCode !== null || child.signalCode !== null; + +const waitForExit = ( + child: ChildProcessWithoutNullStreams, + timeoutMs: number, +): Promise => { + if (hasExited(child)) return Promise.resolve(true); + return new Promise((resolve) => { + const onExit = (): void => { + clearTimeout(timeout); + resolve(true); + }; + const timeout = setTimeout(() => { + child.off("exit", onExit); + resolve(false); + }, timeoutMs); + child.once("exit", onExit); + }); +}; + +const stopProbeProcess = async (): Promise => { + const child = probeProcess; + if (child === undefined) return; + stoppingProbe = true; + rejectPendingProbes(new Error("Python probe stopped before responding")); + if (!hasExited(child)) child.stdin.end(); + if (!(await waitForExit(child, 500))) child.kill("SIGTERM"); + if (!(await waitForExit(child, 500))) child.kill("SIGKILL"); + if (!(await waitForExit(child, 500))) + throw new Error("Python probe did not stop after SIGKILL"); + probeProcess = undefined; +}; + +const probe = (input: ProbeInput): Promise => { + const child = probeProcess; + if (probeFailure !== undefined) return Promise.reject(probeFailure); + if (child === undefined || hasExited(child)) + return Promise.reject(new Error("Python probe is not running")); + return new Promise((resolve, reject) => { + const pending: PendingProbe = { resolve, reject, timeout: undefined }; + pending.timeout = setTimeout(() => { + failProbeProcess( + new Error( + `Python probe request timed out after ${String(PROBE_TIMEOUT_MS)} ms`, + ), + ); + }, PROBE_TIMEOUT_MS); + pendingProbes.push(pending); + child.stdin.write(`${JSON.stringify(input)}\n`, (error) => { + if (error !== null && error !== undefined) failProbeProcess(error); + }); + }); +}; + +beforeAll(startProbeProcess); +beforeEach(async () => { + if ( + probeFailure === undefined && + probeProcess !== undefined && + !hasExited(probeProcess) + ) + return; + await stopProbeProcess(); + await startProbeProcess(); +}); +afterAll(stopProbeProcess); + const invalidRequest = (action: "match" | "search", message: string) => ({ action, ok: false, diff --git a/tests/browserCli.test.ts b/tests/browserCli.test.ts index 36880527a..85338a399 100644 --- a/tests/browserCli.test.ts +++ b/tests/browserCli.test.ts @@ -34,10 +34,30 @@ describe("browser CLI parity", () => { browser.allowedOrigin, ]), }; - const listed = await runCli( - ["list-browser-targets", browser.endpoint, "--approved", "--json"], - environment, - ); + const [listed, inspected] = await Promise.all([ + runCli( + ["list-browser-targets", browser.endpoint, "--approved", "--json"], + environment, + ), + runCli( + [ + "inspect-web-page", + browser.endpoint, + "allowed-page", + "--approved", + "--observation-ms", + "0", + "--include-console-text", + "--console-text-approved", + "--include-json-body-shapes", + "--json-body-schema-approved", + "--include-websocket-shapes", + "--websocket-shape-approved", + "--json", + ], + environment, + ), + ]); expect(listed).toMatchObject({ operation: "list_browser_targets", provider: { id: "rea-cdp-browser" }, @@ -45,24 +65,6 @@ describe("browser CLI parity", () => { targets: { items: [{ target_id: "allowed-page" }] }, }, }); - const inspected = await runCli( - [ - "inspect-web-page", - browser.endpoint, - "allowed-page", - "--approved", - "--observation-ms", - "0", - "--include-console-text", - "--console-text-approved", - "--include-json-body-shapes", - "--json-body-schema-approved", - "--include-websocket-shapes", - "--websocket-shape-approved", - "--json", - ], - environment, - ); expect(inspected).toMatchObject({ operation: "inspect_web_page", provider: { id: "rea-cdp-browser" }, @@ -79,15 +81,30 @@ describe("browser CLI parity", () => { }, }); const inspection = normalizedResult(inspected); - const compared = await runCli( - [ - "compare-web-captures", - JSON.stringify({ inspection }), - JSON.stringify({ inspection }), - "--json", - ], - environment, - ); + const [compared, analyzed] = await Promise.all([ + runCli( + [ + "compare-web-captures", + JSON.stringify({ inspection }), + JSON.stringify({ inspection }), + "--json", + ], + environment, + ), + runCli( + [ + "analyze-web-bundle", + browser.endpoint, + "allowed-page", + "--approved", + "--source-capture-approved", + "--observation-ms", + "0", + "--json", + ], + environment, + ), + ]); expect(compared).toMatchObject({ operation: "compare_web_captures", normalized_result: { @@ -97,19 +114,6 @@ describe("browser CLI parity", () => { }, }, }); - const analyzed = await runCli( - [ - "analyze-web-bundle", - browser.endpoint, - "allowed-page", - "--approved", - "--source-capture-approved", - "--observation-ms", - "0", - "--json", - ], - environment, - ); expect(analyzed).toMatchObject({ operation: "analyze_web_bundle", normalized_result: { @@ -117,18 +121,32 @@ describe("browser CLI parity", () => { observations: { source_maps: { status: "not_requested" } }, }, }); - const observedSession = await runCli( - [ - "observe-web-session", - browser.endpoint, - "allowed-page", - "--approved", - "--observation-ms", - "5", - "--json", - ], - environment, - ); + const [observedSession, webMcp] = await Promise.all([ + runCli( + [ + "observe-web-session", + browser.endpoint, + "allowed-page", + "--approved", + "--observation-ms", + "5", + "--json", + ], + environment, + ), + runCli( + [ + "discover-webmcp-tools", + browser.endpoint, + "allowed-page", + "--approved", + "--observation-ms", + "0", + "--json", + ], + environment, + ), + ]); expect(observedSession).toMatchObject({ operation: "observe_web_session", normalized_result: { @@ -138,18 +156,6 @@ describe("browser CLI parity", () => { ]), }, }); - const webMcp = await runCli( - [ - "discover-webmcp-tools", - browser.endpoint, - "allowed-page", - "--approved", - "--observation-ms", - "0", - "--json", - ], - environment, - ); expect(webMcp).toMatchObject({ operation: "discover_webmcp_tools", normalized_result: { @@ -158,17 +164,34 @@ describe("browser CLI parity", () => { }, }, }); - const screenshot = await runCli( - [ - "capture-web-screenshot", - browser.endpoint, - "allowed-page", - "--approved", - "--screenshot-approved", - "--json", - ], - environment, - ); + const [screenshot, policy] = await Promise.all([ + runCli( + [ + "capture-web-screenshot", + browser.endpoint, + "allowed-page", + "--approved", + "--screenshot-approved", + "--json", + ], + environment, + ), + runCli( + [ + "policy", + "explain", + "browser_observe", + "--origins", + browser.endpoint, + "--origins", + browser.allowedOrigin, + "--network", + "loopback", + "--json", + ], + environment, + ), + ]); const artifact = screenshotArtifact(screenshot); expect(artifact).toMatchObject({ media_type: "image/png", bytes: 70 }); const visual = await runCli( @@ -184,21 +207,6 @@ describe("browser CLI parity", () => { operation: "compare_web_screenshots", normalized_result: { status: "identical", changed_pixels: 0 }, }); - const policy = await runCli( - [ - "policy", - "explain", - "browser_observe", - "--origins", - browser.endpoint, - "--origins", - browser.allowedOrigin, - "--network", - "loopback", - "--json", - ], - environment, - ); expect(policy).toEqual({ allowed: true, grant_id: "administrator:browser_observe", diff --git a/tests/cliOutput.test.ts b/tests/cliOutput.test.ts index 5373a070d..1af676f01 100644 --- a/tests/cliOutput.test.ts +++ b/tests/cliOutput.test.ts @@ -66,33 +66,35 @@ describe("CLI output boundary", () => { it( "sanitizes a real missing-argument dispatcher failure", async () => { - await expect( - execFileAsync(process.execPath, ["scripts/rea.mjs", "analyze"], { - cwd: process.cwd(), + await Promise.all([ + expect( + execFileAsync(process.execPath, ["scripts/rea.mjs", "analyze"], { + cwd: process.cwd(), + }), + ).rejects.toMatchObject({ + stdout: + 'code: VALIDATION_ERROR\nmessage: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n', }), - ).rejects.toMatchObject({ - stdout: - 'code: VALIDATION_ERROR\nmessage: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n', - }); - await expect( - execFileAsync( - process.execPath, - ["scripts/rea.mjs", "--full-output", "--json", "analyze"], - { cwd: process.cwd() }, - ), - ).rejects.toMatchObject({ - stdout: expect.not.stringContaining("fieldErrors"), - }); - await expect( - execFileAsync( - process.execPath, - ["scripts/rea.mjs", "--full-output", "analyze"], - { cwd: process.cwd() }, - ), - ).rejects.toMatchObject({ - stdout: - 'ok: false\nerror:\n code: VALIDATION_ERROR\n message: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n', - }); + expect( + execFileAsync( + process.execPath, + ["scripts/rea.mjs", "--full-output", "--json", "analyze"], + { cwd: process.cwd() }, + ), + ).rejects.toMatchObject({ + stdout: expect.not.stringContaining("fieldErrors"), + }), + expect( + execFileAsync( + process.execPath, + ["scripts/rea.mjs", "--full-output", "analyze"], + { cwd: process.cwd() }, + ), + ).rejects.toMatchObject({ + stdout: + 'ok: false\nerror:\n code: VALIDATION_ERROR\n message: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n', + }), + ]); }, CLI_INTEGRATION_TIMEOUT_MS, ); @@ -108,22 +110,24 @@ describe("CLI output boundary", () => { await createPackageWithOptions(source, archive, { unpack: "*.js" }); await writeFile(join(`${archive}.unpacked`, "main.js"), "changed();\n"); - for (const flags of [["--json"], ["--full-output", "--json"]]) { - const execution = execFileAsync(process.execPath, [ - "scripts/rea.mjs", - ...flags, - "inventory-artifact", - archive, - ]); - const failure = await execution.catch((cause: unknown) => cause); - expect(failure).toMatchObject({ code: 1 }); - const { stdout } = failure as { readonly stdout: string }; - const output = JSON.stringify(JSON.parse(stdout) as unknown); - expect(output).toContain('"logical_path":"main.js"'); - expect(output).toMatch(/"declared_sha256":"[a-f0-9]{64}"/u); - expect(output).toMatch(/"calculated_sha256":"[a-f0-9]{64}"/u); - expect(output).toContain('"unpacked":true'); - } + await Promise.all( + [["--json"], ["--full-output", "--json"]].map(async (flags) => { + const execution = execFileAsync(process.execPath, [ + "scripts/rea.mjs", + ...flags, + "inventory-artifact", + archive, + ]); + const failure = await execution.catch((cause: unknown) => cause); + expect(failure).toMatchObject({ code: 1 }); + const { stdout } = failure as { readonly stdout: string }; + const output = JSON.stringify(JSON.parse(stdout) as unknown); + expect(output).toContain('"logical_path":"main.js"'); + expect(output).toMatch(/"declared_sha256":"[a-f0-9]{64}"/u); + expect(output).toMatch(/"calculated_sha256":"[a-f0-9]{64}"/u); + expect(output).toContain('"unpacked":true'); + }), + ); }, CLI_INTEGRATION_TIMEOUT_MS, ); @@ -143,18 +147,24 @@ describe("CLI output boundary", () => { ["--token-count", "--json"], ]; - for (const flags of variants) { - await expect( - execFileAsync(process.execPath, [ - "scripts/rea.mjs", - ...flags, - "investigate-versions", - "/tmp/left", - "/tmp/right", - "/tmp/workspace.json", - ]), - ).rejects.toMatchObject({ code: 1, stdout: expect.any(String) }); - } + for (let index = 0; index < variants.length; index += 2) + await Promise.all( + variants.slice(index, index + 2).map(async (flags) => { + await expect( + execFileAsync(process.execPath, [ + "scripts/rea.mjs", + ...flags, + "investigate-versions", + "/tmp/left", + "/tmp/right", + "/tmp/workspace.json", + ]), + ).rejects.toMatchObject({ + code: 1, + stdout: expect.any(String), + }); + }), + ); }, CLI_INTEGRATION_TIMEOUT_MS, ); diff --git a/tests/contractSnapshot.test.ts b/tests/contractSnapshot.test.ts index ff56c53ba..0d7b68041 100644 --- a/tests/contractSnapshot.test.ts +++ b/tests/contractSnapshot.test.ts @@ -16,6 +16,25 @@ import { sessionOutputSchemas, } from "../src/contracts/toolOutputSchemas.js"; +const convertContractJsonSchema = (schema: z.ZodType) => + z.toJSONSchema(schema, { + target: "draft-07", + unrepresentable: "any", + }); + +const jsonSchemaCache = new WeakMap< + z.ZodType, + ReturnType +>(); + +const contractJsonSchema = (schema: z.ZodType) => { + const cached = jsonSchemaCache.get(schema); + if (cached !== undefined) return cached; + const converted = convertContractJsonSchema(schema); + jsonSchemaCache.set(schema, converted); + return converted; +}; + const emptySchemaPaths = (value: unknown, path = "$"): string[] => { if (Array.isArray(value)) return value.flatMap((item, index) => @@ -41,14 +60,8 @@ describe("tool contract surface", () => { ]; expect(contracts).toHaveLength(60); for (const contract of contracts) { - const inputSchema = z.toJSONSchema(contract.inputSchema, { - target: "draft-07", - unrepresentable: "any", - }); - const outputSchema = z.toJSONSchema(contract.outputSchema, { - target: "draft-07", - unrepresentable: "any", - }); + const inputSchema = contractJsonSchema(contract.inputSchema); + const outputSchema = contractJsonSchema(contract.outputSchema); expect(inputSchema.type).toBe("object"); expect(outputSchema.type).toBe("object"); expect(typeof contract.annotations.idempotentHint).toBe("boolean"); @@ -128,10 +141,7 @@ describe("tool contract surface", () => { ...ENHANCED_TOOL_CONTRACTS, ...SESSION_TOOL_CONTRACTS, ]) { - const schema = z.toJSONSchema(contract.outputSchema, { - target: "draft-07", - unrepresentable: "any", - }); + const schema = contractJsonSchema(contract.outputSchema); expect(JSON.stringify(schema)).not.toContain('"result":{}'); expect(contract.description.length).toBeGreaterThan(100); expect(contract.description).toMatch(/[.;]/u); @@ -150,10 +160,7 @@ describe("tool contract surface", () => { ]; expect(contracts).toHaveLength(78); for (const contract of contracts) { - const schema = z.toJSONSchema(contract.outputSchema, { - target: "draft-07", - unrepresentable: "any", - }); + const schema = contractJsonSchema(contract.outputSchema); expect(emptySchemaPaths(schema), contract.name).toEqual([]); } }); diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index 4b1a3b01f..70ab4c67c 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -214,12 +214,11 @@ describe("enhanced MCP tools", () => { ["analyze_function", { procedure: "0x1" }], ["trace_feature", { query: "hello", max_operations: 10 }], ] as const; - const results = []; - for (const [name, arguments_] of calls) { - results.push( + const results = await Promise.all( + calls.map(async ([name, arguments_]) => jsonResult(await client.callTool({ name, arguments: arguments_ })), - ); - } + ), + ); expect(results[0]).toMatchObject({ count: 1 }); expect(results[1]).toMatchObject({ count: 1 }); expect(results[2]).toMatchObject({ count: 1 }); diff --git a/tests/fixtures/bridgeRegexProbe.py b/tests/fixtures/bridgeRegexProbe.py index 48509dba0..d4655670b 100644 --- a/tests/fixtures/bridgeRegexProbe.py +++ b/tests/fixtures/bridgeRegexProbe.py @@ -58,5 +58,11 @@ def _probe(namespace, payload): if __name__ == "__main__": bridge = _load_bridge(sys.argv[1]) - request = json.loads(sys.argv[2]) - print(json.dumps(_probe(bridge, request), sort_keys=True)) + if len(sys.argv) >= 3: + print(json.dumps(_probe(bridge, json.loads(sys.argv[2])), sort_keys=True)) + else: + for line in sys.stdin: + if not line.strip(): + continue + request = json.loads(line) + print(json.dumps(_probe(bridge, request), sort_keys=True), flush=True) diff --git a/tests/providerArchitecture.test.ts b/tests/providerArchitecture.test.ts index 54844b7db..2d8e768b5 100644 --- a/tests/providerArchitecture.test.ts +++ b/tests/providerArchitecture.test.ts @@ -13,18 +13,26 @@ const sourceRoot = new URL("../src/", import.meta.url); describe("provider-neutral architecture", () => { it("keeps Hopper imports behind its adapter and composition root", async () => { const forbiddenRoots = ["domain", "contracts", "server", "application"]; - const violations: string[] = []; - for (const root of forbiddenRoots) { - for (const file of await typescriptFiles( - fileURLToPath(new URL(`${root}/`, sourceRoot)), - )) { - if (root === "application" && file.endsWith("/runtime.ts")) continue; - const source = await readFile(file, "utf8"); - if (/from\s+["'][^"']*\/hopper\//u.test(source)) { - violations.push(relative(fileURLToPath(sourceRoot), file)); - } - } - } + const inspected = await Promise.all( + forbiddenRoots.map(async (root) => { + const files = await typescriptFiles( + fileURLToPath(new URL(`${root}/`, sourceRoot)), + ); + return Promise.all( + files.map(async (file) => { + if (root === "application" && file.endsWith("/runtime.ts")) + return undefined; + const source = await readFile(file, "utf8"); + return /from\s+["'][^"']*\/hopper\//u.test(source) + ? relative(fileURLToPath(sourceRoot), file) + : undefined; + }), + ); + }), + ); + const violations = inspected + .flat() + .filter((violation) => violation !== undefined); expect(violations).toEqual([]); }); diff --git a/tsconfig.build.json b/tsconfig.build.json index 49b57cbfe..f58161bd5 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,7 +1,9 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "incremental": true, "rootDir": "src", + "tsBuildInfoFile": "node_modules/.cache/rea-tsconfig-build.tsbuildinfo", "types": ["node"] }, "include": ["src/**/*.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index 90c93393d..f439c57c5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,36 @@ +import { availableParallelism } from "node:os"; + import { defineConfig } from "vitest/config"; +const TEST_FILES = ["tests/**/*.test.ts"]; +const PROCESS_CAPTURE_TEST = "tests/processCapture.test.ts"; +const dualCoreProjects = + availableParallelism() === 2 + ? { + maxWorkers: 2, + projects: [ + { + test: { + name: "parallel", + include: TEST_FILES, + exclude: [PROCESS_CAPTURE_TEST], + }, + }, + { + test: { + name: "process-capture", + include: [PROCESS_CAPTURE_TEST], + fileParallelism: false, + }, + }, + ], + } + : {}; + export default defineConfig({ test: { - include: ["tests/**/*.test.ts"], + include: TEST_FILES, + ...dualCoreProjects, retry: 2, reporters: ["default", "verbose"], coverage: {