fix(apple): keep empty asset catalog pages complete

This commit is contained in:
morluto committed 2026-10-20 18:38:05 +08:00
1 parent 3a9176098d
commit 9db5f1b276
4 files changed
+141 -31

No files matched your search

@@ -64,33 +64,42 @@ describe("Apple asset catalog application workflow", () => {
]);
});
it("reports an app without compiled catalogs as an empty observed inventory", async () => {
const bundle = await createTestTempDirectory("rea-asset-catalog-absent-");
await mkdir(join(bundle, "Contents", "Resources"), { recursive: true });
await writeFile(join(bundle, "Contents", "Resources", "icon.icns"), "");
// Names that merely end in Assets.car are ordinary resources.
await mkdir(join(bundle, "Contents", "Resources", "NotAssets.car"));
await writeFile(join(bundle, "Contents", "Resources", "OldAssets.car"), "");
const result = await analyzeAppleAssetCatalogs({
environment: {},
bundlePath: bundle,
targetSha256: "a".repeat(64),
runAssetUtil: () => {
throw new Error("assetutil must not run without a catalog");
},
});
expect(result).toMatchObject({
catalogs: [],
total_records: 0,
records: [],
resource_key_matches: [],
next_offset: null,
truncated: false,
});
expect(result.limitations).toContain(
"No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.",
);
});
it.each([0, 1, 5])(
"reports an app without compiled catalogs as empty at offset %i",
async (offset) => {
const bundle = await createTestTempDirectory("rea-asset-catalog-absent-");
await mkdir(join(bundle, "Contents", "Resources"), { recursive: true });
await writeFile(join(bundle, "Contents", "Resources", "icon.icns"), "");
// Names that merely end in Assets.car are ordinary resources.
await mkdir(join(bundle, "Contents", "Resources", "NotAssets.car"));
await writeFile(
join(bundle, "Contents", "Resources", "OldAssets.car"),
"",
);
const result = await analyzeAppleAssetCatalogs({
environment: {},
bundlePath: bundle,
targetSha256: "a".repeat(64),
page: { offset },
runAssetUtil: () => {
throw new Error("assetutil must not run without a catalog");
},
});
expect(result).toMatchObject({
catalogs: [],
total_records: 0,
offset,
limit: 1_000,
records: [],
resource_key_matches: [],
next_offset: null,
truncated: false,
});
expect(result.limitations).toContain(
"No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.",
);
},
);
it("does not report absence when the only catalog entry is a symlink", async () => {
const bundle = await createTestTempDirectory("rea-asset-catalog-link-");
@@ -137,6 +146,31 @@ describe("Apple asset catalog application workflow", () => {
});
});
it.each([0, 1, 5])(
"keeps an empty assetutil inventory complete at offset %i",
async (offset) => {
const bundle = await createTestTempDirectory("rea-asset-catalog-empty-");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
await writeFile(join(resources, "Assets.car"), "inert catalog fixture");
const result = await analyzeAppleAssetCatalogs({
environment: {},
bundlePath: bundle,
targetSha256: "a".repeat(64),
page: { offset, limit: 2 },
runAssetUtil: () => Promise.resolve("[]"),
});
expect(result).toMatchObject({
total_records: 0,
offset,
limit: 2,
records: [],
next_offset: null,
truncated: false,
});
},
);
it.each([1, 2])(
"joins NIBArchive keys against %i supplied assetutil renditions",
async (count) => {
@@ -104,3 +104,72 @@ describe("compiled Apple asset catalog projection", () => {
);
});
});
it("reports truncation only when a nonempty inventory has omitted records", () => {
const empty = [{ path: "Assets.car", sha256: "a".repeat(64), records: [] }];
for (const offset of [0, 1, 5]) {
expect(
projectAppleAssetCatalogPage({
targetSha256: "c".repeat(64),
catalogs: empty,
offset,
limit: 1,
}),
).toMatchObject({
total_records: 0,
records: [],
next_offset: null,
truncated: false,
});
}
const records = parseAppleAssetCatalogRecords([
{ Name: "One" },
{ Name: "Two" },
{ Name: "Three" },
]);
const catalogs = [{ path: "Assets.car", sha256: "a".repeat(64), records }];
expect(
projectAppleAssetCatalogPage({
targetSha256: "c".repeat(64),
catalogs,
offset: 0,
limit: 3,
}),
).toMatchObject({
records: records.map((metadata, index) => ({ index, metadata })),
next_offset: null,
truncated: false,
});
expect(
projectAppleAssetCatalogPage({
targetSha256: "c".repeat(64),
catalogs,
offset: 0,
limit: 2,
}),
).toMatchObject({
records: expect.arrayContaining([
expect.objectContaining({ index: 0 }),
expect.objectContaining({ index: 1 }),
]),
next_offset: 2,
truncated: true,
});
expect(
projectAppleAssetCatalogPage({
targetSha256: "c".repeat(64),
catalogs,
offset: 1,
limit: 2,
}),
).toMatchObject({ next_offset: null, truncated: true });
expect(
projectAppleAssetCatalogPage({
targetSha256: "c".repeat(64),
catalogs,
offset: 3,
limit: 2,
}),
).toMatchObject({ records: [], next_offset: null, truncated: true });
});
+1 -1
View File
@@ -188,7 +188,7 @@ export const projectAppleAssetCatalogPage = (input: {
records,
resource_key_matches: resourceKeyMatches,
next_offset: next < all.length ? next : null,
truncated: input.offset > 0 || next < all.length,
truncated: records.length !== all.length,
limitations: [
"Rendition metadata is reported as emitted by the installed assetutil version; private fields and platform-specific interpretations are not normalized.",
"Asset catalog inspection does not decode or extract rendition image bytes.",
@@ -46,7 +46,7 @@ const keyedArchive = (nested: boolean) =>
});
cliTest.skipIf(process.platform !== "darwin")(
"retains recognized NIBArchive resource keys and provenance through CLI and MCP without a catalog",
"retains resource keys and complete empty inventory through CLI and MCP at a positive offset",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-asset-resource-keys-");
const { call } = await connectLocalToolsMcp();
@@ -91,13 +91,20 @@ cliTest.skipIf(process.platform !== "darwin")(
[field]: "Toolbar",
});
const inspected = await cli.run({
arguments: ["inspect-asset-catalog", app, "--json"],
arguments: ["inspect-asset-catalog", app, "--offset", "1", "--json"],
});
expect(inspected.exitCode, inspected.stderr).toBe(0);
const result = appleAssetCatalogResultSchema.parse(
parseEvidence(inspected.json).normalized_result,
);
expect(result.catalogs).toEqual([]);
expect(result).toMatchObject({
total_records: 0,
offset: 1,
records: [],
next_offset: null,
truncated: false,
});
expect(result.resource_key_matches).toEqual(
!recognized
? []
@@ -117,7 +124,7 @@ cliTest.skipIf(process.platform !== "darwin")(
);
const opened = await call("open_binary", { path: app });
expect(opened.isError).not.toBe(true);
const mcp = await call("inspect_asset_catalog", {});
const mcp = await call("inspect_asset_catalog", { offset: 1 });
expect(mcp.isError).not.toBe(true);
expect(parseEvidence(mcp.structuredContent).normalized_result).toEqual(
result,