diff --git a/src/__tests__/runtime-snapshot.test.ts b/src/__tests__/runtime-snapshot.test.ts index a5cc49b58..82f0e1492 100644 --- a/src/__tests__/runtime-snapshot.test.ts +++ b/src/__tests__/runtime-snapshot.test.ts @@ -446,5 +446,5 @@ function assertReactNativeOverlayWarning(warnings: string[] | undefined) { assert.equal(warnings?.length, 1); assert.match(warnings[0] ?? '', /Hint: React Native warning\/error overlay detected/); assert.match(warnings[0] ?? '', /agent-device react-native dismiss-overlay/); - assert.match(warnings[0] ?? '', /agent-device snapshot -i -c/); + assert.match(warnings[0] ?? '', /verifies the overlay is gone/); } diff --git a/src/commands/react-native/overlay.ts b/src/commands/react-native/overlay.ts index e1e02a4f6..901ac1f1d 100644 --- a/src/commands/react-native/overlay.ts +++ b/src/commands/react-native/overlay.ts @@ -1,49 +1,121 @@ -import { centerOfRect, type Point, type SnapshotNode } from '../../utils/snapshot.ts'; import { - hasKnownReactNativeOverlayText, - isReactNativeCollapsedWarningLabel, - isReactNativeOpenDebuggerWarningLabel, - isReactNativeStackFrame, -} from '../../utils/react-native-overlay-signals.ts'; + centerOfRect, + type Point, + type RawSnapshotNode, + type Rect, + type SnapshotNode, +} from '../../utils/snapshot.ts'; + +type ReactNativeOverlayNode = Pick< + RawSnapshotNode, + 'index' | 'type' | 'role' | 'subrole' | 'label' | 'value' | 'identifier' | 'rect' | 'hittable' +>; export type ReactNativeOverlayState = { detected: boolean; redBox: boolean; - dismissRefs: string[]; - minimizeRefs: string[]; - collapsedRefs: string[]; dismissNodes: SnapshotNode[]; minimizeNodes: SnapshotNode[]; collapsedNodes: SnapshotNode[]; + primaryAction: ReactNativeOverlayDismissTarget | null; }; export type ReactNativeOverlayDismissTarget = { action: 'close' | 'dismiss' | 'minimize' | 'close-collapsed-banner'; point: Point; + rect?: Rect; ref?: string; label?: string; warning?: string; }; +type ReactNativeOverlayFacts = { + dismissNodes: SnapshotNode[]; + minimizeNodes: SnapshotNode[]; + collapsedNodes: SnapshotNode[]; + redBox: boolean; + detected: boolean; +}; + +const KNOWN_OVERLAY_TEXT_PATTERN = + /\b(logbox|redbox|reload js|copy stack|component stack|call stack|runtime error|open debugger to view warnings)\b/; +const REDBOX_TEXT_PATTERN = + /\b(redbox|runtime error|reload js|copy stack|component stack|call stack)\b/; +const REACT_NATIVE_STACK_FRAME_PATTERNS = [ + /\b[\w.$<>/-]+\.(?:tsx?|jsx?):\d+(?::\d+)?\b/, + /\b[\w.$<>/-]+\.(?:tsx?|jsx?)\s+\(\d+:\d+\)/, +] as const; +const COLLAPSED_WARNING_PREFIX_PATTERNS = [ + /^!,\s+/, + /^(warn|warning|error):\s+/, + /\b(?:possible\s+)?unhandled (?:promise )?rejection\b/, +] as const; +const COLLAPSED_WARNING_TEXT_MARKERS = [ + 'open debugger to view warnings', + 'getsnapshot should be cached to avoid an infinite loop', + 'unique "key" prop', + "unique 'key' prop", + 'virtualizedlists should never be nested', + 'failed prop type', +] as const; +const CLOSE_ICON_LABELS = new Set(['x', '×', '✕', '✖', '⨯']); + export function formatReactNativeOverlayWarning(nodes: SnapshotNode[]): string | undefined { - const overlay = detectReactNativeOverlay(nodes); + const overlay = analyzeReactNativeOverlay(nodes); if (!overlay.detected) return undefined; return [ 'Hint: React Native warning/error overlay detected. It overlays part of the app and should be handled before interacting.', 'Run: agent-device react-native dismiss-overlay', - 'Then run: agent-device snapshot -i -c', - 'Use refs from the new snapshot.', + 'The command verifies the overlay is gone. Run agent-device snapshot -i -c afterward only when you need fresh refs for the next action.', ].join('\n'); } export function detectReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOverlayState { - const text = nodes - .map((node) => - [node.label, node.value, node.identifier, node.type, node.role].filter(Boolean).join(' '), - ) - .join('\n') - .toLowerCase(); + return analyzeReactNativeOverlay(nodes); +} + +export function analyzeReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOverlayState { + const facts = collectReactNativeOverlayFacts(nodes); + const primaryAction = facts.detected ? resolveSafeDismissAction(facts) : null; + + return { + detected: facts.detected, + redBox: facts.redBox, + dismissNodes: facts.dismissNodes, + minimizeNodes: facts.minimizeNodes, + collapsedNodes: facts.collapsedNodes, + primaryAction, + }; +} + +export function readReactNativeOverlayActionNodes( + overlay: Pick, +): SnapshotNode[] { + return [...overlay.dismissNodes, ...overlay.minimizeNodes, ...overlay.collapsedNodes]; +} + +export function isReactNativeCollapsedWarningWrapperCandidate( + node: ReactNativeOverlayNode, +): boolean { + return ( + isReactNativeCollapsedWarningLabel(node.label?.trim()) && isFullScreenOverlayRect(node.rect) + ); +} +export function isReactNativeCollapsedWarningWrapperWithVisibleBanner( + node: ReactNativeOverlayNode, + descendants: ReactNativeOverlayNode[], +): boolean { + const nodeLabel = node.label?.trim(); + if (!nodeLabel || !isReactNativeCollapsedWarningWrapperCandidate(node)) return false; + return descendants.some( + (descendant) => + descendant.label?.trim() === nodeLabel && isReactNativeCollapsedWarningBanner(descendant), + ); +} + +function collectReactNativeOverlayFacts(nodes: SnapshotNode[]): ReactNativeOverlayFacts { + const text = nodes.map(formatNodeSearchText).join('\n').toLowerCase(); const dismissNodes = collectOverlayNodes(nodes, isDismissControlLabel); const minimizeNodes = collectOverlayNodes(nodes, isMinimizeLabel); const collapsedNodes = collectOverlayNodes( @@ -55,45 +127,32 @@ export function detectReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOver nodes, isReactNativeOpenDebuggerWarningLabel, ); - const dismissRefs = refsOf(dismissNodes); - const minimizeRefs = refsOf(minimizeNodes); - const collapsedRefs = refsOf(collapsedNodes); const hasReactNativeStackFrame = isReactNativeStackFrame(text); - const hasControllessRedBoxText = - /\buncaught\b/.test(text) && /unable to download asset/.test(text); - const hasOverlayControl = - dismissRefs.length > 0 || minimizeRefs.length > 0 || /\b(reload js|copy stack)\b/.test(text); - const redBox = - /\b(redbox|runtime error|reload js|copy stack|component stack|call stack)\b/.test(text) || - hasControllessRedBoxText || - (hasReactNativeStackFrame && hasOverlayControl); - const detected = - collapsedRefs.length > 0 || - openDebuggerWarningNodes.length > 0 || - hasControllessRedBoxText || - (hasOverlayControl && (hasKnownReactNativeOverlayText(text) || hasReactNativeStackFrame)); + const hasControllessRedBoxText = hasUnableToDownloadAssetRedBox(text); + const hasOverlayControl = hasReactNativeOverlayControlText(text, dismissNodes, minimizeNodes); return { - detected, - redBox, - dismissRefs, - minimizeRefs, - collapsedRefs, dismissNodes, minimizeNodes, collapsedNodes, + redBox: isReactNativeRedBox(text, hasReactNativeStackFrame, hasOverlayControl), + detected: isReactNativeOverlayDetected({ + text, + hasReactNativeStackFrame, + hasOverlayControl, + hasControllessRedBoxText, + collapsedNodes, + openDebuggerWarningNodes, + }), }; } -export function resolveReactNativeOverlayDismissTarget( - nodes: SnapshotNode[], +function resolveSafeDismissAction( + facts: ReactNativeOverlayFacts, ): ReactNativeOverlayDismissTarget | null { - const overlay = detectReactNativeOverlay(nodes); - if (!overlay.detected) return null; - - if (overlay.redBox) { - const minimize = firstNodeWithRect(overlay.minimizeNodes); + if (facts.redBox) { + const minimize = firstControlNodeWithRect(facts.minimizeNodes); if (minimize) return targetFromNode(minimize, 'minimize'); - const dismiss = firstNodeWithRect(overlay.dismissNodes); + const dismiss = firstControlNodeWithRect(facts.dismissNodes); return dismiss ? { ...targetFromNode(dismiss, actionFromDismissNode(dismiss)), @@ -102,35 +161,136 @@ export function resolveReactNativeOverlayDismissTarget( : null; } - const dismiss = firstNodeWithRect(overlay.dismissNodes); + const dismiss = firstControlNodeWithRect(facts.dismissNodes); if (dismiss) return targetFromNode(dismiss, actionFromDismissNode(dismiss)); - const collapsed = chooseCollapsedWarningNode(overlay.collapsedNodes); + const collapsed = chooseCollapsedWarningNode( + facts.collapsedNodes.filter(isSafeCollapsedWarningCoordinateFallback), + ); if (!collapsed?.rect) return null; return { action: 'close-collapsed-banner', point: collapsedBannerClosePoint(collapsed), + rect: collapsed.rect, ref: collapsed.ref, label: readNodeLabel(collapsed), }; } +function formatNodeSearchText(node: SnapshotNode): string { + return [node.label, node.value, node.identifier, node.type, node.role].filter(Boolean).join(' '); +} + +function hasKnownReactNativeOverlayText(text: string): boolean { + return KNOWN_OVERLAY_TEXT_PATTERN.test(text); +} + +function isReactNativeStackFrame(text: string): boolean { + return REACT_NATIVE_STACK_FRAME_PATTERNS.some((pattern) => pattern.test(text)); +} + +function hasUnableToDownloadAssetRedBox(text: string): boolean { + return /\buncaught\b/.test(text) && /unable to download asset/.test(text); +} + +function hasReactNativeOverlayControlText( + text: string, + dismissNodes: SnapshotNode[], + minimizeNodes: SnapshotNode[], +): boolean { + return ( + dismissNodes.length > 0 || minimizeNodes.length > 0 || /\b(reload js|copy stack)\b/.test(text) + ); +} + +function isReactNativeRedBox( + text: string, + hasReactNativeStackFrame: boolean, + hasOverlayControl: boolean, +): boolean { + return ( + REDBOX_TEXT_PATTERN.test(text) || + hasUnableToDownloadAssetRedBox(text) || + (hasReactNativeStackFrame && hasOverlayControl) + ); +} + +function isReactNativeOverlayDetected(params: { + text: string; + hasReactNativeStackFrame: boolean; + hasOverlayControl: boolean; + hasControllessRedBoxText: boolean; + collapsedNodes: SnapshotNode[]; + openDebuggerWarningNodes: SnapshotNode[]; +}): boolean { + return ( + params.collapsedNodes.length > 0 || + params.openDebuggerWarningNodes.length > 0 || + params.hasControllessRedBoxText || + (params.hasOverlayControl && + (hasKnownReactNativeOverlayText(params.text) || params.hasReactNativeStackFrame)) + ); +} + +function isReactNativeCollapsedWarningLabel(rawLabel: string | undefined): boolean { + const label = rawLabel?.trim().toLowerCase(); + if (!label) return false; + return ( + COLLAPSED_WARNING_TEXT_MARKERS.some((marker) => label.includes(marker)) || + COLLAPSED_WARNING_PREFIX_PATTERNS.some((pattern) => pattern.test(label)) + ); +} + +function isReactNativeOpenDebuggerWarningLabel(label: string): boolean { + return label.includes('open debugger to view warnings') || /^!,\s+open debugger\b/.test(label); +} + function isDismissControlLabel(label: string): boolean { - return label === 'dismiss' || label === 'close' || isCloseIconLabel(label); + return isDismissLabel(label) || isCloseLabel(label) || isCloseIconLabel(label); +} + +function isDismissLabel(label: string): boolean { + return /^dismiss(?:\s*\([^)]*\))?$/i.test(label); +} + +function isCloseLabel(label: string): boolean { + return /^close(?:\s*\([^)]*\))?$/i.test(label); } function isCloseIconLabel(label: string): boolean { - return ['x', '×', '✕', '✖', '⨯'].includes(label); + return CLOSE_ICON_LABELS.has(label); } function isMinimizeLabel(label: string): boolean { - return /^minimi[sz]e$/.test(label); + return /^minimi[sz]e(?:\b|\s|\()/i.test(label); +} + +function isCollapsedWarningSummaryLabel(label: string): boolean { + return /^!,\s+/.test(label); } function isLikelyCollapsedWarningControl(node: SnapshotNode): boolean { return !node.rect || node.rect.height <= 180; } +function isSafeCollapsedWarningCoordinateFallback(node: SnapshotNode): boolean { + const label = readNodeLabel(node)?.trim().toLowerCase() ?? ''; + return ( + (isReactNativeOpenDebuggerWarningLabel(label) || isCollapsedWarningSummaryLabel(label)) && + isReactNativeCollapsedWarningBanner(node) + ); +} + +function isFullScreenOverlayRect(rect: RawSnapshotNode['rect']): boolean { + if (!rect) return false; + return rect.x <= 1 && rect.y <= 1 && rect.width >= 300 && rect.height >= 600; +} + +function isReactNativeCollapsedWarningBanner(node: ReactNativeOverlayNode): boolean { + if (!node.rect) return false; + return node.rect.width >= 120 && node.rect.height >= 36 && node.rect.height <= 180; +} + function collectOverlayNodes( nodes: SnapshotNode[], matches: (label: string) => boolean, @@ -149,12 +309,25 @@ function collectOverlayNodes( return matchedNodes; } -function refsOf(nodes: SnapshotNode[]): string[] { - return nodes.map((node) => node.ref); +function firstControlNodeWithRect(nodes: SnapshotNode[]): SnapshotNode | null { + return selectHighestScoredNode(nodes, controlNodeScores); +} + +function isSemanticControlNode(node: ReactNativeOverlayNode): boolean { + const roleText = [node.type, node.role, node.subrole].join(' ').toLowerCase(); + return /\b(button|menuitem|link)\b/.test(roleText); } -function firstNodeWithRect(nodes: SnapshotNode[]): SnapshotNode | null { - return nodes.find((node) => node.rect) ?? null; +function rectArea(rect: Rect | undefined): number { + return rect ? rect.width * rect.height : Number.POSITIVE_INFINITY; +} + +function controlNodeScores(node: SnapshotNode): number[] { + return [ + booleanScore(isSemanticControlNode(node)), + booleanScore(node.hittable), + -rectArea(node.rect), + ]; } function targetFromNode( @@ -167,6 +340,7 @@ function targetFromNode( return { action, point: centerOfRect(node.rect), + rect: node.rect, ref: node.ref, label: readNodeLabel(node), }; @@ -174,24 +348,37 @@ function targetFromNode( function actionFromDismissNode(node: SnapshotNode): ReactNativeOverlayDismissTarget['action'] { const label = readNodeLabel(node)?.trim().toLowerCase(); - if (label === 'dismiss') return 'dismiss'; + if (label && isDismissLabel(label)) return 'dismiss'; return 'close'; } function chooseCollapsedWarningNode(nodes: SnapshotNode[]): SnapshotNode | null { + return selectHighestScoredNode(nodes, collapsedWarningScores); +} + +function selectHighestScoredNode( + nodes: SnapshotNode[], + scoreNode: (node: SnapshotNode) => number[], +): SnapshotNode | null { const withRect = nodes.filter((node) => node.rect); if (withRect.length === 0) return null; - return ( - withRect.sort((a, b) => { - const aHittable = a.hittable === true ? 1 : 0; - const bHittable = b.hittable === true ? 1 : 0; - if (aHittable !== bHittable) return bHittable - aHittable; - const aWidth = a.rect?.width ?? 0; - const bWidth = b.rect?.width ?? 0; - if (aWidth !== bWidth) return bWidth - aWidth; - return (b.rect?.y ?? 0) - (a.rect?.y ?? 0); - })[0] ?? null - ); + return withRect.sort((a, b) => compareScoreVectors(scoreNode(b), scoreNode(a)))[0] ?? null; +} + +function collapsedWarningScores(node: SnapshotNode): number[] { + return [booleanScore(node.hittable), node.rect?.width ?? 0, node.rect?.y ?? 0]; +} + +function booleanScore(value: unknown): number { + return value === true ? 1 : 0; +} + +function compareScoreVectors(left: number[], right: number[]): number { + for (let index = 0; index < left.length; index += 1) { + const difference = left[index]! - right[index]!; + if (difference !== 0) return difference; + } + return 0; } function collapsedBannerClosePoint(node: SnapshotNode): Point { @@ -214,6 +401,6 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } -function readNodeLabel(node: SnapshotNode): string | undefined { +function readNodeLabel(node: ReactNativeOverlayNode): string | undefined { return node.label ?? node.value ?? node.identifier; } diff --git a/src/compat/maestro/runtime-interactions.ts b/src/compat/maestro/runtime-interactions.ts index 470fa631f..6ea29ba4f 100644 --- a/src/compat/maestro/runtime-interactions.ts +++ b/src/compat/maestro/runtime-interactions.ts @@ -1,6 +1,9 @@ import { getSnapshotReferenceFrame } from '../../daemon/touch-reference-frame.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon/types.ts'; -import { detectReactNativeOverlay } from '../../commands/react-native/overlay.ts'; +import { + detectReactNativeOverlay, + readReactNativeOverlayActionNodes, +} from '../../commands/react-native/overlay.ts'; import { buildSwipeGesturePlan, clampGesturePoint, @@ -524,7 +527,7 @@ async function maybeDismissReactNativeOverlayTapTarget( function isReactNativeOverlayControlTarget(target: ResolvedMaestroSnapshotTarget): boolean { const overlay = detectReactNativeOverlay(target.snapshot.nodes); if (!overlay.detected) return false; - return [...overlay.dismissNodes, ...overlay.minimizeNodes, ...overlay.collapsedNodes].some( + return readReactNativeOverlayActionNodes(overlay).some( (node) => node.index === target.node.index, ); } diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index 3f92efbb5..09279b182 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -14,7 +14,10 @@ import { import type { TouchReferenceFrame } from '../../daemon/touch-reference-frame.ts'; import type { DaemonRequest } from '../../daemon/types.ts'; import type { Selector, SelectorTerm } from '../../daemon/selectors-parse.ts'; -import { detectReactNativeOverlay } from '../../commands/react-native/overlay.ts'; +import { + detectReactNativeOverlay, + readReactNativeOverlayActionNodes, +} from '../../commands/react-native/overlay.ts'; const MAESTRO_TAP_TARGET_TYPE_RANK = new Map([ ['button', 0], @@ -228,11 +231,8 @@ function filterReactNativeOverlayBlockedMatches( if (!overlay.redBox) { return { matches, blockedByReactNativeOverlay: false }; } - const visibleOverlayControls = [ - ...overlay.dismissNodes, - ...overlay.minimizeNodes, - ...overlay.collapsedNodes, - ].filter( + const overlayControls = readReactNativeOverlayActionNodes(overlay); + const visibleOverlayControls = overlayControls.filter( (node) => evaluateIsPredicate({ predicate: 'visible', @@ -242,7 +242,7 @@ function filterReactNativeOverlayBlockedMatches( }).pass, ); if (visibleOverlayControls.length === 0) { - if (overlay.redBox && !hasReactNativeOverlayDismissCandidates(overlay)) { + if (overlayControls.length === 0) { return { matches: [], blockedByReactNativeOverlay: true }; } return { matches, blockedByReactNativeOverlay: false }; @@ -255,18 +255,6 @@ function filterReactNativeOverlayBlockedMatches( }; } -function hasReactNativeOverlayDismissCandidates(overlay: { - dismissNodes: SnapshotNode[]; - minimizeNodes: SnapshotNode[]; - collapsedNodes: SnapshotNode[]; -}): boolean { - return ( - overlay.dismissNodes.length > 0 || - overlay.minimizeNodes.length > 0 || - overlay.collapsedNodes.length > 0 - ); -} - export function readMaestroSelectorPlatform(flags: DaemonRequest['flags']): Platform { return flags?.platform === 'android' ? 'android' : 'ios'; } diff --git a/src/daemon/handlers/__tests__/react-native.test.ts b/src/daemon/handlers/__tests__/react-native.test.ts index adc976359..9ed677501 100644 --- a/src/daemon/handlers/__tests__/react-native.test.ts +++ b/src/daemon/handlers/__tests__/react-native.test.ts @@ -33,6 +33,72 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste const sessionName = 'rn-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); + mockCaptureSnapshot + .mockResolvedValueOnce({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e90', + label: '!, Open debugger to view warnings.', + rect: { x: 0, y: 794, width: 402, height: 52 }, + hittable: true, + }, + ], + createdAt: Date.now(), + }, + }) + .mockResolvedValueOnce({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e1', + label: 'Submit order', + rect: { x: 24, y: 600, width: 180, height: 52 }, + }, + ], + createdAt: Date.now(), + }, + }); + + const response = await handleReactNativeCommands({ + req: { + token: 't', + session: sessionName, + command: 'react-native', + positionals: ['dismiss-overlay'], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + contextFromFlags: () => ({}), + }); + + expect(response?.ok).toBe(true); + expect(mockDispatchCommand).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'ios' }), + 'press', + ['379', '820'], + undefined, + expect.any(Object), + ); + expect(response?.ok && response.data).toMatchObject({ + action: 'dismiss-overlay', + overlayAction: 'close-collapsed-banner', + verified: true, + verificationRequired: false, + x: 379, + y: 820, + }); +}); + +test('react-native dismiss-overlay prefers non-trailing collapsed warning close controls', async () => { + const sessionName = 'rn-session'; + const sessionStore = makeSessionStore(); + sessionStore.set(sessionName, makeSession(sessionName)); + mockDispatchCommand.mockResolvedValue({ x: 27, y: 820 }); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -43,6 +109,13 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste rect: { x: 0, y: 794, width: 402, height: 52 }, hittable: true, }, + { + index: 1, + ref: 'e91', + label: 'Close', + rect: { x: 10, y: 803, width: 34, height: 34 }, + hittable: true, + }, ], createdAt: Date.now(), }, @@ -66,27 +139,224 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste expect(mockDispatchCommand).toHaveBeenCalledWith( expect.objectContaining({ platform: 'ios' }), 'press', - ['379', '820'], + ['27', '820'], undefined, expect.any(Object), ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', - overlayAction: 'close-collapsed-banner', - verified: false, - verificationRequired: true, - nextCommand: 'agent-device snapshot -i -c', - x: 379, + overlayAction: 'close', + ref: 'e91', + x: 27, y: 820, }); }); +test('react-native dismiss-overlay does not confuse app dismiss buttons with overlay controls', async () => { + const sessionName = 'rn-collapsed-with-app-dismiss-session'; + const sessionStore = makeSessionStore(); + sessionStore.set(sessionName, makeSession(sessionName)); + mockDispatchCommand.mockResolvedValue({ x: 379, y: 820 }); + mockCaptureSnapshot + .mockResolvedValueOnce({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e20', + label: 'Dismiss notice', + rect: { x: 34, y: 839, width: 333, height: 45 }, + hittable: true, + }, + { + index: 1, + ref: 'e50', + label: '!, Agent Device RN overlay verification error', + rect: { x: 10, y: 787, width: 382, height: 67 }, + hittable: true, + }, + ], + createdAt: Date.now(), + }, + }) + .mockResolvedValueOnce({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e1', + label: 'Agent Device Tester', + rect: { x: 18, y: 62, width: 366, height: 729 }, + }, + ], + createdAt: Date.now(), + }, + }); + + const response = await handleReactNativeCommands({ + req: { + token: 't', + session: sessionName, + command: 'react-native', + positionals: ['dismiss-overlay'], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + contextFromFlags: () => ({}), + }); + + expect(response?.ok).toBe(true); + expect(mockDispatchCommand).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'ios' }), + 'press', + ['369', '813'], + undefined, + expect.any(Object), + ); + expect(response?.ok && response.data).toMatchObject({ + action: 'dismiss-overlay', + overlayAction: 'close-collapsed-banner', + ref: 'e50', + label: '!, Agent Device RN overlay verification error', + verified: true, + verificationRequired: false, + x: 369, + y: 813, + }); +}); + +test('react-native dismiss-overlay rejects unsafe collapsed warning coordinate fallback', async () => { + const sessionName = 'rn-session'; + const sessionStore = makeSessionStore(); + sessionStore.set(sessionName, makeSession(sessionName)); + mockCaptureSnapshot.mockResolvedValue({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e90', + label: 'Warning: Each child in a list should have a unique "key" prop.', + rect: { x: 0, y: 794, width: 402, height: 52 }, + hittable: true, + }, + ], + createdAt: Date.now(), + }, + }); + + const response = await handleReactNativeCommands({ + req: { + token: 't', + session: sessionName, + command: 'react-native', + positionals: ['dismiss-overlay'], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + contextFromFlags: () => ({}), + }); + + expect(response?.ok).toBe(false); + expect(mockDispatchCommand).not.toHaveBeenCalled(); + expect(!response?.ok && response?.error).toMatchObject({ + code: 'COMMAND_FAILED', + details: { + hint: expect.stringContaining('screenshot --overlay-refs'), + }, + }); +}); + test('react-native dismiss-overlay minimizes RedBox error overlays instead of dismissing them', async () => { const sessionName = 'rn-redbox-session'; const sessionStore = makeSessionStore(); sessionStore.set(sessionName, makeSession(sessionName)); mockDispatchCommand.mockResolvedValue({ x: 265, y: 752 }); - mockCaptureSnapshot.mockResolvedValue({ + mockCaptureSnapshot + .mockResolvedValueOnce({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e1', + label: 'Runtime Error', + rect: { x: 0, y: 0, width: 390, height: 100 }, + }, + { + index: 1, + ref: 'e2', + label: 'Dismiss', + rect: { x: 20, y: 730, width: 150, height: 44 }, + }, + { + index: 2, + ref: 'e3', + label: 'Minimize', + rect: { x: 190, y: 730, width: 150, height: 44 }, + }, + ], + createdAt: Date.now(), + }, + }) + .mockResolvedValueOnce({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e20', + label: '!, Runtime Error: NativeModule is null', + rect: { x: 10, y: 786, width: 382, height: 67 }, + }, + ], + createdAt: Date.now(), + }, + }); + + const response = await handleReactNativeCommands({ + req: { + token: 't', + session: sessionName, + command: 'react-native', + positionals: ['dismiss-overlay'], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + contextFromFlags: () => ({}), + }); + + expect(response?.ok).toBe(true); + expect(mockDispatchCommand).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'ios' }), + 'press', + ['265', '752'], + undefined, + expect.any(Object), + ); + expect(response?.ok && response.data).toMatchObject({ + action: 'dismiss-overlay', + overlayAction: 'minimize', + ref: 'e3', + minimized: true, + verified: true, + verificationRequired: false, + message: 'React Native RedBox minimize action sent and verified minimized', + x: 265, + y: 752, + }); + expect(response?.ok && response.data?.dismissed).toBeUndefined(); +}); + +test('react-native dismiss-overlay reports unverified minimize when RedBox controls remain', async () => { + const sessionName = 'rn-redbox-still-full-session'; + const sessionStore = makeSessionStore(); + sessionStore.set(sessionName, makeSession(sessionName)); + mockDispatchCommand.mockResolvedValue({ x: 265, y: 752 }); + const fullRedBoxSnapshot = { snapshot: { nodes: [ { @@ -110,7 +380,10 @@ test('react-native dismiss-overlay minimizes RedBox error overlays instead of di ], createdAt: Date.now(), }, - }); + }; + mockCaptureSnapshot + .mockResolvedValueOnce(fullRedBoxSnapshot) + .mockResolvedValueOnce(fullRedBoxSnapshot); const response = await handleReactNativeCommands({ req: { @@ -127,19 +400,16 @@ test('react-native dismiss-overlay minimizes RedBox error overlays instead of di }); expect(response?.ok).toBe(true); - expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios' }), - 'press', - ['265', '752'], - undefined, - expect.any(Object), - ); expect(response?.ok && response.data).toMatchObject({ action: 'dismiss-overlay', overlayAction: 'minimize', - ref: 'e3', - x: 265, - y: 752, + minimized: false, + verified: false, + verificationRequired: true, + verificationWarning: expect.stringContaining('RedBox controls are still detected'), + nextCommand: 'agent-device screenshot --overlay-refs', + message: + 'React Native RedBox minimize action sent, but full RedBox controls are still detected', }); }); @@ -198,6 +468,234 @@ test('react-native dismiss-overlay falls back to Dismiss when RedBox Minimize is }); }); +test('react-native dismiss-overlay accepts RedBox control labels with keyboard shortcut suffixes', async () => { + const sessionName = 'rn-redbox-shortcut-session'; + const sessionStore = makeSessionStore(); + sessionStore.set(sessionName, makeSession(sessionName)); + mockDispatchCommand.mockResolvedValue({ x: 70, y: 722 }); + mockCaptureSnapshot.mockResolvedValue({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e1', + label: 'Runtime Error: NativeModule is null', + rect: { x: 0, y: 0, width: 390, height: 620 }, + }, + { + index: 1, + ref: 'e2', + label: 'Dismiss (ESC)', + rect: { x: 18, y: 700, width: 104, height: 44 }, + }, + ], + createdAt: Date.now(), + }, + }); + + const response = await handleReactNativeCommands({ + req: { + token: 't', + session: sessionName, + command: 'react-native', + positionals: ['dismiss-overlay'], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + contextFromFlags: () => ({}), + }); + + expect(response?.ok).toBe(true); + expect(mockDispatchCommand).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'ios' }), + 'press', + ['70', '722'], + undefined, + expect.any(Object), + ); + expect(response?.ok && response.data).toMatchObject({ + action: 'dismiss-overlay', + overlayAction: 'dismiss', + ref: 'e2', + warning: 'RedBox Minimize control was not exposed; used Dismiss fallback', + }); +}); + +test('react-native dismiss-overlay prefers concrete RedBox buttons over labeled wrappers', async () => { + const sessionName = 'rn-redbox-wrapper-session'; + const sessionStore = makeSessionStore(); + sessionStore.set(sessionName, makeSession(sessionName)); + mockDispatchCommand.mockResolvedValue({ x: 201, y: 827 }); + mockCaptureSnapshot.mockResolvedValue({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e1', + label: 'Runtime Error: NativeModule is null', + rect: { x: 0, y: 0, width: 402, height: 720 }, + }, + { + index: 1, + ref: 'e42', + type: 'XCUIElementTypeOther', + label: 'Dismiss (ESC)', + rect: { x: 0, y: 802, width: 402, height: 50 }, + }, + { + index: 2, + ref: 'e43', + type: 'XCUIElementTypeButton', + label: 'Dismiss (ESC)', + rect: { x: 156, y: 805, width: 90, height: 44 }, + }, + ], + createdAt: Date.now(), + }, + }); + + const response = await handleReactNativeCommands({ + req: { + token: 't', + session: sessionName, + command: 'react-native', + positionals: ['dismiss-overlay'], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + contextFromFlags: () => ({}), + }); + + expect(response?.ok).toBe(true); + expect(response?.ok && response.data).toMatchObject({ + action: 'dismiss-overlay', + overlayAction: 'dismiss', + ref: 'e43', + x: 201, + y: 827, + }); +}); + +test('react-native dismiss-overlay reports verified success after a clean post-dismiss snapshot', async () => { + const sessionName = 'rn-verify-session'; + const sessionStore = makeSessionStore(); + sessionStore.set(sessionName, makeSession(sessionName, 'android')); + mockDispatchCommand.mockResolvedValue({ x: 105, y: 714 }); + mockCaptureSnapshot + .mockResolvedValueOnce({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e1', + label: 'LogBox', + rect: { x: 0, y: 640, width: 390, height: 120 }, + }, + { + index: 1, + ref: 'e2', + label: 'Close', + rect: { x: 84, y: 692, width: 42, height: 44 }, + }, + ], + createdAt: Date.now(), + }, + }) + .mockResolvedValueOnce({ + snapshot: { + nodes: [ + { + index: 0, + ref: 'e1', + label: 'Submit order', + rect: { x: 24, y: 600, width: 180, height: 52 }, + }, + ], + createdAt: Date.now(), + }, + }); + + const response = await handleReactNativeCommands({ + req: { + token: 't', + session: sessionName, + command: 'react-native', + positionals: ['dismiss-overlay'], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + contextFromFlags: () => ({}), + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('Expected react-native dismiss-overlay to succeed'); + if (!response.data) throw new Error('Expected react-native dismiss-overlay response data'); + expect(mockCaptureSnapshot).toHaveBeenCalledTimes(2); + expect(response.data).toMatchObject({ + action: 'dismiss-overlay', + overlayAction: 'close', + verified: true, + verificationRequired: false, + }); + expect(response.data.nextCommand).toBeUndefined(); +}); + +test('react-native dismiss-overlay reports still-visible overlays with recovery guidance', async () => { + const sessionName = 'rn-verify-still-visible-session'; + const sessionStore = makeSessionStore(); + sessionStore.set(sessionName, makeSession(sessionName, 'android')); + mockDispatchCommand.mockResolvedValue({ x: 105, y: 714 }); + const overlaySnapshot = { + snapshot: { + nodes: [ + { + index: 0, + ref: 'e1', + label: 'LogBox', + rect: { x: 0, y: 640, width: 390, height: 120 }, + }, + { + index: 1, + ref: 'e2', + label: 'Close', + rect: { x: 84, y: 692, width: 42, height: 44 }, + }, + ], + createdAt: Date.now(), + }, + }; + mockCaptureSnapshot.mockResolvedValueOnce(overlaySnapshot).mockResolvedValueOnce(overlaySnapshot); + + const response = await handleReactNativeCommands({ + req: { + token: 't', + session: sessionName, + command: 'react-native', + positionals: ['dismiss-overlay'], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + contextFromFlags: () => ({}), + }); + + expect(response?.ok).toBe(true); + expect(response?.ok && response.data).toMatchObject({ + action: 'dismiss-overlay', + verified: false, + verificationRequired: true, + verificationWarning: expect.stringContaining('screenshot --overlay-refs'), + nextCommand: 'agent-device screenshot --overlay-refs', + }); +}); + test('react-native dismiss-overlay ignores app copy that only mentions RN overlay terms', async () => { const sessionName = 'rn-copy-session'; const sessionStore = makeSessionStore(); @@ -244,16 +742,16 @@ function makeSessionStore(): SessionStore { return new SessionStore(path.join(root, 'sessions')); } -function makeSession(name: string): SessionState { +function makeSession(name: string, platform: 'ios' | 'android' = 'ios'): SessionState { return { name, createdAt: Date.now(), actions: [], device: { - platform: 'ios', + platform, id: 'sim-1', - name: 'iPhone', - kind: 'simulator', + name: platform === 'ios' ? 'iPhone' : 'Pixel', + kind: platform === 'ios' ? 'simulator' : 'emulator', booted: true, }, }; diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index 1453e4087..eaeb21d0a 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -2,11 +2,11 @@ import { dispatchCommand } from '../../core/dispatch.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { - detectReactNativeOverlay, - resolveReactNativeOverlayDismissTarget, + analyzeReactNativeOverlay, type ReactNativeOverlayDismissTarget, } from '../../commands/react-native/overlay.ts'; import { normalizeError } from '../../utils/errors.ts'; +import { stripUndefined } from '../../utils/parsing.ts'; import { successText } from '../../utils/success-text.ts'; import type { SnapshotState } from '../../utils/snapshot.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; @@ -44,9 +44,10 @@ export async function handleReactNativeCommands( params.contextFromFlags, { interactiveOnly: true }, ); - const target = resolveReactNativeOverlayDismissTarget(snapshot.nodes); + const overlay = analyzeReactNativeOverlay(snapshot.nodes); + const target = overlay.primaryAction; if (!target) { - return responseForMissingReactNativeOverlayTarget(snapshot); + return responseForMissingReactNativeOverlayTarget(overlay.detected); } return await dismissReactNativeOverlayTarget(params, session, snapshot, target); } catch (error) { @@ -66,9 +67,8 @@ function parseReactNativeArgs( }; } -function responseForMissingReactNativeOverlayTarget(snapshot: SnapshotState): DaemonResponse { - const overlay = detectReactNativeOverlay(snapshot.nodes); - if (!overlay.detected) { +function responseForMissingReactNativeOverlayTarget(overlayDetected: boolean): DaemonResponse { + if (!overlayDetected) { return { ok: true, data: { @@ -105,22 +105,25 @@ async function dismissReactNativeOverlayTarget( params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), )) ?? {}; const actionFinishedAt = Date.now(); - const responseData = { + const verification = await verifyReactNativeOverlayDismissal(params, session, target.action); + const responseData = stripUndefined({ ...readSnapshotNodesReferenceFrame(snapshot.nodes), ...data, action: 'dismiss-overlay', overlayAction: target.action, x: target.point.x, y: target.point.y, - ...(target.ref ? { ref: target.ref } : {}), - ...(target.label ? { label: target.label } : {}), - ...(target.warning ? { warning: target.warning } : {}), - dismissed: true, - verified: false, - verificationRequired: true, - nextCommand: 'agent-device snapshot -i -c', - ...successText(formatDismissMessage(target)), - }; + ref: target.ref, + label: target.label, + warning: target.warning, + dismissed: target.action === 'minimize' ? undefined : true, + minimized: target.action === 'minimize' ? verification.verified : undefined, + verified: verification.verified, + verificationRequired: !verification.verified, + verificationWarning: verification.verificationWarning, + nextCommand: verification.nextCommand, + ...successText(formatDismissMessage(target, verification)), + }); return finalizeTouchInteraction({ session, sessionStore, @@ -134,9 +137,67 @@ async function dismissReactNativeOverlayTarget( }); } -function formatDismissMessage(target: ReactNativeOverlayDismissTarget): string { +async function verifyReactNativeOverlayDismissal( + params: InteractionHandlerParams, + session: SessionState, + action: ReactNativeOverlayDismissTarget['action'], +): Promise<{ + verified: boolean; + verificationWarning?: string; + nextCommand?: string; +}> { + const { req, sessionStore } = params; + const verificationSnapshot = await captureSnapshotForSession( + session, + req.flags, + sessionStore, + params.contextFromFlags, + { interactiveOnly: true }, + ); + const overlay = analyzeReactNativeOverlay(verificationSnapshot.nodes); + if (action === 'minimize') { + return verifyReactNativeRedBoxMinimized(overlay); + } + if (!overlay.detected) { + return { + verified: true, + }; + } + return { + verified: false, + verificationWarning: + 'React Native overlay is still detected after dismissal. Use screenshot --overlay-refs for visual evidence and report the overlay instead of pressing the warning body.', + nextCommand: 'agent-device screenshot --overlay-refs', + }; +} + +function verifyReactNativeRedBoxMinimized(overlay: ReturnType): { + verified: boolean; + verificationWarning?: string; + nextCommand?: string; +} { + if (overlay.minimizeNodes.length === 0 && overlay.dismissNodes.length === 0) { + return { verified: true }; + } + return { + verified: false, + verificationWarning: + 'React Native RedBox controls are still detected after minimize. Use screenshot --overlay-refs for visual evidence and report the overlay instead of pressing the warning body.', + nextCommand: 'agent-device screenshot --overlay-refs', + }; +} + +function formatDismissMessage( + target: ReactNativeOverlayDismissTarget, + verification: { verified: boolean }, +): string { if (target.action === 'minimize') { - return 'React Native RedBox minimize action sent; run snapshot -i before continuing'; + return verification.verified + ? 'React Native RedBox minimize action sent and verified minimized' + : 'React Native RedBox minimize action sent, but full RedBox controls are still detected'; + } + if (verification.verified) { + return 'React Native overlay dismiss action sent and verified gone'; } - return 'React Native overlay dismiss action sent; run snapshot -i before continuing'; + return 'React Native overlay dismiss action sent, but verification still detects an overlay'; } diff --git a/src/daemon/screenshot-overlay.ts b/src/daemon/screenshot-overlay.ts index 06bfe1d39..8c6e0dade 100644 --- a/src/daemon/screenshot-overlay.ts +++ b/src/daemon/screenshot-overlay.ts @@ -7,6 +7,7 @@ import { type SnapshotState, } from '../utils/snapshot.ts'; import { decodePng, PNG } from '../utils/png.ts'; +import { analyzeReactNativeOverlay } from '../commands/react-native/overlay.ts'; import { findNearestAncestor, normalizeType } from './snapshot-processing.ts'; import { resolveAndroidOverlaySourceRect } from './screenshot-overlay-android.ts'; import { hasPositiveRect, rectArea, rectContains } from './screenshot-overlay-rects.ts'; @@ -115,6 +116,13 @@ export function buildScreenshotOverlayRefs( }); } } + addReactNativeOverlayActionCandidates( + snapshot, + snapshotBounds, + candidatesByRef, + screenshotWidth, + screenshotHeight, + ); const ranked = suppressContainedCandidates([...candidatesByRef.values()]) .sort(compareOverlayCandidatesByScore) @@ -130,6 +138,44 @@ export function buildScreenshotOverlayRefs( })); } +function addReactNativeOverlayActionCandidates( + snapshot: SnapshotState, + snapshotBounds: Rect | null, + candidatesByRef: Map, + screenshotWidth: number, + screenshotHeight: number, +): void { + const overlay = analyzeReactNativeOverlay(snapshot.nodes); + const action = overlay.primaryAction; + if (!action?.ref || !action.rect || !hasPositiveRect(action.rect)) return; + + const overlayRect = projectRectToScreenshot( + snapshot, + snapshotBounds, + action.rect, + screenshotWidth, + screenshotHeight, + ); + if (!hasPositiveRect(overlayRect)) return; + const candidate: OverlayCandidate = { + ref: action.ref, + label: action.label, + rect: action.rect, + overlayRect, + score: 100, + }; + const existing = candidatesByRef.get(action.ref); + candidatesByRef.set( + action.ref, + existing + ? { + ...existing, + score: Math.max(existing.score, candidate.score), + } + : candidate, + ); +} + function resolveOverlaySourceRect( snapshot: SnapshotState, target: SnapshotNode, diff --git a/src/daemon/snapshot-presentation/ios/noise.ts b/src/daemon/snapshot-presentation/ios/noise.ts index 89b991c44..aa2458c65 100644 --- a/src/daemon/snapshot-presentation/ios/noise.ts +++ b/src/daemon/snapshot-presentation/ios/noise.ts @@ -1,5 +1,8 @@ import type { RawSnapshotNode } from '../../../utils/snapshot.ts'; -import { isReactNativeCollapsedWarningLabel } from '../../../utils/react-native-overlay-signals.ts'; +import { + isReactNativeCollapsedWarningWrapperCandidate, + isReactNativeCollapsedWarningWrapperWithVisibleBanner, +} from '../../../commands/react-native/overlay.ts'; import { normalizeType } from '../../snapshot-processing.ts'; import { collectIosScrollIndicatorPresentation } from './scroll.ts'; import { @@ -31,37 +34,25 @@ function collectIosReactNativeOverlayWrapperSuppression( nodes: RawSnapshotNode[], suppressedIndexes: Set, ): void { - forEachOtherNodeWithLabel(nodes, (node, nodeLabel, position) => { - if (!isReactNativeCollapsedWarningLabel(nodeLabel) || !isFullScreenOverlayRect(node.rect)) { - return; - } - - const hasVisibleBannerDescendant = Boolean( - findDescendant( - nodes, - position, - (descendant) => - descendant.label?.trim() === nodeLabel && isReactNativeCollapsedWarningBanner(descendant), - ), - ); - if (hasVisibleBannerDescendant) { + forEachOtherNodeWithLabel(nodes, (node, _nodeLabel, position) => { + if (!isReactNativeCollapsedWarningWrapperCandidate(node)) return; + if ( + isReactNativeCollapsedWarningWrapperWithVisibleBanner( + node, + collectDescendantNodes(nodes, position), + ) + ) { suppressedIndexes.add(node.index); } }); } -function isFullScreenOverlayRect(rect: RawSnapshotNode['rect']): boolean { - if (!rect) { - return false; - } - return rect.x <= 1 && rect.y <= 1 && rect.width >= 300 && rect.height >= 600; -} - -function isReactNativeCollapsedWarningBanner(node: RawSnapshotNode): boolean { - if (!node.rect) { - return false; - } - return node.rect.width >= 120 && node.rect.height >= 36 && node.rect.height <= 180; +function collectDescendantNodes(nodes: RawSnapshotNode[], position: number): RawSnapshotNode[] { + const descendants: RawSnapshotNode[] = []; + forEachDescendant(nodes, position, (descendant) => { + descendants.push(descendant); + }); + return descendants; } function collectIosRepeatedStaticSuppression( diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index e6d0d70e2..463528449 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1200,8 +1200,8 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /network dump --include headers/); assert.match(help, /If snapshot reports a React Native warning\/error overlay/); assert.match(help, /agent-device react-native dismiss-overlay/); - assert.match(help, /agent-device snapshot -i -c/); - assert.match(help, /Use refs from the new snapshot/); + assert.match(help, /verifies the overlay is gone with a fresh post-dismiss snapshot/); + assert.match(help, /overlay is still visible/); assert.match(help, /Do not manually press warning\/error text bodies/); assert.match(help, /dismiss-overlay command owns the narrow LogBox\/RedBox targeting policy/); assert.match(help, /Android runtime permission dialogs and native alerts are handled by alert/); diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 5fe27c9fe..f80003232 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -387,7 +387,8 @@ React Native dev loop: Expo Go/dev clients are host shells. Use provided project URLs, verify with snapshot -i after opening, and ask instead of inventing app ids or URLs. Help workflow owns the full Expo URL command shapes. Overlays and busy RN UIs: - If snapshot reports a React Native warning/error overlay, handle it before interacting with the app: run agent-device react-native dismiss-overlay, then agent-device snapshot -i -c. Use refs from the new snapshot. + If snapshot reports a React Native warning/error overlay, handle it before interacting with the app: run agent-device react-native dismiss-overlay. The command sends the safe LogBox/RedBox action and verifies the overlay is gone with a fresh post-dismiss snapshot. + If the command reports the overlay is still visible, use screenshot --overlay-refs for visual evidence and report the overlay instead of pressing warning/error text manually. Do not manually press warning/error text bodies, collapsed banner bodies, full-screen warning parents, or broad LogBox/RedBox refs. The dismiss-overlay command owns the narrow LogBox/RedBox targeting policy. Report the overlay in the final summary. Use screenshot --overlay-refs before dismissing only if visual evidence is required. If snapshot times out because the UI never becomes idle, Android accessibility may be blocked by busy or continuously changing app UI. After that timeout, use screenshot as visual truth instead of repeatedly retrying snapshots. diff --git a/src/utils/react-native-overlay-signals.ts b/src/utils/react-native-overlay-signals.ts deleted file mode 100644 index b46b231a0..000000000 --- a/src/utils/react-native-overlay-signals.ts +++ /dev/null @@ -1,32 +0,0 @@ -export function hasKnownReactNativeOverlayText(text: string): boolean { - return /\b(logbox|redbox|reload js|copy stack|component stack|call stack|runtime error|open debugger to view warnings)\b/.test( - text, - ); -} - -export function isReactNativeStackFrame(text: string): boolean { - return ( - /\b[\w.$<>/-]+\.(?:tsx?|jsx?):\d+(?::\d+)?\b/.test(text) || - /\b[\w.$<>/-]+\.(?:tsx?|jsx?)\s+\(\d+:\d+\)/.test(text) - ); -} - -export function isReactNativeCollapsedWarningLabel(rawLabel: string | undefined): boolean { - const label = rawLabel?.trim().toLowerCase(); - if (!label) return false; - return ( - label.includes('open debugger to view warnings') || - /^!,\s+/.test(label) || - /^(warn|warning|error):\s+/.test(label) || - /\b(?:possible\s+)?unhandled (?:promise )?rejection\b/.test(label) || - label.includes('getsnapshot should be cached to avoid an infinite loop') || - label.includes('unique "key" prop') || - label.includes("unique 'key' prop") || - label.includes('virtualizedlists should never be nested') || - label.includes('failed prop type') - ); -} - -export function isReactNativeOpenDebuggerWarningLabel(label: string): boolean { - return label.includes('open debugger to view warnings') || /^!,\s+open debugger\b/.test(label); -}