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, ): Promise => { 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); }, );