Add Vitest test suite covering contracts, tools, and runtime
This commit is contained in:
parent
c047fcbd5c
commit
567351c491
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseConfig } from "../src/config.js";
|
||||
|
||||
describe("runtime configuration", () => {
|
||||
it("requires a target and defaults to Hopper's documented launcher", () => {
|
||||
expect(parseConfig({}).ok).toBe(false);
|
||||
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.hopperLauncherPath).toContain("/MacOS/hopper");
|
||||
expect(result.value.hopperTargetKind).toBe("executable");
|
||||
expect(result.value.hopperLoaderArgs).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses database kind and loader arguments", () => {
|
||||
expect(
|
||||
parseConfig({
|
||||
HOPPER_TARGET_PATH: "/fixture/sample.hop",
|
||||
HOPPER_TARGET_KIND: "database",
|
||||
HOPPER_LOADER_ARGS_JSON: '["-l","FAT","--aarch64","-l","Mach-O"]',
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
hopperLauncherPath:
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
hopperTargetPath: "/fixture/sample.hop",
|
||||
hopperTargetKind: "database",
|
||||
hopperLoaderArgs: ["-l", "FAT", "--aarch64", "-l", "Mach-O"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["not-json", "{}", '["ok",1]'])(
|
||||
"rejects invalid loader args: %s",
|
||||
(encoded) => {
|
||||
expect(
|
||||
parseConfig({
|
||||
HOPPER_TARGET_PATH: "/tmp/a",
|
||||
HOPPER_LOADER_ARGS_JSON: encoded,
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ok } from "../src/domain/result.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
|
||||
const hopper = {
|
||||
callTool: () => Promise.resolve(ok(null)),
|
||||
};
|
||||
|
||||
describe("beta.3 server composition", () => {
|
||||
it("constructs independent MCP server instances", () => {
|
||||
expect(createServer(hopper)).not.toBe(createServer(hopper));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { HopperToolPort } from "../src/application/HopperToolPort.js";
|
||||
import { ENHANCED_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
|
||||
import { ok } from "../src/domain/result.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../src/hopper/protocol.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
|
||||
const PROCEDURES = {
|
||||
"0x1": "_TtC7Fixture5Class",
|
||||
"0x2": "_TtV7Fixture6Struct",
|
||||
"0x3": "_TtP7Fixture8Protocol",
|
||||
"0x4": "_TtO7Fixture4Enum",
|
||||
"0x5": "_TtE7Fixture9Extension",
|
||||
"0x6": "prefix_TtOther",
|
||||
};
|
||||
|
||||
const fixturePort = (): HopperToolPort => ({
|
||||
callTool: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(ok(PROCEDURES));
|
||||
case "list_names":
|
||||
return Promise.resolve(
|
||||
ok([
|
||||
{ address: "0x10", name: "_OBJC_CLASS_$_Fixture" },
|
||||
{ address: "0x11", name: "_OBJC_CLASS_$_Fixture" },
|
||||
{ address: "0x12", name: "_OBJC_PROTOCOL_$_FixtureDelegate" },
|
||||
]),
|
||||
);
|
||||
case "procedure_pseudo_code": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"),
|
||||
);
|
||||
}
|
||||
case "procedure_callees": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
procedure === "0x1"
|
||||
? ["0x2", "0x3"]
|
||||
: procedure === "0x2"
|
||||
? ["0x1"]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
case "procedure_callers":
|
||||
return Promise.resolve(ok(["0x9"]));
|
||||
case "address_name":
|
||||
return Promise.resolve(
|
||||
ok({ address: "0x1", name: arguments_.address ?? "" }),
|
||||
);
|
||||
case "xrefs":
|
||||
return Promise.resolve(ok(["0x20", "0x21"]));
|
||||
case "list_segments":
|
||||
return Promise.resolve(
|
||||
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
|
||||
);
|
||||
case "list_documents":
|
||||
return Promise.resolve(ok(["fixture"]));
|
||||
case "list_strings":
|
||||
return Promise.resolve(ok([{ address: "0x30", value: "hello" }]));
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const resources: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
resources.splice(0).map(async (resource) => resource.close()),
|
||||
);
|
||||
});
|
||||
|
||||
const connect = async (hopper: HopperToolPort = fixturePort()) => {
|
||||
const server = createServer(hopper);
|
||||
const client = new Client({ name: "enhanced-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
};
|
||||
|
||||
const jsonResult = (result: CallToolResult): JsonValue => {
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
if (text?.type !== "text")
|
||||
throw new Error("Tool result omitted text content");
|
||||
const decoded: unknown = JSON.parse(text.text);
|
||||
const parsed = jsonValueSchema.safeParse(decoded);
|
||||
if (!parsed.success) throw new Error("Tool result was not JSON");
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
describe("enhanced MCP tools", () => {
|
||||
it("lists the complete 31 plus 8 surface", async () => {
|
||||
const client = await connect();
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools).toHaveLength(39);
|
||||
expect(
|
||||
listed.tools
|
||||
.map(({ name }) => name)
|
||||
.filter((name) =>
|
||||
ENHANCED_TOOL_CONTRACTS.some((tool) => tool.name === name),
|
||||
)
|
||||
.sort(),
|
||||
).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort());
|
||||
});
|
||||
|
||||
it("executes all eight tools through production registration", async () => {
|
||||
const client = await connect();
|
||||
const calls = [
|
||||
["swift_classes", { pattern: "Fixture" }],
|
||||
["get_objc_classes", { pattern: "Fixture" }],
|
||||
["get_objc_protocols", {}],
|
||||
["batch_decompile", { addresses: ["0x1", "0x2"] }],
|
||||
["get_call_graph", { address: "0x1", direction: "forward", depth: 2 }],
|
||||
["analyze_swift_types", {}],
|
||||
["find_xrefs_to_name", { name: "entry" }],
|
||||
["binary_overview", {}],
|
||||
] as const;
|
||||
const results = [];
|
||||
for (const [name, arguments_] of calls) {
|
||||
results.push(
|
||||
jsonResult(await client.callTool({ name, arguments: arguments_ })),
|
||||
);
|
||||
}
|
||||
expect(results[0]).toMatchObject({ count: 1 });
|
||||
// Legacy behavior treats any `_OBJC_` label, including protocol labels, as a class.
|
||||
expect(results[1]).toMatchObject({ count: 2 });
|
||||
expect(results[2]).toMatchObject({ count: 1 });
|
||||
expect(results[3]).toEqual({ "0x1": "pseudo:0x1", "0x2": "pseudo:0x2" });
|
||||
expect(results[4]).toEqual({
|
||||
"0": [{ address: "0x1", calls: ["0x2", "0x3"] }],
|
||||
"1": [
|
||||
{ address: "0x2", calls: ["0x1"] },
|
||||
{ address: "0x3", calls: [] },
|
||||
],
|
||||
});
|
||||
expect(results[5]).toMatchObject({ total: 6 });
|
||||
expect(results[6]).toEqual({ xrefs: ["0x20", "0x21"] });
|
||||
expect(results[7]).toMatchObject({
|
||||
document: "fixture",
|
||||
segment_count: 1,
|
||||
procedure_count: 6,
|
||||
string_count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds batch concurrency at the parsed maximum of 20", async () => {
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
const hopper: HopperToolPort = {
|
||||
callTool: async (name) => {
|
||||
if (name !== "procedure_pseudo_code") return ok(null);
|
||||
active += 1;
|
||||
maximum = Math.max(maximum, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
active -= 1;
|
||||
return ok("pseudo");
|
||||
},
|
||||
};
|
||||
const client = await connect(hopper);
|
||||
const result = await client.callTool({
|
||||
name: "batch_decompile",
|
||||
arguments: {
|
||||
addresses: Array.from(
|
||||
{ length: 20 },
|
||||
(_, index) => `0x${String(index)}`,
|
||||
),
|
||||
},
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(maximum).toBe(20);
|
||||
});
|
||||
|
||||
it("returns a typed tool error for malformed Hopper boundary values", async () => {
|
||||
const client = await connect({
|
||||
callTool: () => Promise.resolve(ok(["not", "a", "procedure", "map"])),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "swift_classes",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"HopperProtocolError",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { createServer } from "node:net";
|
||||
|
||||
const [socketPath, token] = process.argv.slice(2);
|
||||
const server = createServer((socket) => {
|
||||
socket.on("error", () => undefined);
|
||||
socket.setEncoding("utf8");
|
||||
let buffer = "";
|
||||
const send = (message, fragmented = false) => {
|
||||
const line = `${JSON.stringify(message)}\n`;
|
||||
if (!fragmented) return socket.write(line);
|
||||
const midpoint = Math.floor(line.length / 2);
|
||||
socket.write(line.slice(0, midpoint));
|
||||
setTimeout(() => socket.write(line.slice(midpoint)), 2);
|
||||
};
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline >= 0) {
|
||||
const request = JSON.parse(buffer.slice(0, newline));
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (request.token !== token) {
|
||||
send({ id: request.id, error: { code: -32001, message: "bad token" } });
|
||||
} else if (request.method === "health") {
|
||||
send(
|
||||
{
|
||||
id: request.id,
|
||||
result: { name: "betterBinaryMCP Hopper bridge", version: "1.0.0" },
|
||||
},
|
||||
true,
|
||||
);
|
||||
} else if (request.method === "shutdown") {
|
||||
send({ id: request.id, result: { shutdown: true } });
|
||||
setTimeout(() => server.close(), 2);
|
||||
} else if (request.method === "hang") {
|
||||
// Deliberately leave the request pending.
|
||||
} else if (request.method === "exit") {
|
||||
process.exit(7);
|
||||
} else if (request.method === "malformed") {
|
||||
socket.write("{not-json}\n");
|
||||
} else if (request.method === "wrong_id") {
|
||||
send({ id: request.id + 100, result: {} });
|
||||
} else if (request.method === "remote_error") {
|
||||
send({
|
||||
id: request.id,
|
||||
error: { code: -32001, message: "safe fake failure" },
|
||||
});
|
||||
} else {
|
||||
const delay = request.params?.delay ?? 0;
|
||||
setTimeout(
|
||||
() => send({ id: request.id, result: request.params ?? {} }),
|
||||
delay,
|
||||
);
|
||||
}
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(socketPath);
|
||||
process.on("SIGTERM", () => server.close());
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const bootstrapIndex = process.argv.indexOf("--python") + 1;
|
||||
const actionIndex = Math.max(
|
||||
process.argv.indexOf("--executable"),
|
||||
process.argv.indexOf("--database"),
|
||||
);
|
||||
if (
|
||||
bootstrapIndex === 0 ||
|
||||
actionIndex === -1 ||
|
||||
bootstrapIndex > actionIndex
|
||||
) {
|
||||
process.stderr.write("Python bootstrap must precede the Hopper action\n");
|
||||
process.exit(2);
|
||||
}
|
||||
const source = readFileSync(process.argv[bootstrapIndex], "utf8");
|
||||
const [socketLine, tokenLine] = source.split("\n");
|
||||
const socketPath = JSON.parse(socketLine.slice(socketLine.indexOf("=") + 1));
|
||||
const token = JSON.parse(tokenLine.slice(tokenLine.indexOf("=") + 1));
|
||||
const bridge = fileURLToPath(new URL("./fakeHopper.mjs", import.meta.url));
|
||||
const child = spawn(process.execPath, [bridge, socketPath, token], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on("exit", (code) => process.exit(code ?? 0));
|
||||
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
|
||||
import { ok } from "../src/domain/result.js";
|
||||
import type {
|
||||
BridgeLauncher,
|
||||
BridgeSession,
|
||||
} from "../src/hopper/BridgeLauncher.js";
|
||||
import { HopperClient } from "../src/hopper/HopperClient.js";
|
||||
|
||||
const fixturePath = fileURLToPath(
|
||||
new URL("./fixtures/fakeHopper.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
class FixtureLauncher implements BridgeLauncher {
|
||||
socketPaths: string[] = [];
|
||||
|
||||
constructor(readonly tokenOverride?: string) {}
|
||||
|
||||
launch(session: BridgeSession) {
|
||||
this.socketPaths.push(session.socketPath);
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
process: spawn(
|
||||
process.execPath,
|
||||
[
|
||||
fixturePath,
|
||||
session.socketPath,
|
||||
this.tokenOverride ?? session.token,
|
||||
],
|
||||
{
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
},
|
||||
),
|
||||
ownsProcessLifetime: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const clients: HopperClient[] = [];
|
||||
const startClient = async () => {
|
||||
const client = new HopperClient({
|
||||
launcher: new FixtureLauncher(),
|
||||
requestTimeoutMs: 100,
|
||||
startupTimeoutMs: 1_000,
|
||||
});
|
||||
clients.push(client);
|
||||
await expect(client.start()).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { name: "betterBinaryMCP Hopper bridge", version: "1.0.0" },
|
||||
});
|
||||
return client;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.splice(0).map((client) => client.close()));
|
||||
});
|
||||
|
||||
describe("HopperClient", () => {
|
||||
it("keeps the native socket path below macOS sockaddr_un limits", async () => {
|
||||
const launcher = new FixtureLauncher();
|
||||
const client = new HopperClient({ launcher, startupTimeoutMs: 1_000 });
|
||||
clients.push(client);
|
||||
const started = await client.start();
|
||||
expect(started.ok).toBe(true);
|
||||
expect(Buffer.byteLength(launcher.socketPaths[0] ?? "")).toBeLessThan(104);
|
||||
});
|
||||
|
||||
it("parses fragmented health and correlates concurrent responses", async () => {
|
||||
const client = await startClient();
|
||||
const slow = client.callTool("echo", { label: "slow", delay: 30 });
|
||||
const fast = client.callTool("echo", { label: "fast", delay: 1 });
|
||||
await expect(Promise.all([slow, fast])).resolves.toEqual([
|
||||
{ ok: true, value: { label: "slow", delay: 30 } },
|
||||
{ ok: true, value: { label: "fast", delay: 1 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes every established operation through the authenticated bridge", async () => {
|
||||
const client = await startClient();
|
||||
const results = await Promise.all(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => client.callTool(name, {})),
|
||||
);
|
||||
expect(results).toHaveLength(31);
|
||||
expect(results.every((result) => result.ok)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a bridge session with the wrong capability token", async () => {
|
||||
const client = new HopperClient({
|
||||
launcher: new FixtureLauncher("wrong-token"),
|
||||
startupTimeoutMs: 1_000,
|
||||
});
|
||||
clients.push(client);
|
||||
const result = await client.start();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("HopperRemoteError");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["malformed", "HopperProtocolError"],
|
||||
["wrong_id", "HopperProtocolError"],
|
||||
["remote_error", "HopperRemoteError"],
|
||||
["hang", "HopperTimeoutError"],
|
||||
["exit", "HopperProcessError"],
|
||||
])("projects %s as %s", async (method, expectedTag) => {
|
||||
const client = await startClient();
|
||||
const result = await client.callTool(method);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe(expectedTag);
|
||||
});
|
||||
|
||||
it("cancels and ignores late responses without corrupting the session", async () => {
|
||||
const client = await startClient();
|
||||
const controller = new AbortController();
|
||||
const pending = client.callTool("hang", {}, { signal: controller.signal });
|
||||
controller.abort();
|
||||
const cancelled = await pending;
|
||||
expect(cancelled.ok).toBe(false);
|
||||
if (!cancelled.ok)
|
||||
expect(cancelled.error._tag).toBe("HopperCancelledError");
|
||||
|
||||
const timedOut = await client.callTool(
|
||||
"echo",
|
||||
{ delay: 30 },
|
||||
{ timeoutMs: 5 },
|
||||
);
|
||||
expect(timedOut.ok).toBe(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
await expect(client.callTool("echo", { value: "alive" })).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { value: "alive" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseDocuments,
|
||||
parseListCount,
|
||||
parseNames,
|
||||
parseProcedures,
|
||||
parseRelatedAddresses,
|
||||
parseSegments,
|
||||
} from "../src/domain/hopperValues.js";
|
||||
|
||||
describe("Hopper boundary values", () => {
|
||||
it("parses Hopper's address-keyed name and string maps", () => {
|
||||
expect(parseNames({ "0x1000": "_main" })).toEqual({
|
||||
ok: true,
|
||||
value: [{ address: "0x1000", name: "_main" }],
|
||||
});
|
||||
expect(
|
||||
parseListCount({ "0x1000": "hello", "0x2000": "world" }, "strings"),
|
||||
).toEqual({ ok: true, value: 2 });
|
||||
});
|
||||
|
||||
it("parses direct and wrapped protocol shapes", () => {
|
||||
expect(parseProcedures({ procedures: { "0x1": "main" } })).toEqual({
|
||||
ok: true,
|
||||
value: [{ address: "0x1", name: "main" }],
|
||||
});
|
||||
expect(parseNames({ names: [{ address: "0x2", name: "label" }] }).ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(parseRelatedAddresses({ callers: ["0x3"] }, "callers")).toEqual({
|
||||
ok: true,
|
||||
value: ["0x3"],
|
||||
});
|
||||
expect(
|
||||
parseSegments([{ name: "__TEXT", start: "0x1", end: "0x2" }]).ok,
|
||||
).toBe(true);
|
||||
expect(parseDocuments(["fixture"])).toEqual({
|
||||
ok: true,
|
||||
value: ["fixture"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["procedures", () => parseProcedures(["not-a-map"])],
|
||||
["names", () => parseNames([{ address: 1, name: "bad" }])],
|
||||
["relations", () => parseRelatedAddresses([1], "callees")],
|
||||
["segments", () => parseSegments([{ name: 1 }])],
|
||||
["documents", () => parseDocuments([1])],
|
||||
])(
|
||||
"rejects malformed %s instead of manufacturing empty output",
|
||||
(_label, parse) => {
|
||||
const result = parse();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("HopperProtocolError");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { HopperToolPort } from "../src/application/HopperToolPort.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
|
||||
import { HopperRemoteError } from "../src/domain/errors.js";
|
||||
import { err, ok } from "../src/domain/result.js";
|
||||
import type { JsonValue } from "../src/hopper/protocol.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
|
||||
const VALID_INPUTS: Readonly<
|
||||
Record<string, Readonly<Record<string, JsonValue>>>
|
||||
> = {
|
||||
address_name: {},
|
||||
comment: {},
|
||||
current_address: {},
|
||||
current_procedure: {},
|
||||
current_document: {},
|
||||
goto_address: { address: "0x1000" },
|
||||
inline_comment: {},
|
||||
list_bookmarks: {},
|
||||
list_documents: {},
|
||||
list_names: {},
|
||||
list_procedures: {},
|
||||
list_segments: {},
|
||||
list_strings: {},
|
||||
next_address: {},
|
||||
prev_address: {},
|
||||
procedure_address: { procedure: "main" },
|
||||
procedure_assembly: { procedure: "main" },
|
||||
procedure_callees: { procedure: "main" },
|
||||
procedure_callers: { procedure: "main" },
|
||||
procedure_info: { procedure: "main" },
|
||||
procedure_pseudo_code: { procedure: "main" },
|
||||
search_procedures: { pattern: "main" },
|
||||
search_strings: { pattern: "hello" },
|
||||
set_address_name: { address: "0x1000", name: "entry" },
|
||||
set_addresses_names: { names: { "0x1000": "entry" } },
|
||||
set_bookmark: { address: "0x1000" },
|
||||
set_comment: { address: "0x1000", comment: "entry point" },
|
||||
set_current_document: { document: "fixture" },
|
||||
set_inline_comment: { address: "0x1000", comment: "entry point" },
|
||||
unset_bookmark: { address: "0x1000" },
|
||||
xrefs: {},
|
||||
};
|
||||
|
||||
interface Invocation {
|
||||
readonly name: string;
|
||||
readonly arguments_: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
const resources: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
resources.splice(0).map(async (resource) => resource.close()),
|
||||
);
|
||||
});
|
||||
|
||||
const connect = async (hopper: HopperToolPort) => {
|
||||
const server = createServer(hopper);
|
||||
const client = new Client({ name: "contract-test", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
};
|
||||
|
||||
describe("official Hopper proxy tools", () => {
|
||||
it("lists exactly the 31 official contracts", async () => {
|
||||
const client = await connect({
|
||||
callTool: () => Promise.resolve(ok(null)),
|
||||
});
|
||||
const listed = await client.listTools();
|
||||
const officialNames = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
expect(
|
||||
listed.tools
|
||||
.map(({ name }) => name)
|
||||
.filter((name) => officialNames.has(name))
|
||||
.sort(),
|
||||
).toEqual(OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).sort());
|
||||
});
|
||||
|
||||
it("executes every handler and projects omitted Python optionals to null", async () => {
|
||||
const invocations: Invocation[] = [];
|
||||
const client = await connect({
|
||||
callTool: (name, arguments_) => {
|
||||
invocations.push({ name, arguments_ });
|
||||
return Promise.resolve(ok({ name, arguments: arguments_ }));
|
||||
},
|
||||
});
|
||||
|
||||
for (const contract of OFFICIAL_TOOL_CONTRACTS) {
|
||||
const result = await client.callTool({
|
||||
name: contract.name,
|
||||
arguments: VALID_INPUTS[contract.name],
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
}
|
||||
|
||||
expect(invocations.map(({ name }) => name)).toEqual(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
expect(
|
||||
invocations.find(({ name }) => name === "address_name")?.arguments_,
|
||||
).toEqual({
|
||||
document: null,
|
||||
address: null,
|
||||
});
|
||||
expect(
|
||||
invocations.find(({ name }) => name === "search_procedures")?.arguments_,
|
||||
).toEqual({
|
||||
pattern: "main",
|
||||
case_sensitive: false,
|
||||
document: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns stable safe MCP error content", async () => {
|
||||
const client = await connect({
|
||||
callTool: () => Promise.resolve(err(new HopperRemoteError(-1, "denied"))),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "list_documents",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "HopperRemoteError: Hopper request failed (-1): denied",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const mainPath = fileURLToPath(new URL("../dist/main.js", import.meta.url));
|
||||
const fixturePath = fileURLToPath(
|
||||
new URL("./fixtures/fakeLauncher.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
describe("production stdio runtime", () => {
|
||||
it("starts the built entrypoint, lists 39 tools, calls one, and shuts down", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [mainPath],
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOPPER_LAUNCHER_PATH: process.execPath,
|
||||
HOPPER_TARGET_PATH: "/fixture/sample",
|
||||
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" });
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools).toHaveLength(39);
|
||||
const result = await client.callTool({
|
||||
name: "current_document",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
expect(stderr).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
categorizeSwiftTypes,
|
||||
discoverObjcClasses,
|
||||
discoverObjcProtocols,
|
||||
discoverSwiftClasses,
|
||||
} from "../src/domain/symbolAnalysis.js";
|
||||
|
||||
describe("symbol analysis", () => {
|
||||
it("filters Swift classes and reports the uncapped match count", () => {
|
||||
const symbols = Array.from({ length: 120 }, (_, index) => ({
|
||||
address: `0x${String(index)}`,
|
||||
name: `_TtCFixture${String(index)}`,
|
||||
}));
|
||||
const result = discoverSwiftClasses(symbols, "Fixture");
|
||||
if (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
!Array.isArray(result)
|
||||
) {
|
||||
expect(result.count).toBe(120);
|
||||
expect(result.classes).toHaveLength(100);
|
||||
expect(Array.isArray(result.classes) ? result.classes[0] : null).toEqual({
|
||||
address: "0x0",
|
||||
name: "_TtCFixture0",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates Objective-C classes and protocols by symbol name", () => {
|
||||
const names = [
|
||||
{ address: "0x1", name: "_OBJC_CLASS_$_App" },
|
||||
{ address: "0x2", name: "_OBJC_CLASS_$_App" },
|
||||
{ address: "0x3", name: "_OBJC_PROTOCOL_$_Delegate" },
|
||||
{ address: "0x4", name: "_OBJC_PROTOCOL_$_Delegate" },
|
||||
];
|
||||
expect(discoverObjcClasses(names, "App")).toMatchObject({ count: 1 });
|
||||
expect(discoverObjcProtocols(names)).toMatchObject({ count: 1 });
|
||||
});
|
||||
|
||||
it("categorizes every Swift mangling family and deduplicates names", () => {
|
||||
const result = categorizeSwiftTypes([
|
||||
{ address: "1", name: "_TtCClass" },
|
||||
{ address: "2", name: "_TtVStruct" },
|
||||
{ address: "3", name: "_TtOEnum" },
|
||||
{ address: "4", name: "_TtPProtocol" },
|
||||
{ address: "5", name: "_TtEExtension" },
|
||||
{ address: "6", name: "prefix_TtOther" },
|
||||
{ address: "7", name: "_TtCClass" },
|
||||
]);
|
||||
expect(result).toMatchObject({
|
||||
total: 6,
|
||||
categories: {
|
||||
classes: { count: 1 },
|
||||
structs: { count: 1 },
|
||||
enums: { count: 1 },
|
||||
protocols: { count: 1 },
|
||||
extensions: { count: 1 },
|
||||
other: { count: 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ENHANCED_TOOL_CONTRACTS,
|
||||
OFFICIAL_TOOL_CONTRACTS,
|
||||
TOOL_CONTRACTS,
|
||||
} from "../src/contracts/toolContracts.js";
|
||||
|
||||
describe("tool contract inventory", () => {
|
||||
it("preserves the complete 31 plus 8 tool surface", () => {
|
||||
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31);
|
||||
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(8);
|
||||
expect(TOOL_CONTRACTS).toHaveLength(39);
|
||||
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(39);
|
||||
});
|
||||
|
||||
it("retains documented enhanced-tool limits at the input boundary", () => {
|
||||
const batch = ENHANCED_TOOL_CONTRACTS.find(
|
||||
({ name }) => name === "batch_decompile",
|
||||
);
|
||||
const graph = ENHANCED_TOOL_CONTRACTS.find(
|
||||
({ name }) => name === "get_call_graph",
|
||||
);
|
||||
|
||||
expect(
|
||||
batch?.inputSchema.safeParse({
|
||||
addresses: Array.from({ length: 21 }, () => "0x1"),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
graph?.inputSchema.safeParse({ address: "0x1", depth: 6 }).success,
|
||||
).toBe(false);
|
||||
expect(graph?.inputSchema.parse({ address: "0x1" })).toEqual({
|
||||
address: "0x1",
|
||||
depth: 2,
|
||||
direction: "forward",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue