test(cli): verify self-import parity and cleanup

This commit is contained in:
morluto
2026-10-09 07:29:16 +08:00
parent a69b8337ab
commit 55c9ce26d1
2 changed files with 202 additions and 19 deletions
@@ -1,8 +1,10 @@
import { execFile } from "node:child_process";
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { join, relative, resolve } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { afterEach, describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -13,7 +15,11 @@ import {
SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
} from "../../../src/contracts/javascript/javascriptApplicationWorkflowExamples.js";
import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import {
javascriptApplicationAnalysisResultSchema,
type JavaScriptApplicationAnalysisResult,
} from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { z } from "zod";
const execute = promisify(execFile);
const temporary: string[] = [];
@@ -26,6 +32,144 @@ afterEach(async () => {
);
});
const writeSelfImportFixture = async (root: string): Promise<void> => {
await Promise.all([
writeFile(
join(root, "esm.js"),
'import "./esm.js"; export const value = 1;\n',
),
writeFile(
join(root, "common.cjs"),
'const self = require("./common.cjs"); module.exports = self;\n',
),
writeFile(
join(root, "util.ts"),
'import "./util.ts"; export const value = 1;\n',
),
]);
};
const expectSelfImportEvidence = (
graph: JavaScriptApplicationAnalysisResult["graph"],
): void => {
const rawSelfImports = graph.edges.filter(
({ relation, properties, evidence }) =>
relation === "imports" &&
(properties.kind === "static-import" || properties.kind === "require") &&
evidence.location.available &&
evidence.location.value.kind === "source-range" &&
((properties.specifier === "./esm.js" &&
properties.kind === "static-import" &&
evidence.location.value.source === "esm.js") ||
(properties.specifier === "./common.cjs" &&
properties.kind === "require" &&
evidence.location.value.source === "common.cjs") ||
(properties.specifier === "./util.ts" &&
properties.kind === "static-import" &&
evidence.location.value.source === "util.ts")),
);
expect(rawSelfImports).toEqual(
expect.arrayContaining([
expect.objectContaining({
properties: expect.objectContaining({
kind: "static-import",
specifier: "./esm.js",
}),
evidence: expect.objectContaining({
authority: "static-relationship-inference",
location: expect.objectContaining({
available: true,
value: expect.objectContaining({
kind: "source-range",
source: "esm.js",
start: expect.objectContaining({ line: 1, column: 0 }),
}),
}),
}),
}),
expect.objectContaining({
properties: expect.objectContaining({
kind: "require",
specifier: "./common.cjs",
}),
evidence: expect.objectContaining({
authority: "static-relationship-inference",
location: expect.objectContaining({
available: true,
value: expect.objectContaining({
kind: "source-range",
source: "common.cjs",
start: expect.objectContaining({ line: 1, column: 13 }),
}),
}),
}),
}),
expect.objectContaining({
properties: expect.objectContaining({
kind: "static-import",
specifier: "./util.ts",
}),
evidence: expect.objectContaining({
authority: "static-relationship-inference",
location: expect.objectContaining({
available: true,
value: expect.objectContaining({
kind: "source-range",
source: "util.ts",
start: expect.objectContaining({ line: 1, column: 0 }),
}),
}),
}),
}),
]),
);
expect(rawSelfImports).toHaveLength(3);
};
const analyzeThroughStdioMcp = async (
inputPath: string,
): Promise<JavaScriptApplicationAnalysisResult> => {
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
cwd: process.cwd(),
env: {
PATH: process.env.PATH ?? "",
REA_LOG_LEVEL: "silent",
},
stderr: "pipe",
});
const client = new Client({
name: "javascript-self-import-parity",
version: "1",
});
try {
await client.connect(transport);
const response = await client.callTool({
name: "analyze_javascript_application",
arguments: { input_path: inputPath },
});
expect(response.isError).not.toBe(true);
expect(response.content).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "text" })]),
);
const result = z
.object({ result: javascriptApplicationAnalysisResultSchema })
.parse(response.structuredContent).result;
expect(result).toMatchObject({
input_path: inputPath,
format: "directory",
});
return result;
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
};
describe("JavaScript application path CLI", () => {
it("accepts a relative local application path and preserves canonical Evidence identity", async () => {
const root = await createTestTempDirectory("rea-relative-application-cli-");
@@ -52,6 +196,30 @@ describe("JavaScript application path CLI", () => {
subject: { local_path: absolute.value.subject?.local_path },
});
}, 20_000);
it("returns the self-import graph consistently through CLI and stdio MCP", async () => {
const root = await createTestTempDirectory(
"rea-self-import-application-cli-",
);
temporary.push(root);
await writeSelfImportFixture(root);
const analyzed = await runCli([
"analyze-javascript-application",
root,
"--json",
]);
const result = z
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
.parse(analyzed);
const graph = result.normalized_result.graph;
expectSelfImportEvidence(graph);
const mcpResult = await analyzeThroughStdioMcp(root);
expect(mcpResult.graph).toEqual(graph);
expect(mcpResult.semantic_graph).toEqual(
result.normalized_result.semantic_graph,
);
}, 20_000);
});
describe("application workflow CLI parity", () => {
+32 -17
View File
@@ -107,12 +107,19 @@ it("translates SIGINT after real CLI arming into awaited instrumentation cleanup
}
});
it.each([
["observe-web-execution", "SIGINT", 130],
["observe-web-execution", "SIGTERM", 143],
["inspect-web-event-listeners", "SIGINT", 130],
["inspect-web-event-listeners", "SIGTERM", 143],
] as const)(
const cancellationSignals =
process.platform === "win32"
? (["SIGINT"] as const)
: (["SIGINT", "SIGTERM"] as const);
const webRuntimeCancellationCases = (
["observe-web-execution", "inspect-web-event-listeners"] as const
).flatMap((command) =>
cancellationSignals.map(
(signal) => [command, signal, signal === "SIGINT" ? 130 : 143] as const,
),
);
it.each(webRuntimeCancellationCases)(
"%s preserves %s status when cancellation interrupts CDP discovery",
async (command, signal, expectedExitCode) => {
const sockets = new Set<Socket>();
@@ -127,20 +134,24 @@ it.each([
if (address === null || typeof address === "string")
throw new Error("Expected a TCP loopback listener");
const endpoint = `http://127.0.0.1:${String(address.port)}`;
let contactedResolve!: () => void;
const contacted = new Promise<void>((resolve) => {
contactedResolve = resolve;
});
server.on("request", contactedResolve);
const contacted = once(server, "request");
const args =
command === "observe-web-execution"
? [command, endpoint, "page-fixture", "--observation-ms", "30000", "--json"]
? [
command,
endpoint,
"page-fixture",
"--observation-ms",
"30000",
"--json",
]
: [command, endpoint, "page-fixture", "body", "--json"];
const child = spawn(process.execPath, [entrypoint, ...args], {
env: environment(),
stdio: ["ignore", "pipe", "pipe"],
});
const closed = once(child, "close");
let stdout = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
@@ -162,7 +173,6 @@ it.each([
} finally {
if (contactTimeout !== undefined) clearTimeout(contactTimeout);
}
const closed = once(child, "close");
child.kill(signal);
const [exitCode, terminationSignal] = await closed;
expect(terminationSignal).toBeNull();
@@ -170,13 +180,18 @@ it.each([
expect(JSON.parse(stdout)).toMatchObject({
code: "cancelled",
category: "cancelled",
details: { cleanup: "complete" },
});
} finally {
clearTimeout(timeout);
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
try {
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
await closed;
} finally {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
},
);