mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(apple): decode NIBArchive booleans with AppKit's polarity (#6255)
The NIBArchive value decoder read type tag 4 as true and 5 as false, the reverse of what ibtool writes and AppKit's decoder returns, so every compiled-nib boolean in decode-interface-builder output was inverted. The test fixture encoder shared the reversed mapping, hiding it in round trips. Name the value-type tags once and use them in both the read and skip paths, fix the fixture encoder, pin raw tags in a unit test, and make verify:interface-builder compare both polarities with ibtool --export. Fixes #5541 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -1243,7 +1243,9 @@ host behavior or native process-supervision controls.
|
||||
real `.nib` with Xcode `ibtool`, wraps it in a temporary app bundle, and runs
|
||||
the compiled CLI and stdio MCP server with production providers. It compares
|
||||
their decoded results and checks the view hierarchy, outlet, action, evidence coverage, and truncation
|
||||
status. Storyboard compilation additionally requires an installed iOS platform.
|
||||
status. It also recompiles the XIB with toggled control booleans and requires
|
||||
both decodes to match `ibtool --export` for each source. Storyboard compilation
|
||||
additionally requires an installed iOS platform.
|
||||
|
||||
Keep the provider-specific acceptance path independent from optional
|
||||
cross-compilers. Cross-format failures belong to the cross-format lane and must
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
artifactCli,
|
||||
artifactCliEvidence,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
@@ -25,6 +26,56 @@ const nibPath = join(resourcesPath, "Main.nib");
|
||||
const sourceRoot = fileURLToPath(
|
||||
new URL("../../../tests/conformance/interface-builder/", import.meta.url),
|
||||
);
|
||||
const mainSource = join(sourceRoot, "Main.xib");
|
||||
// ibtool property names mapped to the keys AppKit archives for NSControl.
|
||||
const buttonBooleanKeys = {
|
||||
continuous: "NSControlContinuous",
|
||||
refusesFirstResponder: "NSControlRefusesFirstResponder",
|
||||
};
|
||||
const plistDocument = (body) =>
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">${body}</plist>\n`;
|
||||
const compileNib = (source) =>
|
||||
exec("xcrun", [
|
||||
"ibtool",
|
||||
"--errors",
|
||||
"--warnings",
|
||||
"--compile",
|
||||
nibPath,
|
||||
source,
|
||||
]);
|
||||
/** Apple's source-level property values: the oracle for compiled booleans. */
|
||||
const exportedButtonBooleans = async (source) => {
|
||||
const exported = join(fixtureRoot, "export.plist");
|
||||
const { stdout } = await exec("xcrun", [
|
||||
"ibtool",
|
||||
"--export",
|
||||
join(fixtureRoot, "export-request.plist"),
|
||||
source,
|
||||
]);
|
||||
await writeFile(exported, stdout);
|
||||
const { stdout: json } = await exec("plutil", [
|
||||
"-convert",
|
||||
"json",
|
||||
"-o",
|
||||
"-",
|
||||
exported,
|
||||
]);
|
||||
return JSON.parse(json)["com.apple.ibtool.document.export"].button;
|
||||
};
|
||||
const decodedButtonBooleans = (result) => {
|
||||
const button = result.graph.nodes.find(
|
||||
(node) => node.kind === "control" && node.name === "NSButton",
|
||||
);
|
||||
assert.ok(button, "compiled button control was not decoded");
|
||||
return Object.fromEntries(
|
||||
Object.entries(buttonBooleanKeys).map(([property, key]) => [
|
||||
property,
|
||||
button.attributes.nibValues?.[key],
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
let report;
|
||||
try {
|
||||
@@ -36,14 +87,7 @@ try {
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict><key>CFBundleExecutable</key><string>Fixture</string><key>CFBundleIdentifier</key><string>dev.rea.interface-builder-fixture</string></dict></plist>\n`,
|
||||
);
|
||||
await exec("xcrun", [
|
||||
"ibtool",
|
||||
"--errors",
|
||||
"--warnings",
|
||||
"--compile",
|
||||
nibPath,
|
||||
join(sourceRoot, "Main.xib"),
|
||||
]);
|
||||
await compileNib(mainSource);
|
||||
await exec("cc", [join(sourceRoot, "fixture.c"), "-o", executablePath]);
|
||||
|
||||
const evidence = await artifactCliEvidence(
|
||||
@@ -110,6 +154,53 @@ try {
|
||||
);
|
||||
assert.equal(decoded.graph.truncated, false);
|
||||
|
||||
// Compiled booleans must keep their source polarity in both directions.
|
||||
const properties = Object.keys(buttonBooleanKeys);
|
||||
await writeFile(
|
||||
join(fixtureRoot, "export-request.plist"),
|
||||
plistDocument(
|
||||
`<dict><key>NSControl</key><array>${properties.map((property) => `<string>${property}</string>`).join("")}</array></dict>`,
|
||||
),
|
||||
);
|
||||
await writeFile(
|
||||
join(fixtureRoot, "toggle.plist"),
|
||||
plistDocument(
|
||||
`<dict><key>button</key><dict>${properties.map((property) => `<key>${property}</key><true/>`).join("")}</dict></dict>`,
|
||||
),
|
||||
);
|
||||
const toggledSource = join(fixtureRoot, "Toggled.xib");
|
||||
await exec("xcrun", [
|
||||
"ibtool",
|
||||
"--import",
|
||||
join(fixtureRoot, "toggle.plist"),
|
||||
"--write",
|
||||
toggledSource,
|
||||
mainSource,
|
||||
]);
|
||||
const mainBooleans = await exportedButtonBooleans(mainSource);
|
||||
const toggledBooleans = await exportedButtonBooleans(toggledSource);
|
||||
assert.deepEqual(mainBooleans, {
|
||||
continuous: false,
|
||||
refusesFirstResponder: false,
|
||||
});
|
||||
assert.deepEqual(toggledBooleans, {
|
||||
continuous: true,
|
||||
refusesFirstResponder: true,
|
||||
});
|
||||
assert.deepEqual(
|
||||
decodedButtonBooleans(decoded),
|
||||
mainBooleans,
|
||||
"compiled false booleans differ from the source export",
|
||||
);
|
||||
await compileNib(toggledSource);
|
||||
assert.deepEqual(
|
||||
decodedButtonBooleans(
|
||||
await artifactCli("decode-interface-builder", appPath),
|
||||
),
|
||||
toggledBooleans,
|
||||
"compiled true booleans differ from the source export",
|
||||
);
|
||||
|
||||
report = {
|
||||
ok: true,
|
||||
fixture: "compiled-appkit-nib",
|
||||
@@ -122,6 +213,7 @@ try {
|
||||
connections: decoded.documents[0].connection_count,
|
||||
recovered_control: button.name,
|
||||
recovered_action: action.name,
|
||||
button_booleans: { main: mainBooleans, toggled: toggledBooleans },
|
||||
hierarchy_complete: decoded.documents[0].hierarchy_complete,
|
||||
truncated: decoded.graph.truncated,
|
||||
};
|
||||
|
||||
@@ -75,7 +75,8 @@ const varint = (value: number): Buffer => {
|
||||
const encodeValue = (
|
||||
value: number | string | boolean | null | { ref: number },
|
||||
): Buffer => {
|
||||
if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]);
|
||||
// Tags as written by ibtool and read by AppKit: 4 is NO, 5 is YES.
|
||||
if (typeof value === "boolean") return Buffer.from([value ? 5 : 4]);
|
||||
if (value === null) return Buffer.from([9]);
|
||||
if (typeof value === "number") {
|
||||
const bytes = Buffer.alloc(5);
|
||||
|
||||
@@ -77,6 +77,37 @@ describe("NIBArchive decoder", () => {
|
||||
expect(decoded.coder_version).toBe(10);
|
||||
});
|
||||
|
||||
it("reads boolean value tags with AppKit's polarity", () => {
|
||||
// Raw tags, independent of the fixture encoder's boolean encoding: AppKit's
|
||||
// decodeBoolForKey: returns NO for tag 4 and YES for tag 5 in compiled nibs.
|
||||
const archive = encodeArchive({
|
||||
classes: ["NSButton"],
|
||||
objects: [
|
||||
{
|
||||
classIndex: 0,
|
||||
values: {
|
||||
NSControlContinuous: null,
|
||||
NSControlRefusesFirstResponder: null,
|
||||
NSEnabled: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const values = archive.readUInt32LE(38);
|
||||
// Value records: key varint, then type tag; both fields start as nil (9).
|
||||
expect([...archive.subarray(values, values + 4)]).toEqual([
|
||||
0x80, 9, 0x81, 9,
|
||||
]);
|
||||
const tagged = Buffer.from(archive);
|
||||
tagged[values + 1] = 4;
|
||||
tagged[values + 3] = 5;
|
||||
expect(decodeNibArchive(tagged).objects[0]?.values).toEqual({
|
||||
NSControlContinuous: false,
|
||||
NSControlRefusesFirstResponder: true,
|
||||
NSEnabled: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts AppKit outlet, action, and binding connector objects", () => {
|
||||
const archive = encodeArchive({
|
||||
classes: [
|
||||
|
||||
@@ -338,6 +338,24 @@ const parseValues = (
|
||||
return output;
|
||||
};
|
||||
|
||||
/**
|
||||
* Value type tags in a NIBArchive value table. The two boolean tags carry no
|
||||
* payload; AppKit's own nib decoder reads tag 4 as NO and tag 5 as YES.
|
||||
*/
|
||||
const NibValueType = {
|
||||
int8: 0,
|
||||
int16: 1,
|
||||
int32: 2,
|
||||
int64: 3,
|
||||
booleanFalse: 4,
|
||||
booleanTrue: 5,
|
||||
float32: 6,
|
||||
float64: 7,
|
||||
data: 8,
|
||||
nil: 9,
|
||||
objectReference: 10,
|
||||
} as const;
|
||||
|
||||
const readValue = (
|
||||
bytes: Buffer,
|
||||
cursor: { offset: number },
|
||||
@@ -347,24 +365,24 @@ const readValue = (
|
||||
budget: InterfaceBuilderDecodeBudget,
|
||||
): JsonValue => {
|
||||
switch (type) {
|
||||
case 0:
|
||||
case NibValueType.int8:
|
||||
ensureRange(cursor.offset, 1, end, `value ${index}`);
|
||||
return bytes.readInt8(cursor.offset++);
|
||||
case 1:
|
||||
case NibValueType.int16:
|
||||
ensureRange(cursor.offset, 2, end, `value ${index}`);
|
||||
{
|
||||
const value = bytes.readInt16LE(cursor.offset);
|
||||
cursor.offset += 2;
|
||||
return value;
|
||||
}
|
||||
case 2:
|
||||
case NibValueType.int32:
|
||||
ensureRange(cursor.offset, 4, end, `value ${index}`);
|
||||
{
|
||||
const value = bytes.readInt32LE(cursor.offset);
|
||||
cursor.offset += 4;
|
||||
return value;
|
||||
}
|
||||
case 3:
|
||||
case NibValueType.int64:
|
||||
ensureRange(cursor.offset, 8, end, `value ${index}`);
|
||||
{
|
||||
const value = bytes.readBigInt64LE(cursor.offset);
|
||||
@@ -374,25 +392,25 @@ const readValue = (
|
||||
? Number(value)
|
||||
: value.toString();
|
||||
}
|
||||
case 4:
|
||||
return true;
|
||||
case 5:
|
||||
case NibValueType.booleanFalse:
|
||||
return false;
|
||||
case 6:
|
||||
case NibValueType.booleanTrue:
|
||||
return true;
|
||||
case NibValueType.float32:
|
||||
ensureRange(cursor.offset, 4, end, `value ${index}`);
|
||||
{
|
||||
const value = bytes.readFloatLE(cursor.offset);
|
||||
cursor.offset += 4;
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
case 7:
|
||||
case NibValueType.float64:
|
||||
ensureRange(cursor.offset, 8, end, `value ${index}`);
|
||||
{
|
||||
const value = bytes.readDoubleLE(cursor.offset);
|
||||
cursor.offset += 8;
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
case 8: {
|
||||
case NibValueType.data: {
|
||||
const length = readVarint(bytes, cursor, end);
|
||||
const next = checkedEnd(
|
||||
cursor.offset,
|
||||
@@ -408,9 +426,9 @@ const readValue = (
|
||||
cursor.offset = next;
|
||||
return { $nib_data_base64: encoded };
|
||||
}
|
||||
case 9:
|
||||
case NibValueType.nil:
|
||||
return null;
|
||||
case 10:
|
||||
case NibValueType.objectReference:
|
||||
ensureRange(cursor.offset, 4, end, `value ${index} reference`);
|
||||
{
|
||||
const objectIndex = bytes.readUInt32LE(cursor.offset);
|
||||
@@ -432,33 +450,33 @@ const skipValue = (
|
||||
index: number,
|
||||
): void => {
|
||||
switch (type) {
|
||||
case 0:
|
||||
case NibValueType.int8:
|
||||
ensureRange(cursor.offset, 1, end, `value ${index}`);
|
||||
cursor.offset += 1;
|
||||
return;
|
||||
case 1:
|
||||
case NibValueType.int16:
|
||||
ensureRange(cursor.offset, 2, end, `value ${index}`);
|
||||
cursor.offset += 2;
|
||||
return;
|
||||
case 2:
|
||||
case 10:
|
||||
case NibValueType.int32:
|
||||
case NibValueType.objectReference:
|
||||
ensureRange(cursor.offset, 4, end, `value ${index}`);
|
||||
cursor.offset += 4;
|
||||
return;
|
||||
case 3:
|
||||
case 7:
|
||||
case NibValueType.int64:
|
||||
case NibValueType.float64:
|
||||
ensureRange(cursor.offset, 8, end, `value ${index}`);
|
||||
cursor.offset += 8;
|
||||
return;
|
||||
case 6:
|
||||
case NibValueType.float32:
|
||||
ensureRange(cursor.offset, 4, end, `value ${index}`);
|
||||
cursor.offset += 4;
|
||||
return;
|
||||
case 4:
|
||||
case 5:
|
||||
case 9:
|
||||
case NibValueType.booleanFalse:
|
||||
case NibValueType.booleanTrue:
|
||||
case NibValueType.nil:
|
||||
return;
|
||||
case 8: {
|
||||
case NibValueType.data: {
|
||||
const length = readVarint(bytes, cursor, end);
|
||||
cursor.offset = checkedEnd(
|
||||
cursor.offset,
|
||||
|
||||
Reference in New Issue
Block a user