mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(apple): own ordered nib collections and registry parentage
This commit is contained in:
@@ -454,10 +454,6 @@ complete observation.
|
||||
undecodable filenames and skipped symlinks, as partial coverage. Known
|
||||
executable roots remain available; unexamined names and symlink targets
|
||||
cannot establish an empty root set or the absence of other roots.
|
||||
Embedded `UINib` archive data remains retained and undecoded; affected
|
||||
object, connection, class and hierarchy coverage is partial. Recognized
|
||||
`UIStoryboardShowSegueTemplate` source records similarly qualify connection
|
||||
coverage until their navigation semantics are decoded.
|
||||
- `inspect_asset_catalog` / `rea inspect-asset-catalog <app>` reads compiled
|
||||
`Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw
|
||||
rendition fields, pagination and exact UI resource-name matches are returned.
|
||||
@@ -557,6 +553,17 @@ complete observation.
|
||||
coverage. Source I/O and identity failures retain their acquisition error
|
||||
instead of being labeled invalid archive content. Rejected plist decodes
|
||||
release their representation reservation for later documents.
|
||||
Repeated NIBArchive fields retain all serialized values in order. Array
|
||||
outlet connections resolve each serialized member and retain the collection
|
||||
object ID and member position on the outlet observation. AppKit object-parent
|
||||
registries use paired keys and parents; objects paired with `NSRoot` are
|
||||
top-level, while nested objects keep their recorded parent. Conflicting or
|
||||
unreadable registry pairs remain incomplete hierarchy evidence.
|
||||
Embedded `UINib` archive data remains retained and undecoded; affected
|
||||
object, connection, class and hierarchy coverage is partial. Recognized
|
||||
`UIStoryboardShowSegueTemplate` source records similarly qualify connection
|
||||
coverage until their navigation semantics are decoded.
|
||||
|
||||
- `inspect_native_dispatch_metadata` /
|
||||
`rea inspect-native-dispatch-metadata <app-or-binary>` prefers a validated macOS Mach-O byte reader. It decodes
|
||||
64-bit little-endian Objective-C class/metaclass records, superclass pointers,
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
} from "../../domain/propertyListKeys.js";
|
||||
import { decodeNibArchive, type NibArchiveDocument } from "./NibArchive.js";
|
||||
import {
|
||||
decodeNibArchive,
|
||||
nibCollectionMembers,
|
||||
type NibArchiveDocument,
|
||||
} from "./NibArchive.js";
|
||||
import { preflightPropertyListDecode } from "./PropertyListDecodeBudget.js";
|
||||
import {
|
||||
interfaceBuilderDecodeBudgetFailure,
|
||||
@@ -633,7 +637,12 @@ const projectNibArchive = (
|
||||
};
|
||||
let projectedFields = 0;
|
||||
for (const object of archive.objects)
|
||||
projectedFields += Object.keys(object.values).length;
|
||||
projectedFields +=
|
||||
Object.keys(object.values).length +
|
||||
Object.values(object.repeated_values ?? {}).reduce(
|
||||
(count, values) => count + values.length,
|
||||
0,
|
||||
);
|
||||
budget.reserve(
|
||||
archive.objects.length * 128 + projectedFields * 128,
|
||||
"NIBArchive graph projection exceeds the aggregate Interface Builder decode budget",
|
||||
@@ -648,6 +657,7 @@ const projectNibArchive = (
|
||||
number,
|
||||
Readonly<Record<string, JsonValue>>
|
||||
>();
|
||||
const projectedRepeatedValues = new Map<number, JsonValue>();
|
||||
for (const object of archive.objects) {
|
||||
const projected = projectPlistValue(object.values);
|
||||
nonFiniteRealCount += projected.nonFiniteRealCount;
|
||||
@@ -656,6 +666,12 @@ const projectNibArchive = (
|
||||
object.id,
|
||||
projected.value as Readonly<Record<string, JsonValue>>,
|
||||
);
|
||||
if (object.repeated_values !== undefined) {
|
||||
const repeated = projectPlistValue(object.repeated_values);
|
||||
nonFiniteRealCount += repeated.nonFiniteRealCount;
|
||||
negativeZeroRealCount += repeated.negativeZeroRealCount;
|
||||
projectedRepeatedValues.set(object.id, repeated.value);
|
||||
}
|
||||
}
|
||||
const dereference = (value: JsonValue | undefined): JsonValue | undefined => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
@@ -714,7 +730,7 @@ const projectNibArchive = (
|
||||
const objects = Object.fromEntries(
|
||||
archive.objects
|
||||
.filter((object) => {
|
||||
const name = runtimeClass(object.id) ?? "";
|
||||
const name = normalizeClassName(object.class_name);
|
||||
return (
|
||||
!/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector)$/u.test(
|
||||
name,
|
||||
@@ -761,6 +777,9 @@ const projectNibArchive = (
|
||||
label: name,
|
||||
objectID: String(object.id),
|
||||
nibValues,
|
||||
...(projectedRepeatedValues.has(object.id)
|
||||
? { nibRepeatedValues: projectedRepeatedValues.get(object.id)! }
|
||||
: {}),
|
||||
...(className === normalizeClassName(object.class_name)
|
||||
? {}
|
||||
: {
|
||||
@@ -790,7 +809,6 @@ const projectNibArchive = (
|
||||
const className = normalizeClassName(object.class_name);
|
||||
// AppKit connectors use NS-prefixed keys; UIKit's runtime outlet and
|
||||
// event connections use UI-prefixed keys with the same roles.
|
||||
const uiKit = isUIKitRuntimeConnectionClass(className);
|
||||
if (!isInterfaceBuilderConnectionClass(className)) continue;
|
||||
const values = object.values;
|
||||
const connectionFields = interfaceBuilderConnectionFieldNames(className);
|
||||
@@ -814,14 +832,49 @@ const projectNibArchive = (
|
||||
// The source is the outlet owner or the sending control; the destination
|
||||
// is the outlet value or the action target, and a nil target is the
|
||||
// first responder.
|
||||
(connections[String(source)] ??= []).push({
|
||||
type,
|
||||
"destination-id": destination === null ? null : String(destination),
|
||||
label,
|
||||
source_id: String(source),
|
||||
archive_object_id: String(object.id),
|
||||
...(typeof eventMask === "number" ? { ui_event_mask: eventMask } : {}),
|
||||
});
|
||||
const members =
|
||||
className === "UIRuntimeOutletCollectionConnection"
|
||||
? nibCollectionMembers(
|
||||
destination === null ? undefined : byId.get(destination),
|
||||
)
|
||||
: undefined;
|
||||
if (
|
||||
className === "UIRuntimeOutletCollectionConnection" &&
|
||||
members === undefined
|
||||
) {
|
||||
unresolvedConnections += 1;
|
||||
continue;
|
||||
}
|
||||
if (members !== undefined && members.length === 0) {
|
||||
unexaminedSources.push({
|
||||
reason: "empty_outlet_collection_not_projected",
|
||||
facets: ["connections"],
|
||||
description: `Outlet collection object ${object.id} has no serialized members; its raw collection endpoint is not projected as a relationship.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const destinations =
|
||||
members === undefined ? [destination] : members.map(reference);
|
||||
for (const [memberIndex, member] of destinations.entries()) {
|
||||
if (members !== undefined && member === null) {
|
||||
unresolvedConnections += 1;
|
||||
continue;
|
||||
}
|
||||
(connections[String(source)] ??= []).push({
|
||||
type,
|
||||
"destination-id": member === null ? null : String(member),
|
||||
label,
|
||||
source_id: String(source),
|
||||
archive_object_id: String(object.id),
|
||||
...(members === undefined
|
||||
? {}
|
||||
: {
|
||||
archive_collection_id: destination,
|
||||
archive_collection_member_index: memberIndex,
|
||||
}),
|
||||
...(typeof eventMask === "number" ? { ui_event_mask: eventMask } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
let hierarchyOmitted = 0;
|
||||
let hierarchyNodeCount = 0;
|
||||
@@ -861,7 +914,7 @@ const projectNibArchive = (
|
||||
continue;
|
||||
}
|
||||
if (/array|set/iu.test(target.class_name)) {
|
||||
for (const candidate of Object.values(target.values)) {
|
||||
for (const candidate of nibCollectionMembers(target) ?? []) {
|
||||
const child = reference(candidate);
|
||||
const nested =
|
||||
child === null ? null : hierarchyFor(child, nextSeen, depth + 1);
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
type NibFixtureValue =
|
||||
| number
|
||||
| string
|
||||
| boolean
|
||||
| null
|
||||
| { ref: number }
|
||||
| { float32: number }
|
||||
| { float64: number };
|
||||
type NibFixtureFields =
|
||||
| Readonly<Record<string, NibFixtureValue>>
|
||||
| readonly (readonly [string, NibFixtureValue])[];
|
||||
const fieldEntries = (
|
||||
values: NibFixtureFields,
|
||||
): readonly (readonly [string, NibFixtureValue])[] =>
|
||||
Array.isArray(values) ? values : Object.entries(values);
|
||||
|
||||
/** Encode a minimal NIBArchive object table for decoder and projection tests. */
|
||||
export const encodeNibArchiveFixture = (input: {
|
||||
readonly classes: readonly string[];
|
||||
readonly objects: readonly {
|
||||
readonly classIndex: number;
|
||||
readonly values: Readonly<
|
||||
Record<
|
||||
string,
|
||||
| number
|
||||
| string
|
||||
| boolean
|
||||
| null
|
||||
| { ref: number }
|
||||
| { float32: number }
|
||||
| { float64: number }
|
||||
>
|
||||
>;
|
||||
readonly values: NibFixtureFields;
|
||||
}[];
|
||||
}): Buffer => {
|
||||
const keys = [
|
||||
...new Set(input.objects.flatMap(({ values }) => Object.keys(values))),
|
||||
...new Set(
|
||||
input.objects.flatMap(({ values }) =>
|
||||
fieldEntries(values).map(([key]) => key),
|
||||
),
|
||||
),
|
||||
];
|
||||
const keyIndex = new Map(keys.map((key, index) => [key, index]));
|
||||
const valueRecords = input.objects.flatMap(({ values }) =>
|
||||
Object.entries(values).map(([key, value]) =>
|
||||
fieldEntries(values).map(([key, value]) =>
|
||||
Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]),
|
||||
),
|
||||
);
|
||||
let valueStart = 0;
|
||||
const records = input.objects.map(({ classIndex, values }) => {
|
||||
const count = Object.keys(values).length;
|
||||
const count = fieldEntries(values).length;
|
||||
const result = Buffer.concat([
|
||||
varint(classIndex),
|
||||
varint(valueStart),
|
||||
|
||||
@@ -184,3 +184,26 @@ describe("NIBArchive decoder", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("retains repeated serialized collection fields in source order", () => {
|
||||
const decoded = decodeNibArchive(
|
||||
encodeArchive({
|
||||
classes: ["NSArray", "NSButton"],
|
||||
objects: [
|
||||
{
|
||||
classIndex: 0,
|
||||
values: [
|
||||
["UINibEncoderEmptyKey", { ref: 2 }],
|
||||
["UINibEncoderEmptyKey", { ref: 1 }],
|
||||
],
|
||||
},
|
||||
{ classIndex: 1, values: {} },
|
||||
{ classIndex: 1, values: {} },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(decoded.objects[0]?.values).toEqual({});
|
||||
expect(decoded.objects[0]?.repeated_values).toEqual({
|
||||
UINibEncoderEmptyKey: [{ $nib_object_ref: 2 }, { $nib_object_ref: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,12 +29,37 @@ export interface NibArchiveDocument {
|
||||
readonly objects: readonly {
|
||||
readonly id: number;
|
||||
readonly class_name: string;
|
||||
/** Single-valued fields. Repeated serialized keys are disjoint below. */
|
||||
readonly values: Readonly<Record<string, JsonValue>>;
|
||||
readonly repeated_values?: Readonly<Record<string, readonly JsonValue[]>>;
|
||||
}[];
|
||||
readonly classes: readonly string[];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/** Serialized collection members in source order; metadata fields are not members. */
|
||||
export const nibCollectionMembers = (
|
||||
object: NibArchiveDocument["objects"][number] | undefined,
|
||||
): readonly JsonValue[] | undefined => {
|
||||
if (
|
||||
object === undefined ||
|
||||
!/^(?:NS|NSMutable)(?:Array|Set)$/u.test(
|
||||
object.class_name.replace(/\0+$/u, ""),
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
const keys = ["UINibEncoderEmptyKey", "NS.objects"];
|
||||
const populated = keys.filter(
|
||||
(key) =>
|
||||
Object.hasOwn(object.values, key) ||
|
||||
Object.hasOwn(object.repeated_values ?? {}, key),
|
||||
);
|
||||
if (populated.length > 1) return undefined;
|
||||
const key = populated[0];
|
||||
if (key === undefined) return [];
|
||||
return object.repeated_values?.[key] ?? [object.values[key]!];
|
||||
};
|
||||
|
||||
/** Decode Apple's length-prefixed NIBArchive representation without loading UI classes. */
|
||||
export const decodeNibArchive = (
|
||||
bytes: Buffer,
|
||||
@@ -127,6 +152,7 @@ export const decodeNibArchive = (
|
||||
const className = classes[object.class_index];
|
||||
if (className === undefined) return [];
|
||||
const fields: Record<string, JsonValue> = {};
|
||||
const repeated: Record<string, JsonValue[]> = {};
|
||||
for (
|
||||
let index = object.value_start;
|
||||
index < object.value_start + object.value_count;
|
||||
@@ -135,7 +161,26 @@ export const decodeNibArchive = (
|
||||
const value = values.get(index);
|
||||
if (value === undefined) continue;
|
||||
const key = keys[value.key_index];
|
||||
if (key !== undefined)
|
||||
if (key === undefined) continue;
|
||||
if (Object.hasOwn(repeated, key)) {
|
||||
budget.reserve(
|
||||
16,
|
||||
"NIBArchive repeated fields exceed the decode budget",
|
||||
);
|
||||
repeated[key]!.push(value.value);
|
||||
} else if (Object.hasOwn(fields, key)) {
|
||||
budget.reserve(
|
||||
96,
|
||||
"NIBArchive repeated fields exceed the decode budget",
|
||||
);
|
||||
Object.defineProperty(repeated, key, {
|
||||
value: [fields[key]!, value.value],
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
delete fields[key];
|
||||
} else
|
||||
// Archive keys are data, including the legacy __proto__ setter name.
|
||||
Object.defineProperty(fields, key, {
|
||||
value: value.value,
|
||||
@@ -144,7 +189,16 @@ export const decodeNibArchive = (
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
return [{ id, class_name: className, values: fields }];
|
||||
return [
|
||||
{
|
||||
id,
|
||||
class_name: className,
|
||||
values: fields,
|
||||
...(Object.keys(repeated).length === 0
|
||||
? {}
|
||||
: { repeated_values: repeated }),
|
||||
},
|
||||
];
|
||||
});
|
||||
let connectionObjectCount = 0;
|
||||
for (const object of objects)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NibArchiveDocument } from "./NibArchive.js";
|
||||
import { nibCollectionMembers, type NibArchiveDocument } from "./NibArchive.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
|
||||
export type NibHierarchyNode = {
|
||||
@@ -17,7 +17,7 @@ export const projectNibViewHierarchy = (
|
||||
objects,
|
||||
retainedIds,
|
||||
);
|
||||
if (parents.size === 0) return { hierarchy: null, omitted: invalid };
|
||||
if (involved.size === 0) return { hierarchy: null, omitted: invalid };
|
||||
const children = new Map<number, number[]>();
|
||||
for (const [child, parent] of parents) {
|
||||
if (!retainedIds.has(parent) || !retainedIds.has(child)) continue;
|
||||
@@ -182,6 +182,51 @@ const collectNibParents = (
|
||||
involved.add(object.id);
|
||||
if (retainedIds.has(parent)) involved.add(parent);
|
||||
}
|
||||
const byId = new Map(objects.map((object) => [object.id, object]));
|
||||
for (const object of objects) {
|
||||
if (object.class_name.replace(/\0+$/u, "") !== "NSIBObjectData") continue;
|
||||
const keysRef = reference(object.values.NSObjectsKeys);
|
||||
const valuesRef = reference(object.values.NSObjectsValues);
|
||||
if (keysRef === null && valuesRef === null) continue;
|
||||
const keys = nibCollectionMembers(
|
||||
keysRef === null ? undefined : byId.get(keysRef),
|
||||
);
|
||||
const values = nibCollectionMembers(
|
||||
valuesRef === null ? undefined : byId.get(valuesRef),
|
||||
);
|
||||
if (
|
||||
keys === undefined ||
|
||||
values === undefined ||
|
||||
keys.length !== values.length
|
||||
) {
|
||||
invalid += 1;
|
||||
continue;
|
||||
}
|
||||
const rootOwner = reference(object.values.NSRoot);
|
||||
for (let index = 0; index < keys.length; index += 1) {
|
||||
const child = reference(keys[index]);
|
||||
const parent = reference(values[index]);
|
||||
if (child === null || parent === null) {
|
||||
invalid += 1;
|
||||
continue;
|
||||
}
|
||||
// Registry keys include non-UI records. Only retained objects contribute
|
||||
// hierarchy, and a paired NSRoot owner identifies a top-level object.
|
||||
if (!retainedIds.has(child)) continue;
|
||||
involved.add(child);
|
||||
const prior = parents.get(child);
|
||||
if (parent === rootOwner) {
|
||||
if (prior !== undefined) invalid += 1;
|
||||
continue;
|
||||
}
|
||||
if (prior !== undefined && prior !== parent) {
|
||||
invalid += 1;
|
||||
continue;
|
||||
}
|
||||
parents.set(child, parent);
|
||||
if (retainedIds.has(parent)) involved.add(parent);
|
||||
}
|
||||
}
|
||||
return { parents, involved, invalid };
|
||||
};
|
||||
|
||||
|
||||
@@ -2232,3 +2232,156 @@ it.each(["embedded", "segue", "neither"] as const)(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 2])(
|
||||
"projects every serialized outlet collection member in source order: %i",
|
||||
async (count) => {
|
||||
const bundle = await createTestTempDirectory("rea-ui-outlet-collection-");
|
||||
await writeFile(
|
||||
join(bundle, "Main.nib"),
|
||||
encodeNibArchiveFixture({
|
||||
classes: [
|
||||
"UIViewController",
|
||||
"UIButton",
|
||||
"UIRuntimeOutletCollectionConnection",
|
||||
"NSMutableArray",
|
||||
"NSString",
|
||||
],
|
||||
objects: [
|
||||
{ classIndex: 0, values: {} },
|
||||
{ classIndex: 1, values: {} },
|
||||
{ classIndex: 1, values: {} },
|
||||
{
|
||||
classIndex: 2,
|
||||
values: {
|
||||
UISource: { ref: 0 },
|
||||
UIDestination: { ref: 4 },
|
||||
UILabel: { ref: 5 },
|
||||
},
|
||||
},
|
||||
{
|
||||
classIndex: 3,
|
||||
values: [
|
||||
["UINibEncoderEmptyKey", { ref: 2 }],
|
||||
...(count === 2
|
||||
? ([["UINibEncoderEmptyKey", { ref: 1 }]] as const)
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{ classIndex: 4, values: { "NS.bytes": "buttons" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
});
|
||||
const edges = result.graph.edges.filter(
|
||||
({ relation }) => relation === "outlet_to",
|
||||
);
|
||||
expect(edges).toHaveLength(count);
|
||||
expect(
|
||||
edges.every(
|
||||
({ resolution, reason }) =>
|
||||
resolution === "observed" && reason === null,
|
||||
),
|
||||
).toBe(true);
|
||||
const outlets = result.graph.nodes.filter(({ kind }) => kind === "outlet");
|
||||
expect(
|
||||
outlets.map(({ attributes }) => [
|
||||
attributes.archive_collection_id,
|
||||
attributes.archive_collection_member_index,
|
||||
]),
|
||||
).toEqual(Array.from({ length: count }, (_, index) => [4, index]));
|
||||
expect(
|
||||
edges.map(
|
||||
({ to }) =>
|
||||
result.graph.nodes.find(({ id }) => id === to)?.attributes
|
||||
.interface_builder_object_id,
|
||||
),
|
||||
).toEqual(count === 2 ? ["2", "1"] : ["2"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses paired AppKit registry parents without promoting nested objects to roots", async () => {
|
||||
const bundle = await createTestTempDirectory("rea-ui-parent-registry-");
|
||||
await writeFile(
|
||||
join(bundle, "Main.nib"),
|
||||
encodeNibArchiveFixture({
|
||||
classes: [
|
||||
"NSIBObjectData",
|
||||
"NSCustomObject",
|
||||
"NSView",
|
||||
"NSArray",
|
||||
"NSString",
|
||||
],
|
||||
objects: [
|
||||
{
|
||||
classIndex: 0,
|
||||
values: {
|
||||
NSRoot: { ref: 1 },
|
||||
NSObjectsKeys: { ref: 6 },
|
||||
NSObjectsValues: { ref: 7 },
|
||||
},
|
||||
},
|
||||
{ classIndex: 1, values: {} },
|
||||
{ classIndex: 1, values: { NSClassName: { ref: 8 } } },
|
||||
{ classIndex: 1, values: { NSClassName: { ref: 9 } } },
|
||||
{ classIndex: 2, values: {} },
|
||||
{ classIndex: 2, values: { NSSuperview: { ref: 4 } } },
|
||||
{
|
||||
classIndex: 3,
|
||||
values: [
|
||||
["UINibEncoderEmptyKey", { ref: 2 }],
|
||||
["UINibEncoderEmptyKey", { ref: 3 }],
|
||||
["UINibEncoderEmptyKey", { ref: 4 }],
|
||||
["UINibEncoderEmptyKey", { ref: 5 }],
|
||||
],
|
||||
},
|
||||
{
|
||||
classIndex: 3,
|
||||
values: [
|
||||
["UINibEncoderEmptyKey", { ref: 1 }],
|
||||
["UINibEncoderEmptyKey", { ref: 1 }],
|
||||
["UINibEncoderEmptyKey", { ref: 1 }],
|
||||
["UINibEncoderEmptyKey", { ref: 4 }],
|
||||
],
|
||||
},
|
||||
{ classIndex: 4, values: { "NS.bytes": "NSApplication" } },
|
||||
{ classIndex: 4, values: { "NS.bytes": "VerifierHelper" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
});
|
||||
const nodes = result.graph.nodes;
|
||||
const nodeId = (object: string) =>
|
||||
nodes.find(
|
||||
({ attributes }) => attributes.interface_builder_object_id === object,
|
||||
)?.id;
|
||||
for (const object of ["2", "3", "4"])
|
||||
expect(result.graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "contains",
|
||||
from: expect.stringMatching(/:root$/u),
|
||||
to: nodeId(object),
|
||||
}),
|
||||
);
|
||||
expect(result.graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "contains",
|
||||
from: nodeId("4"),
|
||||
to: nodeId("5"),
|
||||
}),
|
||||
);
|
||||
expect(result.graph.edges).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "contains",
|
||||
from: expect.stringMatching(/:root$/u),
|
||||
to: nodeId("5"),
|
||||
}),
|
||||
);
|
||||
expect(result.documents[0]?.hierarchy_complete).toBe(true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user