mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
1f0f728c63
HopperProvider passed the bridge reply as rawResult and, for every operation except address_to_file_offset, the same object as the result. Every Hopper Evidence record (decompilation, procedure and string listings, xrefs, ...) therefore carried its complete result twice, and an MCP response four times counting its text copy. raw_result is now null unless REA transformed the reply, which only the file-offset mapping does; that case keeps Hopper's original reply. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
8.3 KiB
TypeScript
249 lines
8.3 KiB
TypeScript
import { fileURLToPath } from "node:url";
|
|
|
|
import { Client } from "@modelcontextprotocol/client";
|
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
import { describe, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js";
|
|
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
|
import { evidenceResultOf } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
|
|
const mainPath = fileURLToPath(
|
|
new URL("../../../dist/main.js", import.meta.url),
|
|
);
|
|
const fixturePath = fileURLToPath(
|
|
new URL("../../fixtures/fakeLauncher.mjs", import.meta.url),
|
|
);
|
|
|
|
const expectAvailableToolInventory = async (client: Client) => {
|
|
const listed = await client.listTools();
|
|
const status = await client.callTool({
|
|
name: "binary_session",
|
|
arguments: {},
|
|
});
|
|
expect(status.isError).not.toBe(true);
|
|
const availability = z
|
|
.object({
|
|
result: z.object({
|
|
tool_availability: z.array(
|
|
z.object({ name: z.string(), available: z.boolean() }),
|
|
),
|
|
}),
|
|
})
|
|
.parse(status.structuredContent).result.tool_availability;
|
|
// This asserts the compiled entrypoint matches the source catalog. When it
|
|
// fails, check that `dist/` is current (`npm run build:cached`) before
|
|
// suspecting a registration bug -- a stale build is by far the likeliest
|
|
// cause of a pure count mismatch here.
|
|
expect(
|
|
availability.length,
|
|
"compiled tool inventory is stale; run npm run build:cached",
|
|
).toBe(CATALOG_IDENTITY.counts.mcp_tools);
|
|
expect(new Set(listed.tools.map(({ name }) => name))).toEqual(
|
|
new Set(TOOL_CONTRACTS.map(({ name }) => name)),
|
|
);
|
|
return status;
|
|
};
|
|
|
|
/**
|
|
* Parses complete newline-delimited records, dropping any partial trailing
|
|
* line so a chunk boundary cannot turn into a JSON parse error.
|
|
*/
|
|
const completeStderrRecords = (stderr: string): unknown[] =>
|
|
stderr
|
|
.split("\n")
|
|
.slice(0, -1)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((line): unknown => JSON.parse(line));
|
|
|
|
const callCurrentDocument = async (target?: {
|
|
readonly path: string;
|
|
readonly kind?: "database";
|
|
}) => {
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [mainPath],
|
|
cwd: process.cwd(),
|
|
env: {
|
|
PATH: process.env.PATH ?? "",
|
|
HOPPER_LAUNCHER_PATH: process.execPath,
|
|
...(target === undefined
|
|
? {}
|
|
: {
|
|
HOPPER_TARGET_PATH: target.path,
|
|
...(target.kind === undefined
|
|
? {}
|
|
: { HOPPER_TARGET_KIND: target.kind }),
|
|
}),
|
|
HOPPER_LOADER_ARGS_JSON: JSON.stringify([fixturePath]),
|
|
},
|
|
stderr: "pipe",
|
|
});
|
|
let stderr = "";
|
|
transport.stderr?.on("data", (chunk: Buffer | string) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
const client = new Client({ name: "runtime-smoke", version: "1.0.0" });
|
|
|
|
let result: Awaited<ReturnType<Client["callTool"]>>;
|
|
try {
|
|
await client.connect(transport);
|
|
result = await client.callTool({
|
|
name: "current_document",
|
|
arguments: {},
|
|
});
|
|
} finally {
|
|
await client.close();
|
|
await transport.close();
|
|
}
|
|
return { result, stderr, records: completeStderrRecords(stderr) };
|
|
};
|
|
|
|
describe("production stdio runtime", () => {
|
|
// The `test:acceptance` lane runs the real entrypoint on the current host;
|
|
// these cases state each supported host's result in the test name and body.
|
|
|
|
it("reports a missing target through MCP and structured logs", async () => {
|
|
const { result, stderr, records } = await callCurrentDocument();
|
|
expect(result.isError).toBe(true);
|
|
expect(result.content).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "text",
|
|
text: expect.stringContaining("No app is open."),
|
|
}),
|
|
]),
|
|
);
|
|
expect(records).toContainEqual(
|
|
expect.objectContaining({
|
|
application: "rea",
|
|
mode: "mcp",
|
|
layer: "server",
|
|
tool: "current_document",
|
|
status: "error",
|
|
}),
|
|
);
|
|
expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON");
|
|
expect(stderr).not.toContain(fixturePath);
|
|
}, 15_000);
|
|
|
|
it.runIf(process.platform === "darwin")(
|
|
"returns the selected document on macOS through MCP and structured logs",
|
|
async () => {
|
|
const { result, stderr, records } = await callCurrentDocument({
|
|
path: process.execPath,
|
|
});
|
|
expect(result.isError).not.toBe(true);
|
|
const observation = evidenceResultOf(z.literal("fixture")).parse(
|
|
result.structuredContent,
|
|
);
|
|
const evidence = parseEvidence(observation);
|
|
expect(evidence.operation).toBe("current_document");
|
|
expect(evidence.provider.id).toBe("hopper");
|
|
expect(evidence.normalized_result).toBe(observation.normalized_result);
|
|
// Hopper's reply is the result; it is not repeated as raw_result.
|
|
expect(evidence.raw_result).toBeNull();
|
|
expect(records).toContainEqual(
|
|
expect.objectContaining({
|
|
application: "rea",
|
|
mode: "mcp",
|
|
layer: "server",
|
|
tool: "current_document",
|
|
status: "ok",
|
|
}),
|
|
);
|
|
expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON");
|
|
expect(stderr).not.toContain(fixturePath);
|
|
},
|
|
15_000,
|
|
);
|
|
|
|
it("serves the compiled catalog without an initial target or fatal record", async () => {
|
|
const transport = new StdioClientTransport({
|
|
command: process.execPath,
|
|
args: [mainPath],
|
|
cwd: process.cwd(),
|
|
env: {
|
|
PATH: process.env.PATH ?? "",
|
|
},
|
|
stderr: "pipe",
|
|
});
|
|
let stderr = "";
|
|
transport.stderr?.on("data", (chunk: Buffer | string) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
const client = new Client({ name: "catalog-runtime", version: "1.0.0" });
|
|
|
|
try {
|
|
await client.connect(transport);
|
|
// Keep catalog parity independent of initial target/provider startup.
|
|
const status = await expectAvailableToolInventory(client);
|
|
expect(
|
|
z
|
|
.object({ result: z.object({ open: z.boolean() }) })
|
|
.parse(status.structuredContent).result,
|
|
).toEqual({ open: false });
|
|
// Closing the empty session produces a structured lifecycle record,
|
|
// making the fatal-record check observable without launching a provider.
|
|
const closed = await client.callTool({
|
|
name: "close_binary",
|
|
arguments: {},
|
|
});
|
|
expect(closed.isError).not.toBe(true);
|
|
} finally {
|
|
await client.close();
|
|
await transport.close();
|
|
}
|
|
// The transport has closed, so the stderr pipe has ended and every record
|
|
// the child wrote has been delivered. Reading while the child is still
|
|
// running races the pipe and can observe an empty or partial buffer, which
|
|
// would silently miss the fatal record this test exists to catch.
|
|
const records = completeStderrRecords(stderr);
|
|
expect(
|
|
records.length,
|
|
"no startup records were observed, so the fatal-record check is vacuous",
|
|
).toBeGreaterThan(0);
|
|
expect(records).toContainEqual(
|
|
expect.objectContaining({ tool: "close_binary", status: "ok" }),
|
|
);
|
|
const fatal = records.filter(
|
|
(record): record is { level: number } =>
|
|
typeof record === "object" &&
|
|
record !== null &&
|
|
"level" in record &&
|
|
typeof record.level === "number" &&
|
|
record.level >= 50,
|
|
);
|
|
expect(fatal).toEqual([]);
|
|
}, 15_000);
|
|
|
|
it.runIf(process.platform === "darwin")(
|
|
"serves a database-kind initial target without a fatal record on macOS",
|
|
async () => {
|
|
const { result, records } = await callCurrentDocument({
|
|
path: fileURLToPath(import.meta.url),
|
|
kind: "database",
|
|
});
|
|
expect(result.isError).not.toBe(true);
|
|
evidenceResultOf(z.literal("fixture")).parse(result.structuredContent);
|
|
expect(records).toContainEqual(
|
|
expect.objectContaining({ tool: "current_document", status: "ok" }),
|
|
);
|
|
expect(
|
|
records.some(
|
|
(record) =>
|
|
typeof record === "object" &&
|
|
record !== null &&
|
|
"level" in record &&
|
|
typeof record.level === "number" &&
|
|
record.level >= 50,
|
|
),
|
|
).toBe(false);
|
|
},
|
|
15_000,
|
|
);
|
|
});
|