Skip to content

Commit b5cd1c4

Browse files
authored
fix: handle busy Android RN snapshots (#554)
1 parent dea6c3b commit b5cd1c4

7 files changed

Lines changed: 438 additions & 159 deletions

File tree

src/__tests__/runtime-snapshot.test.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,8 @@ test('runtime snapshot warns on collapsed Android React Native warning banners',
149149
ref: 'e1',
150150
index: 0,
151151
depth: 0,
152-
type: 'android.view.ViewGroup',
153-
label: '!, Open debugger to view warnings.',
154-
},
155-
{
156-
ref: 'e2',
157-
index: 1,
158-
depth: 1,
159152
type: 'android.widget.TextView',
160-
label: 'Open debugger to view warnings.',
153+
label: 'Warning: Each child in a list should have a unique "key" prop.',
161154
},
162155
],
163156
truncated: false,
@@ -167,6 +160,38 @@ test('runtime snapshot warns on collapsed Android React Native warning banners',
167160
const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true });
168161

169162
assertReactNativeOverlayWarning(result.warnings);
163+
assert.match(result.warnings?.[0] ?? '', /Press @e1/);
164+
});
165+
166+
test('runtime snapshot prefers TextView Minimize over Dismiss on Android React Native stack overlays', async () => {
167+
const result = await createSnapshotOnlyDevice({
168+
nodes: [
169+
{ ref: 'e1', index: 0, depth: 0, type: 'android.widget.TextView', label: 'useOnyx.ts:80:43' },
170+
{ ref: 'e2', index: 1, depth: 1, type: 'android.widget.Button', label: 'Dismiss' },
171+
{ ref: 'e3', index: 2, depth: 1, type: 'android.widget.TextView', label: 'Minimize' },
172+
],
173+
truncated: false,
174+
backend: 'android',
175+
}).capture.snapshot({ session: 'default', interactiveOnly: true });
176+
177+
assertReactNativeOverlayWarning(result.warnings);
178+
assert.match(result.warnings?.[0] ?? '', /press @e3/);
179+
assert.match(result.warnings?.[0] ?? '', /Prefer Minimize over Dismiss/);
180+
});
181+
182+
test('runtime snapshot does not suggest Dismiss for Android RedBox stacks without Minimize', async () => {
183+
const result = await createSnapshotOnlyDevice({
184+
nodes: [
185+
{ ref: 'e1', index: 0, depth: 0, type: 'android.widget.TextView', label: 'useOnyx.ts:80:43' },
186+
{ ref: 'e2', index: 1, depth: 1, type: 'android.widget.Button', label: 'Dismiss' },
187+
],
188+
truncated: false,
189+
backend: 'android',
190+
}).capture.snapshot({ session: 'default', interactiveOnly: true });
191+
192+
assertReactNativeOverlayWarning(result.warnings);
193+
assert.match(result.warnings?.[0] ?? '', /RedBox stack overlay/);
194+
assert.doesNotMatch(result.warnings?.[0] ?? '', /Dismiss before continuing|press @e2/);
170195
});
171196

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

src/commands/capture-snapshot.ts

Lines changed: 165 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -215,46 +215,67 @@ function buildSnapshotWarnings(params: {
215215
runtimeNow: number;
216216
}): string[] {
217217
const warnings = [...(params.result.warnings ?? [])];
218-
const interactiveOnly = params.options.interactiveOnly === true;
219-
const analysis = params.result.analysis;
220-
const androidSnapshot = params.result.androidSnapshot;
218+
warnings.push(...buildEmptyAndroidInteractiveWarnings(params));
219+
220+
const helperFallbackWarning = formatAndroidHelperFallbackWarning(params.result.androidSnapshot);
221+
if (helperFallbackWarning) warnings.push(helperFallbackWarning);
222+
223+
const reactNativeOverlayWarning = formatReactNativeOverlayWarning(params.snapshot.nodes);
224+
if (reactNativeOverlayWarning) warnings.push(reactNativeOverlayWarning);
225+
226+
const recentDropWarning = formatRecentSnapshotDropWarning(params);
227+
if (recentDropWarning) warnings.push(recentDropWarning);
221228

229+
warnings.push(...formatFreshnessWarnings(params.result.freshness, params.snapshot.backend));
230+
return Array.from(new Set(warnings));
231+
}
232+
233+
function buildEmptyAndroidInteractiveWarnings(params: {
234+
result: BackendSnapshotResult;
235+
snapshot: SnapshotState;
236+
options: SnapshotCommandOptions;
237+
}): string[] {
238+
const analysis = params.result.analysis;
222239
if (
223-
params.snapshot.backend === 'android' &&
224-
interactiveOnly &&
225-
params.snapshot.nodes.length === 0 &&
226-
analysis &&
227-
(analysis.rawNodeCount ?? 0) >= 12
240+
params.snapshot.backend !== 'android' ||
241+
params.options.interactiveOnly !== true ||
242+
params.snapshot.nodes.length > 0 ||
243+
!analysis ||
244+
(analysis.rawNodeCount ?? 0) < 12
228245
) {
229-
warnings.push(
230-
`Interactive snapshot is empty after filtering ${analysis.rawNodeCount} raw Android nodes. Likely causes: the app content is not accessibility-visible yet, a transient route change, or depth/filter options hid the target.`,
231-
);
232-
if (
233-
typeof params.options.depth === 'number' &&
234-
typeof analysis.maxDepth === 'number' &&
235-
analysis.maxDepth >= params.options.depth + 2
236-
) {
237-
warnings.push(
238-
`Interactive output is empty at depth ${params.options.depth}; retry without -d.`,
239-
);
240-
}
246+
return [];
241247
}
242248

243-
if (androidSnapshot?.backend === 'uiautomator-dump') {
244-
const reason = androidSnapshot.fallbackReason
245-
? ` Reason: ${androidSnapshot.fallbackReason}`
246-
: '';
249+
const warnings = [
250+
`Interactive snapshot is empty after filtering ${analysis.rawNodeCount} raw Android nodes. Likely causes: the app content is not accessibility-visible yet, a transient route change, or depth/filter options hid the target.`,
251+
];
252+
if (
253+
typeof params.options.depth === 'number' &&
254+
typeof analysis.maxDepth === 'number' &&
255+
analysis.maxDepth >= params.options.depth + 2
256+
) {
247257
warnings.push(
248-
`Android snapshot helper unavailable; using stock UIAutomator dump, which can time out on busy React Native UIs.${reason}`,
258+
`Interactive output is empty at depth ${params.options.depth}; retry without -d.`,
249259
);
250260
}
261+
return warnings;
262+
}
251263

252-
if (hasReactNativeOverlay(params.snapshot.nodes)) {
253-
warnings.push(
254-
'Possible React Native warning/error overlay detected. Capture screenshot --overlay-refs, check react-devtools errors if connected, dismiss Dismiss/Close only if unrelated, re-snapshot, and report it.',
255-
);
256-
}
264+
function formatAndroidHelperFallbackWarning(
265+
androidSnapshot: AndroidSnapshotBackendMetadata | undefined,
266+
): string | undefined {
267+
if (androidSnapshot?.backend !== 'uiautomator-dump') return undefined;
268+
const reason = androidSnapshot.fallbackReason ? ` Reason: ${androidSnapshot.fallbackReason}` : '';
269+
return `Android snapshot helper unavailable; using stock UIAutomator dump, which can time out on busy React Native UIs.${reason}`;
270+
}
257271

272+
function formatRecentSnapshotDropWarning(params: {
273+
result: BackendSnapshotResult;
274+
snapshot: SnapshotState;
275+
session: CommandSessionRecord | undefined;
276+
capturedAt: number;
277+
runtimeNow: number;
278+
}): string | undefined {
258279
const previousSnapshot = params.session?.snapshot;
259280
const isRecentSnapshot = previousSnapshot
260281
? [params.capturedAt, params.runtimeNow].some((timestamp) => {
@@ -268,41 +289,138 @@ function buildSnapshotWarnings(params: {
268289
isRecentSnapshot &&
269290
isLikelyStaleSnapshotDrop(previousSnapshot.nodes.length, params.snapshot.nodes.length)
270291
) {
271-
warnings.push(
272-
'Recent snapshots dropped sharply in node count, which suggests stale or mid-transition UI. Use screenshot as visual truth, wait briefly, then re-snapshot once.',
273-
);
292+
return STALE_SNAPSHOT_DROP_WARNING;
274293
}
294+
return undefined;
295+
}
275296

276-
const freshness = params.result.freshness;
277-
if (freshness?.staleAfterRetries && params.snapshot.backend === 'android') {
278-
if (freshness.reason === 'stuck-route') {
279-
warnings.push(
280-
`Recent ${freshness.action} was followed by a nearly identical snapshot after ${freshness.retryCount} automatic retr${freshness.retryCount === 1 ? 'y' : 'ies'}. If you expected navigation or submit, the tree may still be stale. Use screenshot as visual truth, wait briefly, then re-snapshot once.`,
281-
);
282-
} else if (freshness.reason === 'sharp-drop') {
283-
warnings.push(
284-
'Recent snapshots dropped sharply in node count, which suggests stale or mid-transition UI. Use screenshot as visual truth, wait briefly, then re-snapshot once.',
285-
);
286-
}
287-
}
297+
const STALE_SNAPSHOT_DROP_WARNING =
298+
'Recent snapshots dropped sharply in node count, which suggests stale or mid-transition UI. Use screenshot as visual truth, wait briefly, then re-snapshot once.';
288299

289-
return Array.from(new Set(warnings));
300+
function formatFreshnessWarnings(
301+
freshness: BackendSnapshotResult['freshness'],
302+
backend: SnapshotState['backend'],
303+
): string[] {
304+
if (!freshness?.staleAfterRetries || backend !== 'android') return [];
305+
if (freshness.reason === 'stuck-route') {
306+
return [
307+
`Recent ${freshness.action} was followed by a nearly identical snapshot after ${freshness.retryCount} automatic retr${freshness.retryCount === 1 ? 'y' : 'ies'}. If you expected navigation or submit, the tree may still be stale. Use screenshot as visual truth, wait briefly, then re-snapshot once.`,
308+
];
309+
}
310+
return freshness.reason === 'sharp-drop' ? [STALE_SNAPSHOT_DROP_WARNING] : [];
290311
}
291312

292313
function isLikelyStaleSnapshotDrop(previousCount: number, currentCount: number): boolean {
293314
if (previousCount < 12) return false;
294315
return currentCount <= Math.floor(previousCount * 0.2);
295316
}
296317

297-
function hasReactNativeOverlay(nodes: SnapshotNode[]): boolean {
318+
function formatReactNativeOverlayWarning(nodes: SnapshotNode[]): string | undefined {
319+
const overlay = detectReactNativeOverlay(nodes);
320+
if (!overlay.detected) return undefined;
321+
if (overlay.redBox) return formatRedBoxOverlayWarning(overlay.minimizeRefs);
322+
if (overlay.dismissRefs.length > 0) {
323+
return `Possible React Native warning/error overlay detected. Dismiss before continuing: press ${formatRefList(
324+
overlay.dismissRefs,
325+
)}, then snapshot -i and report the warning/error in the final summary. Use screenshot --overlay-refs only if visual evidence is required.`;
326+
}
327+
if (overlay.collapsedRefs.length > 0) {
328+
return `Possible React Native warning/error overlay detected. Warning banner detected. Press ${formatRefList(
329+
overlay.collapsedRefs,
330+
)} to expand or clear it; if Dismiss/Close appears, press it, then snapshot -i and report the warning/error in the final summary.`;
331+
}
332+
return 'Possible React Native warning/error overlay detected. Dismiss visible Dismiss/Close before continuing, then snapshot -i and report the warning/error in the final summary. Use screenshot --overlay-refs only if visual evidence is required.';
333+
}
334+
335+
type ReactNativeOverlayState = {
336+
detected: boolean;
337+
redBox: boolean;
338+
dismissRefs: string[];
339+
minimizeRefs: string[];
340+
collapsedRefs: string[];
341+
};
342+
343+
function detectReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOverlayState {
298344
const text = nodes
299345
.map((node) =>
300346
[node.label, node.value, node.identifier, node.type, node.role].filter(Boolean).join(' '),
301347
)
302348
.join('\n')
303349
.toLowerCase();
304350

351+
const dismissRefs = collectOverlayRefs(nodes, isDismissLabel);
352+
const minimizeRefs = collectOverlayRefs(nodes, isMinimizeLabel);
353+
const collapsedRefs = collectOverlayRefs(nodes, isCollapsedReactNativeWarningLabel);
354+
const hasReactNativeStackFrame = isReactNativeStackFrame(text);
355+
const hasOverlayControl = dismissRefs.length > 0 || minimizeRefs.length > 0;
356+
const redBox =
357+
/\b(redbox|runtime error|reload js|copy stack|component stack|call stack)\b/.test(text) ||
358+
(hasReactNativeStackFrame && hasOverlayControl);
359+
const detected =
360+
hasKnownReactNativeOverlayText(text) ||
361+
collapsedRefs.length > 0 ||
362+
(hasReactNativeStackFrame && hasOverlayControl);
363+
return { detected, redBox, dismissRefs, minimizeRefs, collapsedRefs };
364+
}
365+
366+
function formatRedBoxOverlayWarning(minimizeRefs: string[]): string {
367+
if (minimizeRefs.length > 0) {
368+
return `Possible React Native warning/error overlay detected. React Native RedBox stack overlay detected. Minimize before continuing: press ${formatRefList(
369+
minimizeRefs,
370+
)}, then snapshot -i and report the error in the final summary. Prefer Minimize over Dismiss when the error may re-render immediately.`;
371+
}
372+
return 'Possible React Native warning/error overlay detected. React Native RedBox stack overlay detected. Do not press Dismiss if the error may re-render immediately; use screenshot --overlay-refs if visual evidence is required and report the error in the final summary.';
373+
}
374+
375+
function hasKnownReactNativeOverlayText(text: string): boolean {
305376
return /\b(logbox|redbox|reload js|copy stack|component stack|call stack|runtime error|open debugger to view warnings)\b/.test(
306377
text,
307378
);
308379
}
380+
381+
function isReactNativeStackFrame(text: string): boolean {
382+
return (
383+
/\b[\w.$<>/-]+\.(?:tsx?|jsx?):\d+(?::\d+)?\b/.test(text) ||
384+
/\b[\w.$<>/-]+\.(?:tsx?|jsx?)\s+\(\d+:\d+\)/.test(text)
385+
);
386+
}
387+
388+
function isDismissLabel(label: string): boolean {
389+
return label === 'dismiss' || label === 'close';
390+
}
391+
392+
function isMinimizeLabel(label: string): boolean {
393+
return /^minimi[sz]e$/.test(label);
394+
}
395+
396+
function isCollapsedReactNativeWarningLabel(label: string): boolean {
397+
return (
398+
label.includes('open debugger to view warnings') ||
399+
/^!,\s+/.test(label) ||
400+
/^(warn|warning|error):\s+/.test(label) ||
401+
/\b(?:possible\s+)?unhandled (?:promise )?rejection\b/.test(label) ||
402+
label.includes('getsnapshot should be cached to avoid an infinite loop') ||
403+
label.includes('unique "key" prop') ||
404+
label.includes("unique 'key' prop") ||
405+
label.includes('virtualizedlists should never be nested') ||
406+
label.includes('failed prop type')
407+
);
408+
}
409+
410+
function collectOverlayRefs(nodes: SnapshotNode[], matches: (label: string) => boolean): string[] {
411+
const refs: string[] = [];
412+
for (const node of nodes) {
413+
if (!node.ref) continue;
414+
const label = (node.label ?? '').trim().toLowerCase();
415+
if (!matches(label)) continue;
416+
refs.push(node.ref);
417+
}
418+
return refs;
419+
}
420+
421+
function formatRefList(refs: string[]): string {
422+
return refs
423+
.slice(0, 3)
424+
.map((ref) => `@${ref}`)
425+
.join(', ');
426+
}

0 commit comments

Comments
 (0)