Skip to content
Merged
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
47 changes: 47 additions & 0 deletions src/compat/maestro/__tests__/runtime-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
55 changes: 18 additions & 37 deletions src/compat/maestro/runtime-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -48,7 +54,7 @@ type ReactNativeOverlayFilterResult = {
blockedByReactNativeOverlay: boolean;
};

type SnapshotNodeByIndex = Map<number, SnapshotNode>;
type SnapshotNodeByIndex = ReturnType<typeof buildSnapshotNodeByIndex>;

type MaestroMatchWithScreenContainer = {
candidate: MaestroResolvedSnapshotMatch;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<T>(
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]));
}
42 changes: 42 additions & 0 deletions src/utils/__tests__/snapshot-processing.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
27 changes: 14 additions & 13 deletions src/utils/selector-is-predicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<number>();
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 (
Expand Down
60 changes: 48 additions & 12 deletions src/utils/snapshot-processing.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -105,22 +105,58 @@ export function findNearestHittableAncestor(
return findNearestAncestor(nodes, node, (parent) => parent.hittable === true);
}

export function buildSnapshotNodeByIndex(nodes: SnapshotState['nodes']): Map<number, SnapshotNode> {
return new Map(nodes.map((candidate) => [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<T>(
nodes: SnapshotState['nodes'],
node: SnapshotNode,
nodeByIndex: ReadonlyMap<number, SnapshotNode>,
resolve: (ancestor: SnapshotNode) => T | null,
): T | null {
let current: SnapshotNode | undefined = node;
const visited = new Set<number>();
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<number, SnapshotNode>,
): boolean {
return Boolean(
findSnapshotAncestor(nodes, node, nodeByIndex, (candidate) =>
candidate === ancestor || candidate.index === ancestor.index ? candidate : null,
),
);
}

/**
* Returns the nearest ancestor matching `predicate`; false means keep walking.
*/
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<string>();
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 {
Expand Down
Loading