Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/capture-kit/src/durable-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
113 changes: 111 additions & 2 deletions src/platforms/harmonyos/__tests__/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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]'), {
Expand All @@ -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 });
});
48 changes: 35 additions & 13 deletions src/platforms/harmonyos/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading