Skip to content

Commit b434591

Browse files
committed
fix: improve maestro tab target swipes
1 parent 34713df commit b434591

5 files changed

Lines changed: 193 additions & 20 deletions

File tree

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

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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 { invokeMaestroTapOn } from '../runtime-interactions.ts';
4+
import { invokeMaestroSwipeScreen, invokeMaestroTapOn } from '../runtime-interactions.ts';
55

66
test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot', async () => {
77
const selector =
@@ -34,6 +34,31 @@ test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot',
3434
expect(clicks).toEqual([['86', '89']]);
3535
});
3636

37+
test('invokeMaestroSwipeScreen uses a conservative Android content-lane directional swipe', async () => {
38+
const swipes: string[][] = [];
39+
const response = await invokeMaestroSwipeScreen({
40+
baseReq: {
41+
token: 'test',
42+
session: 'pager',
43+
flags: { platform: 'android' },
44+
},
45+
positionals: ['direction', 'left', '300'],
46+
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
47+
if (req.command === 'snapshot') {
48+
return { ok: true, data: fullScreenSnapshot(1080, 2340) };
49+
}
50+
if (req.command === 'swipe') {
51+
swipes.push(req.positionals ?? []);
52+
return { ok: true, data: {} };
53+
}
54+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
55+
},
56+
});
57+
58+
expect(response.ok).toBe(true);
59+
expect(swipes).toEqual([['756', '1521', '324', '1521', '300']]);
60+
});
61+
3762
function currentBreadcrumbSnapshot(): SnapshotState {
3863
return {
3964
createdAt: Date.now(),
@@ -62,6 +87,30 @@ function currentBreadcrumbSnapshot(): SnapshotState {
6287
};
6388
}
6489

90+
function fullScreenSnapshot(width: number, height: number): SnapshotState {
91+
return {
92+
createdAt: Date.now(),
93+
nodes: [
94+
{
95+
index: 0,
96+
ref: 'e1',
97+
type: 'Application',
98+
label: 'Android Test App',
99+
depth: 0,
100+
rect: { x: 0, y: 0, width, height },
101+
},
102+
{
103+
index: 1,
104+
ref: 'e2',
105+
type: 'Window',
106+
depth: 1,
107+
parentIndex: 0,
108+
rect: { x: 0, y: 0, width, height },
109+
},
110+
],
111+
};
112+
}
113+
65114
function appNode(): SnapshotState['nodes'][number] {
66115
return {
67116
index: 0,

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,55 @@ test('resolveMaestroNodeFromSnapshot keeps concrete child matches over tab-strip
250250
});
251251
});
252252

253+
test('resolveMaestroNodeFromSnapshot infers leading breadcrumb slot when selected child is omitted', () => {
254+
const snapshot: SnapshotState = {
255+
createdAt: Date.now(),
256+
nodes: [
257+
{
258+
index: 1,
259+
ref: 'e1',
260+
type: 'ScrollView',
261+
label: 'Article by Gandalf',
262+
rect: { x: 0, y: 58.33333333333333, width: 402, height: 58.33333333333333 },
263+
depth: 4,
264+
},
265+
{
266+
index: 2,
267+
ref: 'e2',
268+
type: 'Other',
269+
label: 'Feed',
270+
rect: { x: 170.3333282470703, y: 65.33333587646484, width: 54, height: 48 },
271+
depth: 5,
272+
parentIndex: 1,
273+
},
274+
{
275+
index: 3,
276+
ref: 'e3',
277+
type: 'Other',
278+
label: 'Albums',
279+
rect: { x: 231.6666717529297, y: 65.33333587646484, width: 75, height: 48 },
280+
depth: 5,
281+
parentIndex: 1,
282+
},
283+
],
284+
};
285+
286+
const target = resolveMaestroNodeFromSnapshot(
287+
snapshot,
288+
'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"',
289+
{},
290+
'ios',
291+
{ referenceWidth: 402, referenceHeight: 874 },
292+
{ promoteTapTarget: true },
293+
);
294+
295+
expect(target).toMatchObject({
296+
ok: true,
297+
node: expect.objectContaining({ index: 1 }),
298+
rect: { x: 0, y: 58.33333333333333, width: 168, height: 58.33333333333333 },
299+
});
300+
});
301+
253302
function makeReactNativeOverlaySnapshot(): SnapshotState {
254303
return {
255304
createdAt: Date.now(),

src/compat/maestro/runtime-interactions.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,12 +270,14 @@ function resolveDirectionalScreenSwipe(
270270
case 'down':
271271
return { ok: true, start: point(50, 20), end: point(50, 80), durationMs };
272272
case 'left': {
273-
const yPercent = androidHorizontalContentSwipeY(platform, 80, 50, 20, 50);
274-
return { ok: true, start: point(80, yPercent), end: point(20, yPercent), durationMs };
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 };
275276
}
276277
case 'right': {
277-
const yPercent = androidHorizontalContentSwipeY(platform, 20, 50, 80, 50);
278-
return { ok: true, start: point(20, yPercent), end: point(80, yPercent), durationMs };
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 };
279281
}
280282
default:
281283
return {
@@ -288,6 +290,15 @@ function resolveDirectionalScreenSwipe(
288290
}
289291
}
290292

293+
function androidHorizontalDirectionalSwipeX(
294+
platform: string,
295+
startX: number,
296+
endX: number,
297+
): [number, number] {
298+
if (platform !== 'android') return [startX, endX];
299+
return startX < endX ? [30, 70] : [70, 30];
300+
}
301+
291302
function resolvePercentScreenSwipe(
292303
args: string[],
293304
frame: { referenceWidth: number; referenceHeight: number },
@@ -320,7 +331,7 @@ function androidHorizontalContentSwipeY(
320331
): number {
321332
if (platform !== 'android') return y2;
322333
if (y1 !== y2 || y1 !== 50) return y2;
323-
if (Math.abs(x2 - x1) < 50) return y2;
334+
if (Math.abs(x2 - x1) < 30) return y2;
324335
// Maestro's Android driver treats 50% horizontal swipes as content swipes.
325336
// Raw `adb input swipe` at the physical screen midpoint can land above
326337
// horizontally paged content in React Native layouts, so use a lower content

src/compat/maestro/runtime-targets.ts

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -476,24 +476,88 @@ function inferMaestroMissingTabSlotMatch(
476476
query: string,
477477
): MaestroResolvedSnapshotMatch | null {
478478
if (!isMaestroTabStripContainerMatch(match, query)) return null;
479-
const children: Array<SnapshotNode & { rect: Rect }> = [];
480-
for (const node of nodes) {
481-
if (node.parentIndex !== match.node.index || !node.rect) continue;
482-
const candidate = node as SnapshotNode & { rect: Rect };
483-
if (isMaestroTabStripChildCandidate(candidate, match.rect, query)) {
484-
children.push(candidate);
485-
}
486-
}
487-
children.sort((left, right) => left.rect.x - right.rect.x);
479+
const children = collectMaestroTabStripChildCandidates(nodes, match, query);
488480
if (children.length === 0) return null;
489481
const medianChildWidth = median(children.map((child) => child.rect.width));
490-
const gaps = resolveHorizontalGaps(
482+
const allGaps = resolveHorizontalGaps(
491483
match.rect,
492484
children.map((child) => child.rect),
493-
).filter((gap) => isPlausibleMissingTabSlot(gap.width, medianChildWidth));
494-
if (gaps.length !== 1) return null;
495-
const gap = gaps[0];
485+
);
486+
const gap = selectMaestroMissingSlotGap(match, query, allGaps, medianChildWidth);
496487
if (!gap) return null;
488+
return matchWithRect(match, gap);
489+
}
490+
491+
function collectMaestroTabStripChildCandidates(
492+
nodes: SnapshotState['nodes'],
493+
match: MaestroResolvedSnapshotMatch,
494+
query: string,
495+
): Array<SnapshotNode & { rect: Rect }> {
496+
return nodes
497+
.filter((node): node is SnapshotNode & { rect: Rect } => {
498+
return (
499+
node.parentIndex === match.node.index &&
500+
Boolean(node.rect) &&
501+
isMaestroTabStripChildCandidate(node as SnapshotNode & { rect: Rect }, match.rect, query)
502+
);
503+
})
504+
.sort((left, right) => left.rect.x - right.rect.x);
505+
}
506+
507+
function selectMaestroMissingSlotGap(
508+
match: MaestroResolvedSnapshotMatch,
509+
query: string,
510+
gaps: Array<{ x: number; width: number }>,
511+
medianChildWidth: number,
512+
): { x: number; width: number } | null {
513+
const plausibleGaps = gaps.filter((gap) => isPlausibleMissingTabSlot(gap.width, medianChildWidth));
514+
const leadingTextSlot = inferMaestroLeadingTextSlotGap(match, query, gaps);
515+
const hasPlausibleLeadingGap = plausibleGaps.some((gap) => isLeadingGap(match.rect, gap));
516+
if (leadingTextSlot && !hasPlausibleLeadingGap) return leadingTextSlot;
517+
if (plausibleGaps.length === 1) return plausibleGaps[0] ?? null;
518+
return leadingTextSlot;
519+
}
520+
521+
function inferMaestroLeadingTextSlotGap(
522+
match: MaestroResolvedSnapshotMatch,
523+
query: string,
524+
gaps: Array<{ x: number; width: number }>,
525+
): { x: number; width: number } | null {
526+
const leadingGap = gaps.find((gap) => Math.abs(gap.x - match.rect.x) < 1);
527+
const estimatedLabelWidth = Math.max(48, Math.min(220, query.length * 8 + 24));
528+
if (!isLeadingTextSlotCandidate(match, query, leadingGap, estimatedLabelWidth)) return null;
529+
return {
530+
x: match.rect.x,
531+
width: Math.min(estimatedLabelWidth, leadingGap.width),
532+
};
533+
}
534+
535+
function isLeadingTextSlotCandidate(
536+
match: MaestroResolvedSnapshotMatch,
537+
query: string,
538+
gap: { x: number; width: number } | undefined,
539+
estimatedLabelWidth: number,
540+
): gap is { x: number; width: number } {
541+
if (!gap) return false;
542+
return (
543+
normalizeType(match.node.type ?? '') === 'scrollview' &&
544+
maestroVisibleTextMatchRank(match.node, query) <= 1 &&
545+
match.rect.width >= 240 &&
546+
match.rect.height >= 32 &&
547+
match.rect.height <= 80 &&
548+
gap.width <= match.rect.width * 0.55 &&
549+
gap.width >= estimatedLabelWidth * 0.6
550+
);
551+
}
552+
553+
function isLeadingGap(rect: Rect, gap: { x: number; width: number }): boolean {
554+
return Math.abs(gap.x - rect.x) < 1;
555+
}
556+
557+
function matchWithRect(
558+
match: MaestroResolvedSnapshotMatch,
559+
gap: { x: number; width: number },
560+
): MaestroResolvedSnapshotMatch {
497561
return {
498562
...match,
499563
rect: {

src/daemon/handlers/__tests__/session-replay-vars.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1473,7 +1473,7 @@ test('runReplayScriptFile uses Android content lane for Maestro horizontal scree
14731473
calls.map((call) => [call.command, call.positionals]),
14741474
[
14751475
['snapshot', []],
1476-
['swipe', ['320', '520', '80', '520', '300']],
1476+
['swipe', ['280', '520', '120', '520', '300']],
14771477
['swipe', ['360', '520', '40', '520', '300']],
14781478
],
14791479
);

0 commit comments

Comments
 (0)