Skip to content

Commit 10fc86f

Browse files
authored
refactor: converge Maestro gesture handling (#624)
* refactor: converge Maestro gesture handling * fix: clarify Maestro gesture clamping
1 parent ea69ebd commit 10fc86f

6 files changed

Lines changed: 306 additions & 68 deletions

File tree

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest';
2-
import { pointForMaestroTapOnTarget } from '../runtime-geometry.ts';
2+
import { pointForMaestroTapOnTarget, swipeCoordinatesFromTarget } from '../runtime-geometry.ts';
33

44
test('pointForMaestroTapOnTarget biases large scroll-area text containers toward the visible label', () => {
55
const point = pointForMaestroTapOnTarget(
@@ -38,3 +38,49 @@ test('pointForMaestroTapOnTarget centers tall Android bottom-tab containers', ()
3838

3939
expect(point).toEqual({ x: 675, y: 2164 });
4040
});
41+
42+
test('swipeCoordinatesFromTarget preserves Maestro target-relative swipe distance', () => {
43+
const swipe = swipeCoordinatesFromTarget(
44+
{
45+
node: {
46+
index: 12,
47+
ref: 'e12',
48+
type: 'Cell',
49+
label: 'Card',
50+
rect: { x: 100, y: 200, width: 100, height: 80 },
51+
},
52+
rect: { x: 100, y: 200, width: 100, height: 80 },
53+
frame: { referenceWidth: 402, referenceHeight: 874 },
54+
},
55+
'right',
56+
);
57+
58+
expect(swipe).toEqual({
59+
ok: true,
60+
start: { x: 150, y: 240 },
61+
end: { x: 300, y: 240 },
62+
});
63+
});
64+
65+
test('swipeCoordinatesFromTarget clamps swipe endpoints to the viewport margin', () => {
66+
const swipe = swipeCoordinatesFromTarget(
67+
{
68+
node: {
69+
index: 12,
70+
ref: 'e12',
71+
type: 'Cell',
72+
label: 'Card',
73+
rect: { x: 340, y: 200, width: 100, height: 80 },
74+
},
75+
rect: { x: 340, y: 200, width: 100, height: 80 },
76+
frame: { referenceWidth: 402, referenceHeight: 874 },
77+
},
78+
'right',
79+
);
80+
81+
expect(swipe).toEqual({
82+
ok: true,
83+
start: { x: 390, y: 240 },
84+
end: { x: 394, y: 240 },
85+
});
86+
});

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

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { expect, test } from 'vitest';
22
import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts';
33
import type { SnapshotState } from '../../../utils/snapshot.ts';
4-
import { invokeMaestroSwipeScreen, invokeMaestroTapOn } from '../runtime-interactions.ts';
4+
import {
5+
invokeMaestroSwipeScreen,
6+
invokeMaestroTapOn,
7+
invokeMaestroTapPointPercent,
8+
} from '../runtime-interactions.ts';
59

610
test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot', async () => {
711
const selector =
@@ -59,6 +63,81 @@ test('invokeMaestroSwipeScreen uses an Android content-lane directional swipe',
5963
expect(swipes).toEqual([['864', '1521', '216', '1521', '300']]);
6064
});
6165

66+
test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async () => {
67+
const swipes: string[][] = [];
68+
const response = await invokeMaestroSwipeScreen({
69+
baseReq: {
70+
token: 'test',
71+
session: 'article',
72+
flags: { platform: 'ios' },
73+
},
74+
positionals: ['percent', '50', '75', '50', '35', '300'],
75+
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
76+
if (req.command === 'snapshot') {
77+
return { ok: true, data: fullScreenSnapshot(400, 800) };
78+
}
79+
if (req.command === 'swipe') {
80+
swipes.push(req.positionals ?? []);
81+
return { ok: true, data: {} };
82+
}
83+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
84+
},
85+
});
86+
87+
expect(response.ok).toBe(true);
88+
expect(swipes).toEqual([['200', '600', '200', '280', '300']]);
89+
});
90+
91+
test('invokeMaestroSwipeScreen keeps Android horizontal percentage swipes on the content lane', async () => {
92+
const swipes: string[][] = [];
93+
const response = await invokeMaestroSwipeScreen({
94+
baseReq: {
95+
token: 'test',
96+
session: 'pager',
97+
flags: { platform: 'android' },
98+
},
99+
positionals: ['percent', '90', '50', '10', '50', '300'],
100+
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
101+
if (req.command === 'snapshot') {
102+
return { ok: true, data: fullScreenSnapshot(390, 600) };
103+
}
104+
if (req.command === 'swipe') {
105+
swipes.push(req.positionals ?? []);
106+
return { ok: true, data: {} };
107+
}
108+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
109+
},
110+
});
111+
112+
expect(response.ok).toBe(true);
113+
expect(swipes).toEqual([['351', '390', '39', '390', '300']]);
114+
});
115+
116+
test('invokeMaestroTapPointPercent shares percentage point geometry without clamping', async () => {
117+
const clicks: string[][] = [];
118+
const response = await invokeMaestroTapPointPercent({
119+
baseReq: {
120+
token: 'test',
121+
session: 'article',
122+
flags: { platform: 'ios' },
123+
},
124+
positionals: ['125', '-10'],
125+
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
126+
if (req.command === 'snapshot') {
127+
return { ok: true, data: fullScreenSnapshot(400, 800) };
128+
}
129+
if (req.command === 'click') {
130+
clicks.push(req.positionals ?? []);
131+
return { ok: true, data: {} };
132+
}
133+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
134+
},
135+
});
136+
137+
expect(response.ok).toBe(true);
138+
expect(clicks).toEqual([['500', '-80']]);
139+
});
140+
62141
function currentBreadcrumbSnapshot(): SnapshotState {
63142
return {
64143
createdAt: Date.now(),

src/compat/maestro/runtime-geometry.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { clampToRange } from '../../core/scroll-gesture.ts';
12
import type { Rect, SnapshotNode } from '../../utils/snapshot.ts';
23
import { interiorCoordinate, pointInsideRect } from '../../utils/rect-center.ts';
34
import { normalizeType } from '../../utils/snapshot-processing.ts';
@@ -39,25 +40,37 @@ export function swipeCoordinatesFromTarget(
3940
return {
4041
ok: true,
4142
start: center,
42-
end: { x: center.x, y: clampCoordinate(center.y - verticalDistance, minY, maxY) },
43+
end: {
44+
x: center.x,
45+
y: clampToRange(center.y - verticalDistance, minY, maxY),
46+
},
4347
};
4448
case 'down':
4549
return {
4650
ok: true,
4751
start: center,
48-
end: { x: center.x, y: clampCoordinate(center.y + verticalDistance, minY, maxY) },
52+
end: {
53+
x: center.x,
54+
y: clampToRange(center.y + verticalDistance, minY, maxY),
55+
},
4956
};
5057
case 'left':
5158
return {
5259
ok: true,
5360
start: center,
54-
end: { x: clampCoordinate(center.x - horizontalDistance, minX, maxX), y: center.y },
61+
end: {
62+
x: clampToRange(center.x - horizontalDistance, minX, maxX),
63+
y: center.y,
64+
},
5565
};
5666
case 'right':
5767
return {
5868
ok: true,
5969
start: center,
60-
end: { x: clampCoordinate(center.x + horizontalDistance, minX, maxX), y: center.y },
70+
end: {
71+
x: clampToRange(center.x + horizontalDistance, minX, maxX),
72+
y: center.y,
73+
},
6174
};
6275
default:
6376
return { ok: false, message: 'swipe.label direction must be up, down, left, or right.' };
@@ -94,10 +107,6 @@ function swipeDistance(frameSize: number | undefined, rectSize: number): number
94107
);
95108
}
96109

97-
function clampCoordinate(value: number, min: number, max: number): number {
98-
return Math.round(Math.min(max, Math.max(min, value)));
99-
}
100-
101110
function shouldBiasMaestroVisibleTextTap(
102111
node: SnapshotNode,
103112
isVisibleTextSelector: boolean,

src/compat/maestro/runtime-interactions.ts

Lines changed: 46 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts';
22
import type { DaemonRequest, DaemonResponse } from '../../daemon/types.ts';
3+
import {
4+
buildSwipeGesturePlan,
5+
clampGesturePoint,
6+
pointFromPercent,
7+
type ScrollDirection,
8+
} from '../../core/scroll-gesture.ts';
39
import type { ReplayVarScope } from '../../replay/vars.ts';
410
import { sleep } from '../../utils/timeouts.ts';
511
import { pointForMaestroTapOnTarget, swipeCoordinatesFromTarget } from './runtime-geometry.ts';
@@ -122,13 +128,11 @@ export async function invokeMaestroTapPointPercent(params: {
122128
);
123129
}
124130

131+
const point = pointFromPercent(frame, xPercent, yPercent);
125132
return await params.invoke({
126133
...params.baseReq,
127134
command: 'click',
128-
positionals: [
129-
String(Math.round((frame.referenceWidth * xPercent) / 100)),
130-
String(Math.round((frame.referenceHeight * yPercent) / 100)),
131-
],
135+
positionals: [String(point.x), String(point.y)],
132136
});
133137
}
134138

@@ -263,22 +267,12 @@ function resolveDirectionalScreenSwipe(
263267
response: errorResponse('INVALID_ARGS', 'Maestro direction swipe requires a direction.'),
264268
};
265269
}
266-
const point = (xPercent: number, yPercent: number) => percentPoint(frame, xPercent, yPercent, 8);
267270
switch (direction) {
268271
case 'up':
269-
return { ok: true, start: point(50, 80), end: point(50, 20), durationMs };
270272
case 'down':
271-
return { ok: true, start: point(50, 20), end: point(50, 80), durationMs };
272-
case 'left': {
273-
const [startX, endX] = androidHorizontalDirectionalSwipeX(platform, 80, 20);
274-
const yPercent = androidHorizontalContentSwipeY(platform, startX, 50, endX, 50);
275-
return { ok: true, start: point(startX, yPercent), end: point(endX, yPercent), durationMs };
276-
}
277-
case 'right': {
278-
const [startX, endX] = androidHorizontalDirectionalSwipeX(platform, 20, 80);
279-
const yPercent = androidHorizontalContentSwipeY(platform, startX, 50, endX, 50);
280-
return { ok: true, start: point(startX, yPercent), end: point(endX, yPercent), durationMs };
281-
}
273+
case 'left':
274+
case 'right':
275+
return buildMaestroDirectionalScreenSwipe(direction, frame, platform, durationMs);
282276
default:
283277
return {
284278
ok: false,
@@ -290,13 +284,33 @@ function resolveDirectionalScreenSwipe(
290284
}
291285
}
292286

293-
function androidHorizontalDirectionalSwipeX(
287+
function buildMaestroDirectionalScreenSwipe(
288+
direction: ScrollDirection,
289+
frame: { referenceWidth: number; referenceHeight: number },
294290
platform: string,
295-
startX: number,
296-
endX: number,
297-
): [number, number] {
298-
if (platform !== 'android') return [startX, endX];
299-
return startX < endX ? [20, 80] : [80, 20];
291+
durationMs: string | undefined,
292+
): MaestroScreenSwipeResolution {
293+
const plan = buildSwipeGesturePlan({
294+
direction,
295+
amount: 0.6,
296+
referenceWidth: frame.referenceWidth,
297+
referenceHeight: frame.referenceHeight,
298+
});
299+
const start = clampGesturePoint({ x: plan.x1, y: plan.y1 }, frame, 8);
300+
const end = clampGesturePoint({ x: plan.x2, y: plan.y2 }, frame, 8);
301+
302+
if ((direction === 'left' || direction === 'right') && platform === 'android') {
303+
const contentLaneY = pointFromPercent(frame, 50, 65, { marginPx: 8 }).y;
304+
start.y = contentLaneY;
305+
end.y = contentLaneY;
306+
}
307+
308+
return {
309+
ok: true,
310+
start,
311+
end,
312+
durationMs,
313+
};
300314
}
301315

302316
function resolvePercentScreenSwipe(
@@ -313,55 +327,30 @@ function resolvePercentScreenSwipe(
313327
};
314328
}
315329
const [x1, y1, x2, y2] = values as [number, number, number, number];
316-
const adjustedY = androidHorizontalContentSwipeY(platform, x1, y1, x2, y2);
330+
const lane = maestroHorizontalContentSwipeLanePercent(platform, x1, y1, x2, y2);
317331
return {
318332
ok: true,
319-
start: percentPoint(frame, x1, adjustedY, 1),
320-
end: percentPoint(frame, x2, adjustedY, 1),
333+
start: pointFromPercent(frame, x1, lane.startY, { marginPx: 1 }),
334+
end: pointFromPercent(frame, x2, lane.endY, { marginPx: 1 }),
321335
durationMs,
322336
};
323337
}
324338

325-
function androidHorizontalContentSwipeY(
339+
function maestroHorizontalContentSwipeLanePercent(
326340
platform: string,
327341
x1: number,
328342
y1: number,
329343
x2: number,
330344
y2: number,
331-
): number {
332-
if (platform !== 'android') return y2;
333-
if (y1 !== y2 || y1 !== 50) return y2;
334-
if (Math.abs(x2 - x1) < 30) return y2;
345+
): { startY: number; endY: number } {
346+
if (platform !== 'android') return { startY: y1, endY: y2 };
347+
if (y1 !== y2 || y1 !== 50) return { startY: y1, endY: y2 };
348+
if (Math.abs(x2 - x1) < 30) return { startY: y1, endY: y2 };
335349
// Maestro's Android driver treats 50% horizontal swipes as content swipes.
336350
// Raw `adb input swipe` at the physical screen midpoint can land above
337351
// horizontally paged content in React Native layouts, so use a lower content
338352
// lane for full-width horizontal Maestro percentage swipes.
339-
return 65;
340-
}
341-
342-
function percentPoint(
343-
frame: { referenceWidth: number; referenceHeight: number },
344-
xPercent: number,
345-
yPercent: number,
346-
marginPx: number,
347-
): { x: number; y: number } {
348-
return {
349-
x: clampPoint(
350-
Math.round((frame.referenceWidth * xPercent) / 100),
351-
marginPx,
352-
frame.referenceWidth,
353-
),
354-
y: clampPoint(
355-
Math.round((frame.referenceHeight * yPercent) / 100),
356-
marginPx,
357-
frame.referenceHeight,
358-
),
359-
};
360-
}
361-
362-
function clampPoint(value: number, marginPx: number, size: number): number {
363-
const max = Math.max(marginPx, size - marginPx);
364-
return Math.min(max, Math.max(marginPx, value));
353+
return { startY: 65, endY: 65 };
365354
}
366355

367356
async function probeMaestroScrollVisibility(

0 commit comments

Comments
 (0)