mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
* fix(artifacts): project compiled AppKit actions from the control to its target NSNibControlConnector archives the sending control as NSSource and the action target as NSDestination; a nil target is the first responder. The NIBArchive projection swapped them for control connectors, so every compiled AppKit action ran from its target to the control. The action trace, which follows control -> action, could not find them, and the selector node was keyed by the control instead of the target class. When the target was nil, the swapped source was null and the action was dropped: Chess lost 29 menu actions, KeepingYouAwake 7. Connectors now keep NSSource -> NSDestination. The NIBArchive test encoder moves to a fixture module so a bundle test can build compiled NIBs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(artifacts): report observed action routes when a compiled route is missing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(artifacts): report compiled connector records when a storyboard route is missing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(artifacts): write the storyboard fixture's action in Cocoa syntax MacFixture.storyboard declared its button action with the iOS attribute, <action selector="buildTapped:" destination="vc1">. Cocoa documents name an action's receiver with target=. ibtool compiled the iOS spelling into an NSNibControlConnector whose NSSource is the view controller placeholder and whose NSDestination is the button. That is the reverse of what Xcode-authored AppKit NIBs contain (Music, Chess, KeepingYouAwake, and Transmission all archive NSSource as the control), and it is what the old inversion had matched. With target= the compiled fixture archives the button as NSSource. The temporary connector dump is removed; the observed routes remain in the assertion message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { execFile } from "node:child_process";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
import { buildBinary } from "plist";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { analyzeInterfaceBuilderBundle } from "../../../src/application/InterfaceBuilderAnalysis.js";
|
|
import { encodeNibArchiveFixture } from "../../../src/artifacts/NibArchive.fixture.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
const compile = promisify(execFile);
|
|
|
|
describe("compiled Interface Builder bundle reader", () => {
|
|
it("reads nib plist archives and reports provenance", async () => {
|
|
const root = await createTestTempDirectory("rea-ib-test-");
|
|
const bundle = join(root, "Example.app");
|
|
const nib = join(
|
|
bundle,
|
|
"Contents",
|
|
"Resources",
|
|
"Main.storyboardc",
|
|
"Main.nib",
|
|
);
|
|
await mkdir(nib, { recursive: true });
|
|
await writeFile(
|
|
join(nib, "objects.nib"),
|
|
buildBinary({
|
|
$archiver: "NSKeyedArchiver",
|
|
$version: 100000,
|
|
$objects: [
|
|
"$null",
|
|
{ $class: { UID: 2 }, title: "Build" },
|
|
{
|
|
$classname: "UIButton",
|
|
$classes: ["UIButton", "UIControl", "UIView", "NSObject"],
|
|
},
|
|
],
|
|
$top: { root: { UID: 1 } },
|
|
}),
|
|
);
|
|
await writeFile(
|
|
join(bundle, "Contents", "Resources", "Main.storyboardc", "Info.plist"),
|
|
'<?xml version="1.0"?><plist><dict><key>notAnArchive</key><string>scene-index</string></dict></plist>',
|
|
);
|
|
await mkdir(join(bundle, "Contents", "Resources", "outside.nib"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(
|
|
join(bundle, "Contents", "Resources", "outside.nib", "not-nib.txt"),
|
|
"ignored",
|
|
);
|
|
|
|
const analysis = await analyzeInterfaceBuilderBundle({
|
|
bundlePath: bundle,
|
|
targetSha256: "b".repeat(64),
|
|
});
|
|
expect(analysis.documents).toMatchObject([
|
|
{
|
|
relative_path:
|
|
"Contents/Resources/Main.storyboardc/Main.nib/objects.nib",
|
|
document_kind: "storyboard_scene",
|
|
object_count: 1,
|
|
},
|
|
]);
|
|
expect(analysis.documents).toHaveLength(1);
|
|
expect(analysis.graph.target_sha256).toBe("b".repeat(64));
|
|
expect(analysis.graph.nodes.some(({ name }) => name === "Build")).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("projects compiled AppKit actions from the control to their target", async () => {
|
|
const root = await createTestTempDirectory("rea-ib-test-");
|
|
const bundle = join(root, "Example.app");
|
|
const resources = join(bundle, "Contents", "Resources");
|
|
await mkdir(resources, { recursive: true });
|
|
// NSNibControlConnector archives the sending control as NSSource and its
|
|
// target as NSDestination; a nil target is the first responder.
|
|
await writeFile(
|
|
join(resources, "Panel.nib"),
|
|
encodeNibArchiveFixture({
|
|
classes: [
|
|
"NSNibExternalObjectPlaceholder",
|
|
"NSButton",
|
|
"NSNibControlConnector",
|
|
"NSString",
|
|
"NSMenuItem",
|
|
],
|
|
objects: [
|
|
{ classIndex: 0, values: {} },
|
|
{ classIndex: 1, values: {} },
|
|
{
|
|
classIndex: 2,
|
|
values: {
|
|
NSSource: { ref: 1 },
|
|
NSDestination: { ref: 0 },
|
|
NSLabel: { ref: 3 },
|
|
},
|
|
},
|
|
{ classIndex: 3, values: { "NS.bytes": "doOK:" } },
|
|
{ classIndex: 4, values: {} },
|
|
{
|
|
classIndex: 2,
|
|
values: {
|
|
NSSource: { ref: 4 },
|
|
NSDestination: null,
|
|
NSLabel: { ref: 6 },
|
|
},
|
|
},
|
|
{ classIndex: 3, values: { "NS.bytes": "arrangeInFront:" } },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const analysis = await analyzeInterfaceBuilderBundle({
|
|
bundlePath: bundle,
|
|
targetSha256: "e".repeat(64),
|
|
});
|
|
const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node]));
|
|
const routes = analysis.graph.nodes
|
|
.filter(({ kind }) => kind === "action")
|
|
.map((action) => ({
|
|
action: action.name,
|
|
from: analysis.graph.edges
|
|
.filter(
|
|
({ relation, to }) =>
|
|
relation === "target_action" && to === action.id,
|
|
)
|
|
.map(({ from }) => byId.get(from)?.name),
|
|
to: analysis.graph.edges
|
|
.filter(
|
|
({ relation, from, to }) =>
|
|
relation === "target_action" &&
|
|
from === action.id &&
|
|
(to === null || byId.get(to)?.kind !== "objc_selector"),
|
|
)
|
|
.map(({ to }) => (to === null ? null : byId.get(to)?.name)),
|
|
}))
|
|
.sort((left, right) => left.action.localeCompare(right.action));
|
|
expect(routes).toEqual([
|
|
{ action: "arrangeInFront:", from: ["NSMenuItem"], to: [null] },
|
|
{
|
|
action: "doOK:",
|
|
from: ["NSButton"],
|
|
to: ["NSNibExternalObjectPlaceholder"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("honors cancellation during directory traversal", async () => {
|
|
const root = await createTestTempDirectory("rea-ib-test-");
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
await expect(
|
|
analyzeInterfaceBuilderBundle({
|
|
bundlePath: root,
|
|
targetSha256: "c".repeat(64),
|
|
signal: controller.signal,
|
|
}),
|
|
).rejects.toMatchObject({ reason: "cancelled" });
|
|
});
|
|
});
|
|
|
|
describe("bounded Interface Builder archive decoding", () => {
|
|
it("counts malformed archives against the document limit", async () => {
|
|
const root = await createTestTempDirectory("rea-ib-test-");
|
|
const bundle = join(root, "Example.app");
|
|
const resources = join(bundle, "Contents", "Resources");
|
|
await mkdir(resources, { recursive: true });
|
|
await writeFile(join(resources, "BadOne.nib"), Buffer.from("bplist00bad"));
|
|
await writeFile(join(resources, "BadTwo.nib"), Buffer.from("bplist00bad"));
|
|
|
|
const result = await analyzeInterfaceBuilderBundle({
|
|
bundlePath: bundle,
|
|
targetSha256: "e".repeat(64),
|
|
limits: { max_documents: 1 },
|
|
});
|
|
|
|
expect(result.graph.coverage).toContainEqual(
|
|
expect.objectContaining({
|
|
facet: "archive_decode",
|
|
status: "partial",
|
|
examined: 1,
|
|
omitted: 1,
|
|
}),
|
|
);
|
|
expect(result.graph.truncated).toBe(true);
|
|
});
|
|
|
|
it("marks archive decoding partial when __proto__ entries are omitted", async () => {
|
|
const root = await createTestTempDirectory("rea-ib-test-");
|
|
const bundle = join(root, "Example.app");
|
|
const resources = join(bundle, "Contents", "Resources");
|
|
await mkdir(resources, { recursive: true });
|
|
await writeFile(
|
|
join(resources, "Prototype.nib"),
|
|
'<?xml version="1.0"?><plist version="1.0"><dict><key>$archiver</key><string>NSKeyedArchiver</string><key>__proto__</key><string>hidden</string><key>$objects</key><array><string>$null</string></array><key>$top</key><dict/></dict></plist>',
|
|
);
|
|
|
|
const result = await analyzeInterfaceBuilderBundle({
|
|
bundlePath: bundle,
|
|
targetSha256: "f".repeat(64),
|
|
});
|
|
|
|
expect(result.graph.coverage).toContainEqual(
|
|
expect.objectContaining({
|
|
facet: "archive_decode",
|
|
status: "partial",
|
|
reason: "dictionary_entries_omitted",
|
|
}),
|
|
);
|
|
expect(result.graph.truncated).toBe(true);
|
|
expect(result.limitations).toContain(
|
|
"Contents/Resources/Prototype.nib: 1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
|
|
);
|
|
});
|
|
|
|
it.skipIf(process.platform !== "darwin" || !existsSync("/usr/bin/ibtool"))(
|
|
"decodes an Xcode-compiled storyboard NIB and recovers its UI routes",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-ib-compiled-test-");
|
|
const bundle = join(root, "Example.app");
|
|
const resources = join(bundle, "Contents", "Resources");
|
|
const source = join(
|
|
process.cwd(),
|
|
"tests",
|
|
"fixtures",
|
|
"interface-builder",
|
|
"MacFixture.storyboard",
|
|
);
|
|
await mkdir(resources, { recursive: true });
|
|
await compile("/usr/bin/ibtool", [
|
|
"--compile",
|
|
join(resources, "MacFixture.storyboardc"),
|
|
source,
|
|
]);
|
|
|
|
const analysis = await analyzeInterfaceBuilderBundle({
|
|
bundlePath: bundle,
|
|
targetSha256: "d".repeat(64),
|
|
});
|
|
const names = analysis.graph.nodes.map(({ name }) => name);
|
|
expect(names).toContain("BuildViewController");
|
|
expect(names).toContain("Button");
|
|
expect(names).toContain("buildTapped:");
|
|
expect(
|
|
analysis.graph.edges.some(
|
|
({ relation }) => relation === "target_action",
|
|
),
|
|
).toBe(true);
|
|
const action = analysis.graph.nodes.find(
|
|
({ kind, name }) => kind === "action" && name === "buildTapped:",
|
|
);
|
|
expect(action).toBeDefined();
|
|
const actionSource = analysis.graph.edges.find(
|
|
({ from, relation, to }) =>
|
|
relation === "target_action" &&
|
|
to === action?.id &&
|
|
analysis.graph.nodes.find(({ id }) => id === from)?.kind ===
|
|
"control",
|
|
);
|
|
const describeNode = (id: string | null) => {
|
|
const node = analysis.graph.nodes.find((item) => item.id === id);
|
|
return node === undefined ? String(id) : `${node.kind}:${node.name}`;
|
|
};
|
|
const actionRoutes = analysis.graph.edges
|
|
.filter(
|
|
({ relation, from, to }) =>
|
|
relation === "target_action" &&
|
|
(from === action?.id || to === action?.id),
|
|
)
|
|
.map(({ from, to }) => `${describeNode(from)} -> ${describeNode(to)}`);
|
|
expect(actionSource, actionRoutes.join("; ")).toBeDefined();
|
|
expect(
|
|
analysis.graph.edges.some(
|
|
({ from, relation, to }) =>
|
|
from === action?.id &&
|
|
relation === "target_action" &&
|
|
to !== null &&
|
|
analysis.graph.nodes.find(({ id }) => id === to)?.kind ===
|
|
"placeholder",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
analysis.graph.edges.some(({ relation }) => relation === "contains"),
|
|
).toBe(true);
|
|
expect(analysis.graph.coverage).toContainEqual(
|
|
expect.objectContaining({
|
|
facet: "archive_decode",
|
|
status: "complete",
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
});
|