Skip to content

Commit 5bdbb1d

Browse files
committed
fix: stabilize Maestro React Navigation flows
1 parent c78ccea commit 5bdbb1d

8 files changed

Lines changed: 331 additions & 17 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type { CommandFlags } from '../../../core/dispatch.ts';
4+
import type { DaemonRequest, DaemonResponse, SessionAction } from '../../../daemon/types.ts';
5+
import { invokeMaestroRunFlowWhen } from '../runtime-flow.ts';
6+
7+
test('invokeMaestroRunFlowWhen waits briefly for visible conditions', async () => {
8+
let snapshots = 0;
9+
const invokedActions: SessionAction[] = [];
10+
const batchSteps: CommandFlags['batchSteps'] = [
11+
{ command: 'click', positionals: ['label="Dismiss"'] },
12+
];
13+
14+
const response = await invokeMaestroRunFlowWhen({
15+
baseReq: {
16+
token: 't',
17+
session: 's',
18+
flags: { platform: 'android' },
19+
},
20+
positionals: ['visible', 'label="Dismiss" || text="Dismiss" || id="Dismiss"'],
21+
batchSteps,
22+
line: 12,
23+
step: 4,
24+
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
25+
assert.equal(req.command, 'snapshot');
26+
snapshots += 1;
27+
return {
28+
ok: true,
29+
data: {
30+
createdAt: Date.now(),
31+
nodes:
32+
snapshots === 1
33+
? []
34+
: [
35+
{
36+
index: 1,
37+
ref: 'e1',
38+
type: 'android.widget.TextView',
39+
label: 'Dismiss',
40+
rect: { x: 201, y: 2180, width: 138, height: 55 },
41+
depth: 20,
42+
},
43+
],
44+
},
45+
};
46+
},
47+
invokeReplayAction: async ({ action }): Promise<DaemonResponse> => {
48+
invokedActions.push(action);
49+
return { ok: true, data: { clicked: true } };
50+
},
51+
});
52+
53+
assert.equal(response.ok, true);
54+
assert.equal(snapshots, 2);
55+
assert.deepEqual(
56+
invokedActions.map((action) => [action.command, action.positionals]),
57+
[['click', ['label="Dismiss"']]],
58+
);
59+
if (response.ok) {
60+
assert.equal(response.data?.ran, 1);
61+
}
62+
});
63+
64+
test('invokeMaestroRunFlowWhen keeps notVisible conditions immediate', async () => {
65+
let snapshots = 0;
66+
const response = await invokeMaestroRunFlowWhen({
67+
baseReq: {
68+
token: 't',
69+
session: 's',
70+
flags: { platform: 'android' },
71+
},
72+
positionals: ['notVisible', 'label="Loading" || text="Loading" || id="Loading"'],
73+
batchSteps: [{ command: 'click', positionals: ['label="Continue"'] }],
74+
line: 14,
75+
step: 7,
76+
invoke: async (): Promise<DaemonResponse> => {
77+
snapshots += 1;
78+
return {
79+
ok: true,
80+
data: {
81+
createdAt: Date.now(),
82+
nodes: [
83+
{
84+
index: 1,
85+
ref: 'e1',
86+
type: 'android.widget.TextView',
87+
label: 'Loading',
88+
rect: { x: 120, y: 420, width: 160, height: 48 },
89+
depth: 8,
90+
},
91+
],
92+
},
93+
};
94+
},
95+
invokeReplayAction: async (): Promise<DaemonResponse> => {
96+
throw new Error('notVisible should skip while the selector is visible');
97+
},
98+
});
99+
100+
assert.equal(response.ok, true);
101+
assert.equal(snapshots, 1);
102+
if (response.ok) {
103+
assert.equal(response.data?.skipped, true);
104+
}
105+
});

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ 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 () => {
37+
test('invokeMaestroSwipeScreen uses an Android content-lane directional swipe', async () => {
3838
const swipes: string[][] = [];
3939
const response = await invokeMaestroSwipeScreen({
4040
baseReq: {
@@ -56,7 +56,7 @@ test('invokeMaestroSwipeScreen uses a conservative Android content-lane directio
5656
});
5757

5858
expect(response.ok).toBe(true);
59-
expect(swipes).toEqual([['756', '1521', '324', '1521', '300']]);
59+
expect(swipes).toEqual([['864', '1521', '216', '1521', '300']]);
6060
});
6161

6262
function currentBreadcrumbSnapshot(): SnapshotState {

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,67 @@ test('resolveMaestroNodeFromSnapshot preserves read order for duplicate matches
169169
});
170170
});
171171

172+
test('resolveMaestroNodeFromSnapshot prefers duplicate text on foreground overlapping screen', () => {
173+
const snapshot: SnapshotState = {
174+
createdAt: Date.now(),
175+
nodes: [
176+
{
177+
index: 1,
178+
ref: 'e1',
179+
type: 'android.widget.ScrollView',
180+
label: 'Article, Go back, Show Dialog',
181+
rect: { x: 0, y: 120, width: 1080, height: 1800 },
182+
depth: 6,
183+
},
184+
{
185+
index: 2,
186+
ref: 'e2',
187+
type: 'android.widget.Button',
188+
label: 'Show Dialog',
189+
rect: { x: 720, y: 980, width: 280, height: 88 },
190+
enabled: true,
191+
hittable: true,
192+
depth: 14,
193+
parentIndex: 1,
194+
},
195+
{
196+
index: 30,
197+
ref: 'e30',
198+
type: 'android.widget.ScrollView',
199+
label: 'NewsFeed, Push NewsFeed, Show Dialog',
200+
rect: { x: 0, y: 120, width: 1080, height: 1800 },
201+
depth: 6,
202+
},
203+
{
204+
index: 31,
205+
ref: 'e31',
206+
type: 'android.widget.Button',
207+
label: 'Show Dialog',
208+
rect: { x: 720, y: 1320, width: 280, height: 88 },
209+
enabled: true,
210+
hittable: true,
211+
depth: 14,
212+
parentIndex: 30,
213+
},
214+
],
215+
};
216+
217+
const target = resolveMaestroNodeFromSnapshot(
218+
snapshot,
219+
'label="Show Dialog" || text="Show Dialog" || id="Show Dialog"',
220+
{},
221+
'android',
222+
{ referenceWidth: 1080, referenceHeight: 2340 },
223+
{ promoteTapTarget: true },
224+
);
225+
226+
expect(target).toMatchObject({
227+
ok: true,
228+
node: expect.objectContaining({ index: 31 }),
229+
rect: { x: 720, y: 1320, width: 280, height: 88 },
230+
});
231+
});
232+
172233
test('resolveVisibleMaestroNodeFromSnapshot requires visible text matches to be on screen', () => {
173234
const snapshot: SnapshotState = {
174235
createdAt: Date.now(),

src/compat/maestro/runtime-flow.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@ import {
1717
readMaestroSelectorPlatform,
1818
resolveVisibleMaestroNodeFromSnapshot,
1919
} from './runtime-targets.ts';
20+
import { sleep } from '../../utils/timeouts.ts';
21+
22+
const MAESTRO_RUN_FLOW_WHEN_POLICY = {
23+
visibleTimeoutMs: 1000,
24+
visiblePollMs: 250,
25+
} as const;
2026

2127
type MaestroRunFlowWhenCondition =
22-
| { ok: true; mode: string; predicate: string; selector: string }
28+
| { ok: true; mode: string; selector: string }
2329
| { ok: false; response: DaemonResponse };
2430

2531
export async function invokeMaestroRunFlowWhen(params: {
@@ -80,7 +86,6 @@ function readMaestroRunFlowWhenCondition(positionals: string[]): MaestroRunFlowW
8086
return {
8187
ok: true,
8288
mode,
83-
predicate: mode === 'visible' ? 'visible' : 'hidden',
8489
selector,
8590
};
8691
}
@@ -92,22 +97,67 @@ async function evaluateMaestroRunFlowWhenCondition(
9297
},
9398
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
9499
): Promise<{ ok: true; matched: boolean } | { ok: false; response: DaemonResponse }> {
100+
if (condition.mode === 'visible') {
101+
return await waitForMaestroRunFlowVisibleCondition(params, condition);
102+
}
103+
95104
const response = await captureMaestroRawSnapshot(params);
96105
if (!response.ok) return { ok: false, response };
106+
const result = readMaestroRunFlowVisibleCondition(params, condition.selector, response);
107+
if (!result.ok) {
108+
return {
109+
ok: false,
110+
response: result.response,
111+
};
112+
}
113+
return { ok: true, matched: !result.matched };
114+
}
115+
116+
async function waitForMaestroRunFlowVisibleCondition(
117+
params: {
118+
baseReq: ReplayBaseRequest;
119+
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
120+
},
121+
condition: Extract<MaestroRunFlowWhenCondition, { ok: true }>,
122+
): Promise<{ ok: true; matched: boolean } | { ok: false; response: DaemonResponse }> {
123+
// Maestro conditionals commonly guard UI that appears immediately after the
124+
// previous command. Keep this short and only for visible; notVisible stays a
125+
// point-in-time condition so optional cleanup blocks do not become waits.
126+
const startedAt = Date.now();
127+
while (true) {
128+
const response = await captureMaestroRawSnapshot(params);
129+
if (!response.ok) return { ok: false, response };
130+
const result = readMaestroRunFlowVisibleCondition(params, condition.selector, response);
131+
if (!result.ok) return { ok: false, response: result.response };
132+
if (result.matched) return { ok: true, matched: true };
133+
if (Date.now() - startedAt >= MAESTRO_RUN_FLOW_WHEN_POLICY.visibleTimeoutMs) {
134+
return { ok: true, matched: false };
135+
}
136+
await sleep(MAESTRO_RUN_FLOW_WHEN_POLICY.visiblePollMs);
137+
}
138+
}
139+
140+
function readMaestroRunFlowVisibleCondition(
141+
params: {
142+
baseReq: ReplayBaseRequest;
143+
},
144+
selector: string,
145+
response: Extract<DaemonResponse, { ok: true }>,
146+
): { ok: true; matched: boolean } | { ok: false; response: DaemonResponse } {
97147
const snapshot = readSnapshotState(response.data);
98148
if (!snapshot) {
99149
return {
100150
ok: false,
101151
response: errorResponse('COMMAND_FAILED', 'Unable to read snapshot data for runFlow.when.'),
102152
};
103153
}
104-
const visible = resolveVisibleMaestroNodeFromSnapshot(
154+
const matched = resolveVisibleMaestroNodeFromSnapshot(
105155
snapshot,
106-
condition.selector,
156+
selector,
107157
readMaestroSelectorPlatform(params.baseReq.flags),
108158
getSnapshotReferenceFrame(snapshot),
109159
).ok;
110-
return { ok: true, matched: condition.mode === 'visible' ? visible : !visible };
160+
return { ok: true, matched };
111161
}
112162

113163
async function invokeMaestroRunFlowWhenSteps(

src/compat/maestro/runtime-interactions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ function androidHorizontalDirectionalSwipeX(
296296
endX: number,
297297
): [number, number] {
298298
if (platform !== 'android') return [startX, endX];
299-
return startX < endX ? [30, 70] : [70, 30];
299+
return startX < endX ? [20, 80] : [80, 20];
300300
}
301301

302302
function resolvePercentScreenSwipe(

0 commit comments

Comments
 (0)