Skip to content

Commit 618af90

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

9 files changed

Lines changed: 360 additions & 105 deletions
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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { expect, test } from 'vitest';
2+
import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts';
3+
import type { SnapshotState } from '../../../utils/snapshot.ts';
4+
import { invokeMaestroTapOn } from '../runtime-interactions.ts';
5+
6+
test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot', async () => {
7+
const selector =
8+
'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"';
9+
10+
const clicks: string[][] = [];
11+
let snapshots = 0;
12+
const response = await invokeMaestroTapOn({
13+
baseReq: {
14+
token: 'test',
15+
session: 'nav',
16+
flags: { platform: 'ios' },
17+
},
18+
positionals: [selector],
19+
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
20+
if (req.command === 'snapshot') {
21+
snapshots += 1;
22+
return { ok: true, data: currentBreadcrumbSnapshot() };
23+
}
24+
if (req.command === 'click') {
25+
clicks.push(req.positionals ?? []);
26+
return { ok: true, data: {} };
27+
}
28+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
29+
},
30+
});
31+
32+
expect(response.ok).toBe(true);
33+
expect(snapshots).toBe(1);
34+
expect(clicks).toEqual([['86', '89']]);
35+
});
36+
37+
function currentBreadcrumbSnapshot(): SnapshotState {
38+
return {
39+
createdAt: Date.now(),
40+
nodes: [
41+
appNode(),
42+
windowNode(),
43+
{
44+
index: 2,
45+
ref: 'e3',
46+
type: 'ScrollView',
47+
label: 'Article by Gandalf',
48+
depth: 4,
49+
parentIndex: 1,
50+
rect: { x: 0, y: 58.33333333333333, width: 402, height: 58.33333333333333 },
51+
},
52+
{
53+
index: 3,
54+
ref: 'e4',
55+
type: 'Cell',
56+
label: 'Article by Gandalf',
57+
depth: 5,
58+
parentIndex: 2,
59+
rect: { x: 8, y: 65.33333587646484, width: 155, height: 48 },
60+
},
61+
],
62+
};
63+
}
64+
65+
function appNode(): SnapshotState['nodes'][number] {
66+
return {
67+
index: 0,
68+
ref: 'e1',
69+
type: 'Application',
70+
label: 'React Navigation Example',
71+
depth: 0,
72+
rect: { x: 0, y: 0, width: 402, height: 874 },
73+
};
74+
}
75+
76+
function windowNode(): SnapshotState['nodes'][number] {
77+
return {
78+
index: 1,
79+
ref: 'e2',
80+
type: 'Window',
81+
depth: 1,
82+
parentIndex: 0,
83+
rect: { x: 0, y: 0, width: 402, height: 874 },
84+
};
85+
}

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-assertions.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { sleep } from '../../utils/timeouts.ts';
66
import {
77
captureMaestroRawSnapshot,
88
errorResponse,
9-
rememberMaestroSnapshot,
109
readSnapshotState,
1110
type MaestroRuntimeInvoke,
1211
type ReplayBaseRequest,
@@ -57,7 +56,6 @@ export async function invokeMaestroAssertVisible(params: {
5756
getSnapshotReferenceFrame(snapshot),
5857
);
5958
if (target.ok) {
60-
rememberMaestroSnapshot(params.scope, response.data, selector);
6159
return {
6260
ok: true,
6361
data: {

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 } {

src/compat/maestro/runtime-support.ts

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ export type MaestroRuntimeInvoke = (req: DaemonRequest) => Promise<DaemonRespons
2020
export type FailedDaemonResponse = Extract<DaemonResponse, { ok: false }>;
2121

2222
const maestroReferenceFrameCache = new WeakMap<ReplayVarScope, TouchReferenceFrame>();
23-
const maestroSnapshotCache = new WeakMap<
24-
ReplayVarScope,
25-
{ snapshot: SnapshotState; frame: TouchReferenceFrame | undefined; selector: string }
26-
>();
2723

2824
export function errorResponse(
2925
code: string,
@@ -73,31 +69,6 @@ export function readCachedMaestroReferenceFrame(
7369
return scope ? maestroReferenceFrameCache.get(scope) : undefined;
7470
}
7571

76-
export function rememberMaestroSnapshot(
77-
scope: ReplayVarScope | undefined,
78-
data: unknown,
79-
selector: string,
80-
): void {
81-
if (!scope) return;
82-
const snapshot = readSnapshotState(data);
83-
if (!snapshot) return;
84-
maestroSnapshotCache.set(scope, {
85-
snapshot,
86-
frame: getSnapshotReferenceFrame(snapshot),
87-
selector,
88-
});
89-
}
90-
91-
export function consumeMaestroSnapshot(
92-
scope: ReplayVarScope | undefined,
93-
selector: string,
94-
): { snapshot: SnapshotState; frame: TouchReferenceFrame | undefined } | undefined {
95-
if (!scope) return undefined;
96-
const cached = maestroSnapshotCache.get(scope);
97-
maestroSnapshotCache.delete(scope);
98-
return cached?.selector === selector ? cached : undefined;
99-
}
100-
10172
function rememberMaestroReferenceFrame(scope: ReplayVarScope, data: unknown): void {
10273
const snapshot = readSnapshotState(data);
10374
const frame = getSnapshotReferenceFrame(snapshot);

0 commit comments

Comments
 (0)