From 567351c4910edab5bb892ac308d9717e4f5c5a16 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:25:29 +0800 Subject: [PATCH] Add Vitest test suite covering contracts, tools, and runtime --- tests/config.test.ts | 47 ++++++++ tests/createServer.test.ts | 14 +++ tests/enhancedTools.test.ts | 198 ++++++++++++++++++++++++++++++++ tests/fixtures/fakeHopper.mjs | 60 ++++++++++ tests/fixtures/fakeLauncher.mjs | 27 +++++ tests/hopperClient.test.ts | 138 ++++++++++++++++++++++ tests/hopperValues.test.ts | 58 ++++++++++ tests/officialTools.test.ts | 139 ++++++++++++++++++++++ tests/runtime.test.ts | 47 ++++++++ tests/symbolAnalysis.test.ts | 64 +++++++++++ tests/toolContracts.test.ts | 39 +++++++ 11 files changed, 831 insertions(+) create mode 100644 tests/config.test.ts create mode 100644 tests/createServer.test.ts create mode 100644 tests/enhancedTools.test.ts create mode 100644 tests/fixtures/fakeHopper.mjs create mode 100644 tests/fixtures/fakeLauncher.mjs create mode 100644 tests/hopperClient.test.ts create mode 100644 tests/hopperValues.test.ts create mode 100644 tests/officialTools.test.ts create mode 100644 tests/runtime.test.ts create mode 100644 tests/symbolAnalysis.test.ts create mode 100644 tests/toolContracts.test.ts diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 000000000..fb64dc75b --- /dev/null +++ b/tests/config.test.ts @@ -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); + }, + ); +}); diff --git a/tests/createServer.test.ts b/tests/createServer.test.ts new file mode 100644 index 000000000..2a9cc3483 --- /dev/null +++ b/tests/createServer.test.ts @@ -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)); + }); +}); diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts new file mode 100644 index 000000000..560f5d59b --- /dev/null +++ b/tests/enhancedTools.test.ts @@ -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 }> = []; + +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", + ); + }); +}); diff --git a/tests/fixtures/fakeHopper.mjs b/tests/fixtures/fakeHopper.mjs new file mode 100644 index 000000000..f5c034bf3 --- /dev/null +++ b/tests/fixtures/fakeHopper.mjs @@ -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()); diff --git a/tests/fixtures/fakeLauncher.mjs b/tests/fixtures/fakeLauncher.mjs new file mode 100644 index 000000000..0e182ba7d --- /dev/null +++ b/tests/fixtures/fakeLauncher.mjs @@ -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")); diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts new file mode 100644 index 000000000..f4cdb1b7e --- /dev/null +++ b/tests/hopperClient.test.ts @@ -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" }, + }); + }); +}); diff --git a/tests/hopperValues.test.ts b/tests/hopperValues.test.ts new file mode 100644 index 000000000..75d016f67 --- /dev/null +++ b/tests/hopperValues.test.ts @@ -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"); + }, + ); +}); diff --git a/tests/officialTools.test.ts b/tests/officialTools.test.ts new file mode 100644 index 000000000..9afe4f249 --- /dev/null +++ b/tests/officialTools.test.ts @@ -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>> +> = { + 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>; +} + +const resources: Array<{ close(): Promise }> = []; + +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", + }, + ]); + }); +}); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts new file mode 100644 index 000000000..5419b94d4 --- /dev/null +++ b/tests/runtime.test.ts @@ -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(""); + }); +}); diff --git a/tests/symbolAnalysis.test.ts b/tests/symbolAnalysis.test.ts new file mode 100644 index 000000000..7dd271364 --- /dev/null +++ b/tests/symbolAnalysis.test.ts @@ -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 }, + }, + }); + }); +}); diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts new file mode 100644 index 000000000..e211f6dda --- /dev/null +++ b/tests/toolContracts.test.ts @@ -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", + }); + }); +});