Skip to content

Commit 4392fb0

Browse files
committed
fix: stabilize maestro post-gesture snapshots
1 parent 4eeec60 commit 4392fb0

10 files changed

Lines changed: 77 additions & 10 deletions

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ test('invokeMaestroTapOn resolves mutating taps from the current snapshot', asyn
1111
const selector =
1212
'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"';
1313

14-
const { response, clicks, snapshots } = await runTapOn(selector, () =>
14+
const { response, clicks, clickFlags, snapshots } = await runTapOn(selector, () =>
1515
currentBreadcrumbSnapshot(),
1616
);
1717

1818
expect(response.ok).toBe(true);
1919
expect(snapshots).toBe(1);
2020
expect(clicks).toEqual([['86', '89']]);
21+
expect(clickFlags[0]?.postGestureStabilization).toBe(true);
2122
});
2223

2324
test('invokeMaestroTapOn uses optimized interactive snapshots by default', async () => {
@@ -125,6 +126,7 @@ test('invokeMaestroTapOn clicks explicit React Native overlay controls directly'
125126

126127
test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gesture presets', async () => {
127128
const gestures: string[][] = [];
129+
const gestureFlags: Array<DaemonRequest['flags']> = [];
128130
const response = await invokeMaestroSwipeScreen({
129131
baseReq: {
130132
token: 'test',
@@ -135,6 +137,7 @@ test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gest
135137
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
136138
if (req.command === 'gesture') {
137139
gestures.push(req.positionals ?? []);
140+
gestureFlags.push(req.flags);
138141
return { ok: true, data: {} };
139142
}
140143
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
@@ -143,6 +146,7 @@ test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gest
143146

144147
expect(response.ok).toBe(true);
145148
expect(gestures).toEqual([['swipe', 'left', '300']]);
149+
expect(gestureFlags[0]?.postGestureStabilization).toBeUndefined();
146150
});
147151

148152
test('invokeMaestroSwipeScreen mirrors horizontal directional swipe presets', async () => {
@@ -169,6 +173,7 @@ test('invokeMaestroSwipeScreen mirrors horizontal directional swipe presets', as
169173

170174
test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async () => {
171175
const swipes: string[][] = [];
176+
const swipeFlags: Array<DaemonRequest['flags']> = [];
172177
const response = await invokeMaestroSwipeScreen({
173178
baseReq: {
174179
token: 'test',
@@ -182,6 +187,7 @@ test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async (
182187
}
183188
if (req.command === 'swipe') {
184189
swipes.push(req.positionals ?? []);
190+
swipeFlags.push(req.flags);
185191
return { ok: true, data: {} };
186192
}
187193
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
@@ -190,6 +196,7 @@ test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async (
190196

191197
expect(response.ok).toBe(true);
192198
expect(swipes).toEqual([['200', '600', '200', '280', '300']]);
199+
expect(swipeFlags[0]?.postGestureStabilization).toBe(true);
193200
});
194201

195202
test('invokeMaestroSwipeScreen keeps Android horizontal percentage swipes on the content lane', async () => {
@@ -219,6 +226,7 @@ test('invokeMaestroSwipeScreen keeps Android horizontal percentage swipes on the
219226

220227
test('invokeMaestroTapPointPercent shares percentage point geometry without clamping', async () => {
221228
const clicks: string[][] = [];
229+
const clickFlags: Array<DaemonRequest['flags']> = [];
222230
const response = await invokeMaestroTapPointPercent({
223231
baseReq: {
224232
token: 'test',
@@ -232,6 +240,7 @@ test('invokeMaestroTapPointPercent shares percentage point geometry without clam
232240
}
233241
if (req.command === 'click') {
234242
clicks.push(req.positionals ?? []);
243+
clickFlags.push(req.flags);
235244
return { ok: true, data: {} };
236245
}
237246
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
@@ -240,6 +249,7 @@ test('invokeMaestroTapPointPercent shares percentage point geometry without clam
240249

241250
expect(response.ok).toBe(true);
242251
expect(clicks).toEqual([['500', '-80']]);
252+
expect(clickFlags[0]?.postGestureStabilization).toBe(true);
243253
});
244254

245255
function currentBreadcrumbSnapshot(): SnapshotState {
@@ -277,10 +287,12 @@ async function runTapOn(
277287
response: DaemonResponse;
278288
commands: string[];
279289
clicks: string[][];
290+
clickFlags: Array<DaemonRequest['flags']>;
280291
snapshots: number;
281292
}> {
282293
const commands: string[] = [];
283294
const clicks: string[][] = [];
295+
const clickFlags: Array<DaemonRequest['flags']> = [];
284296
let snapshots = 0;
285297
const response = await invokeMaestroTapOn({
286298
baseReq: {
@@ -297,12 +309,13 @@ async function runTapOn(
297309
}
298310
if (req.command === 'click') {
299311
clicks.push(req.positionals ?? []);
312+
clickFlags.push(req.flags);
300313
return { ok: true, data: {} };
301314
}
302315
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
303316
},
304317
});
305-
return { response, commands, clicks, snapshots };
318+
return { response, commands, clicks, clickFlags, snapshots };
306319
}
307320

308321
function fullScreenSnapshot(width: number, height: number): SnapshotState {

src/compat/maestro/runtime-interactions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ export async function invokeMaestroTapPointPercent(params: {
147147
...params.baseReq,
148148
command: 'click',
149149
positionals: [String(point.x), String(point.y)],
150+
flags: {
151+
...params.baseReq.flags,
152+
postGestureStabilization: true,
153+
},
150154
});
151155
}
152156

@@ -235,6 +239,10 @@ async function invokeSwipeGesture(
235239
String(swipe.end.y),
236240
...(durationMs ? [durationMs] : []),
237241
],
242+
flags: {
243+
...params.baseReq.flags,
244+
postGestureStabilization: true,
245+
},
238246
});
239247
}
240248

@@ -479,6 +487,7 @@ async function clickMaestroSnapshotTarget(
479487
flags: {
480488
...params.baseReq.flags,
481489
interactionOutcome: { retryOnNoChange: true },
490+
postGestureStabilization: true,
482491
},
483492
});
484493
if (response.ok) clearMaestroVisibleContext(params.scope);
@@ -502,6 +511,7 @@ async function invokeMaestroFuzzyTapOn(
502511
...params.baseReq.flags,
503512
findFirst: true,
504513
interactionOutcome: { retryOnNoChange: true },
514+
postGestureStabilization: true,
505515
},
506516
});
507517
emitDiagnostic({

src/core/dispatch-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type CommandFlags = Omit<CliFlags, DaemonExcludedCliFlag> & {
2020
};
2121
launchArgs?: string[];
2222
maestro?: MaestroRuntimeFlags;
23+
postGestureStabilization?: boolean;
2324
replayBackend?: string;
2425
};
2526

src/daemon/__tests__/interaction-outcome-policy.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,12 @@ test('markPendingInteractionOutcome stores retry state only for explicit retry f
8484
assert.equal(longPressSession.pendingInteractionOutcome, undefined);
8585
});
8686

87-
test('stripInternalInteractionOutcomeFlags removes internal retry controls', () => {
87+
test('stripInternalInteractionOutcomeFlags removes internal interaction controls', () => {
8888
assert.deepEqual(
8989
stripInternalInteractionOutcomeFlags({
9090
platform: 'ios',
9191
interactionOutcome: { retryOnNoChange: true },
92+
postGestureStabilization: true,
9293
}),
9394
{ platform: 'ios' },
9495
);

src/daemon/__tests__/post-gesture-stabilization.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ test('markPostGestureStabilization marks Android swipe sessions', () => {
2828
assert.equal(session.postGestureStabilization?.action, 'swipe');
2929
});
3030

31+
test('markPostGestureStabilization marks gesture swipe sessions', () => {
32+
const session = makeSession('android');
33+
34+
markPostGestureStabilization(session, 'gesture', ['swipe', 'left']);
35+
36+
assert.equal(session.postGestureStabilization?.action, 'gesture');
37+
});
38+
39+
test('markPostGestureStabilization ignores non-swipe gesture sessions', () => {
40+
const session = makeSession('android');
41+
42+
markPostGestureStabilization(session, 'gesture', ['pinch', 'in']);
43+
44+
assert.equal(session.postGestureStabilization, undefined);
45+
});
46+
3147
test('capturePostGestureStabilizedSnapshot retries until rects stop moving', async () => {
3248
vi.useFakeTimers();
3349
const session = makeSession();

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1889,9 +1889,17 @@ test('runReplayScriptFile runs Maestro runFlow.when.visible commands when presen
18891889
assert.deepEqual(calls.find((call) => call.command === 'click')?.flags?.interactionOutcome, {
18901890
retryOnNoChange: true,
18911891
});
1892+
assert.equal(
1893+
calls.find((call) => call.command === 'click')?.flags?.postGestureStabilization,
1894+
true,
1895+
);
18921896
assert.deepEqual(calls.find((call) => call.command === 'find')?.flags?.interactionOutcome, {
18931897
retryOnNoChange: true,
18941898
});
1899+
assert.equal(
1900+
calls.find((call) => call.command === 'find')?.flags?.postGestureStabilization,
1901+
true,
1902+
);
18951903
});
18961904

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

src/daemon/handlers/interaction-common.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
markPendingInteractionOutcome,
1414
stripInternalInteractionOutcomeFlags,
1515
} from '../interaction-outcome-policy.ts';
16+
import { markPostGestureStabilization } from '../post-gesture-stabilization.ts';
1617

1718
export type ContextFromFlags = (
1819
flags: CommandFlags | undefined,
@@ -115,6 +116,7 @@ export function finalizeTouchInteraction(params: {
115116
if (isNavigationSensitiveAction(command)) {
116117
markAndroidSnapshotFreshness(session, command, androidFreshnessBaseline ?? session.snapshot);
117118
}
119+
markPostGestureStabilization(session, command, retryPositionals ?? positionals, flags);
118120
recordTouchVisualizationEvent(
119121
session,
120122
command,

src/daemon/interaction-outcome-policy.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ export function emitInteractionSettleTimeout(params: {
137137
export function stripInternalInteractionOutcomeFlags(
138138
flags: CommandFlags | undefined,
139139
): CommandFlags | undefined {
140-
if (!flags?.interactionOutcome) return flags;
141-
const { interactionOutcome: _interactionOutcome, ...publicFlags } = flags;
140+
if (!flags?.interactionOutcome && !flags?.postGestureStabilization) return flags;
141+
const {
142+
interactionOutcome: _interactionOutcome,
143+
postGestureStabilization: _postGestureStabilization,
144+
...publicFlags
145+
} = flags;
142146
return publicFlags;
143147
}
144148

src/daemon/post-gesture-stabilization.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { emitDiagnostic } from '../utils/diagnostics.ts';
2+
import type { CommandFlags } from '../core/dispatch.ts';
23
import type { SnapshotState } from '../utils/snapshot.ts';
34
import { sleep } from '../utils/timeouts.ts';
45
import {
@@ -10,9 +11,14 @@ import type { SessionState } from './types.ts';
1011
const STABILIZATION_DEADLINE_MS = 1_500;
1112
const STABILIZATION_INTERVAL_MS = 200;
1213

13-
export function markPostGestureStabilization(session: SessionState, action: string): void {
14+
export function markPostGestureStabilization(
15+
session: SessionState,
16+
action: string,
17+
positionals: string[] = [],
18+
flags?: CommandFlags,
19+
): void {
1420
if (!supportsPostGestureStabilization(session.device.platform)) return;
15-
if (!isPostGestureStabilizingAction(action)) return;
21+
if (!isPostGestureStabilizingAction(action, positionals, flags)) return;
1622
session.postGestureStabilization = {
1723
action,
1824
markedAt: Date.now(),
@@ -74,8 +80,14 @@ export async function capturePostGestureStabilizedSnapshot(params: {
7480
return previous;
7581
}
7682

77-
function isPostGestureStabilizingAction(action: string): boolean {
78-
return action === 'swipe' || action === 'scroll';
83+
function isPostGestureStabilizingAction(
84+
action: string,
85+
positionals: string[],
86+
flags: CommandFlags | undefined,
87+
): boolean {
88+
if (flags?.postGestureStabilization === true) return true;
89+
if (action === 'swipe' || action === 'scroll') return true;
90+
return action === 'gesture' && positionals[0] === 'swipe';
7991
}
8092

8193
function supportsPostGestureStabilization(platform: SessionState['device']['platform']): boolean {

src/daemon/request-generic-dispatch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export async function dispatchGenericCommand(params: {
120120
if (isNavigationSensitiveAction(platformCommand)) {
121121
markAndroidSnapshotFreshness(session, platformCommand);
122122
}
123-
markPostGestureStabilization(session, platformCommand);
123+
markPostGestureStabilization(session, platformCommand, resolvedPositionals, req.flags);
124124

125125
return { ok: true, data: data ?? {} };
126126
}

0 commit comments

Comments
 (0)