Skip to content

Commit 2f5ee56

Browse files
committed
fix: align maestro tap runtime semantics
1 parent 0bd3c01 commit 2f5ee56

15 files changed

Lines changed: 135 additions & 98 deletions

docs/adr/0015-direct-maestro-engine.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@ matches. It does not adopt the public agent-device command surface's unique-matc
8787
suppressed by Maestro `optional`; atomic iOS dispatch handles that result by performing fresh Maestro
8888
resolution, as described above. Cancellation and infrastructure failures are likewise non-optional.
8989

90-
`tapOn` preserves Maestro's two-attempt `retryIfNoChange` default for coordinate injection, including
91-
the live-bound iOS selector path and non-hittable fallback. After one compatibility polling interval it
92-
compares the hierarchy with the target-resolution hierarchy and retries once when nothing changed. The
93-
observed result primes the next command, so the outcome check does not add another hierarchy read there.
90+
Every `tapOn` waits for hierarchy stability before the next command, matching Maestro's tap boundary.
91+
`retryTapIfNoChange` defaults to false; when explicitly enabled, the runtime compares the stable hierarchy
92+
with the target-resolution hierarchy and retries once when nothing changed. The stable result primes the
93+
next command, so the tap boundary does not add another hierarchy read there.
9494

9595
Successful gestures and scrolls require stabilization before the next command executes. The runtime
9696
port records that requirement without capturing a hierarchy in the gesture command itself. At the next

src/compat/maestro/__tests__/daemon-runtime-port-observation-input.test.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,5 @@ test('commits Maestro input text before dispatching an immediate tap', async ()
151151
'snapshot',
152152
'snapshot',
153153
'click',
154-
'snapshot',
155-
'click',
156-
'snapshot',
157154
]);
158155
});

src/compat/maestro/__tests__/daemon-runtime-port-target-geometry.test.ts

Lines changed: 6 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -131,19 +131,10 @@ test('uses resolved iOS geometry when canonical presentation changes target boun
131131
invalidateObservation() {},
132132
});
133133

134-
expect(requests.map(({ command }) => command)).toEqual([
135-
'snapshot',
136-
'click',
137-
'snapshot',
138-
'click',
139-
'snapshot',
140-
]);
134+
expect(requests.map(({ command }) => command)).toEqual(['snapshot', 'click']);
141135
expect(
142136
requests.filter(({ command }) => command === 'click').map(({ positionals }) => positionals),
143-
).toEqual([
144-
['56', '121'],
145-
['56', '121'],
146-
]);
137+
).toEqual([['56', '121']]);
147138
});
148139

149140
test('atomically dispatches canonical iOS geometry with the same tap point', async () => {
@@ -260,19 +251,10 @@ test('does not collapse distinct nested iOS controls with identical frames', asy
260251
invalidateObservation() {},
261252
});
262253

263-
expect(requests.map(({ command }) => command)).toEqual([
264-
'snapshot',
265-
'click',
266-
'snapshot',
267-
'click',
268-
'snapshot',
269-
]);
254+
expect(requests.map(({ command }) => command)).toEqual(['snapshot', 'click']);
270255
expect(
271256
requests.filter(({ command }) => command === 'click').map(({ positionals }) => positionals),
272-
).toEqual([
273-
['106', '322'],
274-
['106', '322'],
275-
]);
257+
).toEqual([['106', '322']]);
276258
});
277259

278260
test('does not atomically dispatch distinct iOS matches with identical frames', async () => {
@@ -312,17 +294,8 @@ test('does not atomically dispatch distinct iOS matches with identical frames',
312294
invalidateObservation() {},
313295
});
314296

315-
expect(requests.map(({ command }) => command)).toEqual([
316-
'snapshot',
317-
'click',
318-
'snapshot',
319-
'click',
320-
'snapshot',
321-
]);
297+
expect(requests.map(({ command }) => command)).toEqual(['snapshot', 'click']);
322298
expect(
323299
requests.filter(({ command }) => command === 'click').map(({ positionals }) => positionals),
324-
).toEqual([
325-
['106', '322'],
326-
['106', '322'],
327-
]);
300+
).toEqual([['106', '322']]);
328301
});

src/compat/maestro/__tests__/daemon-runtime-port-target-refresh.test.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ test('falls back to fresh Maestro geometry when atomic iOS dispatch resolves off
7979
'click',
8080
'snapshot',
8181
'click',
82-
'snapshot',
8382
]);
8483
expect(requests.filter(({ command }) => command === 'click').at(-1)?.positionals).toEqual([
8584
'220',
@@ -149,20 +148,10 @@ test.each(['ios', 'android'] as const)(
149148
invalidateObservation() {},
150149
});
151150

152-
expect(requests.map((request) => request.command)).toEqual([
153-
'snapshot',
154-
'snapshot',
155-
'click',
156-
'snapshot',
157-
'click',
158-
'snapshot',
159-
]);
151+
expect(requests.map((request) => request.command)).toEqual(['snapshot', 'snapshot', 'click']);
160152
expect(
161153
requests.filter(({ command }) => command === 'click').map(({ positionals }) => positionals),
162-
).toEqual([
163-
['220', '122'],
164-
['220', '122'],
165-
]);
154+
).toEqual([['220', '122']]);
166155
},
167156
);
168157

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,10 @@ test('waits for a delayed input target using fresh snapshots', async () => {
5959
'snapshot',
6060
'snapshot',
6161
'click',
62-
'snapshot',
63-
'click',
64-
'snapshot',
6562
]);
6663
expect(
6764
requests.filter(({ command }) => command === 'click').map(({ positionals }) => positionals),
68-
).toEqual([
69-
['80', '62'],
70-
['80', '62'],
71-
]);
65+
).toEqual([['80', '62']]);
7266
});
7367

7468
test('atomically dispatches a unique exact iOS target from same-generation evidence', async () => {
@@ -149,7 +143,7 @@ test('atomically dispatches a unique exact iOS target from same-generation evide
149143
}),
150144
).resolves.toEqual({});
151145

152-
expect(requests.map((request) => request.command)).toEqual(['snapshot', 'click', 'snapshot']);
146+
expect(requests.map((request) => request.command)).toEqual(['snapshot', 'click']);
153147
expect(observation.identity).toBeDefined();
154148
const click = requests.find((request) => request.command === 'click');
155149
expect(click?.positionals).toEqual(['id="continue"']);
@@ -211,6 +205,7 @@ test('retries an iOS non-hittable coordinate fallback when the hierarchy does no
211205
kind: 'tapOn',
212206
source: { line: 2 },
213207
target: { space: 'target', selector: { text: 'Pop to top' } },
208+
retryTapIfNoChange: true,
214209
},
215210
generation: 0,
216211
env: {},
@@ -221,8 +216,10 @@ test('retries an iOS non-hittable coordinate fallback when the hierarchy does no
221216
'snapshot',
222217
'click',
223218
'snapshot',
219+
'snapshot',
224220
'click',
225221
'snapshot',
222+
'snapshot',
226223
]);
227224
expect(clicks).toBe(2);
228225
});

src/compat/maestro/__tests__/program-ir-parser.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe('parseMaestroProgram', () => {
9393
point: 20%, 30%
9494
- tapOn:
9595
id: submit
96+
retryTapIfNoChange: true
9697
- doubleTapOn:
9798
point: 100,200
9899
- longPressOn:
@@ -128,6 +129,7 @@ describe('parseMaestroProgram', () => {
128129

129130
assert.deepEqual(percentTapCommand.target, { space: 'percent', x: 20, y: 30 });
130131
assert.deepEqual(targetTapCommand.target, { space: 'target', selector: { id: 'submit' } });
132+
assert.equal(targetTapCommand.retryTapIfNoChange, true);
131133
assert.deepEqual(absoluteDoubleTapCommand.target, { space: 'absolute', x: 100, y: 200 });
132134
assert.deepEqual(targetLongPressCommand.target, {
133135
space: 'target',

src/compat/maestro/compatibility-policy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const MAESTRO_COMPATIBILITY_PRESETS = {
1212
retryMaxRetries: 3,
1313
},
1414
command: {
15-
tapMaxAttempts: 2,
15+
retryTapMaxAttempts: 2,
1616
targetLookupTimeoutMs: 17_000,
1717
optionalTargetLookupTimeoutMs: 7_000,
1818
scrollUntilVisibleTimeoutMs: 20_000,

src/compat/maestro/daemon-runtime-port.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,12 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper
132132
},
133133

134134
tapOn: async (input, context) =>
135-
await tapTargetWithOutcome(options, snapshots, input.target, context, {
136-
count: input.repeat,
137-
intervalMs: input.delay,
135+
await tapTargetAndSettle(options, snapshots, input.target, context, {
136+
click: {
137+
count: input.repeat,
138+
intervalMs: input.delay,
139+
},
140+
retryIfNoChange: input.retryTapIfNoChange === true,
138141
}),
139142
doubleTapOn: async (input) => {
140143
snapshots.invalidate();
@@ -387,31 +390,42 @@ function createSnapshotSource(
387390
};
388391
}
389392

390-
async function tapTargetWithOutcome(
393+
async function tapTargetAndSettle(
391394
options: CreateDaemonMaestroRuntimeOperationsOptions,
392395
snapshots: MaestroSnapshotSource,
393396
target: Parameters<MaestroRuntimeOperations['tapOn']>[0]['target'],
394397
context: MaestroRuntimeOperationContext,
395-
flags: MaestroClickOptions,
398+
policy: { click: MaestroClickOptions; retryIfNoChange: boolean },
396399
): Promise<void> {
400+
const dispatch = async () =>
401+
await dispatchTapTarget(options, snapshots, target, context, policy.click);
402+
if (!policy.retryIfNoChange) {
403+
await dispatch();
404+
snapshots.requireStability();
405+
return;
406+
}
407+
397408
const baselineSignature =
398409
target.resolution?.surfaceSignature ??
399410
maestroSnapshotSignature(await snapshots.capture(context));
400-
const dispatch = async () => await dispatchTapTarget(options, snapshots, target, context, flags);
411+
const settle = async () =>
412+
await waitForTypedSnapshotStability({
413+
timeoutMs: MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS,
414+
context,
415+
snapshot: snapshots.capture,
416+
dependencies: options.dependencies,
417+
});
401418

402419
await dispatch();
403-
404-
await options.dependencies.sleep(MAESTRO_OBSERVATION_POLL_MS, context.signal);
405-
let observed = await snapshots.capture(context);
420+
let observed = await settle();
406421
let attempts = 1;
407422
while (
408423
maestroSnapshotSignature(observed) === baselineSignature &&
409-
attempts < MAESTRO_COMPATIBILITY_PRESETS.command.tapMaxAttempts
424+
attempts < MAESTRO_COMPATIBILITY_PRESETS.command.retryTapMaxAttempts
410425
) {
411426
await dispatch();
412427
attempts += 1;
413-
await options.dependencies.sleep(MAESTRO_OBSERVATION_POLL_MS, context.signal);
414-
observed = await snapshots.capture(context);
428+
observed = await settle();
415429
}
416430
snapshots.prime(context.generation + 1, observed);
417431
}

src/compat/maestro/program-ir-gesture-parser.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,12 @@ export function parseMaestroTapOnCommand(
9090

9191
const entries = readMapEntries(value, 'tapOn', context);
9292
if (hasEntry(entries, 'point')) {
93-
assertOnlyKeys(entries, 'tapOn', ['point', 'repeat', 'delay', 'optional', 'label'], context);
93+
assertOnlyKeys(
94+
entries,
95+
'tapOn',
96+
['point', 'retryTapIfNoChange', 'repeat', 'delay', 'optional', 'label'],
97+
context,
98+
);
9499
return {
95100
kind: 'tapOn',
96101
source,
@@ -102,7 +107,7 @@ export function parseMaestroTapOnCommand(
102107
assertOnlyKeys(
103108
entries,
104109
'tapOn',
105-
[...TAP_SELECTOR_KEYS, 'repeat', 'delay', 'optional', 'index', 'childOf'],
110+
[...TAP_SELECTOR_KEYS, 'retryTapIfNoChange', 'repeat', 'delay', 'optional', 'index', 'childOf'],
106111
context,
107112
);
108113
const selectorEntries = entries.filter((entry) => isSelectorKey(entry.key, TAP_SELECTOR_KEYS));
@@ -344,7 +349,10 @@ function tapOptions(
344349
entries: readonly MaestroMapEntry[],
345350
context: MaestroProgramParseContext,
346351
includeLabel: boolean,
347-
): Pick<MaestroTapOnCommand, 'repeat' | 'delay' | 'optional' | 'label'> {
352+
): Pick<MaestroTapOnCommand, 'retryTapIfNoChange' | 'repeat' | 'delay' | 'optional' | 'label'> {
353+
const retryTapIfNoChange = readOptionalEntry(entries, 'retryTapIfNoChange', (entry) =>
354+
readOptionalBoolean(entry, 'tapOn.retryTapIfNoChange', context),
355+
);
348356
const repeat = readOptionalEntry(entries, 'repeat', (entry) =>
349357
readRequiredPositiveInteger(entry, 'tapOn.repeat', context),
350358
);
@@ -358,6 +366,7 @@ function tapOptions(
358366
)
359367
: undefined;
360368
return {
369+
...(retryTapIfNoChange === undefined ? {} : { retryTapIfNoChange }),
361370
...(repeat === undefined ? {} : { repeat }),
362371
...(delay === undefined ? {} : { delay }),
363372
...(optional === undefined ? {} : { optional }),

src/compat/maestro/program-ir.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export type MaestroTapOnCommand = MaestroOptionalCommand & {
5454
kind: 'tapOn';
5555
source: MaestroSourceLocation;
5656
target: MaestroGestureTarget;
57+
retryTapIfNoChange?: boolean;
5758
repeat?: number;
5859
delay?: number;
5960
optional?: boolean;

0 commit comments

Comments
 (0)