Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/__tests__/runtime-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ test('runtime snapshot does not suggest full-screen React Native warning parents
assert.doesNotMatch(result.warnings?.[0] ?? '', /@e1/);
});

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

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

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

test('runtime snapshot warns when iOS hierarchy looks like a React Native overlay', async () => {
Expand Down
14 changes: 1 addition & 13 deletions src/commands/react-native/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type ReactNativeOverlayState = {
};

export type ReactNativeOverlayDismissTarget = {
action: 'close' | 'dismiss' | 'minimize' | 'close-collapsed-banner';
action: 'close' | 'dismiss' | 'close-collapsed-banner';
point: Point;
rect?: Rect;
ref?: string;
Expand Down Expand Up @@ -149,18 +149,6 @@ function collectReactNativeOverlayFacts(nodes: SnapshotNode[]): ReactNativeOverl
function resolveSafeDismissAction(
facts: ReactNativeOverlayFacts,
): ReactNativeOverlayDismissTarget | null {
if (facts.redBox) {
const minimize = firstControlNodeWithRect(facts.minimizeNodes);
if (minimize) return targetFromNode(minimize, 'minimize');
const dismiss = firstControlNodeWithRect(facts.dismissNodes);
return dismiss
? {
...targetFromNode(dismiss, actionFromDismissNode(dismiss)),
warning: 'RedBox Minimize control was not exposed; used Dismiss fallback',
}
: null;
}

const dismiss = firstControlNodeWithRect(facts.dismissNodes);
if (dismiss) return targetFromNode(dismiss, actionFromDismissNode(dismiss));

Expand Down
26 changes: 8 additions & 18 deletions src/compat/maestro/__tests__/runtime-assertions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ test('invokeMaestroAssertVisible retries transient snapshot failures until a lat
}
});

test('invokeMaestroAssertVisible dismisses React Native overlays before retrying native iOS wait', async () => {
test('invokeMaestroAssertVisible does not dismiss React Native overlays during native iOS wait', async () => {
const calls: Array<[string, string[] | undefined]> = [];
let waits = 0;
const response = await invokeMaestroAssertVisible({
Expand All @@ -118,18 +118,13 @@ test('invokeMaestroAssertVisible dismisses React Native overlays before retrying
}
return { ok: true, data: { matches: 1 } };
}
if (req.command === 'react-native') {
return { ok: true, data: { dismissed: true } };
}
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
},
});

assert.equal(response.ok, true);
assert.equal(response.ok, false);
assert.deepEqual(calls, [
['wait', ['Ready', '60000']],
['react-native', ['dismiss-overlay']],
['wait', ['Ready', '60000']],
]);
});

Expand Down Expand Up @@ -185,7 +180,7 @@ test('invokeMaestroAssertVisible treats an elapsed ellipsis loading gate as alre
}
});

test('invokeMaestroAssertVisible dismisses React Native overlays during snapshot assertions', async () => {
test('invokeMaestroAssertVisible reports React Native overlays during snapshot assertions', async () => {
const calls: Array<[string, string[] | undefined]> = [];
let snapshots = 0;
const response = await invokeMaestroAssertVisible({
Expand Down Expand Up @@ -222,19 +217,15 @@ test('invokeMaestroAssertVisible dismisses React Native overlays during snapshot
),
};
}
if (req.command === 'react-native') {
return { ok: true, data: { dismissed: true } };
}
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
},
});

assert.equal(response.ok, true);
assert.deepEqual(calls, [
['snapshot', []],
['react-native', ['dismiss-overlay']],
['snapshot', []],
]);
assert.equal(response.ok, false);
if (!response.ok) {
assert.match(response.error.message, /React Native overlay is covering app content/);
}
assert.deepEqual(calls, [['snapshot', []]]);
});

test('invokeMaestroAssertVisible fails fast when a RedBox has no dismiss target', async () => {
Expand Down Expand Up @@ -278,7 +269,6 @@ test('invokeMaestroAssertVisible fails fast when a RedBox has no dismiss target'
}
assert.deepEqual(calls, [
['snapshot', []],
['react-native', ['dismiss-overlay']],
]);
});

Expand Down
76 changes: 4 additions & 72 deletions src/compat/maestro/__tests__/runtime-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,15 @@ test('invokeMaestroTapOn clicks normal Close/Dismiss buttons when no React Nativ
expect(commands).toEqual(['snapshot', 'click']);
});

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

expect(response.ok).toBe(true);
expect(commands).toEqual(['snapshot', 'react-native']);
});

test('invokeMaestroTapOn dismisses React Native overlays blocking app content and retries', async () => {
const { response, commands, clicks } = await runTapOn('id="article"', (snapshotIndex) =>
snapshotIndex === 1 ? overlayBlockingArticleSnapshot() : articleButtonSnapshot(),
);

expect(response.ok).toBe(true);
expect(commands).toEqual(['snapshot', 'react-native', 'snapshot', 'click']);
expect(clicks).toEqual([['201', '149']]);
expect(commands).toEqual(['snapshot', 'click']);
expect(clicks).toEqual([['355', '30']]);
});

test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gesture presets', async () => {
Expand Down Expand Up @@ -232,9 +223,6 @@ async function runTapOn(
snapshots += 1;
return { ok: true, data: readSnapshot(snapshots) };
}
if (req.command === 'react-native') {
return { ok: true, data: { dismissed: true } };
}
if (req.command === 'click') {
clicks.push(req.positionals ?? []);
return { ok: true, data: {} };
Expand Down Expand Up @@ -288,62 +276,6 @@ function buttonSnapshot(label: string): SnapshotState {
};
}

function articleButtonSnapshot(): SnapshotState {
return {
createdAt: Date.now(),
nodes: [
appNode(),
windowNode(),
{
index: 2,
ref: 'e3',
type: 'Button',
identifier: 'article',
label: 'Article',
depth: 4,
parentIndex: 1,
rect: { x: 142, y: 128.66666412353516, width: 118, height: 40 },
},
],
};
}

function overlayBlockingArticleSnapshot(): SnapshotState {
return {
createdAt: Date.now(),
nodes: [
...articleButtonSnapshot().nodes,
{
index: 10,
ref: 'e10',
type: 'StaticText',
label: 'Runtime Error',
depth: 2,
parentIndex: 1,
rect: { x: 0, y: 0, width: 402, height: 40 },
},
{
index: 11,
ref: 'e11',
type: 'Button',
label: 'Minimize',
depth: 2,
parentIndex: 1,
rect: { x: 320, y: 12, width: 70, height: 36 },
},
{
index: 12,
ref: 'e12',
type: 'StaticText',
label: 'Call Stack',
depth: 2,
parentIndex: 1,
rect: { x: 0, y: 52, width: 402, height: 40 },
},
],
};
}

function overlayDismissButtonSnapshot(): SnapshotState {
return {
createdAt: Date.now(),
Expand Down
72 changes: 13 additions & 59 deletions src/compat/maestro/runtime-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type { SnapshotState } from '../../utils/snapshot.ts';
import { sleep } from '../../utils/timeouts.ts';
import {
captureMaestroRawSnapshot,
dismissReactNativeOverlayIfPresent,
errorResponse,
rememberMaestroVisibleContext,
readSnapshotState,
Expand Down Expand Up @@ -70,11 +69,7 @@ async function invokeNativeMaestroVisibleWait(
nativeWaitQuery: string,
): Promise<DaemonResponse> {
const nativeStartedAt = Date.now();
let nativeResponse = await runNativeVisibleWait(params, args, nativeWaitQuery);
if (!nativeResponse.ok && shouldRetryNativeWaitAfterOverlayDismiss(nativeResponse)) {
const overlayResponse = await dismissReactNativeOverlayIfPresent(params);
if (overlayResponse) nativeResponse = await runNativeVisibleWait(params, args, nativeWaitQuery);
}
const nativeResponse = await runNativeVisibleWait(params, args, nativeWaitQuery);
if (!nativeResponse.ok) return nativeResponse;
return visibleAssertionResponse(
{
Expand Down Expand Up @@ -120,20 +115,12 @@ async function invokeSnapshotMaestroAssertVisible(
const deadlineMs = args.timeoutMs + MAESTRO_ASSERTION_POLICY.assertVisibleGraceMs;
let lastResponse: DaemonResponse | undefined;
let capturedAfterDeadline = false;
let dismissedOverlay = false;
while (true) {
const captureStartedAt = Date.now();
const sample = await readMaestroVisibilitySample(params, args.selector, 'assertVisible');
if (sample.visible) return visibleAssertionResponse(sample.response, args.selector, startedAt);
lastResponse = sample.response;
const failedSample = await handleFailedVisibleSample(params, args, sample, {
dismissedOverlay,
startedAt,
});
if (failedSample.kind === 'retry-after-overlay-dismiss') {
dismissedOverlay = true;
continue;
}
const failedSample = handleFailedVisibleSample(params.baseReq, args, sample, startedAt);
if (failedSample.kind === 'return') return failedSample.response;

const deadline = readVisibleAssertionDeadlineAction({
Expand All @@ -159,30 +146,21 @@ async function invokeSnapshotMaestroAssertVisible(
);
}

async function handleFailedVisibleSample(
params: {
baseReq: ReplayBaseRequest;
invoke: MaestroRuntimeInvoke;
},
function handleFailedVisibleSample(
baseReq: ReplayBaseRequest,
args: MaestroVisibilityAssertionArgs,
sample: Exclude<MaestroVisibilitySample, { visible: true }>,
state: { dismissedOverlay: boolean; startedAt: number },
): Promise<
startedAt: number,
):
| { kind: 'continue' }
| { kind: 'retry-after-overlay-dismiss' }
| { kind: 'return'; response: DaemonResponse }
> {
const overlayRetry = await maybeDismissOverlayAfterSnapshotFailure(
params,
sample.response,
state.dismissedOverlay,
);
if (overlayRetry === 'dismissed') return { kind: 'retry-after-overlay-dismiss' };
if (overlayRetry === 'blocked') return { kind: 'return', response: sample.response };
if (shouldPassAlreadyPastLoading(params.baseReq, args.selector, sample.snapshot)) {
| { kind: 'return'; response: DaemonResponse } {
if (isReactNativeOverlayBlockingAssertion(sample.response)) {
return { kind: 'return', response: sample.response };
}
if (shouldPassAlreadyPastLoading(baseReq, args.selector, sample.snapshot)) {
return {
kind: 'return',
response: alreadyPastLoadingResponse(args.selector, args.timeoutMs, state.startedAt),
response: alreadyPastLoadingResponse(args.selector, args.timeoutMs, startedAt),
};
}
return { kind: 'continue' };
Expand Down Expand Up @@ -218,31 +196,7 @@ function readVisibleAssertionDeadlineAction(params: {
: 'finish';
}

async function maybeDismissOverlayAfterSnapshotFailure(
params: {
baseReq: ReplayBaseRequest;
invoke: MaestroRuntimeInvoke;
},
response: DaemonResponse,
dismissedOverlay: boolean,
): Promise<'dismissed' | 'blocked' | 'none'> {
if (dismissedOverlay || !shouldRetrySnapshotAssertionAfterOverlayDismiss(response)) {
return 'none';
}
const overlayResponse = await dismissReactNativeOverlayIfPresent(params);
return overlayResponse ? 'dismissed' : 'blocked';
}

function shouldRetryNativeWaitAfterOverlayDismiss(response: DaemonResponse): boolean {
return (
!response.ok &&
response.error.code === 'COMMAND_FAILED' &&
(response.error.message.includes('Current surface:') ||
response.error.message.includes('React Native overlay'))
);
}

function shouldRetrySnapshotAssertionAfterOverlayDismiss(response: DaemonResponse): boolean {
function isReactNativeOverlayBlockingAssertion(response: DaemonResponse): boolean {
return (
!response.ok &&
response.error.code === 'COMMAND_FAILED' &&
Expand Down
Loading
Loading