Files
rea/tests/boundary/mcp/javascriptAnalysisCancellation.test.ts

155 lines
5.1 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import pino from "pino";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { createEvidence } from "../../../src/domain/evidence.js";
import { createServer } from "../../../src/server/createServer.js";
import {
createDeferred,
createTestBinarySession,
} from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.each([
{ phase: "parse_javascript_source", message: "second.js", parsedFiles: 1 },
{
phase: "analyze_javascript_application",
message: "Application graph and Electron boundaries reconstructed",
parsedFiles: 2,
},
])(
"retains completed facts and prior Evidence after SDK cancellation during $phase, then accepts subsequent work",
async ({ phase, message, parsedFiles }) => {
const root = await createTestTempDirectory("rea-mcp-js-cancellation-");
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
await writeFile(join(root, "second.js"), "export const pending = 2;\n");
const session = createTestBinarySession(() => {
throw new Error(
"Static JavaScript analysis must not start a binary provider",
);
});
const prior = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "prior_observation",
parameters: {},
result: { preserved: true },
},
);
expect(session.recordEvidence(prior).ok).toBe(true);
const completed = createDeferred<string>();
const completionSchema = z.object({
tool: z.string(),
status: z.string(),
msg: z.string(),
});
const logger = pino(
{ level: "info" },
{
write(line) {
const value: unknown = JSON.parse(line);
const parsed = completionSchema.safeParse(value);
if (
parsed.success &&
parsed.data.tool === "analyze_javascript_application" &&
parsed.data.msg === "MCP tool execution completed"
)
completed.resolve(parsed.data.status);
},
},
);
const server = createServer({ kind: "session", session }, { logger });
const client = new Client({
name: "javascript-cancellation",
version: "1",
});
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const controller = new AbortController();
let requested = false;
const call = client.callTool(
{
name: "analyze_javascript_application",
arguments: {
input_path: root,
format: "directory",
},
},
{
signal: controller.signal,
onprogress(update) {
if (
!update.message?.startsWith(`${phase}:`) ||
!update.message.includes(message)
)
return;
requested = true;
controller.abort(new Error("SDK cancellation verification"));
},
},
);
await expect(call).rejects.toThrow("SDK cancellation verification");
expect(requested).toBe(true);
// A rejected client promise alone does not prove that the server stopped.
expect(await completed.promise).toBe("error");
await client.ping();
const bundle = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
const records = z
.object({
result: z.object({
records: z.array(z.object({ evidence_id: z.string() })),
}),
})
.parse(bundle.structuredContent).result.records;
expect(records).toContainEqual({ evidence_id: prior.evidence_id });
const partial = records.find(
({ evidence_id }) => evidence_id !== prior.evidence_id,
);
if (partial === undefined)
throw new Error(
"Cancelled analysis discarded its completed source facts",
);
expect(
z
.object({
statistics: z.object({
parsed_javascript_files: z.number(),
parse_failures: z.number(),
}),
})
.parse(session.evidenceById(partial.evidence_id)?.normalized_result)
.statistics,
).toEqual({ parsed_javascript_files: parsedFiles, parse_failures: 0 });
const view = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id: partial.evidence_id },
view: { kind: "summary" },
},
});
expect(view.isError, JSON.stringify(view)).not.toBe(true);
const next = await client.callTool({
name: "analyze_javascript_application",
arguments: {
input_path: root,
format: "directory",
},
});
expect(next.isError, JSON.stringify(next)).not.toBe(true);
await client.ping();
},
);