Files
rea/tests/boundary/process/androidSearchEvidenceBudget.test.ts
T
morluto 76b56957e9 refactor(android): own search summaries at the producer boundary
Reuse validated class pages for inline pagination audits while retaining complete provider replies. Move retention into the shared Android workflow and remove the MCP-only service wrapper and application text reparser. Preserve unknown JSON metadata, late cancellation and retention failures.
2026-10-23 21:12:42 +08:00

313 lines
10 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it as test, onTestFinished } from "vitest";
import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js";
import { androidResultSchemas } from "../../../src/domain/android/androidAnalysis.js";
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
import { parseEvidence, type Evidence } from "../../../src/domain/evidence.js";
import { err } from "../../../src/domain/result.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;
interface RawJadxCall {
readonly operation: string;
readonly input?: { readonly offset?: number; readonly query?: string };
readonly response: {
readonly _meta?: Readonly<Record<string, unknown>>;
readonly content?: readonly {
readonly text?: string;
readonly annotations?: unknown;
readonly _meta?: Readonly<Record<string, unknown>>;
}[];
};
}
const callsOf = (raw: unknown): readonly RawJadxCall[] =>
(raw as { calls?: readonly RawJadxCall[] }).calls ?? [];
const listClassCalls = (raw: unknown): readonly RawJadxCall[] =>
callsOf(raw).filter(({ operation }) => operation === "list_classes");
const responsePage = (call: RawJadxCall): Record<string, unknown> => {
const text = call.response.content?.[0]?.text;
if (text === undefined) throw new Error("JADX response omitted page text");
const page: unknown = JSON.parse(text);
if (page === null || typeof page !== "object" || Array.isArray(page))
throw new Error("JADX page text was not an object");
return page as Record<string, unknown>;
};
const callMetadata = (call: RawJadxCall) => ({
offset: call.input?.offset,
envelope: call.response._meta,
content: call.response.content?.[0]?._meta,
annotations: call.response.content?.[0]?.annotations,
fixture: responsePage(call).fixture_metadata,
});
/** Count names in complete source pages using the producer's raw JSON. */
const rawInventoryNames = (raw: unknown): number =>
listClassCalls(raw).reduce((sum, call) => {
const items = responsePage(call).items;
if (!Array.isArray(items))
throw new Error("Complete page omitted class names");
return sum + items.length;
}, 0);
test("delivers bounded search_android_classes Evidence while retaining the complete inventory", async () => {
const { provider, apk, launches } = await createJadxProtocolFixture(
"search-audit-metadata",
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 summaryPages = listClassCalls(inventory.raw_result);
const completePages = listClassCalls(inventorySource?.raw_result);
expect(summaryPages).toHaveLength(2);
expect(completePages).toHaveLength(2);
expect(
summaryPages.map((call) => {
const page = responsePage(call);
return {
offset: page.offset,
next_offset: page.next_offset,
total_classes: page.total_classes,
limit: page.limit,
items_omitted: page.items_omitted,
};
}),
).toEqual([
{
offset: 0,
next_offset: 4096,
total_classes: INVENTORY_CLASS_COUNT,
limit: 4096,
items_omitted: 4096,
},
{
offset: 4096,
next_offset: INVENTORY_CLASS_COUNT,
total_classes: INVENTORY_CLASS_COUNT,
limit: 4096,
items_omitted: INVENTORY_CLASS_COUNT - 4096,
},
]);
expect(summaryPages.map(callMetadata)).toEqual(
completePages.map(callMetadata),
);
expect(completePages.map(responsePage).map((page) => page.items)).toEqual([
expect.arrayContaining(["fixture.Target", "fixture.Class0"]),
expect.arrayContaining(["fixture.Class4095", "fixture.Class7094"]),
]);
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");
expect(listClassCalls(empty.raw_result).map(responsePage)).toMatchObject([
{
offset: 0,
next_offset: INVENTORY_CLASS_COUNT,
total_classes: INVENTORY_CLASS_COUNT,
limit: 4096,
items_omitted: 0,
fixture_metadata: {
page_offset: 0,
retained: "unknown page field",
},
},
]);
await client.close();
await server.close();
await verifyJadxFixtureCleanup(launches);
});
test("keeps the complete paged inventory inline without a retaining session", async () => {
const { service, apk, launches, provider } = await createJadxProtocolFixture(
"search-audit-metadata",
undefined,
{ classCount: INVENTORY_CLASS_COUNT - 1 },
);
const result = await service.execute("search_android_classes", {
path: apk,
query: "",
});
if (!result.ok) throw result.error;
// No retaining writer means the CLI receives the unmodified source pages.
expect(rawInventoryNames(result.value.raw_result)).toBe(
INVENTORY_CLASS_COUNT,
);
const fullPages = listClassCalls(result.value.raw_result);
expect(fullPages).toHaveLength(2);
expect(fullPages.map(responsePage)).toMatchObject([
{
offset: 0,
next_offset: 4096,
total_classes: INVENTORY_CLASS_COUNT,
limit: 4096,
items: expect.arrayContaining(["fixture.Target", "fixture.Class0"]),
},
{
offset: 4096,
next_offset: INVENTORY_CLASS_COUNT,
total_classes: INVENTORY_CLASS_COUNT,
limit: 4096,
items: expect.arrayContaining(["fixture.Class4095", "fixture.Class7094"]),
},
]);
expect(responsePage(fullPages[0]!)).toMatchObject({
fixture_metadata: {
page_offset: 0,
retained: "unknown page field",
},
});
for (const [index, call] of fullPages.entries()) {
expect(call.response._meta).toEqual({
fixture_envelope_offset: index * 4096,
});
expect(call.response.content?.[0]?._meta).toEqual({
fixture_content_offset: index * 4096,
});
expect(call.response.content?.[0]?.annotations).toEqual({
audience: ["user"],
priority: 0.5,
});
}
expect(JSON.stringify(result.value.raw_result)).not.toContain(
"inventory_elided",
);
expect(JSON.stringify(result.value.raw_result)).not.toContain(
"items_omitted",
);
await provider.close();
await verifyJadxFixtureCleanup(launches);
});
test("withholds inline summary when retaining complete Evidence fails", async () => {
const { provider, apk, launches } = await createJadxProtocolFixture(
"search-audit-metadata",
undefined,
{ classCount: INVENTORY_CLASS_COUNT - 1 },
);
const writeFailure = new EvidenceIntegrityError(
"The complete Android search Evidence could not be retained",
);
const retained: Evidence[] = [];
const service = new AndroidAnalysisService(provider, (evidence) => {
retained.push(evidence);
return err(writeFailure);
});
const result = await service.execute("search_android_classes", {
path: apk,
query: "",
});
expect(result).toEqual({ ok: false, error: writeFailure });
expect(retained).toHaveLength(1);
expect(retained[0]?.operation).toBe("search_android_classes");
expect(
androidResultSchemas.search_android_classes.parse(
retained[0]?.normalized_result,
),
).toMatchObject({
total_classes: INVENTORY_CLASS_COUNT,
matches: expect.arrayContaining(["fixture.Target", "fixture.Class7094"]),
coverage: "complete",
});
expect(rawInventoryNames(retained[0]?.raw_result)).toBe(
INVENTORY_CLASS_COUNT,
);
expect(JSON.stringify(retained[0]?.raw_result)).not.toContain(
"items_omitted",
);
await provider.close();
await verifyJadxFixtureCleanup(launches);
});