Files
rea/tests/acceptance/applications/interfaceBuilderHierarchyLimits.test.ts

175 lines
5.7 KiB
TypeScript

import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { build } from "plist";
import { expect, onTestFinished } from "vitest";
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { createTestMcp } from "../../support/mcp/mcpFixture.js";
import { openBundleResourcesTarget } from "../../support/mcp/artifactSession.js";
const verifyMcpParity = async (
app: string,
expected: ReturnType<typeof interfaceBuilderAnalysisSchema.parse>,
): Promise<void> => {
const mcp = createTestMcp();
onTestFinished(async () => mcp.close());
const client = await openBundleResourcesTarget(mcp, onTestFinished, app);
const complete = await client.callTool({
name: "decode_interface_builder",
arguments: { max_objects: 2 },
});
expect(complete.isError, JSON.stringify(complete)).not.toBe(true);
const evidence = parseEvidence(complete.structuredContent);
expect(
interfaceBuilderAnalysisSchema.parse(evidence.normalized_result),
).toEqual(expected);
const summarized = await client.callTool({
name: "decode_interface_builder",
arguments: { max_objects: 2, detail: "summary" },
});
expect(summarized.isError, JSON.stringify(summarized)).not.toBe(true);
expect(
parseEvidence(summarized.structuredContent).normalized_result,
).toMatchObject({
kind: "summary",
parent_evidence_id: evidence.evidence_id,
summary: {
documents: expected.documents,
truncated: true,
coverage_statuses: { partial: expect.any(Number) },
},
});
};
const archive = (objects: readonly string[], hierarchy: readonly string[]) =>
build({
"com.apple.ibtool.document.objects": Object.fromEntries(
objects.map((id) => [id, { class: "UIView" }]),
),
"com.apple.ibtool.document.hierarchy": hierarchy.map((objectID) => ({
objectID,
})),
});
const cases = [
{
name: "a later document's root is omitted",
archives: [
["First.nib", archive(["first"], ["first"])],
["Second.nib", archive(["second"], ["second"])],
] as const,
hierarchyComplete: [true, false],
document: "Second.nib",
omittedEdges: 1,
unresolvedEdge: false,
},
{
name: "a retained hierarchy edge has an omitted endpoint",
archives: [
["Single.nib", archive(["kept", "omitted"], ["omitted"])],
] as const,
hierarchyComplete: [false],
document: "Single.nib",
omittedEdges: 0,
unresolvedEdge: true,
},
];
cliTest.for(cases)(
"reports partial hierarchy through the CLI when $name",
async (
{ archives, hierarchyComplete, document, omittedEdges, unresolvedEdge },
{ cli },
) => {
const root = await createTestTempDirectory("rea-ib-hierarchy-limits-");
const app = join(root, "Fixture.app");
const contents = join(app, "Contents");
const resources = join(contents, "Resources");
await mkdir(resources, { recursive: true });
await mkdir(join(contents, "MacOS"));
await writeFile(
join(contents, "MacOS", "Fixture"),
thinMach(0xfeedfacf, 0x0100000c),
);
await writeFile(
join(contents, "Info.plist"),
build({ CFBundleExecutable: "Fixture" }),
);
for (const [name, contents] of archives)
await writeFile(join(resources, name), contents);
const output = await cli.run({
arguments: [
"decode-interface-builder",
app,
"--max-objects",
"2",
"--json",
],
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
});
expect(output.exitCode, output.stdout + output.stderr).toBe(0);
const evidence = parseEvidence(output.json);
const report = interfaceBuilderAnalysisSchema.parse(
evidence.normalized_result,
);
await verifyMcpParity(app, report);
expect(
report.documents.map(({ hierarchy_complete }) => hierarchy_complete),
).toEqual(hierarchyComplete);
expect(report.graph.truncated).toBe(true);
expect(report.graph.nodes).toHaveLength(2);
expect(report.graph.edges).toHaveLength(1);
expect(report.graph.coverage).toContainEqual(
expect.objectContaining({
facet: `hierarchy:Contents/Resources/${document}`,
status: "partial",
reason: "graph_limit_reached",
examined: 1,
omitted: omittedEdges,
}),
);
if (unresolvedEdge)
expect(report.graph.edges[0]).toMatchObject({
relation: "contains",
resolution: "unresolved",
to: null,
reason: "endpoint_omitted_by_object_limit",
});
const viewPath = join(root, "summary.json");
await writeFile(
viewPath,
JSON.stringify({
source: { kind: "inline", evidence: output.json },
view: { kind: "summary" },
}),
);
const summaryOutput = await cli.run({
arguments: ["inspect-analysis-view", viewPath, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(
summaryOutput.exitCode,
summaryOutput.stdout + summaryOutput.stderr,
).toBe(0);
expect(parseEvidence(summaryOutput.json).normalized_result).toMatchObject({
kind: "summary",
parent_evidence_id: evidence.evidence_id,
summary: {
documents: report.documents,
truncated: true,
coverage_statuses: { partial: expect.any(Number) },
},
});
for (const [name, contents] of archives)
expect(await readFile(join(resources, name), "utf8")).toBe(contents);
},
);