Skip to content

Commit 66ec92f

Browse files
authored
fix: retry no-op maestro taps (#644)
* fix: retry no-op maestro taps * fix: dispatch no-op tap retries as press * fix: preserve android freshness during outcome retry
1 parent 096785b commit 66ec92f

15 files changed

Lines changed: 812 additions & 91 deletions

src/compat/maestro/runtime-interactions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,10 @@ async function clickMaestroSnapshotTarget(
502502
...params.baseReq,
503503
command: 'click',
504504
positionals: [String(point.x), String(point.y)],
505+
flags: {
506+
...params.baseReq.flags,
507+
interactionOutcome: { retryOnNoChange: true },
508+
},
505509
});
506510
if (response.ok) clearMaestroVisibleContext(params.scope);
507511
return {
@@ -550,6 +554,7 @@ async function invokeMaestroFuzzyTapOn(
550554
flags: {
551555
...params.baseReq.flags,
552556
findFirst: true,
557+
interactionOutcome: { retryOnNoChange: true },
553558
},
554559
});
555560
if (findResponse.ok) return { retry: false, response: findResponse };

src/core/dispatch-context.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export type MaestroRuntimeFlags = {
1515
export type CommandFlags = Omit<CliFlags, DaemonExcludedCliFlag> & {
1616
batchSteps?: DaemonBatchStep[];
1717
clearAppState?: boolean;
18+
interactionOutcome?: {
19+
retryOnNoChange?: boolean;
20+
};
1821
launchArgs?: string[];
1922
maestro?: MaestroRuntimeFlags;
2023
replayBackend?: string;
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type { SnapshotState } from '../../utils/snapshot.ts';
4+
import {
5+
buildInteractionSurfaceSignature,
6+
classifyInteractionSurfaceChange,
7+
markPendingInteractionOutcome,
8+
stripInternalInteractionOutcomeFlags,
9+
} from '../interaction-outcome-policy.ts';
10+
import type { SessionState } from '../types.ts';
11+
import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
12+
13+
test('classifyInteractionSurfaceChange treats identical surfaces as unchanged', () => {
14+
const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox').nodes);
15+
const after = buildInteractionSurfaceSignature(makeSnapshot('Inbox').nodes);
16+
17+
assert.equal(classifyInteractionSurfaceChange(before, after), 'unchanged');
18+
});
19+
20+
test('classifyInteractionSurfaceChange tolerates tiny rect drift', () => {
21+
const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 100).nodes);
22+
const after = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 100.4).nodes);
23+
24+
assert.equal(classifyInteractionSurfaceChange(before, after), 'unchanged');
25+
});
26+
27+
test('classifyInteractionSurfaceChange detects semantic screen changes', () => {
28+
const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox').nodes);
29+
const after = buildInteractionSurfaceSignature(makeSnapshot('Article detail').nodes);
30+
31+
assert.equal(classifyInteractionSurfaceChange(before, after), 'changed');
32+
});
33+
34+
test('classifyInteractionSurfaceChange detects material layout movement', () => {
35+
const before = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 100).nodes);
36+
const after = buildInteractionSurfaceSignature(makeSnapshot('Inbox', 180).nodes);
37+
38+
assert.equal(classifyInteractionSurfaceChange(before, after), 'changed');
39+
});
40+
41+
test('markPendingInteractionOutcome stores retry state only for explicit retry flags', () => {
42+
const session = makeSession();
43+
markPendingInteractionOutcome({
44+
session,
45+
command: 'click',
46+
positionals: ['20', '40'],
47+
flags: {},
48+
preSnapshot: makeSnapshot('Inbox'),
49+
});
50+
assert.equal(session.pendingInteractionOutcome, undefined);
51+
52+
const retrySession = makeSession();
53+
markPendingInteractionOutcome({
54+
session: retrySession,
55+
command: 'click',
56+
positionals: ['20', '40'],
57+
flags: { interactionOutcome: { retryOnNoChange: true } },
58+
preSnapshot: makeSnapshot('Inbox'),
59+
});
60+
61+
assert.equal(retrySession.pendingInteractionOutcome?.action, 'click');
62+
assert.equal(retrySession.pendingInteractionOutcome?.command, 'press');
63+
assert.equal(retrySession.pendingInteractionOutcome?.attemptsRemaining, 2);
64+
assert.equal(retrySession.pendingInteractionOutcome?.flags?.interactionOutcome, undefined);
65+
66+
const refSession = makeSession();
67+
markPendingInteractionOutcome({
68+
session: refSession,
69+
command: 'click',
70+
positionals: ['@e1'],
71+
flags: { interactionOutcome: { retryOnNoChange: true } },
72+
preSnapshot: makeSnapshot('Inbox'),
73+
});
74+
assert.equal(refSession.pendingInteractionOutcome, undefined);
75+
76+
const longPressSession = makeSession();
77+
markPendingInteractionOutcome({
78+
session: longPressSession,
79+
command: 'longpress',
80+
positionals: ['20', '40', '800'],
81+
flags: { interactionOutcome: { retryOnNoChange: true } },
82+
preSnapshot: makeSnapshot('Inbox'),
83+
});
84+
assert.equal(longPressSession.pendingInteractionOutcome, undefined);
85+
});
86+
87+
test('stripInternalInteractionOutcomeFlags removes internal retry controls', () => {
88+
assert.deepEqual(
89+
stripInternalInteractionOutcomeFlags({
90+
platform: 'ios',
91+
interactionOutcome: { retryOnNoChange: true },
92+
}),
93+
{ platform: 'ios' },
94+
);
95+
});
96+
97+
function makeSession(): SessionState {
98+
return {
99+
name: 'ios',
100+
device: IOS_SIMULATOR,
101+
createdAt: Date.now(),
102+
actions: [],
103+
};
104+
}
105+
106+
function makeSnapshot(label: string, y = 100): SnapshotState {
107+
return {
108+
nodes: [
109+
{
110+
ref: 'e1',
111+
index: 0,
112+
type: 'Application',
113+
label: 'App',
114+
rect: { x: 0, y: 0, width: 390, height: 844 },
115+
},
116+
{
117+
ref: 'e2',
118+
index: 1,
119+
parentIndex: 0,
120+
type: 'Button',
121+
identifier: 'primary-action',
122+
label,
123+
rect: { x: 120, y, width: 80, height: 40 },
124+
},
125+
],
126+
createdAt: Date.now(),
127+
backend: 'xctest',
128+
};
129+
}

src/daemon/handlers/__tests__/find.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ async function runFindClickScenario(options: {
3636
}): Promise<{
3737
response: NonNullable<Awaited<ReturnType<typeof handleFindCommands>>>;
3838
invokeCalls: DaemonRequest[];
39+
session: SessionState;
3940
}> {
4041
const sessionStore = makeSessionStore();
4142
const sessionName = 'default';
42-
sessionStore.set(sessionName, options.session ?? makeSession(sessionName));
43+
const session = options.session ?? makeSession(sessionName);
44+
sessionStore.set(sessionName, session);
4345

4446
if (options.nodes !== undefined) {
4547
mockDispatch.mockImplementation(async (_device, command) => {
@@ -70,7 +72,7 @@ async function runFindClickScenario(options: {
7072
});
7173

7274
expect(response).toBeTruthy();
73-
return { response: response!, invokeCalls };
75+
return { response: response!, invokeCalls, session };
7476
}
7577

7678
test('handleFindCommands click returns deterministic metadata across locator variants', async () => {
@@ -213,6 +215,36 @@ test('handleFindCommands click prefers semantic controls over matching container
213215
expect(invokeCalls[0]!.positionals?.[0]).toBe('@e5');
214216
});
215217

218+
test('handleFindCommands forwards internal interaction outcome flags only to delegated click', async () => {
219+
const { response, invokeCalls, session } = await runFindClickScenario({
220+
positionals: ['Continue', 'click'],
221+
flags: {
222+
findFirst: true,
223+
interactionOutcome: { retryOnNoChange: true },
224+
},
225+
nodes: [
226+
{
227+
index: 0,
228+
ref: 'e1',
229+
type: 'Application',
230+
rect: { x: 0, y: 0, width: 440, height: 956 },
231+
},
232+
{
233+
index: 1,
234+
ref: 'e2',
235+
type: 'Button',
236+
label: 'Continue',
237+
rect: { x: 40, y: 870, width: 360, height: 44 },
238+
parentIndex: 0,
239+
},
240+
],
241+
});
242+
243+
expect(response.ok).toBe(true);
244+
expect(invokeCalls[0]!.flags?.interactionOutcome).toEqual({ retryOnNoChange: true });
245+
expect(session.actions.at(-1)?.flags).toEqual({});
246+
});
247+
216248
test('handleFindCommands wait bypasses snapshot cache while Android freshness recovery is active', async () => {
217249
const sessionName = 'android-find-wait';
218250
const session: SessionState = {

src/daemon/handlers/__tests__/interaction.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,6 +1163,49 @@ test('press @ref preserves native timing in recorded result and touch visualizat
11631163
expect(stored?.recording?.gestureEvents[0]?.tMs).toBe(570);
11641164
});
11651165

1166+
test('press @ref stores resolved coordinate retry payload for lazy outcome retry', async () => {
1167+
const sessionStore = makeSessionStore();
1168+
const sessionName = 'retry-ref';
1169+
const session = makeSession(sessionName);
1170+
session.snapshot = {
1171+
nodes: attachRefs([
1172+
{
1173+
index: 0,
1174+
type: 'XCUIElementTypeButton',
1175+
label: 'Continue',
1176+
identifier: 'auth_continue',
1177+
rect: { x: 10, y: 20, width: 100, height: 40 },
1178+
enabled: true,
1179+
hittable: true,
1180+
},
1181+
]),
1182+
createdAt: Date.now(),
1183+
backend: 'xctest',
1184+
};
1185+
sessionStore.set(sessionName, session);
1186+
mockDispatch.mockResolvedValue({});
1187+
1188+
const response = await handleInteractionCommands({
1189+
req: {
1190+
token: 't',
1191+
session: sessionName,
1192+
command: 'press',
1193+
positionals: ['@e1'],
1194+
flags: { interactionOutcome: { retryOnNoChange: true } },
1195+
},
1196+
sessionName,
1197+
sessionStore,
1198+
contextFromFlags,
1199+
});
1200+
1201+
expect(response?.ok).toBe(true);
1202+
const stored = sessionStore.get(sessionName);
1203+
expect(stored?.pendingInteractionOutcome?.command).toBe('press');
1204+
expect(stored?.pendingInteractionOutcome?.positionals).toEqual(['60', '40']);
1205+
expect(stored?.actions[0]?.positionals).toEqual(['@e1']);
1206+
expect(stored?.actions[0]?.flags).toEqual({});
1207+
});
1208+
11661209
test('longpress @ref resolves the target and dispatches coordinate longpress', async () => {
11671210
const sessionStore = makeSessionStore();
11681211
const sessionName = 'longpress-ref';

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1886,6 +1886,12 @@ test('runReplayScriptFile runs Maestro runFlow.when.visible commands when presen
18861886
['find', ['Continue', 'click']],
18871887
],
18881888
);
1889+
assert.deepEqual(calls.find((call) => call.command === 'click')?.flags?.interactionOutcome, {
1890+
retryOnNoChange: true,
1891+
});
1892+
assert.deepEqual(calls.find((call) => call.command === 'find')?.flags?.interactionOutcome, {
1893+
retryOnNoChange: true,
1894+
});
18891895
});
18901896

18911897
test('runReplayScriptFile runs nested Maestro runtime commands inside runFlow.when', async () => {

0 commit comments

Comments
 (0)