132 lines
4.9 KiB
TypeScript
132 lines
4.9 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import { expect, it, onTestFinished } from "vitest";
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { compareProcessCaptures } from "../../../src/domain/process/processComparison.js";
|
|
import { parseProcessCapture } from "../../../src/domain/process/processCaptureParsing.js";
|
|
import { createServer } from "../../../src/server/createServer.js";
|
|
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import {
|
|
CAPTURE_SKIP_REASON,
|
|
itWithCaptureCapability,
|
|
} from "./processCaptureCapability.js";
|
|
|
|
const exec = promisify(execFile);
|
|
|
|
const captureViaMcp = async (scenario: Record<string, unknown>) => {
|
|
const session = createTestBinarySession(() => {
|
|
throw new Error("Process capture must not launch a binary provider");
|
|
});
|
|
const server = createServer({ kind: "session", session });
|
|
const client = new Client({ name: "filesystem-coverage", 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 result = await client.callTool({
|
|
name: "capture_process_scenario",
|
|
arguments: scenario,
|
|
});
|
|
expect(result.isError, JSON.stringify(result)).not.toBe(true);
|
|
const evidence = parseEvidence(result.structuredContent);
|
|
await client.ping();
|
|
return evidence;
|
|
};
|
|
|
|
itWithCaptureCapability.each(["cli", "mcp"] as const)(
|
|
"reports unknown absence instead of deletion in truncated %s capture Evidence",
|
|
async (adapter) => {
|
|
const root = await createTestTempDirectory("rea-fs-coverage-adapter-");
|
|
await writeFile(join(root, "z.txt"), "unchanged");
|
|
const scenario = {
|
|
executable: process.execPath,
|
|
arguments: ["-e", "require('node:fs').writeFileSync('a.txt','added')"],
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
limits: { files: 2 },
|
|
};
|
|
let evidence;
|
|
if (adapter === "cli") {
|
|
const scenarioPath = join(
|
|
await createTestTempDirectory("rea-fs-scenario-"),
|
|
"scenario.json",
|
|
);
|
|
await writeFile(scenarioPath, JSON.stringify(scenario));
|
|
const { stdout } = await exec(process.execPath, [
|
|
"scripts/rea.mjs",
|
|
"capture-process",
|
|
scenarioPath,
|
|
"--format",
|
|
"json",
|
|
]);
|
|
evidence = parseEvidence(JSON.parse(stdout));
|
|
} else evidence = await captureViaMcp(scenario);
|
|
const capture = parseProcessCapture(evidence.normalized_result);
|
|
expect(capture.truncated).toBe(true);
|
|
expect(await readFile(join(root, "z.txt"), "utf8")).toBe("unchanged");
|
|
expect(
|
|
capture.filesystem_effects.find(({ path }) => path === "root_0:z.txt")
|
|
?.status,
|
|
).toBe("unknown");
|
|
expect(
|
|
capture.filesystem_checkpoints
|
|
.find(({ name }) => name === "after_settlement")
|
|
?.effects.find(({ path }) => path === "root_0:z.txt")?.status,
|
|
).toBe("unknown");
|
|
},
|
|
20_000,
|
|
);
|
|
|
|
it.skipIf(CAPTURE_SKIP_REASON || process.platform === "win32")(
|
|
"keeps unfollowed descendant absence unknown through MCP Evidence and comparison",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-fs-symlink-adapter-");
|
|
const target = await createTestTempDirectory(
|
|
"rea-fs-symlink-adapter-target-",
|
|
);
|
|
await mkdir(join(root, "subtree"));
|
|
await writeFile(join(root, "subtree", "z.txt"), "unchanged");
|
|
await writeFile(join(target, "z.txt"), "unchanged");
|
|
await writeFile(join(root, "removed.txt"), "removed");
|
|
const evidence = await captureViaMcp({
|
|
executable: process.execPath,
|
|
arguments: [
|
|
"-e",
|
|
"const fs=require('node:fs');fs.rmSync('subtree',{recursive:true});fs.symlinkSync(process.argv[1],'subtree');fs.unlinkSync('removed.txt');",
|
|
target,
|
|
],
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
});
|
|
const capture = parseProcessCapture(evidence.normalized_result);
|
|
expect(await readFile(join(root, "subtree", "z.txt"), "utf8")).toBe(
|
|
"unchanged",
|
|
);
|
|
expect(
|
|
capture.filesystem_effects.find(
|
|
({ path }) => path === "root_0:subtree/z.txt",
|
|
)?.status,
|
|
).toBe("unknown");
|
|
expect(
|
|
capture.filesystem_effects.find(
|
|
({ path }) => path === "root_0:removed.txt",
|
|
)?.status,
|
|
).toBe("deleted");
|
|
expect(capture.residual_unknowns).toContainEqual({
|
|
scope: "filesystem",
|
|
reason: expect.any(String),
|
|
});
|
|
expect(compareProcessCaptures(capture, capture).filesystem).toBe("unknown");
|
|
},
|
|
20_000,
|
|
);
|