Filter class names at the native owner before serialization, and resume matching pages through contiguous inventory positions without a second index or query-result cache. Preserve selected source Evidence, query identity, complete top-level coverage and shared CLI/MCP behavior. Fixes #4776. Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
150 lines
5.2 KiB
TypeScript
150 lines
5.2 KiB
TypeScript
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import { expect, it as test, onTestFinished } from "vitest";
|
|
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { createServer } from "../../../src/server/createServer.js";
|
|
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
|
import {
|
|
createJadxProtocolFixture,
|
|
verifyJadxFixtureCleanup,
|
|
} from "../../fixtures/android/jadx.js";
|
|
|
|
const INVENTORY_CLASS_COUNT = 7096;
|
|
|
|
/** Count inventoried names across raw call records: MCP pages ride in text content. */
|
|
const rawInventoryNames = (raw: unknown): number => {
|
|
const calls =
|
|
(
|
|
raw as {
|
|
calls?: {
|
|
operation: string;
|
|
response: { content?: { text?: string }[] };
|
|
}[];
|
|
}
|
|
).calls ?? [];
|
|
return calls
|
|
.flatMap((call) => call.response.content ?? [])
|
|
.map((entry) =>
|
|
entry.text === undefined ? {} : (JSON.parse(entry.text) as unknown),
|
|
)
|
|
.filter((page) => Array.isArray((page as { items?: unknown[] }).items))
|
|
.flatMap((page) => (page as { items: unknown[] }).items).length;
|
|
};
|
|
|
|
test("delivers bounded search_android_classes Evidence while retaining the complete inventory", async () => {
|
|
const { provider, apk, launches } = await createJadxProtocolFixture(
|
|
"normal",
|
|
undefined,
|
|
{ classCount: INVENTORY_CLASS_COUNT - 1 },
|
|
);
|
|
const session = createTestBinarySession(() => {
|
|
throw new Error("APK analysis must not acquire a deep binary provider");
|
|
});
|
|
const server = createServer(
|
|
{ kind: "session", session },
|
|
{ androidAnalysis: provider },
|
|
);
|
|
const client = new Client({
|
|
name: "android-search-inventory-budget",
|
|
version: "1",
|
|
});
|
|
onTestFinished(async () => {
|
|
await client.close();
|
|
await server.close();
|
|
await session.close();
|
|
});
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
|
|
const search = async (query: string) =>
|
|
parseEvidence(
|
|
(
|
|
await client.callTool({
|
|
name: "search_android_classes",
|
|
arguments: { path: apk, query },
|
|
})
|
|
).structuredContent,
|
|
);
|
|
|
|
const single = await search("Target");
|
|
expect(
|
|
androidResultSchemas.search_android_classes.parse(single.normalized_result),
|
|
).toMatchObject({
|
|
query: "Target",
|
|
total_classes: INVENTORY_CLASS_COUNT,
|
|
matches: ["fixture.Target"],
|
|
coverage: "complete",
|
|
});
|
|
// The inline raw record keeps the pagination audit, not the class names.
|
|
const singleRaw = JSON.stringify(single.raw_result);
|
|
expect(singleRaw).not.toContain("fixture.Class");
|
|
expect(singleRaw).toContain("items_omitted");
|
|
expect(Buffer.byteLength(JSON.stringify(single))).toBeLessThan(32 * 1024);
|
|
// Every matching producer row remains in the retained source record.
|
|
const completeId = single.evidence_links[0];
|
|
expect(completeId).toBeDefined();
|
|
expect(completeId).toBe(
|
|
(single.raw_result as { complete_evidence_id: string })
|
|
.complete_evidence_id,
|
|
);
|
|
const complete = session.evidenceById(completeId as string);
|
|
expect(complete).toBeDefined();
|
|
expect(complete?.operation).toBe("search_android_classes");
|
|
expect(rawInventoryNames(complete?.raw_result)).toBe(1);
|
|
expect(JSON.stringify(complete?.raw_result)).toContain("fixture.Target");
|
|
expect(JSON.stringify(complete?.raw_result)).not.toContain("fixture.Class");
|
|
|
|
// Selection precedes paging: a match beyond the first unfiltered page is
|
|
// still found, and the complete inventory remains explicitly available.
|
|
const last = await search("Class7094");
|
|
expect(last.normalized_result).toMatchObject({
|
|
total_classes: INVENTORY_CLASS_COUNT,
|
|
matches: ["fixture.Class7094"],
|
|
coverage: "complete",
|
|
});
|
|
const inventory = await search("");
|
|
const inventorySource = session.evidenceById(
|
|
inventory.evidence_links[0] as string,
|
|
);
|
|
expect(rawInventoryNames(inventorySource?.raw_result)).toBe(
|
|
INVENTORY_CLASS_COUNT,
|
|
);
|
|
|
|
const empty = await search("fixture.Missing");
|
|
expect(
|
|
androidResultSchemas.search_android_classes.parse(empty.normalized_result),
|
|
).toMatchObject({
|
|
query: "fixture.Missing",
|
|
total_classes: INVENTORY_CLASS_COUNT,
|
|
matches: [],
|
|
coverage: "complete",
|
|
});
|
|
expect(JSON.stringify(empty.raw_result)).not.toContain("fixture.Class");
|
|
|
|
await client.close();
|
|
await server.close();
|
|
await verifyJadxFixtureCleanup(launches);
|
|
});
|
|
|
|
test("keeps complete matching producer rows inline without a retaining session", async () => {
|
|
const { service, apk, launches, provider } = await createJadxProtocolFixture(
|
|
"normal",
|
|
undefined,
|
|
{ classCount: INVENTORY_CLASS_COUNT - 1 },
|
|
);
|
|
const result = await service.execute("search_android_classes", {
|
|
path: apk,
|
|
query: "Target",
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
// Complete CLI Evidence keeps every matching row, without unrelated names.
|
|
expect(rawInventoryNames(result.value.raw_result)).toBe(1);
|
|
expect(JSON.stringify(result.value.raw_result)).not.toContain(
|
|
"fixture.Class",
|
|
);
|
|
await provider.close();
|
|
await verifyJadxFixtureCleanup(launches);
|
|
});
|