From abd6ba097208dc5fcb2e9a6b4912dd38f4a23f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 19:21:30 +0200 Subject: [PATCH 1/4] test: make hard-coded caps overridable behind seams (#1781 B5) HarmonyOS snapshot gains a maxNodes seam (mirroring the Android helper and Linux AT-SPI capture options) so the node cap's truncation signal is exercised below the 5,000 default; the durable descriptor JSON node cap gets its boundary test alongside the existing depth one. --- packages/capture-kit/src/durable-json.test.ts | 8 +++ .../harmonyos/__tests__/snapshot.test.ts | 57 ++++++++++++++++++- src/platforms/harmonyos/snapshot.ts | 13 ++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/capture-kit/src/durable-json.test.ts b/packages/capture-kit/src/durable-json.test.ts index f3531d5a2..a9b542875 100644 --- a/packages/capture-kit/src/durable-json.test.ts +++ b/packages/capture-kit/src/durable-json.test.ts @@ -12,6 +12,14 @@ test('bounded durable JSON rejects cycles and excessive depth', () => { assert.equal(isBoundedJsonObject(nested), false); }); +test('bounded durable JSON rejects a wide, shallow document past the node cap', () => { + // Depth 2 everywhere, so only the node budget can reject it: the root plus + // one array plus 4,096 empty objects is 4,098 nodes. + const wide = { items: Array.from({ length: 4_096 }, () => ({})) }; + assert.equal(isBoundedJsonObject(wide), false); + assert.equal(isBoundedJsonObject({ items: wide.items.slice(0, 4_000) }), true); +}); + test('validated durable JSON freezes without recursively revalidating every subtree', () => { let reads = 0; const leaf = Object.defineProperty({}, 'value', { diff --git a/src/platforms/harmonyos/__tests__/snapshot.test.ts b/src/platforms/harmonyos/__tests__/snapshot.test.ts index 9e40ab9b6..372117e04 100644 --- a/src/platforms/harmonyos/__tests__/snapshot.test.ts +++ b/src/platforms/harmonyos/__tests__/snapshot.test.ts @@ -1,6 +1,35 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { parseArkUiBounds, parseHarmonyLayout } from '../snapshot.ts'; +import fs from 'node:fs'; +import { beforeEach, test, vi } from 'vitest'; + +const { runHarmonyHdc } = vi.hoisted(() => ({ runHarmonyHdc: vi.fn() })); + +vi.mock('../hdc.ts', () => ({ runHarmonyHdc })); + +import { parseArkUiBounds, parseHarmonyLayout, snapshotHarmony } from '../snapshot.ts'; + +const DEVICE = { + platform: 'harmonyos' as const, + id: 'harmony-1', + name: 'HarmonyOS test device', + kind: 'device' as const, + target: 'mobile' as const, + booted: true, +}; + +beforeEach(() => { + runHarmonyHdc.mockReset(); +}); + +/** Scripts `uitest dumpLayout` + `file recv` so the pulled layout is `layout`. */ +function scriptHarmonyLayoutDump(layout: unknown): void { + runHarmonyHdc.mockImplementation(async (_device: unknown, args: string[]) => { + if (args[0] === 'file' && args[1] === 'recv') { + fs.writeFileSync(args[3] as string, JSON.stringify(layout), 'utf8'); + } + return { exitCode: 0, stdout: '', stderr: '' }; + }); +} test('parseArkUiBounds converts API 24 layout bounds into a rectangle', () => { assert.deepEqual(parseArkUiBounds('[84,1127][1172,1295]'), { @@ -15,3 +44,27 @@ test('parseArkUiBounds converts API 24 layout bounds into a rectangle', () => { test('parseHarmonyLayout rejects non-object uitest documents', () => { assert.throws(() => parseHarmonyLayout('[]'), /invalid layout JSON/i); }); + +test('snapshotHarmony reports truncation once the node cap is hit instead of dropping nodes silently', async () => { + scriptHarmonyLayoutDump({ + attributes: { type: 'root', bounds: '[0,0][1080,2340]' }, + children: [ + { attributes: { type: 'Button', text: 'first', clickable: 'true' } }, + { attributes: { type: 'Button', text: 'second', clickable: 'true' } }, + { attributes: { type: 'Button', text: 'third', clickable: 'true' } }, + ], + }); + + const capped = await snapshotHarmony(DEVICE, { maxNodes: 2 }); + + assert.equal(capped.truncated, true); + assert.deepEqual( + capped.nodes.map((node) => node.value ?? node.type), + ['Application', 'first'], + ); + assert.equal(capped.analysis.rawNodeCount, 4); + + const uncapped = await snapshotHarmony(DEVICE); + assert.equal(uncapped.truncated, undefined); + assert.equal(uncapped.nodes.length, 4); +}); diff --git a/src/platforms/harmonyos/snapshot.ts b/src/platforms/harmonyos/snapshot.ts index 72af2a496..c84cd15d5 100644 --- a/src/platforms/harmonyos/snapshot.ts +++ b/src/platforms/harmonyos/snapshot.ts @@ -15,9 +15,16 @@ type ArkUiLayoutNode = { children?: ArkUiLayoutNode[]; }; +/** + * `maxNodes` bounds the emitted tree; nodes past it are dropped and the + * result reports `truncated`. Mirrors the Android helper and Linux AT-SPI + * capture seams so the bound is exercisable below the 5,000 default. + */ +export type HarmonySnapshotOptions = SnapshotOptions & { maxNodes?: number }; + export async function snapshotHarmony( device: DeviceInfo, - options: SnapshotOptions = {}, + options: HarmonySnapshotOptions = {}, ): Promise<{ nodes: RawSnapshotNode[]; truncated?: boolean; @@ -75,7 +82,7 @@ export function parseHarmonyLayout(raw: string): ArkUiLayoutNode { function buildHarmonySnapshot( root: ArkUiLayoutNode, - options: SnapshotOptions, + options: HarmonySnapshotOptions, ): { nodes: RawSnapshotNode[]; truncated?: boolean; @@ -85,7 +92,7 @@ function buildHarmonySnapshot( let rawNodeCount = 0; let maxDepth = 0; let truncated = false; - const maxNodes = MAX_NODES; + const maxNodes = options.maxNodes ?? MAX_NODES; const walk = (node: ArkUiLayoutNode, depth: number, parentIndex?: number): void => { rawNodeCount += 1; maxDepth = Math.max(maxDepth, depth); From 4db275a689c398f4c0c775d7306c12164a108afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 19 Aug 2026 09:40:32 +0200 Subject: [PATCH 2/4] test: cover both durable-JSON node-cap guards (#1781 B5) --- packages/capture-kit/src/durable-json.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/capture-kit/src/durable-json.test.ts b/packages/capture-kit/src/durable-json.test.ts index a9b542875..9cd73737d 100644 --- a/packages/capture-kit/src/durable-json.test.ts +++ b/packages/capture-kit/src/durable-json.test.ts @@ -13,11 +13,16 @@ test('bounded durable JSON rejects cycles and excessive depth', () => { }); test('bounded durable JSON rejects a wide, shallow document past the node cap', () => { - // Depth 2 everywhere, so only the node budget can reject it: the root plus - // one array plus 4,096 empty objects is 4,098 nodes. - const wide = { items: Array.from({ length: 4_096 }, () => ({})) }; - assert.equal(isBoundedJsonObject(wide), false); - assert.equal(isBoundedJsonObject({ items: wide.items.slice(0, 4_000) }), true); + // Depth 2 everywhere, so only the node budget can reject it. Both counting + // sites are exercised: the object walk rejects the object-leaf document, the + // array walk rejects the array-leaf one (each is the only guard on its path). + const objectLeaves = { items: Array.from({ length: 4_096 }, () => ({})) }; + assert.equal(isBoundedJsonObject(objectLeaves), false); + assert.equal(isBoundedJsonObject({ items: objectLeaves.items.slice(0, 4_000) }), true); + + const arrayLeaves = { items: Array.from({ length: 4_096 }, () => [] as never[]) }; + assert.equal(isBoundedJsonObject(arrayLeaves), false); + assert.equal(isBoundedJsonObject({ items: arrayLeaves.items.slice(0, 4_000) }), true); }); test('validated durable JSON freezes without recursively revalidating every subtree', () => { From 965611e7144e569f0e66f644432ca4dcc19a4264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 19 Aug 2026 13:14:50 +0200 Subject: [PATCH 3/4] fix(harmonyos): keep snapshot analysis counting past the emitted-node cap The walk returned as soon as the cap filled, so an omitted node's descendants reached neither rawNodeCount nor maxDepth and analysis under-reported exactly the oversized trees the cap exists for. Emission is now capped while accounting continues, matching the whole-tree claim the Android helper's analysis makes. Also pins the durable descriptor JSON node cap at its exact boundary: 4,096 nodes accepted, 4,097 rejected, through both the object-owned and array-owned counting sites. --- packages/capture-kit/src/durable-json.test.ts | 24 ++++++++------- .../harmonyos/__tests__/snapshot.test.ts | 30 +++++++++++++++++++ src/platforms/harmonyos/snapshot.ts | 15 ++++++---- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/capture-kit/src/durable-json.test.ts b/packages/capture-kit/src/durable-json.test.ts index 9cd73737d..e5dcbd9fa 100644 --- a/packages/capture-kit/src/durable-json.test.ts +++ b/packages/capture-kit/src/durable-json.test.ts @@ -12,17 +12,21 @@ test('bounded durable JSON rejects cycles and excessive depth', () => { assert.equal(isBoundedJsonObject(nested), false); }); -test('bounded durable JSON rejects a wide, shallow document past the node cap', () => { - // Depth 2 everywhere, so only the node budget can reject it. Both counting - // sites are exercised: the object walk rejects the object-leaf document, the - // array walk rejects the array-leaf one (each is the only guard on its path). - const objectLeaves = { items: Array.from({ length: 4_096 }, () => ({})) }; - assert.equal(isBoundedJsonObject(objectLeaves), false); - assert.equal(isBoundedJsonObject({ items: objectLeaves.items.slice(0, 4_000) }), true); +test('bounded durable JSON accepts exactly the node cap and rejects one node past it', () => { + // Every node counts: the root object, the `items` array, and each leaf. The + // cap is 4,096 nodes, so 4,094 leaves sit exactly on it and 4,095 leaves are + // the first document over. Depth stays at 2, so only the node budget can + // decide either case, and the leaf kind selects which of the two counting + // sites owns the rejection. + const objectLeaves = (count: number) => ({ items: Array.from({ length: count }, () => ({})) }); + assert.equal(isBoundedJsonObject(objectLeaves(4_094)), true); + assert.equal(isBoundedJsonObject(objectLeaves(4_095)), false); - const arrayLeaves = { items: Array.from({ length: 4_096 }, () => [] as never[]) }; - assert.equal(isBoundedJsonObject(arrayLeaves), false); - assert.equal(isBoundedJsonObject({ items: arrayLeaves.items.slice(0, 4_000) }), true); + const arrayLeaves = (count: number) => ({ + items: Array.from({ length: count }, () => [] as never[]), + }); + assert.equal(isBoundedJsonObject(arrayLeaves(4_094)), true); + assert.equal(isBoundedJsonObject(arrayLeaves(4_095)), false); }); test('validated durable JSON freezes without recursively revalidating every subtree', () => { diff --git a/src/platforms/harmonyos/__tests__/snapshot.test.ts b/src/platforms/harmonyos/__tests__/snapshot.test.ts index 372117e04..07b634e9d 100644 --- a/src/platforms/harmonyos/__tests__/snapshot.test.ts +++ b/src/platforms/harmonyos/__tests__/snapshot.test.ts @@ -68,3 +68,33 @@ test('snapshotHarmony reports truncation once the node cap is hit instead of dro assert.equal(uncapped.truncated, undefined); assert.equal(uncapped.nodes.length, 4); }); + +test('snapshotHarmony keeps counting the tree below a node the cap omitted', async () => { + // The cap fills on `first`, so `branch` and everything under it is omitted. + // `analysis` still describes the tree the device reported, so the omitted + // subtree must reach both counters — five nodes, deepest at depth 3. + scriptHarmonyLayoutDump({ + attributes: { type: 'root', bounds: '[0,0][1080,2340]' }, + children: [ + { attributes: { type: 'Button', text: 'first', clickable: 'true' } }, + { + attributes: { type: 'Column', text: 'branch' }, + children: [ + { + attributes: { type: 'Row', text: 'leaf' }, + children: [{ attributes: { type: 'Text', text: 'deep' } }], + }, + ], + }, + ], + }); + + const capped = await snapshotHarmony(DEVICE, { maxNodes: 2 }); + + assert.equal(capped.truncated, true); + assert.deepEqual( + capped.nodes.map((node) => node.value ?? node.type), + ['Application', 'first'], + ); + assert.deepEqual(capped.analysis, { rawNodeCount: 5, maxDepth: 3 }); +}); diff --git a/src/platforms/harmonyos/snapshot.ts b/src/platforms/harmonyos/snapshot.ts index c84cd15d5..37ecc27cf 100644 --- a/src/platforms/harmonyos/snapshot.ts +++ b/src/platforms/harmonyos/snapshot.ts @@ -93,17 +93,22 @@ function buildHarmonySnapshot( let maxDepth = 0; let truncated = false; const maxNodes = options.maxNodes ?? MAX_NODES; + // Accounting is separate from emission: `analysis` describes the tree the + // device reported, so the walk keeps counting and descending after the + // emitted-node cap fills. Stopping there would under-report `rawNodeCount` + // and `maxDepth` for exactly the oversized trees the cap exists for. const walk = (node: ArkUiLayoutNode, depth: number, parentIndex?: number): void => { rawNodeCount += 1; maxDepth = Math.max(maxDepth, depth); + let currentIndex = parentIndex; if (nodes.length >= maxNodes) { truncated = true; - return; + } else { + const attributes = node.attributes ?? {}; + const candidate = arkUiNodeFromAttributes(attributes, nodes.length, depth, parentIndex); + const include = !options.interactiveOnly || candidate.hittable === true; + currentIndex = include ? nodes.push(candidate) - 1 : parentIndex; } - const attributes = node.attributes ?? {}; - const candidate = arkUiNodeFromAttributes(attributes, nodes.length, depth, parentIndex); - const include = !options.interactiveOnly || candidate.hittable === true; - const currentIndex = include ? nodes.push(candidate) - 1 : parentIndex; if (depth < (options.depth ?? Number.POSITIVE_INFINITY)) { for (const child of node.children ?? []) walk(child, depth + 1, currentIndex); } From 39393b23d1265344e9923e17067b607ae926cc60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 19 Aug 2026 18:29:16 +0200 Subject: [PATCH 4/4] refactor(harmonyos): own the snapshot traversal policy in a pure function B5 asked for caps to be overridable so tests can reach them; for this cap the better answer is to extract the policy rather than widen the production interface. collectArkUiNodes takes every bound explicitly, so the emission limit and the accounting-continues-below-it rule are tested at their owning interface, and snapshotHarmony keeps its SnapshotOptions shape with no option no caller sets. --- .../harmonyos/__tests__/snapshot.test.ts | 96 ++++++++++++------- src/platforms/harmonyos/snapshot.ts | 54 ++++++----- 2 files changed, 93 insertions(+), 57 deletions(-) diff --git a/src/platforms/harmonyos/__tests__/snapshot.test.ts b/src/platforms/harmonyos/__tests__/snapshot.test.ts index 07b634e9d..c3d84bf2a 100644 --- a/src/platforms/harmonyos/__tests__/snapshot.test.ts +++ b/src/platforms/harmonyos/__tests__/snapshot.test.ts @@ -6,7 +6,12 @@ const { runHarmonyHdc } = vi.hoisted(() => ({ runHarmonyHdc: vi.fn() })); vi.mock('../hdc.ts', () => ({ runHarmonyHdc })); -import { parseArkUiBounds, parseHarmonyLayout, snapshotHarmony } from '../snapshot.ts'; +import { + collectArkUiNodes, + parseArkUiBounds, + parseHarmonyLayout, + snapshotHarmony, +} from '../snapshot.ts'; const DEVICE = { platform: 'harmonyos' as const, @@ -17,6 +22,8 @@ const DEVICE = { booted: true, }; +const UNBOUNDED = { maxDepth: Number.POSITIVE_INFINITY, interactiveOnly: false }; + beforeEach(() => { runHarmonyHdc.mockReset(); }); @@ -45,51 +52,54 @@ test('parseHarmonyLayout rejects non-object uitest documents', () => { assert.throws(() => parseHarmonyLayout('[]'), /invalid layout JSON/i); }); -test('snapshotHarmony reports truncation once the node cap is hit instead of dropping nodes silently', async () => { - scriptHarmonyLayoutDump({ - attributes: { type: 'root', bounds: '[0,0][1080,2340]' }, - children: [ - { attributes: { type: 'Button', text: 'first', clickable: 'true' } }, - { attributes: { type: 'Button', text: 'second', clickable: 'true' } }, - { attributes: { type: 'Button', text: 'third', clickable: 'true' } }, - ], - }); - - const capped = await snapshotHarmony(DEVICE, { maxNodes: 2 }); +test('collectArkUiNodes reports truncation once the emitted-node limit is reached', () => { + const root = parseHarmonyLayout( + JSON.stringify({ + attributes: { type: 'root', bounds: '[0,0][1080,2340]' }, + children: [ + { attributes: { type: 'Button', text: 'first', clickable: 'true' } }, + { attributes: { type: 'Button', text: 'second', clickable: 'true' } }, + { attributes: { type: 'Button', text: 'third', clickable: 'true' } }, + ], + }), + ); + const capped = collectArkUiNodes(root, { ...UNBOUNDED, maxNodes: 2 }); assert.equal(capped.truncated, true); assert.deepEqual( capped.nodes.map((node) => node.value ?? node.type), ['Application', 'first'], ); - assert.equal(capped.analysis.rawNodeCount, 4); + assert.deepEqual(capped.analysis, { rawNodeCount: 4, maxDepth: 1 }); - const uncapped = await snapshotHarmony(DEVICE); - assert.equal(uncapped.truncated, undefined); + const uncapped = collectArkUiNodes(root, { ...UNBOUNDED, maxNodes: 5_000 }); + assert.equal(uncapped.truncated, false); assert.equal(uncapped.nodes.length, 4); }); -test('snapshotHarmony keeps counting the tree below a node the cap omitted', async () => { - // The cap fills on `first`, so `branch` and everything under it is omitted. - // `analysis` still describes the tree the device reported, so the omitted - // subtree must reach both counters — five nodes, deepest at depth 3. - scriptHarmonyLayoutDump({ - attributes: { type: 'root', bounds: '[0,0][1080,2340]' }, - children: [ - { attributes: { type: 'Button', text: 'first', clickable: 'true' } }, - { - attributes: { type: 'Column', text: 'branch' }, - children: [ - { - attributes: { type: 'Row', text: 'leaf' }, - children: [{ attributes: { type: 'Text', text: 'deep' } }], - }, - ], - }, - ], - }); +test('collectArkUiNodes keeps counting the tree below a node the limit omitted', () => { + // The limit fills on `first`, so `branch` and everything under it is + // omitted. `analysis` still describes the tree the device reported, so the + // omitted subtree must reach both counters: five nodes, deepest at depth 3. + const root = parseHarmonyLayout( + JSON.stringify({ + attributes: { type: 'root', bounds: '[0,0][1080,2340]' }, + children: [ + { attributes: { type: 'Button', text: 'first', clickable: 'true' } }, + { + attributes: { type: 'Column', text: 'branch' }, + children: [ + { + attributes: { type: 'Row', text: 'leaf' }, + children: [{ attributes: { type: 'Text', text: 'deep' } }], + }, + ], + }, + ], + }), + ); - const capped = await snapshotHarmony(DEVICE, { maxNodes: 2 }); + const capped = collectArkUiNodes(root, { ...UNBOUNDED, maxNodes: 2 }); assert.equal(capped.truncated, true); assert.deepEqual( @@ -98,3 +108,19 @@ test('snapshotHarmony keeps counting the tree below a node the cap omitted', asy ); assert.deepEqual(capped.analysis, { rawNodeCount: 5, maxDepth: 3 }); }); + +test('snapshotHarmony pulls a uitest layout and reports its analysis', async () => { + scriptHarmonyLayoutDump({ + attributes: { type: 'root', bounds: '[0,0][1080,2340]' }, + children: [{ attributes: { type: 'Button', text: 'first', clickable: 'true' } }], + }); + + const snapshot = await snapshotHarmony(DEVICE); + + assert.equal(snapshot.truncated, undefined); + assert.deepEqual( + snapshot.nodes.map((node) => node.value ?? node.type), + ['Application', 'first'], + ); + assert.deepEqual(snapshot.analysis, { rawNodeCount: 2, maxDepth: 1 }); +}); diff --git a/src/platforms/harmonyos/snapshot.ts b/src/platforms/harmonyos/snapshot.ts index 37ecc27cf..669a4188d 100644 --- a/src/platforms/harmonyos/snapshot.ts +++ b/src/platforms/harmonyos/snapshot.ts @@ -15,16 +15,9 @@ type ArkUiLayoutNode = { children?: ArkUiLayoutNode[]; }; -/** - * `maxNodes` bounds the emitted tree; nodes past it are dropped and the - * result reports `truncated`. Mirrors the Android helper and Linux AT-SPI - * capture seams so the bound is exercisable below the 5,000 default. - */ -export type HarmonySnapshotOptions = SnapshotOptions & { maxNodes?: number }; - export async function snapshotHarmony( device: DeviceInfo, - options: HarmonySnapshotOptions = {}, + options: SnapshotOptions = {}, ): Promise<{ nodes: RawSnapshotNode[]; truncated?: boolean; @@ -82,43 +75,60 @@ export function parseHarmonyLayout(raw: string): ArkUiLayoutNode { function buildHarmonySnapshot( root: ArkUiLayoutNode, - options: HarmonySnapshotOptions, + options: SnapshotOptions, ): { nodes: RawSnapshotNode[]; truncated?: boolean; analysis: { rawNodeCount: number; maxDepth: number }; +} { + const { nodes, truncated, analysis } = collectArkUiNodes(root, { + maxNodes: MAX_NODES, + maxDepth: options.depth ?? Number.POSITIVE_INFINITY, + interactiveOnly: options.interactiveOnly === true, + }); + return { nodes, ...(truncated ? { truncated: true } : {}), analysis }; +} + +/** + * Traversal and emission policy for an ArkUI layout tree, with every bound + * passed in. + * + * Emission and accounting are deliberately separate: emission stops at + * `maxNodes`, while `analysis` keeps describing the tree the device reported, + * so the walk continues counting and descending below an omitted node. Halting + * there would under-report `rawNodeCount` and `maxDepth` for exactly the + * oversized trees the cap exists for. + */ +export function collectArkUiNodes( + root: ArkUiLayoutNode, + policy: { maxNodes: number; maxDepth: number; interactiveOnly: boolean }, +): { + nodes: RawSnapshotNode[]; + truncated: boolean; + analysis: { rawNodeCount: number; maxDepth: number }; } { const nodes: RawSnapshotNode[] = []; let rawNodeCount = 0; let maxDepth = 0; let truncated = false; - const maxNodes = options.maxNodes ?? MAX_NODES; - // Accounting is separate from emission: `analysis` describes the tree the - // device reported, so the walk keeps counting and descending after the - // emitted-node cap fills. Stopping there would under-report `rawNodeCount` - // and `maxDepth` for exactly the oversized trees the cap exists for. const walk = (node: ArkUiLayoutNode, depth: number, parentIndex?: number): void => { rawNodeCount += 1; maxDepth = Math.max(maxDepth, depth); let currentIndex = parentIndex; - if (nodes.length >= maxNodes) { + if (nodes.length >= policy.maxNodes) { truncated = true; } else { const attributes = node.attributes ?? {}; const candidate = arkUiNodeFromAttributes(attributes, nodes.length, depth, parentIndex); - const include = !options.interactiveOnly || candidate.hittable === true; + const include = !policy.interactiveOnly || candidate.hittable === true; currentIndex = include ? nodes.push(candidate) - 1 : parentIndex; } - if (depth < (options.depth ?? Number.POSITIVE_INFINITY)) { + if (depth < policy.maxDepth) { for (const child of node.children ?? []) walk(child, depth + 1, currentIndex); } }; walk(root, 0); - return { - nodes, - ...(truncated ? { truncated: true } : {}), - analysis: { rawNodeCount, maxDepth }, - }; + return { nodes, truncated, analysis: { rawNodeCount, maxDepth } }; } function arkUiNodeFromAttributes(