Skip to content

Commit 87cfe81

Browse files
committed
fix: retry no-op maestro taps
1 parent 096785b commit 87cfe81

14 files changed

Lines changed: 689 additions & 76 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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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?.command, 'click');
62+
assert.equal(retrySession.pendingInteractionOutcome?.attemptsRemaining, 2);
63+
assert.equal(retrySession.pendingInteractionOutcome?.flags?.interactionOutcome, undefined);
64+
65+
const refSession = makeSession();
66+
markPendingInteractionOutcome({
67+
session: refSession,
68+
command: 'click',
69+
positionals: ['@e1'],
70+
flags: { interactionOutcome: { retryOnNoChange: true } },
71+
preSnapshot: makeSnapshot('Inbox'),
72+
});
73+
assert.equal(refSession.pendingInteractionOutcome, undefined);
74+
75+
const longPressSession = makeSession();
76+
markPendingInteractionOutcome({
77+
session: longPressSession,
78+
command: 'longpress',
79+
positionals: ['20', '40', '800'],
80+
flags: { interactionOutcome: { retryOnNoChange: true } },
81+
preSnapshot: makeSnapshot('Inbox'),
82+
});
83+
assert.equal(longPressSession.pendingInteractionOutcome, undefined);
84+
});
85+
86+
test('stripInternalInteractionOutcomeFlags removes internal retry controls', () => {
87+
assert.deepEqual(
88+
stripInternalInteractionOutcomeFlags({
89+
platform: 'ios',
90+
interactionOutcome: { retryOnNoChange: true },
91+
}),
92+
{ platform: 'ios' },
93+
);
94+
});
95+
96+
function makeSession(): SessionState {
97+
return {
98+
name: 'ios',
99+
device: IOS_SIMULATOR,
100+
createdAt: Date.now(),
101+
actions: [],
102+
};
103+
}
104+
105+
function makeSnapshot(label: string, y = 100): SnapshotState {
106+
return {
107+
nodes: [
108+
{
109+
ref: 'e1',
110+
index: 0,
111+
type: 'Application',
112+
label: 'App',
113+
rect: { x: 0, y: 0, width: 390, height: 844 },
114+
},
115+
{
116+
ref: 'e2',
117+
index: 1,
118+
parentIndex: 0,
119+
type: 'Button',
120+
identifier: 'primary-action',
121+
label,
122+
rect: { x: 120, y, width: 80, height: 40 },
123+
},
124+
],
125+
createdAt: Date.now(),
126+
backend: 'xctest',
127+
};
128+
}

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 () => {

src/daemon/handlers/__tests__/snapshot-handler.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,87 @@ test('Android ref refresh mode does not retry narrow snapshots as sharp drops',
720720
expect(session.androidSnapshotFreshness).toBeUndefined();
721721
});
722722

723+
test('captureSnapshot lazily retries pending no-change touch before returning fresh state', async () => {
724+
const sessionName = 'ios-lazy-outcome-retry';
725+
const session = makeSession(sessionName, iosSimulatorDevice);
726+
const baselineNodes = [
727+
{
728+
ref: 'e1',
729+
index: 0,
730+
depth: 0,
731+
type: 'Button',
732+
label: 'Open feed',
733+
identifier: 'open-feed',
734+
hittable: true,
735+
rect: { x: 20, y: 120, width: 160, height: 48 },
736+
},
737+
];
738+
session.snapshot = {
739+
nodes: baselineNodes,
740+
createdAt: Date.now(),
741+
backend: 'xctest',
742+
};
743+
session.pendingInteractionOutcome = {
744+
action: 'click',
745+
command: 'click',
746+
positionals: ['100', '144'],
747+
flags: { platform: 'ios' },
748+
markedAt: Date.now(),
749+
attemptsRemaining: 2,
750+
preSignature: [
751+
{
752+
key: 'open-feed|Open feed||Button||enabled|unselected|hittable|#0',
753+
x: 20,
754+
y: 120,
755+
width: 160,
756+
height: 48,
757+
},
758+
],
759+
};
760+
761+
mockDispatch
762+
.mockResolvedValueOnce({
763+
nodes: baselineNodes,
764+
backend: 'xctest',
765+
})
766+
.mockResolvedValueOnce({ clicked: true })
767+
.mockResolvedValueOnce({
768+
nodes: [
769+
{
770+
index: 0,
771+
depth: 0,
772+
type: 'Button',
773+
label: 'Back',
774+
identifier: 'back',
775+
hittable: true,
776+
rect: { x: 20, y: 60, width: 90, height: 44 },
777+
},
778+
{
779+
index: 1,
780+
depth: 0,
781+
type: 'StaticText',
782+
label: 'Feed',
783+
rect: { x: 20, y: 140, width: 160, height: 48 },
784+
},
785+
],
786+
backend: 'xctest',
787+
});
788+
789+
const result = await captureSnapshot({
790+
device: iosSimulatorDevice,
791+
session,
792+
flags: { snapshotInteractiveOnly: true },
793+
logPath: '/tmp/daemon.log',
794+
});
795+
796+
expect(result.snapshot.nodes).toEqual(
797+
expect.arrayContaining([expect.objectContaining({ label: 'Feed' })]),
798+
);
799+
expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'click', 'snapshot']);
800+
expect(mockDispatch.mock.calls[1]?.[2]).toEqual(['100', '144']);
801+
expect(session.pendingInteractionOutcome).toBeUndefined();
802+
});
803+
723804
test('wait text on Android uses freshness-aware capture instead of one-shot snapshot polling', async () => {
724805
const sessionStore = makeSessionStore();
725806
const sessionName = 'android-wait-freshness';

0 commit comments

Comments
 (0)