From 8b06c2b5af18b769ed9c36cfc16a6813ce217809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 30 May 2026 13:20:35 +0200 Subject: [PATCH 1/2] refactor: share Maestro target matching primitives --- .../maestro/__tests__/runtime-targets.test.ts | 47 ++++++++++++++++ src/compat/maestro/runtime-targets.ts | 55 ++++++------------- src/utils/selector-is-predicates.ts | 27 ++++----- src/utils/snapshot-processing.ts | 52 ++++++++++++++---- 4 files changed, 119 insertions(+), 62 deletions(-) diff --git a/src/compat/maestro/__tests__/runtime-targets.test.ts b/src/compat/maestro/__tests__/runtime-targets.test.ts index 7883e0748..49b5f8e68 100644 --- a/src/compat/maestro/__tests__/runtime-targets.test.ts +++ b/src/compat/maestro/__tests__/runtime-targets.test.ts @@ -95,6 +95,53 @@ test('resolveVisibleMaestroNodeFromSnapshot does not block content behind collap }); }); +test('resolveMaestroNodeFromSnapshot childOf resolves parent links by node index', () => { + const snapshot: SnapshotState = { + createdAt: Date.now(), + nodes: [ + { + index: 7, + ref: 'e7', + identifier: 'other-row', + rect: { x: 0, y: 0, width: 320, height: 80 }, + }, + { + index: 99, + ref: 'e99', + parentIndex: 42, + identifier: 'childActionButton', + rect: { x: 240, y: 120, width: 64, height: 48 }, + }, + { + index: 42, + ref: 'e42', + identifier: 'parent-row-secondary', + rect: { x: 0, y: 96, width: 320, height: 80 }, + }, + { + index: 8, + ref: 'e8', + parentIndex: 7, + identifier: 'childActionButton', + rect: { x: 240, y: 16, width: 64, height: 48 }, + }, + ], + }; + + const target = resolveMaestroNodeFromSnapshot( + snapshot, + 'id="childActionButton"', + { childOf: 'id="parent-row-secondary"' }, + 'ios', + { referenceWidth: 320, referenceHeight: 640 }, + ); + + expect(target).toMatchObject({ + ok: true, + node: expect.objectContaining({ index: 99 }), + }); +}); + test('resolveMaestroNodeFromSnapshot prefers foreground duplicate matches', () => { const snapshot: SnapshotState = { createdAt: Date.now(), diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index e0cfa1703..3078ccd5e 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -4,7 +4,13 @@ import { parseSelectorChain } from '../../daemon/selectors.ts'; import { matchesSelector } from '../../daemon/selectors-match.ts'; import { evaluateIsPredicate } from '../../utils/selector-is-predicates.ts'; import { normalizeText } from '../../utils/finders.ts'; -import { extractNodeText, normalizeType } from '../../utils/snapshot-processing.ts'; +import { + buildSnapshotNodeByIndex, + extractNodeText, + findSnapshotAncestor, + isDescendantOfSnapshotNode, + normalizeType, +} from '../../utils/snapshot-processing.ts'; import type { TouchReferenceFrame } from '../../daemon/touch-reference-frame.ts'; import type { DaemonRequest } from '../../daemon/types.ts'; import type { Selector, SelectorTerm } from '../../daemon/selectors-parse.ts'; @@ -48,7 +54,7 @@ type ReactNativeOverlayFilterResult = { blockedByReactNativeOverlay: boolean; }; -type SnapshotNodeByIndex = Map; +type SnapshotNodeByIndex = ReturnType; type MaestroMatchWithScreenContainer = { candidate: MaestroResolvedSnapshotMatch; @@ -344,7 +350,13 @@ function selectMaestroSnapshotMatch( frame, requireOnScreen, ); - const target = chooseMaestroSnapshotMatch(nodes, candidates, index, visibleTextQuery, promoteTapTarget); + const target = chooseMaestroSnapshotMatch( + nodes, + candidates, + index, + visibleTextQuery, + promoteTapTarget, + ); return promoteMaestroSnapshotMatch(nodes, target, nodeByIndex, promoteTapTarget, frame); } @@ -683,7 +695,9 @@ function selectMaestroMissingSlotGap( gaps: Array<{ x: number; width: number }>, medianChildWidth: number, ): { x: number; width: number } | null { - const plausibleGaps = gaps.filter((gap) => isPlausibleMissingTabSlot(gap.width, medianChildWidth)); + const plausibleGaps = gaps.filter((gap) => + isPlausibleMissingTabSlot(gap.width, medianChildWidth), + ); const leadingTextSlot = inferMaestroLeadingTextSlotGap(match, query, gaps); const hasPlausibleLeadingGap = plausibleGaps.some((gap) => isLeadingGap(match.rect, gap)); if (leadingTextSlot && !hasPlausibleLeadingGap) return leadingTextSlot; @@ -891,36 +905,3 @@ function maestroVisibleTextMatchRank(node: SnapshotNode, query: string): number if (values.some((value) => textEqualsOrRegex(value, query))) return 2; return 3; } - -function isDescendantOfSnapshotNode( - nodes: SnapshotState['nodes'], - node: SnapshotNode, - ancestor: SnapshotNode, - nodeByIndex: SnapshotNodeByIndex, -): boolean { - return Boolean( - findSnapshotAncestor(nodes, node, nodeByIndex, (candidate) => - candidate === ancestor || candidate.index === ancestor.index ? candidate : null, - ), - ); -} - -function findSnapshotAncestor( - nodes: SnapshotState['nodes'], - node: SnapshotNode, - nodeByIndex: SnapshotNodeByIndex, - resolve: (ancestor: SnapshotNode) => T | null, -): T | null { - let current: SnapshotNode | undefined = node; - while (typeof current.parentIndex === 'number') { - current = nodeByIndex.get(current.parentIndex) ?? nodes[current.parentIndex]; - if (!current) return null; - const result = resolve(current); - if (result) return result; - } - return null; -} - -function buildSnapshotNodeByIndex(nodes: SnapshotState['nodes']): SnapshotNodeByIndex { - return new Map(nodes.map((candidate) => [candidate.index, candidate])); -} diff --git a/src/utils/selector-is-predicates.ts b/src/utils/selector-is-predicates.ts index cf0181bf6..37e00d72e 100644 --- a/src/utils/selector-is-predicates.ts +++ b/src/utils/selector-is-predicates.ts @@ -2,7 +2,12 @@ import type { Platform } from './device.ts'; import type { SnapshotState } from './snapshot.ts'; import { isNodeVisibleInEffectiveViewport } from './mobile-snapshot-semantics.ts'; import { isNodeEditable, isNodeVisible } from './selector-node.ts'; -import { extractNodeText, normalizeType } from './snapshot-processing.ts'; +import { + buildSnapshotNodeByIndex, + extractNodeText, + findSnapshotAncestor, + normalizeType, +} from './snapshot-processing.ts'; type IsPredicate = 'visible' | 'hidden' | 'exists' | 'editable' | 'selected' | 'text'; @@ -78,20 +83,16 @@ function resolveVisibilityAnchor( nodes: SnapshotState['nodes'], platform: Platform, ): SnapshotState['nodes'][number] | null { - const nodesByIndex = new Map(nodes.map((entry) => [entry.index, entry])); - let current = node; - const visited = new Set(); - while (typeof current.parentIndex === 'number' && !visited.has(current.index)) { - visited.add(current.index); - const parent = nodesByIndex.get(current.parentIndex); - if (!parent) break; - if (isUsefulVisibilityAnchor(parent, platform)) return parent; - current = parent; - } - return null; + const nodesByIndex = buildSnapshotNodeByIndex(nodes); + return findSnapshotAncestor(nodes, node, nodesByIndex, (parent) => + isUsefulVisibilityAnchor(parent, platform) ? parent : null, + ); } -function isUsefulVisibilityAnchor(node: SnapshotState['nodes'][number], platform: Platform): boolean { +function isUsefulVisibilityAnchor( + node: SnapshotState['nodes'][number], + platform: Platform, +): boolean { const type = normalizeType(node.type ?? ''); // These containers often report the full content frame, not the clipped on-screen geometry. if ( diff --git a/src/utils/snapshot-processing.ts b/src/utils/snapshot-processing.ts index 9555c7a0b..1264db4ed 100644 --- a/src/utils/snapshot-processing.ts +++ b/src/utils/snapshot-processing.ts @@ -1,5 +1,5 @@ import type { Platform } from './device.ts'; -import type { RawSnapshotNode, SnapshotState } from './snapshot.ts'; +import type { RawSnapshotNode, SnapshotNode, SnapshotState } from './snapshot.ts'; import { extractReadableText, normalizeType } from './text-surface.ts'; export { normalizeType }; @@ -105,22 +105,50 @@ export function findNearestHittableAncestor( return findNearestAncestor(nodes, node, (parent) => parent.hittable === true); } +export function buildSnapshotNodeByIndex(nodes: SnapshotState['nodes']): Map { + return new Map(nodes.map((candidate) => [candidate.index, candidate])); +} + +export function findSnapshotAncestor( + nodes: SnapshotState['nodes'], + node: SnapshotNode, + nodeByIndex: ReadonlyMap, + resolve: (ancestor: SnapshotNode) => T | null, +): T | null { + let current: SnapshotNode | undefined = node; + const visited = new Set(); + while (typeof current.parentIndex === 'number' && !visited.has(current.index)) { + visited.add(current.index); + current = nodeByIndex.get(current.parentIndex) ?? nodes[current.parentIndex]; + if (!current) return null; + const result = resolve(current); + if (result !== null) return result; + } + return null; +} + +export function isDescendantOfSnapshotNode( + nodes: SnapshotState['nodes'], + node: SnapshotNode, + ancestor: SnapshotNode, + nodeByIndex: ReadonlyMap, +): boolean { + return Boolean( + findSnapshotAncestor(nodes, node, nodeByIndex, (candidate) => + candidate === ancestor || candidate.index === ancestor.index ? candidate : null, + ), + ); +} + export function findNearestAncestor( nodes: SnapshotState['nodes'], node: SnapshotState['nodes'][number], predicate: (node: SnapshotState['nodes'][number]) => boolean, ): SnapshotState['nodes'][number] | null { - let current = node; - const visited = new Set(); - while (current.parentIndex !== undefined) { - if (visited.has(current.ref)) break; - visited.add(current.ref); - const parent = nodes[current.parentIndex]; - if (!parent) break; - if (predicate(parent)) return parent; - current = parent; - } - return null; + const nodesByIndex = buildSnapshotNodeByIndex(nodes); + return findSnapshotAncestor(nodes, node, nodesByIndex, (parent) => + predicate(parent) ? parent : null, + ); } export function extractNodeText(node: SnapshotState['nodes'][number]): string { From 3e1b726d0fde3b49941e6a738dd1ad7811627fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 30 May 2026 13:52:52 +0200 Subject: [PATCH 2/2] test: cover shared snapshot ancestor traversal --- .../__tests__/snapshot-processing.test.ts | 42 +++++++++++++++++++ src/utils/snapshot-processing.ts | 8 ++++ 2 files changed, 50 insertions(+) create mode 100644 src/utils/__tests__/snapshot-processing.test.ts diff --git a/src/utils/__tests__/snapshot-processing.test.ts b/src/utils/__tests__/snapshot-processing.test.ts new file mode 100644 index 000000000..1db833a96 --- /dev/null +++ b/src/utils/__tests__/snapshot-processing.test.ts @@ -0,0 +1,42 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + buildSnapshotNodeByIndex, + findNearestAncestor, + findSnapshotAncestor, +} from '../snapshot-processing.ts'; +import type { SnapshotNode } from '../snapshot.ts'; + +test('findSnapshotAncestor walks non-contiguous parent indexes until resolver returns a value', () => { + const nodes: SnapshotNode[] = [ + { ref: 'e10', index: 10, type: 'Window' }, + { ref: 'e30', index: 30, parentIndex: 20, type: 'Text' }, + { ref: 'e20', index: 20, parentIndex: 10, type: 'Cell' }, + ]; + const visited: number[] = []; + + const ancestor = findSnapshotAncestor( + nodes, + nodes[1]!, + buildSnapshotNodeByIndex(nodes), + (node) => { + visited.push(node.index); + return node.type === 'Window' ? node : null; + }, + ); + + assert.deepEqual(visited, [20, 10]); + assert.equal(ancestor?.index, 10); +}); + +test('findNearestAncestor resolves parents by snapshot index rather than array position', () => { + const nodes: SnapshotNode[] = [ + { ref: 'e10', index: 10, type: 'Window' }, + { ref: 'e30', index: 30, parentIndex: 20, type: 'Text' }, + { ref: 'e20', index: 20, parentIndex: 10, type: 'Cell' }, + ]; + + const ancestor = findNearestAncestor(nodes, nodes[1]!, (node) => node.type === 'Window'); + + assert.equal(ancestor?.index, 10); +}); diff --git a/src/utils/snapshot-processing.ts b/src/utils/snapshot-processing.ts index 1264db4ed..98b09f13b 100644 --- a/src/utils/snapshot-processing.ts +++ b/src/utils/snapshot-processing.ts @@ -109,6 +109,11 @@ export function buildSnapshotNodeByIndex(nodes: SnapshotState['nodes']): Map [candidate.index, candidate])); } +/** + * Walks from the given node through its parent chain and returns the first + * non-null value produced by `resolve`. Returning null from `resolve` skips + * that ancestor and continues walking toward the root. + */ export function findSnapshotAncestor( nodes: SnapshotState['nodes'], node: SnapshotNode, @@ -140,6 +145,9 @@ export function isDescendantOfSnapshotNode( ); } +/** + * Returns the nearest ancestor matching `predicate`; false means keep walking. + */ export function findNearestAncestor( nodes: SnapshotState['nodes'], node: SnapshotState['nodes'][number],