diff --git a/packages/capture-kit/src/durable-json.test.ts b/packages/capture-kit/src/durable-json.test.ts index f3531d5a2..e5dcbd9fa 100644 --- a/packages/capture-kit/src/durable-json.test.ts +++ b/packages/capture-kit/src/durable-json.test.ts @@ -12,6 +12,23 @@ test('bounded durable JSON rejects cycles and excessive depth', () => { assert.equal(isBoundedJsonObject(nested), false); }); +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 = (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', () => { 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..c3d84bf2a 100644 --- a/src/platforms/harmonyos/__tests__/snapshot.test.ts +++ b/src/platforms/harmonyos/__tests__/snapshot.test.ts @@ -1,6 +1,42 @@ 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 { + collectArkUiNodes, + 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, +}; + +const UNBOUNDED = { maxDepth: Number.POSITIVE_INFINITY, interactiveOnly: false }; + +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 +51,76 @@ 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('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.deepEqual(capped.analysis, { rawNodeCount: 4, maxDepth: 1 }); + + const uncapped = collectArkUiNodes(root, { ...UNBOUNDED, maxNodes: 5_000 }); + assert.equal(uncapped.truncated, false); + assert.equal(uncapped.nodes.length, 4); +}); + +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 = collectArkUiNodes(root, { ...UNBOUNDED, 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 }); +}); + +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 72af2a496..669a4188d 100644 --- a/src/platforms/harmonyos/snapshot.ts +++ b/src/platforms/harmonyos/snapshot.ts @@ -80,33 +80,55 @@ function buildHarmonySnapshot( 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 = MAX_NODES; const walk = (node: ArkUiLayoutNode, depth: number, parentIndex?: number): void => { rawNodeCount += 1; maxDepth = Math.max(maxDepth, depth); - if (nodes.length >= maxNodes) { + let currentIndex = parentIndex; + if (nodes.length >= policy.maxNodes) { truncated = true; - return; + } else { + const attributes = node.attributes ?? {}; + const candidate = arkUiNodeFromAttributes(attributes, nodes.length, depth, parentIndex); + const include = !policy.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)) { + 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(