* fix(javascript): exclude overwritten Electron API identities * test(javascript): bind Electron in file-base path fixture * test(electron): bind BrowserWindow in preload override fixtures * test(mcp): allow cold catalog validation in runtime probe * test(electron): bind APIs in option reconstruction fixtures
252 lines
8.5 KiB
TypeScript
252 lines
8.5 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([]);
|
||
// Full catalog discovery and first-call SDK schema compilation take about
|
||
// 8–9 seconds on one local CPU; the 15-second budget repeatedly expired in
|
||
// parallel CI shards. Keep a finite budget for the complete workflow.
|
||
}, 30_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,
|
||
);
|
||
});
|