test: reduce test suite runtime
This commit is contained in:
parent
528c05e2fa
commit
2223da1916
|
|
@ -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 .",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<typeof probeResultSchema>;
|
||||
|
||||
type ProbeInput =
|
||||
| {
|
||||
|
|
@ -63,15 +62,194 @@ type ProbeInput =
|
|||
readonly params: Readonly<Record<string, string | number | boolean>>;
|
||||
};
|
||||
|
||||
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<void> => {
|
||||
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<void>((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<boolean> => {
|
||||
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<void> => {
|
||||
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<ProbeResult> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<typeof convertContractJsonSchema>
|
||||
>();
|
||||
|
||||
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([]);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue