mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 01:41:21 +00:00
fix(apple): retain Interface Builder facts during finalization (#9953)
This commit is contained in:
1 parent
e9e106f580
commit
fcd364069a
4 files changed
+209
-32
No files matched your search
@@ -48,6 +48,7 @@ import {
|
||||
buildInterfaceBuilderAnalysis,
|
||||
interfaceBuilderLimitsSchema,
|
||||
type InterfaceBuilderDocumentInput,
|
||||
type InterfaceBuilderAnalysis,
|
||||
} from "../../domain/apple/interfaceBuilderGraph.js";
|
||||
import { projectNibViewHierarchy } from "./NibViewHierarchy.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
@@ -124,9 +125,9 @@ const decodeInterfaceBuilderBundle = async (
|
||||
const decodeBudget = new InterfaceBuilderDecodeBudget(
|
||||
MAX_AGGREGATE_DECODE_BYTES,
|
||||
);
|
||||
let outcome: { kind: "completed" } | { kind: "failed"; cause: unknown } = {
|
||||
kind: "completed",
|
||||
};
|
||||
let outcome:
|
||||
| { kind: "completed"; value: InterfaceBuilderAnalysis }
|
||||
| { kind: "failed"; cause: unknown };
|
||||
const retainOmittedArchive = (
|
||||
path: string,
|
||||
reason: string,
|
||||
@@ -289,6 +290,34 @@ const decodeInterfaceBuilderBundle = async (
|
||||
else unreportedInvalidArchives += 1;
|
||||
}
|
||||
}
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: input.targetSha256,
|
||||
...(input.resourceRoot === undefined
|
||||
? {}
|
||||
: { resourceRoot: input.resourceRoot }),
|
||||
toolVersion: "rea-interface-builder/1",
|
||||
documents,
|
||||
limits,
|
||||
});
|
||||
const value = finalizeHierarchyCoverage(result, {
|
||||
unexaminedSources,
|
||||
incompleteHierarchies,
|
||||
unresolvedConnections,
|
||||
unknownDates,
|
||||
datePrecisionOverlaps,
|
||||
unknownScalars,
|
||||
nonFiniteReals,
|
||||
negativeZeroReals,
|
||||
invalid,
|
||||
unreportedInvalidArchives,
|
||||
omittedArchives,
|
||||
unreportedOmittedArchives,
|
||||
omissionCounts,
|
||||
attempted,
|
||||
omitted,
|
||||
traversal: traversal.snapshot(),
|
||||
});
|
||||
outcome = { kind: "completed", value };
|
||||
} catch (cause: unknown) {
|
||||
outcome = { kind: "failed", cause };
|
||||
}
|
||||
@@ -297,36 +326,25 @@ const decodeInterfaceBuilderBundle = async (
|
||||
reader,
|
||||
resource: input.bundlePath,
|
||||
});
|
||||
const finalOutcome =
|
||||
outcome.kind === "completed" && input.signal?.aborted
|
||||
? {
|
||||
kind: "failed" as const,
|
||||
cause: new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Interface Builder inspection cancelled during reader finalization",
|
||||
{ cause: input.signal.reason, partialObservation: outcome.value },
|
||||
),
|
||||
}
|
||||
: outcome;
|
||||
if (released.kind === "failed")
|
||||
throw resources.cleanupFailure(outcome, [released]);
|
||||
if (outcome.kind === "failed") throw outcome.cause;
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: input.targetSha256,
|
||||
...(input.resourceRoot === undefined
|
||||
? {}
|
||||
: { resourceRoot: input.resourceRoot }),
|
||||
toolVersion: "rea-interface-builder/1",
|
||||
documents,
|
||||
limits,
|
||||
});
|
||||
return finalizeHierarchyCoverage(result, {
|
||||
unexaminedSources,
|
||||
incompleteHierarchies,
|
||||
unresolvedConnections,
|
||||
unknownDates,
|
||||
datePrecisionOverlaps,
|
||||
unknownScalars,
|
||||
nonFiniteReals,
|
||||
negativeZeroReals,
|
||||
invalid,
|
||||
unreportedInvalidArchives,
|
||||
omittedArchives,
|
||||
unreportedOmittedArchives,
|
||||
omissionCounts,
|
||||
attempted,
|
||||
omitted,
|
||||
traversal: traversal.snapshot(),
|
||||
});
|
||||
throw resources.cleanupFailure(
|
||||
finalOutcome,
|
||||
[released],
|
||||
outcome.kind === "completed" ? outcome.value : undefined,
|
||||
);
|
||||
if (finalOutcome.kind === "failed") throw finalOutcome.cause;
|
||||
return finalOutcome.value;
|
||||
};
|
||||
|
||||
/** Select lexically earliest document paths independent of host traversal order. */
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { buildBinary } from "plist";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
import { translateArtifactFailure } from "../ArtifactOperationFailure.js";
|
||||
|
||||
const bundle = async () => {
|
||||
const path = await createTestTempDirectory("rea-ib-finalization-");
|
||||
await mkdir(join(path, "Contents", "Resources"), { recursive: true });
|
||||
await writeFile(
|
||||
join(path, "Contents", "Resources", "Panel.nib"),
|
||||
Buffer.from(
|
||||
buildBinary({
|
||||
"com.apple.ibtool.document.objects": {
|
||||
button: { customClass: "NSButton", label: "Retained button" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {},
|
||||
"com.apple.ibtool.document.hierarchy": [],
|
||||
"com.apple.ibtool.document.classes": {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
return path;
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ abort: false, failClose: false },
|
||||
{ abort: true, failClose: false },
|
||||
{ abort: false, failClose: true },
|
||||
{ abort: true, failClose: true },
|
||||
])(
|
||||
"retains completed IB facts across finalization %j",
|
||||
async ({ abort, failClose }) => {
|
||||
const bundlePath = await bundle();
|
||||
const input = { bundlePath, targetSha256: "a".repeat(64) };
|
||||
const expected = await analyzeInterfaceBuilderBundle(input);
|
||||
expect(expected.documents).toHaveLength(1);
|
||||
expect(expected.graph.nodes.map(({ name }) => name)).toContain(
|
||||
"Retained button",
|
||||
);
|
||||
const resources = new ArtifactResourceScope();
|
||||
const caller = new AbortController();
|
||||
const reason = { stage: "IB final close" };
|
||||
const denied = new Error("controlled IB directory close failure");
|
||||
let attempts = 0;
|
||||
const nativeClose = DirectoryArtifactReader.prototype.close;
|
||||
const close = vi
|
||||
.spyOn(DirectoryArtifactReader.prototype, "close")
|
||||
.mockImplementation(async function (this: DirectoryArtifactReader) {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
if (abort) caller.abort(reason);
|
||||
if (failClose) throw denied;
|
||||
}
|
||||
await nativeClose.call(this);
|
||||
});
|
||||
try {
|
||||
const outcome = await analyzeInterfaceBuilderBundle({
|
||||
...input,
|
||||
resources,
|
||||
signal: caller.signal,
|
||||
}).then(
|
||||
(value) => ({ ok: true as const, value }),
|
||||
(cause) => ({ ok: false as const, cause: cause as unknown }),
|
||||
);
|
||||
if (!abort && !failClose) {
|
||||
expect(outcome).toEqual({ ok: true, value: expected });
|
||||
return;
|
||||
}
|
||||
expect(outcome.ok).toBe(false);
|
||||
if (outcome.ok) throw new Error("Expected finalization failure");
|
||||
expect(outcome.cause).toMatchObject({
|
||||
reason: abort ? "cancelled" : "io",
|
||||
partialObservation: expected,
|
||||
});
|
||||
const projected = translateArtifactFailure(
|
||||
"decode_interface_builder",
|
||||
outcome.cause,
|
||||
);
|
||||
if (abort) expect(projected.cause).toBe(reason);
|
||||
expect(projected.partialObservation).toEqual(expected);
|
||||
expect(projected.cleanupIncomplete).toBe(failClose);
|
||||
expect(resources.hasPending).toBe(failClose);
|
||||
await resources.close();
|
||||
expect(resources.hasPending).toBe(false);
|
||||
expect(projected.cleanupIncomplete).toBe(false);
|
||||
expect(attempts).toBe(failClose ? 2 : 1);
|
||||
} finally {
|
||||
await resources.close();
|
||||
close.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps a physical IB read failure primary during late abort (close fails: %s)",
|
||||
async (failClose) => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
const bundlePath = await bundle();
|
||||
const caller = new AbortController();
|
||||
const reason = { stage: "after physical refusal" };
|
||||
const failure = new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"controlled physical archive refusal",
|
||||
);
|
||||
const open = vi
|
||||
.spyOn(DirectoryArtifactReader.prototype, "open")
|
||||
.mockRejectedValue(failure);
|
||||
const nativeClose = DirectoryArtifactReader.prototype.close;
|
||||
let attempts = 0;
|
||||
const close = vi
|
||||
.spyOn(DirectoryArtifactReader.prototype, "close")
|
||||
.mockImplementation(async function (this: DirectoryArtifactReader) {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
caller.abort(reason);
|
||||
if (failClose) throw new Error("controlled close failure");
|
||||
}
|
||||
await nativeClose.call(this);
|
||||
});
|
||||
try {
|
||||
const rejected: unknown = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath,
|
||||
targetSha256: "a".repeat(64),
|
||||
resources,
|
||||
signal: caller.signal,
|
||||
}).catch((cause: unknown) => cause);
|
||||
expect(rejected).toMatchObject({
|
||||
reason: "integrity",
|
||||
message: failure.message,
|
||||
});
|
||||
if (failClose) expect((rejected as Error).cause).toBe(failure);
|
||||
else expect(rejected).toBe(failure);
|
||||
const projected = translateArtifactFailure(
|
||||
"decode_interface_builder",
|
||||
rejected,
|
||||
);
|
||||
expect(projected.partialObservation).toBeUndefined();
|
||||
expect(projected.cleanupIncomplete).toBe(failClose);
|
||||
await resources.close();
|
||||
expect(projected.cleanupIncomplete).toBe(false);
|
||||
} finally {
|
||||
await resources.close();
|
||||
open.mockRestore();
|
||||
close.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { InterfaceBuilderAnalysis } from "./apple/interfaceBuilderGraph.js";
|
||||
import type { PartialTaskMeasurement } from "./desktop/taskDifficulty.js";
|
||||
import type { AppleIpsPartialObservation } from "./native/appleIps.js";
|
||||
import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js";
|
||||
@@ -495,6 +496,7 @@ import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js";
|
||||
|
||||
/** Provider-neutral evidence collected before a typed analysis failure. */
|
||||
export type AnalysisPartialObservation =
|
||||
| InterfaceBuilderAnalysis
|
||||
| PartialTaskMeasurement
|
||||
| {
|
||||
readonly kind: "omitted-observation";
|
||||
|
||||
@@ -41,6 +41,10 @@ export const interfaceBuilderAnalysisSchema = bundleResourceBoundResultSchema(
|
||||
},
|
||||
);
|
||||
|
||||
export type InterfaceBuilderAnalysis = z.output<
|
||||
typeof interfaceBuilderAnalysisSchema
|
||||
>;
|
||||
|
||||
export interface InterfaceBuilderDocumentInput {
|
||||
readonly relativePath: string;
|
||||
readonly archiveSha256: string;
|
||||
|
||||
Reference in new issue
Block a user