Add Vitest test suite covering contracts, tools, and runtime

This commit is contained in:
morluto 2026-07-12 03:25:29 +08:00
parent c047fcbd5c
commit 567351c491
11 changed files with 831 additions and 0 deletions

47
tests/config.test.ts Normal file
View File

@ -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);
},
);
});

View File

@ -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));
});
});

198
tests/enhancedTools.test.ts Normal file
View File

@ -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",
);
});
});

60
tests/fixtures/fakeHopper.mjs vendored Normal file
View File

@ -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());

27
tests/fixtures/fakeLauncher.mjs vendored Normal file
View File

@ -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"));

138
tests/hopperClient.test.ts Normal file
View File

@ -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" },
});
});
});

View File

@ -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");
},
);
});

139
tests/officialTools.test.ts Normal file
View File

@ -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",
},
]);
});
});

47
tests/runtime.test.ts Normal file
View File

@ -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("");
});
});

View File

@ -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 },
},
});
});
});

View File

@ -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",
});
});
});