Skip to content

Commit 97f6451

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

18 files changed

Lines changed: 249 additions & 30 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/__tests__/session.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2135,6 +2135,7 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector',
21352135
expect.objectContaining({ platform: 'ios', id: 'sim-1' }),
21362136
{ command: 'uptime' },
21372137
expect.objectContaining({
2138+
cleanStaleBundles: true,
21382139
logPath: expect.stringMatching(/daemon\.log$/),
21392140
requestId: 'prepare-request',
21402141
}),

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,99 @@ test('captureSnapshot lazily retries pending no-change touch before returning fr
802802
expect(session.pendingInteractionOutcome).toBeUndefined();
803803
});
804804

805+
test('captureSnapshot retries pending tap outcome before post-gesture stabilization', async () => {
806+
const sessionName = 'android-maestro-tap-outcome-before-stabilization';
807+
const session = makeSession(sessionName, androidDevice);
808+
const baselineNodes = [
809+
{
810+
ref: 'e1',
811+
index: 0,
812+
depth: 0,
813+
type: 'android.widget.Button',
814+
label: 'Navigate to Third',
815+
hittable: true,
816+
rect: { x: 302, y: 1301, width: 476, height: 110 },
817+
},
818+
];
819+
session.snapshot = {
820+
nodes: baselineNodes,
821+
createdAt: Date.now(),
822+
backend: 'android',
823+
};
824+
session.pendingInteractionOutcome = {
825+
action: 'click',
826+
command: 'press',
827+
positionals: ['540', '1356'],
828+
flags: { platform: 'android' },
829+
markedAt: Date.now(),
830+
attemptsRemaining: 2,
831+
preSignature: [
832+
{
833+
key: '|Navigate to Third||android.widget.Button||enabled|unselected|hittable|#0',
834+
x: 302,
835+
y: 1301,
836+
width: 476,
837+
height: 110,
838+
},
839+
],
840+
};
841+
session.postGestureStabilization = {
842+
action: 'click',
843+
markedAt: Date.now(),
844+
};
845+
846+
mockDispatch
847+
.mockResolvedValueOnce({
848+
nodes: baselineNodes,
849+
backend: 'android',
850+
})
851+
.mockResolvedValueOnce({ clicked: true })
852+
.mockResolvedValueOnce({
853+
nodes: [
854+
{
855+
index: 0,
856+
depth: 0,
857+
type: 'android.widget.TextView',
858+
label: 'Tab Third (3)',
859+
rect: { x: 390, y: 884, width: 300, height: 55 },
860+
},
861+
],
862+
backend: 'android',
863+
})
864+
.mockResolvedValueOnce({
865+
nodes: [
866+
{
867+
index: 0,
868+
depth: 0,
869+
type: 'android.widget.TextView',
870+
label: 'Tab Third (3)',
871+
rect: { x: 390, y: 884, width: 300, height: 55 },
872+
},
873+
],
874+
backend: 'android',
875+
});
876+
877+
const result = await captureSnapshot({
878+
device: androidDevice,
879+
session,
880+
flags: { snapshotInteractiveOnly: true },
881+
logPath: '/tmp/daemon.log',
882+
});
883+
884+
expect(result.snapshot.nodes).toEqual(
885+
expect.arrayContaining([expect.objectContaining({ label: 'Tab Third (3)' })]),
886+
);
887+
expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual([
888+
'snapshot',
889+
'press',
890+
'snapshot',
891+
'snapshot',
892+
]);
893+
expect(mockDispatch.mock.calls[1]?.[2]).toEqual(['540', '1356']);
894+
expect(session.pendingInteractionOutcome).toBeUndefined();
895+
expect(session.postGestureStabilization).toBeUndefined();
896+
});
897+
805898
test('captureSnapshot composes pending outcome retry with Android freshness capture', async () => {
806899
const sessionName = 'android-lazy-outcome-freshness';
807900
const session = makeSession(sessionName, androidDevice);

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/handlers/session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ function buildPrepareIosRunnerOptions(
110110
verbose: req.flags?.verbose,
111111
logPath,
112112
traceLogPath: session?.trace?.outPath,
113+
cleanStaleBundles: true,
113114
requestId: req.meta?.requestId,
114115
};
115116
}

0 commit comments

Comments
 (0)