Skip to content

Commit 2546ca3

Browse files
authored
fix: dismiss RN overlays in Maestro compat (#650)
1 parent 9e65372 commit 2546ca3

10 files changed

Lines changed: 58 additions & 307 deletions

File tree

src/__tests__/runtime-snapshot.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ test('runtime snapshot does not suggest full-screen React Native warning parents
190190
assert.doesNotMatch(result.warnings?.[0] ?? '', /@e1/);
191191
});
192192

193-
test('runtime snapshot prefers TextView Minimize over Dismiss on Android React Native stack overlays', async () => {
193+
test('runtime snapshot recognizes Android React Native stack overlays with Dismiss and Minimize controls', async () => {
194194
const result = await createSnapshotOnlyDevice({
195195
nodes: [
196196
{ ref: 'e1', index: 0, depth: 0, type: 'android.widget.TextView', label: 'useOnyx.ts:80:43' },
@@ -204,7 +204,7 @@ test('runtime snapshot prefers TextView Minimize over Dismiss on Android React N
204204
assertReactNativeOverlayWarning(result.warnings);
205205
});
206206

207-
test('runtime snapshot does not suggest Dismiss for Android RedBox stacks without Minimize', async () => {
207+
test('runtime snapshot recognizes Android RedBox stacks without Minimize', async () => {
208208
const result = await createSnapshotOnlyDevice({
209209
nodes: [
210210
{ ref: 'e1', index: 0, depth: 0, type: 'android.widget.TextView', label: 'useOnyx.ts:80:43' },
@@ -215,7 +215,6 @@ test('runtime snapshot does not suggest Dismiss for Android RedBox stacks withou
215215
}).capture.snapshot({ session: 'default', interactiveOnly: true });
216216

217217
assertReactNativeOverlayWarning(result.warnings);
218-
assert.doesNotMatch(result.warnings?.[0] ?? '', /Dismiss before continuing|press @e2/);
219218
});
220219

221220
test('runtime snapshot warns when iOS hierarchy looks like a React Native overlay', async () => {

src/commands/react-native/overlay.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export type ReactNativeOverlayState = {
2121
};
2222

2323
export type ReactNativeOverlayDismissTarget = {
24-
action: 'close' | 'dismiss' | 'minimize' | 'close-collapsed-banner';
24+
action: 'close' | 'dismiss' | 'close-collapsed-banner';
2525
point: Point;
2626
rect?: Rect;
2727
ref?: string;
@@ -149,18 +149,6 @@ function collectReactNativeOverlayFacts(nodes: SnapshotNode[]): ReactNativeOverl
149149
function resolveSafeDismissAction(
150150
facts: ReactNativeOverlayFacts,
151151
): ReactNativeOverlayDismissTarget | null {
152-
if (facts.redBox) {
153-
const minimize = firstControlNodeWithRect(facts.minimizeNodes);
154-
if (minimize) return targetFromNode(minimize, 'minimize');
155-
const dismiss = firstControlNodeWithRect(facts.dismissNodes);
156-
return dismiss
157-
? {
158-
...targetFromNode(dismiss, actionFromDismissNode(dismiss)),
159-
warning: 'RedBox Minimize control was not exposed; used Dismiss fallback',
160-
}
161-
: null;
162-
}
163-
164152
const dismiss = firstControlNodeWithRect(facts.dismissNodes);
165153
if (dismiss) return targetFromNode(dismiss, actionFromDismissNode(dismiss));
166154

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

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ test('invokeMaestroAssertVisible retries transient snapshot failures until a lat
9393
}
9494
});
9595

96-
test('invokeMaestroAssertVisible dismisses React Native overlays before retrying native iOS wait', async () => {
96+
test('invokeMaestroAssertVisible does not dismiss React Native overlays during native iOS wait', async () => {
9797
const calls: Array<[string, string[] | undefined]> = [];
9898
let waits = 0;
9999
const response = await invokeMaestroAssertVisible({
@@ -118,18 +118,13 @@ test('invokeMaestroAssertVisible dismisses React Native overlays before retrying
118118
}
119119
return { ok: true, data: { matches: 1 } };
120120
}
121-
if (req.command === 'react-native') {
122-
return { ok: true, data: { dismissed: true } };
123-
}
124121
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
125122
},
126123
});
127124

128-
assert.equal(response.ok, true);
125+
assert.equal(response.ok, false);
129126
assert.deepEqual(calls, [
130127
['wait', ['Ready', '60000']],
131-
['react-native', ['dismiss-overlay']],
132-
['wait', ['Ready', '60000']],
133128
]);
134129
});
135130

@@ -185,7 +180,7 @@ test('invokeMaestroAssertVisible treats an elapsed ellipsis loading gate as alre
185180
}
186181
});
187182

188-
test('invokeMaestroAssertVisible dismisses React Native overlays during snapshot assertions', async () => {
183+
test('invokeMaestroAssertVisible reports React Native overlays during snapshot assertions', async () => {
189184
const calls: Array<[string, string[] | undefined]> = [];
190185
let snapshots = 0;
191186
const response = await invokeMaestroAssertVisible({
@@ -222,19 +217,15 @@ test('invokeMaestroAssertVisible dismisses React Native overlays during snapshot
222217
),
223218
};
224219
}
225-
if (req.command === 'react-native') {
226-
return { ok: true, data: { dismissed: true } };
227-
}
228220
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
229221
},
230222
});
231223

232-
assert.equal(response.ok, true);
233-
assert.deepEqual(calls, [
234-
['snapshot', []],
235-
['react-native', ['dismiss-overlay']],
236-
['snapshot', []],
237-
]);
224+
assert.equal(response.ok, false);
225+
if (!response.ok) {
226+
assert.match(response.error.message, /React Native overlay is covering app content/);
227+
}
228+
assert.deepEqual(calls, [['snapshot', []]]);
238229
});
239230

240231
test('invokeMaestroAssertVisible fails fast when a RedBox has no dismiss target', async () => {
@@ -278,7 +269,6 @@ test('invokeMaestroAssertVisible fails fast when a RedBox has no dismiss target'
278269
}
279270
assert.deepEqual(calls, [
280271
['snapshot', []],
281-
['react-native', ['dismiss-overlay']],
282272
]);
283273
});
284274

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

Lines changed: 4 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -40,24 +40,15 @@ test('invokeMaestroTapOn clicks normal Close/Dismiss buttons when no React Nativ
4040
expect(commands).toEqual(['snapshot', 'click']);
4141
});
4242

43-
test('invokeMaestroTapOn uses React Native overlay dismissal for overlay controls', async () => {
44-
const { response, commands } = await runTapOn(
43+
test('invokeMaestroTapOn clicks explicit React Native overlay controls directly', async () => {
44+
const { response, commands, clicks } = await runTapOn(
4545
'label="Dismiss" || text="Dismiss" || id="Dismiss"',
4646
() => overlayDismissButtonSnapshot(),
4747
);
4848

4949
expect(response.ok).toBe(true);
50-
expect(commands).toEqual(['snapshot', 'react-native']);
51-
});
52-
53-
test('invokeMaestroTapOn dismisses React Native overlays blocking app content and retries', async () => {
54-
const { response, commands, clicks } = await runTapOn('id="article"', (snapshotIndex) =>
55-
snapshotIndex === 1 ? overlayBlockingArticleSnapshot() : articleButtonSnapshot(),
56-
);
57-
58-
expect(response.ok).toBe(true);
59-
expect(commands).toEqual(['snapshot', 'react-native', 'snapshot', 'click']);
60-
expect(clicks).toEqual([['201', '149']]);
50+
expect(commands).toEqual(['snapshot', 'click']);
51+
expect(clicks).toEqual([['355', '30']]);
6152
});
6253

6354
test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gesture presets', async () => {
@@ -232,9 +223,6 @@ async function runTapOn(
232223
snapshots += 1;
233224
return { ok: true, data: readSnapshot(snapshots) };
234225
}
235-
if (req.command === 'react-native') {
236-
return { ok: true, data: { dismissed: true } };
237-
}
238226
if (req.command === 'click') {
239227
clicks.push(req.positionals ?? []);
240228
return { ok: true, data: {} };
@@ -288,62 +276,6 @@ function buttonSnapshot(label: string): SnapshotState {
288276
};
289277
}
290278

291-
function articleButtonSnapshot(): SnapshotState {
292-
return {
293-
createdAt: Date.now(),
294-
nodes: [
295-
appNode(),
296-
windowNode(),
297-
{
298-
index: 2,
299-
ref: 'e3',
300-
type: 'Button',
301-
identifier: 'article',
302-
label: 'Article',
303-
depth: 4,
304-
parentIndex: 1,
305-
rect: { x: 142, y: 128.66666412353516, width: 118, height: 40 },
306-
},
307-
],
308-
};
309-
}
310-
311-
function overlayBlockingArticleSnapshot(): SnapshotState {
312-
return {
313-
createdAt: Date.now(),
314-
nodes: [
315-
...articleButtonSnapshot().nodes,
316-
{
317-
index: 10,
318-
ref: 'e10',
319-
type: 'StaticText',
320-
label: 'Runtime Error',
321-
depth: 2,
322-
parentIndex: 1,
323-
rect: { x: 0, y: 0, width: 402, height: 40 },
324-
},
325-
{
326-
index: 11,
327-
ref: 'e11',
328-
type: 'Button',
329-
label: 'Minimize',
330-
depth: 2,
331-
parentIndex: 1,
332-
rect: { x: 320, y: 12, width: 70, height: 36 },
333-
},
334-
{
335-
index: 12,
336-
ref: 'e12',
337-
type: 'StaticText',
338-
label: 'Call Stack',
339-
depth: 2,
340-
parentIndex: 1,
341-
rect: { x: 0, y: 52, width: 402, height: 40 },
342-
},
343-
],
344-
};
345-
}
346-
347279
function overlayDismissButtonSnapshot(): SnapshotState {
348280
return {
349281
createdAt: Date.now(),

src/compat/maestro/runtime-assertions.ts

Lines changed: 13 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type { SnapshotState } from '../../utils/snapshot.ts';
55
import { sleep } from '../../utils/timeouts.ts';
66
import {
77
captureMaestroRawSnapshot,
8-
dismissReactNativeOverlayIfPresent,
98
errorResponse,
109
rememberMaestroVisibleContext,
1110
readSnapshotState,
@@ -70,11 +69,7 @@ async function invokeNativeMaestroVisibleWait(
7069
nativeWaitQuery: string,
7170
): Promise<DaemonResponse> {
7271
const nativeStartedAt = Date.now();
73-
let nativeResponse = await runNativeVisibleWait(params, args, nativeWaitQuery);
74-
if (!nativeResponse.ok && shouldRetryNativeWaitAfterOverlayDismiss(nativeResponse)) {
75-
const overlayResponse = await dismissReactNativeOverlayIfPresent(params);
76-
if (overlayResponse) nativeResponse = await runNativeVisibleWait(params, args, nativeWaitQuery);
77-
}
72+
const nativeResponse = await runNativeVisibleWait(params, args, nativeWaitQuery);
7873
if (!nativeResponse.ok) return nativeResponse;
7974
return visibleAssertionResponse(
8075
{
@@ -120,20 +115,12 @@ async function invokeSnapshotMaestroAssertVisible(
120115
const deadlineMs = args.timeoutMs + MAESTRO_ASSERTION_POLICY.assertVisibleGraceMs;
121116
let lastResponse: DaemonResponse | undefined;
122117
let capturedAfterDeadline = false;
123-
let dismissedOverlay = false;
124118
while (true) {
125119
const captureStartedAt = Date.now();
126120
const sample = await readMaestroVisibilitySample(params, args.selector, 'assertVisible');
127121
if (sample.visible) return visibleAssertionResponse(sample.response, args.selector, startedAt);
128122
lastResponse = sample.response;
129-
const failedSample = await handleFailedVisibleSample(params, args, sample, {
130-
dismissedOverlay,
131-
startedAt,
132-
});
133-
if (failedSample.kind === 'retry-after-overlay-dismiss') {
134-
dismissedOverlay = true;
135-
continue;
136-
}
123+
const failedSample = handleFailedVisibleSample(params.baseReq, args, sample, startedAt);
137124
if (failedSample.kind === 'return') return failedSample.response;
138125

139126
const deadline = readVisibleAssertionDeadlineAction({
@@ -159,30 +146,21 @@ async function invokeSnapshotMaestroAssertVisible(
159146
);
160147
}
161148

162-
async function handleFailedVisibleSample(
163-
params: {
164-
baseReq: ReplayBaseRequest;
165-
invoke: MaestroRuntimeInvoke;
166-
},
149+
function handleFailedVisibleSample(
150+
baseReq: ReplayBaseRequest,
167151
args: MaestroVisibilityAssertionArgs,
168152
sample: Exclude<MaestroVisibilitySample, { visible: true }>,
169-
state: { dismissedOverlay: boolean; startedAt: number },
170-
): Promise<
153+
startedAt: number,
154+
):
171155
| { kind: 'continue' }
172-
| { kind: 'retry-after-overlay-dismiss' }
173-
| { kind: 'return'; response: DaemonResponse }
174-
> {
175-
const overlayRetry = await maybeDismissOverlayAfterSnapshotFailure(
176-
params,
177-
sample.response,
178-
state.dismissedOverlay,
179-
);
180-
if (overlayRetry === 'dismissed') return { kind: 'retry-after-overlay-dismiss' };
181-
if (overlayRetry === 'blocked') return { kind: 'return', response: sample.response };
182-
if (shouldPassAlreadyPastLoading(params.baseReq, args.selector, sample.snapshot)) {
156+
| { kind: 'return'; response: DaemonResponse } {
157+
if (isReactNativeOverlayBlockingAssertion(sample.response)) {
158+
return { kind: 'return', response: sample.response };
159+
}
160+
if (shouldPassAlreadyPastLoading(baseReq, args.selector, sample.snapshot)) {
183161
return {
184162
kind: 'return',
185-
response: alreadyPastLoadingResponse(args.selector, args.timeoutMs, state.startedAt),
163+
response: alreadyPastLoadingResponse(args.selector, args.timeoutMs, startedAt),
186164
};
187165
}
188166
return { kind: 'continue' };
@@ -218,31 +196,7 @@ function readVisibleAssertionDeadlineAction(params: {
218196
: 'finish';
219197
}
220198

221-
async function maybeDismissOverlayAfterSnapshotFailure(
222-
params: {
223-
baseReq: ReplayBaseRequest;
224-
invoke: MaestroRuntimeInvoke;
225-
},
226-
response: DaemonResponse,
227-
dismissedOverlay: boolean,
228-
): Promise<'dismissed' | 'blocked' | 'none'> {
229-
if (dismissedOverlay || !shouldRetrySnapshotAssertionAfterOverlayDismiss(response)) {
230-
return 'none';
231-
}
232-
const overlayResponse = await dismissReactNativeOverlayIfPresent(params);
233-
return overlayResponse ? 'dismissed' : 'blocked';
234-
}
235-
236-
function shouldRetryNativeWaitAfterOverlayDismiss(response: DaemonResponse): boolean {
237-
return (
238-
!response.ok &&
239-
response.error.code === 'COMMAND_FAILED' &&
240-
(response.error.message.includes('Current surface:') ||
241-
response.error.message.includes('React Native overlay'))
242-
);
243-
}
244-
245-
function shouldRetrySnapshotAssertionAfterOverlayDismiss(response: DaemonResponse): boolean {
199+
function isReactNativeOverlayBlockingAssertion(response: DaemonResponse): boolean {
246200
return (
247201
!response.ok &&
248202
response.error.code === 'COMMAND_FAILED' &&

0 commit comments

Comments
 (0)