Skip to content

Commit a6bb865

Browse files
authored
fix: stabilize Android Maestro replay interactions (#805)
* fix: stabilize Android Maestro replay interactions * refactor: dedupe Maestro visible recovery * test: relax Android Maestro snapshot count assertions * refactor: unify Maestro recoverable interaction state
1 parent 93a6998 commit a6bb865

7 files changed

Lines changed: 651 additions & 32 deletions

File tree

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

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
invokeMaestroAssertNotVisible,
88
invokeMaestroAssertVisible,
99
} from '../runtime-assertions.ts';
10+
import { invokeMaestroSwipeScreen, invokeMaestroTapOn } from '../runtime-interactions.ts';
11+
import { rememberMaestroRecoverableInteraction } from '../runtime-support.ts';
1012
import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts';
1113
import type { SnapshotState } from '../../../utils/snapshot.ts';
1214

@@ -140,6 +142,44 @@ test('invokeMaestroAssertVisible uses the Maestro default timeout when omitted',
140142
assert.deepEqual(calls, [['wait', ['Ready', '17000']]]);
141143
});
142144

145+
test('invokeMaestroAssertVisible verifies Android native wait success with exact snapshot matching', async () => {
146+
const calls: Array<[string, string[] | undefined]> = [];
147+
let snapshots = 0;
148+
const response = await invokeMaestroAssertVisible({
149+
baseReq: {
150+
token: 't',
151+
session: 's',
152+
flags: { platform: 'android' },
153+
},
154+
positionals: ['label="Albums" || text="Albums" || id="Albums"', '1000'],
155+
invoke: async (req): Promise<DaemonResponse> => {
156+
calls.push([req.command, req.positionals]);
157+
if (req.command === 'wait') {
158+
return { ok: true, data: { matches: 1 } };
159+
}
160+
if (req.command === 'snapshot') {
161+
snapshots += 1;
162+
return {
163+
ok: true,
164+
data: snapshot([snapshots === 1 ? node('Push albums') : node('Albums')], snapshots),
165+
};
166+
}
167+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
168+
},
169+
});
170+
171+
assert.equal(response.ok, true);
172+
assert.deepEqual(calls, [
173+
['wait', ['Albums', '1000']],
174+
['snapshot', []],
175+
['snapshot', []],
176+
]);
177+
if (response.ok) {
178+
assert.ok(response.data);
179+
assert.equal(response.data.nodeLabel, 'Albums');
180+
}
181+
});
182+
143183
test('invokeMaestroAssertVisible falls back to one snapshot after native wait misses', async () => {
144184
const calls: Array<[string, string[] | undefined]> = [];
145185
const response = await invokeMaestroAssertVisible({
@@ -174,6 +214,270 @@ test('invokeMaestroAssertVisible falls back to one snapshot after native wait mi
174214
]);
175215
});
176216

217+
test('invokeMaestroAssertVisible re-resolves the previous Android tap when its target remains visible', async () => {
218+
const scope = { values: {} };
219+
const calls: Array<[string, string[] | undefined]> = [];
220+
rememberMaestroRecoverableInteraction(scope, {
221+
kind: 'tap',
222+
selector: 'label="Go to Contacts" || text="Go to Contacts" || id="Go to Contacts"',
223+
point: { x: 999, y: 999 },
224+
});
225+
226+
const response = await invokeMaestroAssertVisible({
227+
baseReq: {
228+
token: 't',
229+
session: 's',
230+
flags: { platform: 'android' },
231+
},
232+
scope,
233+
positionals: ['label="Marissa Castillo" || text="Marissa Castillo" || id="Marissa Castillo"'],
234+
invoke: async (req): Promise<DaemonResponse> => {
235+
calls.push([req.command, req.positionals]);
236+
if (req.command === 'wait') {
237+
const waitCalls = calls.filter(([command]) => command === 'wait').length;
238+
if (waitCalls === 2) return { ok: true, data: { matches: 1 } };
239+
return {
240+
ok: false,
241+
error: { code: 'COMMAND_FAILED', message: 'wait timed out for text: Marissa Castillo' },
242+
};
243+
}
244+
if (req.command === 'snapshot') {
245+
return {
246+
ok: true,
247+
data: snapshot([
248+
node('Go to Contacts', {
249+
type: 'android.widget.Button',
250+
identifier: 'go-to-contacts',
251+
}),
252+
]),
253+
};
254+
}
255+
if (req.command === 'click') return { ok: true, data: {} };
256+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
257+
},
258+
});
259+
260+
assert.equal(response.ok, true);
261+
assert.deepEqual(calls, [
262+
['wait', ['Marissa Castillo', '17000']],
263+
['snapshot', []],
264+
['click', ['80', '100']],
265+
['wait', ['Marissa Castillo', '5000']],
266+
]);
267+
if (response.ok) {
268+
assert.ok(response.data);
269+
assert.equal(response.data.retryTap, true);
270+
}
271+
});
272+
273+
test('invokeMaestroAssertVisible retries previous Android text tap when point resolution misses', async () => {
274+
const scope = { values: {} };
275+
const calls: Array<[string, string[] | undefined]> = [];
276+
rememberMaestroRecoverableInteraction(scope, {
277+
kind: 'tap',
278+
selector: 'label="Push article" || text="Push article" || id="Push article"',
279+
point: { x: 999, y: 999 },
280+
});
281+
282+
const response = await invokeMaestroAssertVisible({
283+
baseReq: {
284+
token: 't',
285+
session: 's',
286+
flags: { platform: 'android' },
287+
},
288+
scope,
289+
positionals: [
290+
'label="Article by The Doctor" || text="Article by The Doctor" || id="Article by The Doctor"',
291+
],
292+
invoke: async (req): Promise<DaemonResponse> => {
293+
calls.push([req.command, req.positionals]);
294+
if (req.command === 'wait') {
295+
const waitCalls = calls.filter(([command]) => command === 'wait').length;
296+
if (waitCalls === 2) return { ok: true, data: { matches: 1 } };
297+
return {
298+
ok: false,
299+
error: {
300+
code: 'COMMAND_FAILED',
301+
message: 'wait timed out for text: Article by The Doctor',
302+
},
303+
};
304+
}
305+
if (req.command === 'snapshot') {
306+
return {
307+
ok: true,
308+
data: snapshot([
309+
node('Push article', {
310+
type: 'android.widget.Button',
311+
identifier: undefined,
312+
rect: undefined,
313+
}),
314+
]),
315+
};
316+
}
317+
if (req.command === 'find') return { ok: true, data: {} };
318+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
319+
},
320+
});
321+
322+
assert.equal(response.ok, true);
323+
assert.deepEqual(calls, [
324+
['wait', ['Article by The Doctor', '17000']],
325+
['snapshot', []],
326+
['find', ['Push article', 'click']],
327+
['wait', ['Article by The Doctor', '5000']],
328+
]);
329+
if (response.ok) {
330+
assert.ok(response.data);
331+
assert.equal(response.data.retryTap, true);
332+
}
333+
});
334+
335+
test('invokeMaestroAssertVisible does not retry stale Android taps after swipes', async () => {
336+
const scope = { values: {} };
337+
const calls: Array<[string, string[] | undefined]> = [];
338+
rememberMaestroRecoverableInteraction(scope, {
339+
kind: 'tap',
340+
selector: 'label="Contacts" || text="Contacts" || id="Contacts"',
341+
point: { x: 120, y: 720 },
342+
});
343+
344+
const swipeResponse = await invokeMaestroSwipeScreen({
345+
baseReq: {
346+
token: 't',
347+
session: 's',
348+
flags: { platform: 'android' },
349+
},
350+
scope,
351+
positionals: ['direction', 'left', '300'],
352+
invoke: async (req): Promise<DaemonResponse> => {
353+
calls.push([req.command, req.positionals]);
354+
if (req.command === 'snapshot') {
355+
return {
356+
ok: true,
357+
data: snapshot([
358+
node('Root', {
359+
type: 'android.widget.FrameLayout',
360+
rect: { x: 0, y: 0, width: 390, height: 844 },
361+
}),
362+
]),
363+
};
364+
}
365+
if (req.command === 'swipe') return { ok: true, data: {} };
366+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
367+
},
368+
});
369+
assert.equal(swipeResponse.ok, true);
370+
371+
const response = await invokeMaestroAssertVisible({
372+
baseReq: {
373+
token: 't',
374+
session: 's',
375+
flags: { platform: 'android' },
376+
},
377+
scope,
378+
positionals: [
379+
'label="What is Lorem Ipsum?" || text="What is Lorem Ipsum?" || id="What is Lorem Ipsum?"',
380+
'2000',
381+
],
382+
invoke: async (req): Promise<DaemonResponse> => {
383+
calls.push([req.command, req.positionals]);
384+
if (req.command === 'wait') {
385+
return {
386+
ok: false,
387+
error: {
388+
code: 'COMMAND_FAILED',
389+
message: 'wait timed out for text: What is Lorem Ipsum?',
390+
},
391+
};
392+
}
393+
if (req.command === 'snapshot') {
394+
return {
395+
ok: true,
396+
data: snapshot([node('Contacts'), node('Albums')]),
397+
};
398+
}
399+
if (req.command === 'swipe') return { ok: true, data: {} };
400+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
401+
},
402+
});
403+
404+
assert.equal(response.ok, false);
405+
assert.deepEqual(calls, [
406+
['snapshot', []],
407+
['swipe', ['332', '549', '59', '549', '300']],
408+
['wait', ['What is Lorem Ipsum?', '2000']],
409+
['snapshot', []],
410+
['swipe', ['332', '549', '59', '549', '300']],
411+
['wait', ['What is Lorem Ipsum?', '2000']],
412+
['snapshot', []],
413+
]);
414+
});
415+
416+
test('invokeMaestroAssertVisible does not retry stale Android taps after fuzzy taps', async () => {
417+
const scope = { values: {} };
418+
const calls: Array<[string, string[] | undefined]> = [];
419+
rememberMaestroRecoverableInteraction(scope, {
420+
kind: 'tap',
421+
selector: 'label="Contacts" || text="Contacts" || id="Contacts"',
422+
point: { x: 120, y: 720 },
423+
});
424+
425+
const tapResponse = await invokeMaestroTapOn({
426+
baseReq: {
427+
token: 't',
428+
session: 's',
429+
flags: { platform: 'android' },
430+
},
431+
scope,
432+
positionals: ['label="Search" || text="Search" || id="Search"'],
433+
invoke: async (req): Promise<DaemonResponse> => {
434+
calls.push([req.command, req.positionals]);
435+
if (req.command === 'snapshot') return { ok: true, data: snapshot([node('Search')]) };
436+
if (req.command === 'click') {
437+
return { ok: false, error: { code: 'COMMAND_FAILED', message: 'coordinate miss' } };
438+
}
439+
if (req.command === 'find') return { ok: true, data: {} };
440+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
441+
},
442+
});
443+
444+
assert.equal(tapResponse.ok, true);
445+
446+
const response = await invokeMaestroAssertVisible({
447+
baseReq: {
448+
token: 't',
449+
session: 's',
450+
flags: { platform: 'android' },
451+
},
452+
scope,
453+
positionals: [
454+
'label="Marissa Castillo" || text="Marissa Castillo" || id="Marissa Castillo"',
455+
'0',
456+
],
457+
invoke: async (req): Promise<DaemonResponse> => {
458+
calls.push([req.command, req.positionals]);
459+
if (req.command === 'wait') {
460+
return {
461+
ok: false,
462+
error: { code: 'COMMAND_FAILED', message: 'wait timed out for text: Marissa Castillo' },
463+
};
464+
}
465+
if (req.command === 'snapshot') return { ok: true, data: snapshot([node('Settings')]) };
466+
if (req.command === 'click') return { ok: true, data: {} };
467+
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
468+
},
469+
});
470+
471+
assert.equal(response.ok, false);
472+
assert.deepEqual(calls, [
473+
['snapshot', []],
474+
['click', ['80', '100']],
475+
['find', ['Search', 'click']],
476+
['wait', ['Marissa Castillo', '0']],
477+
['snapshot', []],
478+
]);
479+
});
480+
177481
test('invokeMaestroAssertVisible does not use raw fallback for iOS snapshot misses', async () => {
178482
const snapshotFlags: Array<DaemonRequest['flags']> = [];
179483
const response = await invokeMaestroAssertVisible({

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

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,8 @@ test('invokeMaestroTapOn clicks explicit React Native overlay controls directly'
242242
expect(clicks).toEqual([['355', '30']]);
243243
});
244244

245-
test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gesture presets', async () => {
246-
const gestures: string[][] = [];
247-
const gestureFlags: Array<DaemonRequest['flags']> = [];
245+
test('invokeMaestroSwipeScreen maps Android horizontal directional swipes to content lane', async () => {
246+
const swipes: string[][] = [];
248247
const response = await invokeMaestroSwipeScreen({
249248
baseReq: {
250249
token: 'test',
@@ -253,22 +252,21 @@ test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gest
253252
},
254253
positionals: ['direction', 'left', '300'],
255254
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
256-
if (req.command === 'gesture') {
257-
gestures.push(req.positionals ?? []);
258-
gestureFlags.push(req.flags);
255+
if (req.command === 'snapshot') return { ok: true, data: fullScreenSnapshot(400, 800) };
256+
if (req.command === 'swipe') {
257+
swipes.push(req.positionals ?? []);
259258
return { ok: true, data: {} };
260259
}
261260
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
262261
},
263262
});
264263

265264
expect(response.ok).toBe(true);
266-
expect(gestures).toEqual([['swipe', 'left', '300']]);
267-
expect(gestureFlags[0]?.postGestureStabilization).toBeUndefined();
265+
expect(swipes).toEqual([['340', '520', '60', '520', '300']]);
268266
});
269267

270-
test('invokeMaestroSwipeScreen mirrors horizontal directional swipe presets', async () => {
271-
const gestures: string[][] = [];
268+
test('invokeMaestroSwipeScreen mirrors Android horizontal directional content lane swipes', async () => {
269+
const swipes: string[][] = [];
272270
const response = await invokeMaestroSwipeScreen({
273271
baseReq: {
274272
token: 'test',
@@ -277,16 +275,17 @@ test('invokeMaestroSwipeScreen mirrors horizontal directional swipe presets', as
277275
},
278276
positionals: ['direction', 'right', '300'],
279277
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
280-
if (req.command === 'gesture') {
281-
gestures.push(req.positionals ?? []);
278+
if (req.command === 'snapshot') return { ok: true, data: fullScreenSnapshot(400, 800) };
279+
if (req.command === 'swipe') {
280+
swipes.push(req.positionals ?? []);
282281
return { ok: true, data: {} };
283282
}
284283
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
285284
},
286285
});
287286

288287
expect(response.ok).toBe(true);
289-
expect(gestures).toEqual([['swipe', 'right', '300']]);
288+
expect(swipes).toEqual([['60', '520', '340', '520', '300']]);
290289
});
291290

292291
test('invokeMaestroSwipeOn resolves visible non-interactive text from a regular snapshot', async () => {

0 commit comments

Comments
 (0)