Skip to content

Commit ccdfc49

Browse files
committed
fix: improve maestro text tap targets
1 parent b2a39d2 commit ccdfc49

6 files changed

Lines changed: 318 additions & 53 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { expect, test } from 'vitest';
2+
import { pointForMaestroTapOnTarget } from '../runtime-geometry.ts';
3+
4+
test('pointForMaestroTapOnTarget biases large scroll-area text containers toward the visible label', () => {
5+
const point = pointForMaestroTapOnTarget(
6+
{
7+
node: {
8+
index: 5,
9+
ref: 'e5',
10+
type: 'scroll-area',
11+
label: 'Article',
12+
rect: { x: 0, y: 117, width: 402, height: 180 },
13+
},
14+
rect: { x: 0, y: 117, width: 402, height: 180 },
15+
frame: { referenceWidth: 402, referenceHeight: 874 },
16+
},
17+
true,
18+
);
19+
20+
expect(point).toEqual({ x: 84, y: 141 });
21+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { expect, test } from 'vitest';
2+
import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts';
3+
import type { ReplayVarScope } from '../../../replay/vars.ts';
4+
import type { SnapshotState } from '../../../utils/snapshot.ts';
5+
import { invokeMaestroTapOn } from '../runtime-interactions.ts';
6+
import { rememberMaestroSnapshot } from '../runtime-support.ts';
7+
8+
test('invokeMaestroTapOn resolves mutating taps from a fresh snapshot instead of stale assertion cache', async () => {
9+
const scope: ReplayVarScope = { values: {} };
10+
const selector =
11+
'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"';
12+
rememberMaestroSnapshot(scope, staleLaunchSnapshot(), selector);
13+
14+
const clicks: string[][] = [];
15+
const response = await invokeMaestroTapOn({
16+
baseReq: {
17+
token: 'test',
18+
session: 'nav',
19+
flags: { platform: 'ios' },
20+
},
21+
positionals: [selector],
22+
scope,
23+
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
24+
if (req.command === 'snapshot') return { ok: true, data: currentBreadcrumbSnapshot() };
25+
if (req.command === 'click') {
26+
clicks.push(req.positionals ?? []);
27+
return { ok: true, data: {} };
28+
}
29+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
30+
},
31+
});
32+
33+
expect(response.ok).toBe(true);
34+
expect(clicks).toEqual([['86', '89']]);
35+
});
36+
37+
function staleLaunchSnapshot(): SnapshotState {
38+
return {
39+
createdAt: Date.now(),
40+
nodes: [
41+
appNode(),
42+
windowNode(),
43+
{
44+
index: 2,
45+
ref: 'e3',
46+
type: 'Other',
47+
label: 'Article by Gandalf',
48+
depth: 2,
49+
rect: { x: 0, y: 0, width: 402, height: 48 },
50+
},
51+
],
52+
};
53+
}
54+
55+
function currentBreadcrumbSnapshot(): SnapshotState {
56+
return {
57+
createdAt: Date.now(),
58+
nodes: [
59+
appNode(),
60+
windowNode(),
61+
{
62+
index: 2,
63+
ref: 'e3',
64+
type: 'ScrollView',
65+
label: 'Article by Gandalf',
66+
depth: 4,
67+
parentIndex: 1,
68+
rect: { x: 0, y: 58.33333333333333, width: 402, height: 58.33333333333333 },
69+
},
70+
{
71+
index: 3,
72+
ref: 'e4',
73+
type: 'Cell',
74+
label: 'Article by Gandalf',
75+
depth: 5,
76+
parentIndex: 2,
77+
rect: { x: 8, y: 65.33333587646484, width: 155, height: 48 },
78+
},
79+
],
80+
};
81+
}
82+
83+
function appNode(): SnapshotState['nodes'][number] {
84+
return {
85+
index: 0,
86+
ref: 'e1',
87+
type: 'Application',
88+
label: 'React Navigation Example',
89+
depth: 0,
90+
rect: { x: 0, y: 0, width: 402, height: 874 },
91+
};
92+
}
93+
94+
function windowNode(): SnapshotState['nodes'][number] {
95+
return {
96+
index: 1,
97+
ref: 'e2',
98+
type: 'Window',
99+
depth: 1,
100+
parentIndex: 0,
101+
rect: { x: 0, y: 0, width: 402, height: 874 },
102+
};
103+
}

src/compat/maestro/__tests__/runtime-targets.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,95 @@ test('resolveVisibleMaestroNodeFromSnapshot requires visible text matches to be
161161
});
162162
});
163163

164+
test('resolveMaestroNodeFromSnapshot infers missing selected tab slot from tab-strip children', () => {
165+
const snapshot: SnapshotState = {
166+
createdAt: Date.now(),
167+
nodes: [
168+
{
169+
index: 1,
170+
ref: 'e1',
171+
type: 'ScrollView',
172+
label: 'Chat',
173+
rect: { x: 0, y: 116.66666412353516, width: 402, height: 48 },
174+
depth: 3,
175+
},
176+
{
177+
index: 2,
178+
ref: 'e2',
179+
type: 'Cell',
180+
label: 'Contacts',
181+
rect: { x: 134, y: 116.66666412353516, width: 134, height: 48 },
182+
depth: 4,
183+
parentIndex: 1,
184+
},
185+
{
186+
index: 3,
187+
ref: 'e3',
188+
type: 'Cell',
189+
label: 'Albums',
190+
rect: { x: 268, y: 116.66666412353516, width: 134, height: 48 },
191+
depth: 4,
192+
parentIndex: 1,
193+
},
194+
],
195+
};
196+
197+
const target = resolveMaestroNodeFromSnapshot(
198+
snapshot,
199+
'label="Chat" || text="Chat" || id="Chat"',
200+
{},
201+
'ios',
202+
{ referenceWidth: 402, referenceHeight: 874 },
203+
{ promoteTapTarget: true },
204+
);
205+
206+
expect(target).toMatchObject({
207+
ok: true,
208+
node: expect.objectContaining({ index: 1 }),
209+
rect: { x: 0, y: 116.66666412353516, width: 134, height: 48 },
210+
});
211+
});
212+
213+
test('resolveMaestroNodeFromSnapshot keeps concrete child matches over tab-strip inference', () => {
214+
const snapshot: SnapshotState = {
215+
createdAt: Date.now(),
216+
nodes: [
217+
{
218+
index: 1,
219+
ref: 'e1',
220+
type: 'ScrollView',
221+
label: 'Article by Gandalf',
222+
rect: { x: 0, y: 58.33333333333333, width: 402, height: 58.33333333333333 },
223+
depth: 4,
224+
},
225+
{
226+
index: 2,
227+
ref: 'e2',
228+
type: 'Cell',
229+
label: 'Article by Gandalf',
230+
rect: { x: 8, y: 65.33333587646484, width: 155, height: 48 },
231+
depth: 5,
232+
parentIndex: 1,
233+
},
234+
],
235+
};
236+
237+
const target = resolveMaestroNodeFromSnapshot(
238+
snapshot,
239+
'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"',
240+
{},
241+
'ios',
242+
{ referenceWidth: 402, referenceHeight: 874 },
243+
{ promoteTapTarget: true },
244+
);
245+
246+
expect(target).toMatchObject({
247+
ok: true,
248+
node: expect.objectContaining({ index: 2 }),
249+
rect: { x: 8, y: 65.33333587646484, width: 155, height: 48 },
250+
});
251+
});
252+
164253
function makeReactNativeOverlaySnapshot(): SnapshotState {
165254
return {
166255
createdAt: Date.now(),

src/compat/maestro/runtime-geometry.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Rect, SnapshotNode } from '../../utils/snapshot.ts';
2+
import { normalizeType } from '../../utils/snapshot-processing.ts';
23
import type { MaestroSnapshotTarget } from './runtime-targets.ts';
34

45
const MAESTRO_GEOMETRY_POLICY = {
@@ -108,14 +109,13 @@ function shouldBiasMaestroVisibleTextTap(
108109
rect: Rect,
109110
): boolean {
110111
if (!isVisibleTextSelector) return false;
111-
if (
112-
rect.height < MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.minHeight ||
113-
rect.width < MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.minWidth
114-
) {
112+
if (rect.width < MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.minWidth) {
115113
return false;
116114
}
117-
const type = node.type?.toLowerCase();
118-
return type === 'cell' || type === 'other' || type === 'scrollview';
115+
const type = normalizeType(node.type ?? '');
116+
const scrollableTextContainer = type === 'scrollview' || type === 'scroll-area';
117+
if (rect.height < MAESTRO_GEOMETRY_POLICY.largeTextContainerBias.minHeight) return false;
118+
return type === 'cell' || type === 'other' || scrollableTextContainer;
119119
}
120120

121121
function interiorCoordinate(origin: number, size: number): number {

src/compat/maestro/runtime-interactions.ts

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { sleep } from '../../utils/timeouts.ts';
55
import { pointForMaestroTapOnTarget, swipeCoordinatesFromTarget } from './runtime-geometry.ts';
66
import {
77
captureMaestroRawSnapshot,
8-
consumeMaestroSnapshot,
98
errorResponse,
109
readCachedMaestroReferenceFrame,
1110
readSnapshotState,
@@ -471,14 +470,6 @@ async function resolveMaestroSnapshotTarget(
471470
commandLabel: string,
472471
resolutionOptions: { promoteTapTarget: boolean },
473472
): Promise<{ ok: true; target: MaestroSnapshotTarget } | { ok: false; response: DaemonResponse }> {
474-
const cachedTarget = resolveCachedMaestroSnapshotTarget(
475-
params,
476-
selector,
477-
options,
478-
resolutionOptions,
479-
);
480-
if (cachedTarget.ok) return cachedTarget;
481-
482473
const snapshotResponse = await captureMaestroRawSnapshot(params);
483474
if (!snapshotResponse.ok) return { ok: false, response: snapshotResponse };
484475

@@ -543,37 +534,6 @@ async function resolveMaestroSnapshotTarget(
543534
};
544535
}
545536

546-
function resolveCachedMaestroSnapshotTarget(
547-
params: {
548-
baseReq: ReplayBaseRequest;
549-
scope?: ReplayVarScope;
550-
},
551-
selector: string,
552-
options: MaestroTapOnOptions,
553-
resolutionOptions: { promoteTapTarget: boolean },
554-
): { ok: true; target: MaestroSnapshotTarget } | { ok: false } {
555-
const cached = consumeMaestroSnapshot(params.scope, selector);
556-
if (!cached) return { ok: false };
557-
const resolution = resolveMaestroNodeFromSnapshot(
558-
cached.snapshot,
559-
selector,
560-
options,
561-
readMaestroSelectorPlatform(params.baseReq.flags),
562-
cached.frame,
563-
resolutionOptions,
564-
);
565-
return resolution.ok
566-
? {
567-
ok: true,
568-
target: {
569-
node: resolution.node,
570-
rect: resolution.rect,
571-
frame: cached.frame,
572-
},
573-
}
574-
: { ok: false };
575-
}
576-
577537
function readMaestroTapOnOptions(
578538
rawOptions: string | undefined,
579539
): { ok: true; value: MaestroTapOnOptions | null } | { ok: false; response: DaemonResponse } {

0 commit comments

Comments
 (0)