rea/tests/processTraceAdapters.test.ts

410 lines
12 KiB
TypeScript

import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, describe, expect, it } from "vitest";
import { BinarySession } from "../src/application/BinarySession.js";
import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
import { compareProcessEvidenceFiles } from "../src/application/ProcessCli.js";
import { PROCESS_PROVIDER } from "../src/application/ProcessEvidence.js";
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js";
import { createEvidence, parseEvidence } from "../src/domain/evidence.js";
import { jsonValueSchema } from "../src/domain/jsonValue.js";
import {
compareProcessCaptures,
processCaptureSchema,
} from "../src/domain/processCapture.js";
import type { ProcessTraceSpecification } from "../src/domain/processTraceComparison.js";
import { observed as ok } from "./fixtures/analysisExecution.js";
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
import { createServer } from "../src/server/createServer.js";
const resources: Array<{ close(): Promise<void> }> = [];
const temporaryRoots: string[] = [];
const beforeCheckpoint = {
name: "before",
at_ms: 0,
files: [],
effects: [],
truncated: false,
};
const afterCheckpoint = {
name: "after_settlement",
at_ms: 50,
files: [],
effects: [],
truncated: false,
};
afterEach(async () => {
await Promise.all(resources.splice(0).map((resource) => resource.close()));
await Promise.all(
temporaryRoots
.splice(0)
.map((root) => rm(root, { recursive: true, force: true })),
);
});
const capture = {
...EMPTY_PROCESS_CAPTURE_EXAMPLE,
event_journal: [
{ capture_order: 0, collection: "filesystem_checkpoints", index: 0 },
{ capture_order: 1, collection: "lifecycle", index: 0 },
{ capture_order: 2, collection: "lifecycle", index: 1 },
{ capture_order: 3, collection: "filesystem_checkpoints", index: 1 },
],
} as const;
const traceSpecification: ProcessTraceSpecification = {
version: 1,
events: [
{
id: "before",
source: "filesystem",
exact: beforeCheckpoint,
cardinality: { kind: "required" },
},
{
id: "exit",
source: "lifecycle",
exact: { event: "exit", ...capture.exit },
cardinality: { kind: "required" },
},
{
id: "settlement",
source: "lifecycle",
exact: { event: "settlement", ...capture.settlement },
cardinality: { kind: "required" },
},
{
id: "after",
source: "filesystem",
exact: afterCheckpoint,
cardinality: { kind: "required" },
},
],
language: {
kind: "finite_traces",
variants: [
{
id: "normal",
trace: ["before", "exit", "settlement", "after"],
},
],
},
};
const captureEvidence = (side: "left" | "right") =>
createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-capture/v4",
operation: "capture_process_scenario",
parameters: { side },
result: jsonValueSchema.parse(capture),
confidence: "observed",
authority: "controlled-replay",
locations: [{ kind: "artifact-path", path: `/fixture/${side}` }],
});
describe("declared trace comparison adapters", () => {
it("treats only explicitly ignored schedule metadata as reorderable", () => {
const httpPayload = {
protocol: "http" as const,
direction: "request" as const,
method: "GET",
path: "/status",
data: "",
outcome: "unmatched" as const,
};
const websocketPayload = {
protocol: "websocket" as const,
direction: "received" as const,
method: null,
path: "/ws",
data: "done",
outcome: "matched" as const,
};
const scheduledCapture = (order: "http-first" | "websocket-first") => {
const protocolEvents =
order === "http-first"
? [
{ sequence: 0, at_ms: 1, ...httpPayload },
{ sequence: 1, at_ms: 2, ...websocketPayload },
]
: [
{ sequence: 0, at_ms: 1, ...websocketPayload },
{ sequence: 1, at_ms: 2, ...httpPayload },
];
return processCaptureSchema.parse({
...capture,
protocol_events: protocolEvents,
event_journal: [
{
capture_order: 0,
collection: "filesystem_checkpoints",
index: 0,
},
{ capture_order: 1, collection: "protocol_events", index: 0 },
{ capture_order: 2, collection: "protocol_events", index: 1 },
{ capture_order: 3, collection: "lifecycle", index: 0 },
{ capture_order: 4, collection: "lifecycle", index: 1 },
{
capture_order: 5,
collection: "filesystem_checkpoints",
index: 1,
},
],
});
};
const specification: ProcessTraceSpecification = {
version: 1,
events: [
{
id: "status",
source: "http",
exact: httpPayload,
ignore_fields: ["sequence", "at_ms"],
cardinality: { kind: "required" },
},
{
id: "done",
source: "websocket",
exact: websocketPayload,
ignore_fields: ["sequence", "at_ms"],
cardinality: { kind: "required" },
},
],
language: {
kind: "partial_order",
happens_before: [],
not_before: [],
unordered_groups: [{ events: ["status", "done"] }],
prefix: [],
suffix: [],
},
};
expect(
compareProcessCaptures(
scheduledCapture("http-first"),
scheduledCapture("websocket-first"),
{ traceSpecification: specification },
),
).toMatchObject({
status: "unchanged",
protocol: "unchanged",
trace: {
verdict: "equivalent",
left: { raw_trace: [{ event_id: "status" }, { event_id: "done" }] },
right: { raw_trace: [{ event_id: "done" }, { event_id: "status" }] },
},
});
});
it("does not mask an unselected source in a covered legacy dimension", () => {
const http = {
sequence: 0,
at_ms: 1,
protocol: "http" as const,
direction: "request" as const,
method: "GET",
path: "/status",
data: "",
outcome: "unmatched" as const,
};
const websocket = (data: string) => ({
sequence: 1,
at_ms: 2,
protocol: "websocket" as const,
direction: "received" as const,
method: null,
path: "/ws",
data,
outcome: "matched" as const,
});
const withProtocol = (data: string) =>
processCaptureSchema.parse({
...capture,
protocol_events: [http, websocket(data)],
event_journal: [
{
capture_order: 0,
collection: "filesystem_checkpoints",
index: 0,
},
{ capture_order: 1, collection: "protocol_events", index: 0 },
{ capture_order: 2, collection: "protocol_events", index: 1 },
{ capture_order: 3, collection: "lifecycle", index: 0 },
{ capture_order: 4, collection: "lifecycle", index: 1 },
{
capture_order: 5,
collection: "filesystem_checkpoints",
index: 1,
},
],
});
const specification: ProcessTraceSpecification = {
version: 1,
events: [
{
id: "status",
source: "http",
exact: http,
cardinality: { kind: "required" },
},
],
language: {
kind: "finite_traces",
variants: [{ id: "status", trace: ["status"] }],
},
};
expect(
compareProcessCaptures(withProtocol("left"), withProtocol("right"), {
traceSpecification: specification,
}),
).toMatchObject({
status: "changed",
protocol: "changed",
trace: { verdict: "equivalent" },
});
});
it("does not call identical captures changed when both violate the language", () => {
const parsedCapture = processCaptureSchema.parse(capture);
const specification: ProcessTraceSpecification = {
...traceSpecification,
language: {
kind: "finite_traces",
variants: [
{
id: "reversed",
trace: ["after", "settlement", "exit", "before"],
},
],
},
};
expect(
compareProcessCaptures(parsedCapture, parsedCapture, {
traceSpecification: specification,
}),
).toMatchObject({
status: "unchanged",
trace: { verdict: "nonconforming" },
});
});
it("does not mask distinct payloads when both traces are nonconforming", () => {
const withTerminal = (data: string) =>
processCaptureSchema.parse({
...capture,
frames: [{ sequence: 0, at_ms: 1, data }],
event_journal: [
{
capture_order: 0,
collection: "filesystem_checkpoints",
index: 0,
},
{ capture_order: 1, collection: "frames", index: 0 },
{ capture_order: 2, collection: "lifecycle", index: 0 },
{ capture_order: 3, collection: "lifecycle", index: 1 },
{
capture_order: 4,
collection: "filesystem_checkpoints",
index: 1,
},
],
});
const specification: ProcessTraceSpecification = {
version: 1,
events: [
{
id: "expected",
source: "terminal_raw",
exact: { sequence: 0, at_ms: 1, data: "expected" },
cardinality: { kind: "required" },
},
],
language: {
kind: "finite_traces",
variants: [{ id: "expected", trace: ["expected"] }],
},
};
expect(
compareProcessCaptures(withTerminal("left"), withTerminal("right"), {
traceSpecification: specification,
}),
).toMatchObject({
status: "changed",
terminal: "changed",
trace: { verdict: "nonconforming" },
});
});
it("returns identical CLI and MCP results and derived Evidence identity", async () => {
const left = captureEvidence("left");
const right = captureEvidence("right");
const root = await createTestTempDirectory("rea-trace-adapters-");
temporaryRoots.push(root);
const leftPath = join(root, "left.json");
const rightPath = join(root, "right.json");
const specificationPath = join(root, "trace.json");
await Promise.all([
writeFile(leftPath, JSON.stringify(left)),
writeFile(rightPath, JSON.stringify(right)),
writeFile(specificationPath, JSON.stringify(traceSpecification)),
]);
const cliEvidence = parseEvidence(
await compareProcessEvidenceFiles(leftPath, rightPath, specificationPath),
);
const session = new BinarySession(
(_path) =>
({
execute: () => Promise.resolve(ok(null)),
close: () => Promise.resolve(),
}) satisfies AnalysisClient,
);
expect(session.recordEvidence(left).ok).toBe(true);
expect(session.recordEvidence(right).ok).toBe(true);
const server = createServer(
{ execute: () => Promise.resolve(ok(null)) },
session,
);
const client = new Client({ name: "trace-parity", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(client, server);
await server.connect(serverTransport);
await client.connect(clientTransport);
const response = await client.callTool({
name: "compare_process_captures",
arguments: {
left_evidence_id: left.evidence_id,
right_evidence_id: right.evidence_id,
trace_spec: traceSpecification,
},
});
expect(response.isError).not.toBe(true);
expect(response.structuredContent).toMatchObject({
result: { trace: { verdict: "equivalent" } },
evidence_id: cliEvidence.evidence_id,
});
const structuredResult =
typeof response.structuredContent === "object" &&
response.structuredContent !== null
? Reflect.get(response.structuredContent, "result")
: undefined;
expect(structuredResult).toEqual(cliEvidence.normalized_result);
expect(cliEvidence).toMatchObject({
predicate_type: "rea.process-comparison/v4",
locations: [
{ kind: "artifact-path", path: "/fixture/left" },
{ kind: "artifact-path", path: "/fixture/right" },
],
});
});
});