refactor(runtime): centralize safe JSON parsing and preserve error causes (#629)

* refactor(domain): consolidate canonical ordering and digest helpers (#624)

* refactor(domain): consolidate canonical ordering and digest helpers

Single owner for code-point ordering (domain/canonicalOrdering.ts)
and canonical JSON digests (domain/comparisonSemantics.ts). Delete
all local compareCodePoints/compareText/digestCanonical duplicates
and migrate every importer. No re-export shims.

* fix(ci): allow slower real browser startup

* chore(config): remove dead eslint, dedupe scripts, cacheable turbo tasks

Delete eslint.config.js (oxlint is the linter), drop lint:dead alias,
pin smol-toml, fold jscpd/scan:todos into check:pr, make test:run
cacheable, align oxlint ignores.

* fix(config): hash all test inputs for cached runs

* docs: fix architecture diagram and pretty-print tool catalog

Drop dangling REPLAY edges, add dotnet/reference/error/evaluation
nodes. Emit 2-space JSON from the catalog generator and regenerate.

* test: extract shared fixtures, drop knip carve-outs

Dedupe android/apple session tests, bundler adapter headers, and
reconstruction helpers into tests/support fixtures. Delete unused
type aliases and the knip ignoreIssues block. Lower jscpd threshold
2.7 to 2.5.

* refactor(app): split Setup and ProcessOwnership modules

Extract SetupTypes/SetupHost from Setup.ts and move observation
helpers into ProcessOwnershipObservation/ProcessTree. Migrate all
importers including CLI commands. No re-export shims.

* refactor(runtime): centralize safe JSON parsing and preserve error causes

Add domain/safeJson.safeParseJson and migrate all raw JSON.parse
call sites. Name catch causes and preserve failure reasons instead
of swallowing; annotate genuine best-effort cleanup.

* fix(runtime): retain JSON parse error causes
This commit is contained in:
morluto authored and GitHub committed 2026-10-06 05:00:54 +08:00
1 parent 3c84080aab
commit e7f54b3310
31 files changed
+228 -105

No files matched your search

+6 -2
View File
@@ -422,11 +422,15 @@ export class BinarySession
for (const listener of this.#availabilityListeners) {
try {
const notification = listener();
if (notification !== undefined)
if (notification !== undefined) {
// best-effort cleanup: async observer notifications must not reject
// unhandled; state transitions and other listeners continue.
void notification.catch(() => undefined);
} catch {
}
} catch (cause: unknown) {
// External observers are best-effort notifications. Contain only the
// callback failure so state transitions and other listeners continue.
void cause;
}
}
}
+2
View File
@@ -31,6 +31,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
try {
for await (const child of handle) children.push(child.name);
} finally {
// best-effort cleanup: directory-handle close must not mask traversal.
await handle.close().catch(() => undefined);
}
await assertContainedDirectory(root, directory);
@@ -101,6 +102,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
}
return handle.createReadStream({ autoClose: true });
} catch (cause: unknown) {
// best-effort cleanup: file-handle close must not mask the open failure.
await handle.close().catch(() => undefined);
throw cause;
}
+4 -2
View File
@@ -372,8 +372,10 @@ const decodeUtf8 = (
return new TextDecoder("utf-8", { fatal: true }).decode(
bytes.subarray(start, end),
);
} catch {
throw new TypeError(`NIBArchive ${label} is not valid UTF-8`);
} catch (cause: unknown) {
throw new TypeError(`NIBArchive ${label} is not valid UTF-8`, {
cause,
});
}
};
+1
View File
@@ -153,6 +153,7 @@ export class SafeOutputTree {
);
return { relativePath: path, sha256, bytesWritten: bytes };
} catch (cause: unknown) {
// best-effort cleanup: file-handle close must not mask the write failure.
await handle.close().catch(() => undefined);
throw cause;
}
+1
View File
@@ -95,6 +95,7 @@ export class ZipArtifactReader implements ArtifactReader {
}
async close(): Promise<void> {
// best-effort cleanup: zip-reader close must not mask prior extraction state.
await this.#reader.close().catch(() => undefined);
await this.#source.closeHandle();
}
+4 -4
View File
@@ -6,6 +6,7 @@ import {
BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js";
import { safeParseJson } from "../domain/safeJson.js";
export interface CdpEvent {
readonly method: string;
@@ -140,13 +141,12 @@ export class CdpConnection {
}
#receive(data: RawData): void {
let message: unknown;
try {
message = JSON.parse(rawText(data));
} catch {
const parsed = safeParseJson(rawText(data));
if (!parsed.ok) {
this.#failPending("protocol_error");
return;
}
const message: unknown = parsed.value;
if (!isRecord(message)) {
this.#failPending("protocol_error");
return;
+6 -4
View File
@@ -9,6 +9,7 @@ import {
type BrowserObservationOperation,
} from "../domain/errors.js";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { safeParseJson } from "../domain/safeJson.js";
const endpointVersionSchema = z.object({
Browser: z.string().min(1),
@@ -304,17 +305,18 @@ export const readCdpJson = async (
const chunks: Buffer[] = [];
response.on("data", (chunk: Buffer) => chunks.push(chunk));
response.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch (cause: unknown) {
const parsed = safeParseJson(Buffer.concat(chunks).toString("utf8"));
if (!parsed.ok) {
reject(
new BrowserObservationError(
operation,
"invalid_endpoint_response",
{ cause },
{ cause: parsed.cause },
),
);
return;
}
resolve(parsed.value);
});
response.on("error", (cause: unknown) =>
reject(endpointFailure(cause, operation, signal)),
+6 -4
View File
@@ -60,8 +60,9 @@ export const closeCdpTargetSession = async (
}
try {
await connection.send(`${domain}.disable`, {}, sessionId, signal);
} catch {
// Cleanup continues to the detach or direct-socket close boundary.
} catch (cause: unknown) {
// best-effort cleanup: domain disable continues to detach/close boundary.
void cause;
}
}
if (signal?.aborted === true) {
@@ -76,8 +77,9 @@ export const closeCdpTargetSession = async (
undefined,
signal,
);
} catch {
// Closing REA's socket is the final non-destructive cleanup boundary.
} catch (cause: unknown) {
// best-effort cleanup: closing REA's socket is the final cleanup boundary.
void cause;
}
await connection.close();
};
+2
View File
@@ -178,6 +178,8 @@ export const closePlaywrightScenarioBrowser = async (
await rm(profilePath, { recursive: true, force: true, maxRetries: 3 });
}
})();
// best-effort cleanup: the bounded execution boundary below observes the
// same cleanup promise; this early attachment only prevents unhandled rejection.
void cleanup.catch(() => undefined);
// The shared execution boundary rejects cancellation/timeouts instead of
// returning a successful capture whose browser or profile may still exist.
+2
View File
@@ -201,6 +201,8 @@ export const closeInspectorConnection = async (
signal?: AbortSignal,
): Promise<void> => {
const closing = connection.close();
// best-effort cleanup: the race below observes the close; this only prevents
// unhandled rejection when the caller abandons the close via cancellation.
void closing.catch(() => undefined);
// CdpConnection.close has its own one second transport bound. Do not make a
// cancelled caller wait for that fallback.
+4 -6
View File
@@ -12,6 +12,7 @@ import type {
} from "../domain/webBundleAnalysis.js";
import { webSourceMapsSchema } from "../domain/webBundleAnalysis.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import { safeParseJson } from "../domain/safeJson.js";
export interface WebSourceMapRequest {
readonly scriptKey: string;
@@ -272,12 +273,9 @@ const originalModuleEdges = (
};
const validSourceMapEnvelope = (text: string): boolean => {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return false;
}
const parsedResult = safeParseJson(text);
if (!parsedResult.ok) return false;
const parsed: unknown = parsedResult.value;
if (!isRecord(parsed) || parsed.version !== 3) return false;
if (typeof parsed.mappings === "string") return validSourceMapLeaf(parsed);
if (!Array.isArray(parsed.sections)) return false;
+4 -1
View File
@@ -351,7 +351,10 @@ const isJavaScriptApplicationPath = async (path: string): Promise<boolean> => {
if (lower.endsWith(".app")) return false;
try {
return (await stat(path)).isDirectory();
} catch {
} catch (cause: unknown) {
// A missing or unreadable path is not a JavaScript application directory;
// the caller falls through to direct binary analysis instead.
void cause;
return false;
}
};
+3 -5
View File
@@ -18,6 +18,7 @@ import {
compareWebScreenshotsInputSchema,
} from "./domain/webScreenshot.js";
import type { JsonValue } from "./domain/jsonValue.js";
import { safeParseJson } from "./domain/safeJson.js";
import type { Logger } from "./logger.js";
import { CLI_COMMANDS } from "./cliCommandNames.js";
@@ -181,11 +182,8 @@ const registerScreenshotDiff = (
};
const parseJson = (value: string): unknown => {
try {
return JSON.parse(value);
} catch {
return undefined;
}
const parsed = safeParseJson(value);
return parsed.ok ? parsed.value : undefined;
};
const inputError = (operation: string): JsonValue =>
+25 -17
View File
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js";
import type { JsonValue } from "./domain/jsonValue.js";
import { safeParseJson } from "./domain/safeJson.js";
/** Parse inline JSON or one local JSON file for a CLI workflow. */
export const parseCliJsonInput = async (
@@ -16,27 +17,33 @@ export const parseCliJsonInput = async (
if (["{", "["].includes(value.trimStart()[0] ?? ""))
return { ok: false, error: inputError(operation) };
try {
const parsed = parseJson(await readFile(value));
return parsed === undefined
? jsonFileError(value, operation, "invalid-json")
: { ok: true, value: parsed };
} catch {
return jsonFileError(value, operation, "read-failed");
const decoded = safeParseJson(await readFile(value, "utf8"));
return decoded.ok
? { ok: true, value: decoded.value }
: jsonFileError(value, operation, "invalid-json");
} catch (cause: unknown) {
return jsonFileError(value, operation, "read-failed", cause);
}
};
const parseJson = (value: string | Uint8Array): unknown => {
try {
return JSON.parse(
typeof value === "string"
? value
: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
value,
),
);
} catch {
return undefined;
let text: string;
if (typeof value === "string") {
text = value;
} else {
try {
text = new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true,
}).decode(value);
} catch (cause: unknown) {
// Decoding failure means the bytes are not valid UTF-8 JSON input.
void cause;
return undefined;
}
}
const parsed = safeParseJson(text);
return parsed.ok ? parsed.value : undefined;
};
const inputError = (operation: string): JsonValue => ({
@@ -52,6 +59,7 @@ const jsonFileError = (
path: string | undefined,
operation: string,
reason: "invalid-json" | "read-failed",
cause?: unknown,
) => ({
ok: false as const,
error: {
@@ -59,7 +67,7 @@ const jsonFileError = (
...projectAnalysisError(
new AnalysisInputError(
operation,
undefined,
cause === undefined ? undefined : { cause },
reason === "invalid-json"
? [{ path: [], reason: "invalid_format", expected: "JSON" }]
: [],
+14 -14
View File
@@ -1,3 +1,5 @@
import { safeParseJson } from "./domain/safeJson.js";
const SAFE_VALIDATION_MESSAGE =
"REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again.";
const UNSUPPORTED_OUTPUT_COMBINATION_MESSAGE =
@@ -94,20 +96,18 @@ export const renderCliOutputArgumentError = (
export const sanitizeCliOutput = (output: string): string => {
const trimmed = output.trimStart();
if (trimmed.startsWith("{")) {
try {
const parsed: unknown = JSON.parse(trimmed);
if (!isRecord(parsed)) return output;
const error = validationError(parsed);
if (error !== undefined) {
const safeError = {
code: "VALIDATION_ERROR",
message: SAFE_VALIDATION_MESSAGE,
};
if (error === parsed) return `${JSON.stringify(safeError)}\n`;
return `${JSON.stringify({ ...parsed, error: safeError })}\n`;
}
} catch {
return output;
const decoded = safeParseJson(trimmed);
if (!decoded.ok) return output;
const parsed: unknown = decoded.value;
if (!isRecord(parsed)) return output;
const error = validationError(parsed);
if (error !== undefined) {
const safeError = {
code: "VALIDATION_ERROR",
message: SAFE_VALIDATION_MESSAGE,
};
if (error === parsed) return `${JSON.stringify(safeError)}\n`;
return `${JSON.stringify({ ...parsed, error: safeError })}\n`;
}
return output;
}
+12
View File
@@ -0,0 +1,12 @@
import { expect, it } from "vitest";
import { safeParseJson } from "./safeJson.js";
it("keeps the original JSON parse failure as the cause", () => {
const parsed = safeParseJson("{");
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("expected invalid JSON to fail");
expect(parsed.error).toContain("JSON");
expect(parsed.cause).toBeInstanceOf(SyntaxError);
});
+17
View File
@@ -0,0 +1,17 @@
/** JSON parsing without throws at an input boundary. */
export type SafeJsonParseResult =
| { readonly ok: true; readonly value: unknown }
| { readonly ok: false; readonly error: string; readonly cause: unknown };
/** Parse JSON text, returning the failure reason instead of throwing. */
export const safeParseJson = (value: string): SafeJsonParseResult => {
try {
return { ok: true, value: JSON.parse(value) as unknown };
} catch (cause: unknown) {
return {
ok: false,
error: cause instanceof Error ? cause.message : String(cause),
cause,
};
}
};
+14 -4
View File
@@ -153,10 +153,19 @@ export class GhidraClient {
this.#startupController = undefined;
}
};
void started.then((result) => {
signal?.removeEventListener("abort", onAbort);
if (!result.ok) reset();
}, reset);
void started.then(
(result) => {
signal?.removeEventListener("abort", onAbort);
if (!result.ok) reset();
},
(cause: unknown) => {
this.#logger.debug(
{ error: cause instanceof Error ? cause.message : String(cause) },
"Ghidra startup promise rejected during bookkeeping",
);
reset();
},
);
return started;
}
@@ -388,6 +397,7 @@ export class GhidraClient {
forceStop: boolean,
): Promise<void> {
try {
// best-effort cleanup: a rejected startup must not mask close/cleanup.
await starting?.catch(() => undefined);
await this.#cleanup(forceStop);
} finally {
+1
View File
@@ -264,6 +264,7 @@ const report = async (
completed: number,
message: string,
): Promise<void> => {
// best-effort cleanup: progress reporting must not fail session cleanup.
await progress
?.report({
phase: "hopper_cleanup",
+7 -1
View File
@@ -175,7 +175,11 @@ export class HopperClient {
release();
if (!result.ok) reset();
},
() => {
(cause: unknown) => {
this.#logger.debug(
{ error: cause instanceof Error ? cause.message : String(cause) },
"Hopper startup promise rejected during bookkeeping",
);
release();
reset();
},
@@ -323,6 +327,8 @@ export class HopperClient {
options: HopperClientCloseOptions,
): Promise<Result<null, AnalysisError>> {
try {
// best-effort cleanup: a rejected startup must not mask close/cleanup;
// #cleanup reports the authoritative close result.
await starting?.catch(() => undefined);
return await this.#cleanup(options);
} finally {
+16 -14
View File
@@ -8,6 +8,7 @@ import {
} from "node:net";
import { join, resolve } from "node:path";
import { HopperStartError } from "../domain/errors.js";
import { safeParseJson } from "../domain/safeJson.js";
interface HopperTargetLeaseOwner {
readonly runId: string;
@@ -144,22 +145,23 @@ const readOwner = (
response += chunk.toString("utf8");
const newline = response.indexOf("\n");
if (newline < 0) return;
try {
const value: unknown = JSON.parse(response.slice(0, newline));
if (
typeof value === "object" &&
value !== null &&
typeof Reflect.get(value, "runId") === "string" &&
typeof Reflect.get(value, "processId") === "number"
)
finish({
runId: Reflect.get(value, "runId") as string,
processId: Reflect.get(value, "processId") as number,
});
else finish(undefined);
} catch {
const parsed = safeParseJson(response.slice(0, newline));
if (!parsed.ok) {
finish(undefined);
return;
}
const value: unknown = parsed.value;
if (
typeof value === "object" &&
value !== null &&
typeof Reflect.get(value, "runId") === "string" &&
typeof Reflect.get(value, "processId") === "number"
)
finish({
runId: Reflect.get(value, "runId") as string,
processId: Reflect.get(value, "processId") as number,
});
else finish(undefined);
});
socket.on("error", () => finish(undefined));
socket.on("end", () => finish(undefined));
+8 -9
View File
@@ -4,6 +4,7 @@ import {
isHopperStartupFailureCode,
type HopperStartupDiagnostic,
} from "../domain/hopperStartupFailure.js";
import { safeParseJson } from "../domain/safeJson.js";
export const LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX = "REA_X11_DIAGNOSTIC=";
@@ -70,13 +71,11 @@ export const parseLinuxPrivateDisplayDiagnostic = (
if (records.length !== 1) return { ok: false, reason: "diagnostic_multiple" };
const record = records[0];
if (record === undefined) return { ok: false, reason: "diagnostic_missing" };
try {
const parsed = diagnosticSchema.safeParse(
JSON.parse(record.slice(LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX.length)),
);
if (!parsed.success) return { ok: false, reason: "diagnostic_malformed" };
return { ok: true, value: parsed.data };
} catch {
return { ok: false, reason: "diagnostic_malformed" };
}
const decoded = safeParseJson(
record.slice(LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX.length),
);
if (!decoded.ok) return { ok: false, reason: "diagnostic_malformed" };
const parsed = diagnosticSchema.safeParse(decoded.value);
if (!parsed.success) return { ok: false, reason: "diagnostic_malformed" };
return { ok: true, value: parsed.data };
};
+7 -6
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { HopperProtocolError, HopperRemoteError } from "../domain/errors.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import { safeParseJson } from "../domain/safeJson.js";
const remoteErrorSchema = z
.object({
@@ -82,14 +83,14 @@ export type HopperBridgeMessage = z.infer<typeof messageSchema>;
export const parseBridgeMessageLine = (
line: string,
): Result<HopperBridgeMessage, HopperProtocolError> => {
let decoded: unknown;
try {
decoded = JSON.parse(line);
} catch (cause: unknown) {
const decodedResult = safeParseJson(line);
if (!decodedResult.ok)
return err(
new HopperProtocolError("Hopper returned malformed JSON", { cause }),
new HopperProtocolError("Hopper returned malformed JSON", {
cause: decodedResult.cause,
}),
);
}
const decoded: unknown = decodedResult.value;
const parsed = messageSchema.safeParse(decoded);
return parsed.success
+4 -1
View File
@@ -120,7 +120,10 @@ export const runEntrypoint = async (
): Promise<void> => {
try {
setExitCode(await start());
} catch {
} catch (cause: unknown) {
// Intentionally redact the startup cause from caller-visible output;
// unexpected failures must not leak internal details.
void cause;
writeStderr(`${SERVER_START_FAILED}\n`);
setExitCode(1);
}
+11 -3
View File
@@ -43,8 +43,11 @@ export const startMcpTransport = async (
let optionalProviders: OptionalProviders = {};
try {
optionalProviders = await serverContext.loadOptionalProviders();
} catch {
} catch (cause: unknown) {
serverLogger.warn(
{
error: cause instanceof Error ? cause.message : String(cause),
},
"Optional MCP providers could not load; affected tools remain unavailable",
);
}
@@ -63,9 +66,14 @@ export const startMcpTransport = async (
},
},
);
} catch {
} catch (cause: unknown) {
await session.close();
serverLogger.error(MCP_CONNECTION_START_FAILED);
serverLogger.error(
{
error: cause instanceof Error ? cause.message : String(cause),
},
MCP_CONNECTION_START_FAILED,
);
dependencies.writeStderr(`${MCP_CONNECTION_START_FAILED}\n`);
return { ok: false };
}
+2 -1
View File
@@ -415,6 +415,7 @@ class NativeMacOSClient implements AnalysisClient {
);
if (!capture.ok) return capture;
const parsed = parsePlistJson(capture.value.stdout);
if (!parsed.ok) return parsed;
const provenance = [classified.value, capture.value].map((item) =>
invocation(item, plist.value, "$PLIST"),
);
@@ -424,7 +425,7 @@ class NativeMacOSClient implements AnalysisClient {
: /XML|text/iu.test(classified.value.stdout)
? "xml"
: "unknown",
...parsed,
...parsed.value,
source_path: plist.value,
provenance,
limitations: [],
+7 -1
View File
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { safeParseJson } from "../domain/safeJson.js";
import type { NativeUiHelper } from "./NativeUiObservation.js";
/** Lazily compile one owned helper per observation/scenario and remove its compiler cache. */
@@ -42,7 +43,12 @@ export const createNativeUiHelperRuntime = () => {
...(signal === undefined ? {} : { signal }),
},
);
return JSON.parse(output.stdout) as unknown;
const parsed = safeParseJson(output.stdout);
if (!parsed.ok)
throw new Error(`Native helper returned invalid JSON: ${parsed.error}`, {
cause: parsed.cause,
});
return parsed.value;
};
return {
invoke,
+29 -4
View File
@@ -1,18 +1,43 @@
import { z } from "zod";
import { AnalysisOutputError } from "../../domain/errors.js";
import { err, ok, type Result } from "../../domain/result.js";
import { safeParseJson } from "../../domain/safeJson.js";
const plistObject = z.record(z.string(), z.unknown());
/** Parse plutil JSON output and project stable bundle metadata. */
export const parsePlistJson = (output: string) => {
export const parsePlistJson = (
output: string,
): Result<
{
readonly value: unknown;
readonly bundle: {
readonly identifier: string | null;
readonly executable: string | null;
readonly name: string | null;
readonly version: string | null;
readonly short_version: string | null;
};
},
AnalysisOutputError
> => {
// plutil output can carry a UTF-8 BOM, which JSON.parse rejects outright.
const value: unknown = JSON.parse(output.replace(/^\uFEFF/u, ""));
const parsed = safeParseJson(output.replace(/^\uFEFF/u, ""));
if (!parsed.ok)
return err(
new AnalysisOutputError("inspect_plist", parsed.error, {
cause: parsed.cause,
}),
);
const value: unknown = parsed.value;
const object = plistObject.safeParse(value);
const field = (name: string): string | null => {
if (!object.success) return null;
const candidate = object.data[name];
return typeof candidate === "string" ? candidate : null;
};
return {
return ok({
value,
bundle: {
identifier: field("CFBundleIdentifier"),
@@ -21,5 +46,5 @@ export const parsePlistJson = (output: string) => {
version: field("CFBundleVersion"),
short_version: field("CFBundleShortVersionString"),
},
};
});
};
@@ -289,7 +289,11 @@ const applyExclusion = (
): { readonly ok: true; readonly value: boolean } | { readonly ok: false } => {
try {
return { ok: true, value: shouldExclude?.(path) === true };
} catch {
} catch (cause: unknown) {
// Exclusion predicates are caller-supplied; a throwing predicate fails
// closed and the caller-visible `{ ok: false }` preserves the rejection
// without propagating an arbitrary predicate cause.
void cause;
return { ok: false };
}
};
@@ -220,6 +220,7 @@ export const readStableFile = async (
if (message === undefined) throw cause;
return entryFailure(path, "file", "io", message, safeSize(expected.size));
} finally {
// best-effort cleanup: file-handle close must not mask the read result.
await handle?.close().catch(() => undefined);
}
};
@@ -32,5 +32,7 @@ it("accepts a byte-order mark before plist JSON", () => {
const parsed = parsePlistJson(
'\uFEFF{"CFBundleIdentifier":"com.owned.fixture"}',
);
expect(parsed.bundle.identifier).toBe("com.owned.fixture");
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("expected plist JSON to parse");
expect(parsed.value.bundle.identifier).toBe("com.owned.fixture");
});