diff --git a/examples/test-app/replays/checkout-form-android.ad b/examples/test-app/replays/checkout-form-android.ad index 53b62ff99..8d1d0ada4 100644 --- a/examples/test-app/replays/checkout-form-android.ad +++ b/examples/test-app/replays/checkout-form-android.ad @@ -2,6 +2,7 @@ context platform=android timeout=60000 env APP_TARGET="Agent Device Tester" env APP_URL="" open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" +react-native dismiss-overlay wait "label=\"Form\"" 30000 click "label=\"Form\"" wait "Checkout form" 5000 @@ -12,10 +13,11 @@ wait "Checkout form" 5000 scroll down 0.6 click id="shipping-pickup" click id="payment-cash" -wait "Delivery choices" 5000 +wait "Delivery" 5000 scroll down 0.7 click id="checkbox-agree" click id="submit-order" +scroll up 0.6 wait "Order summary" 5000 wait "Ada Lovelace chose pickup with cash payment." 5000 close diff --git a/examples/test-app/replays/checkout-form.ad b/examples/test-app/replays/checkout-form.ad index 8bd23afbd..4c89212df 100644 --- a/examples/test-app/replays/checkout-form.ad +++ b/examples/test-app/replays/checkout-form.ad @@ -2,6 +2,7 @@ context platform=ios timeout=60000 env APP_TARGET="Agent Device Tester" env APP_URL="" open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" +react-native dismiss-overlay wait "label=\"Form\"" 30000 click "label=\"Form\"" wait "Checkout form" 5000 diff --git a/examples/test-app/replays/gesture-lab.ad b/examples/test-app/replays/gesture-lab.ad index a47066ee1..2d6b3aee5 100644 --- a/examples/test-app/replays/gesture-lab.ad +++ b/examples/test-app/replays/gesture-lab.ad @@ -4,6 +4,7 @@ env APP_TARGET="Agent Device Tester" env APP_URL="" open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" +react-native dismiss-overlay wait "Gesture lab" 30000 gesture fling left 195 443 180 diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift index 1b6306f3f..a7a8cfeca 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift @@ -283,6 +283,13 @@ extension RunnerTests { func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? { let point = CGPoint(x: x, y: y) + let textInputCandidates = textInputCandidatesAt(app: app, point: point) + for element in textInputCandidates where prefersExpandedTextRead(element) { + if let text = readableText(for: element) { + return text + } + } + let candidates = app.descendants(matching: .any).allElementsBoundByIndex .filter { element in element.exists && !element.frame.isEmpty && element.frame.contains(point) @@ -337,15 +344,18 @@ extension RunnerTests { } func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? { - let point = CGPoint(x: x, y: y) - var matched: XCUIElement? + return textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first + } + + private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] { + var candidates: [XCUIElement] = [] let exceptionMessage = RunnerObjCExceptionCatcher.catchException({ // Query the text-input element types directly instead of enumerating the entire tree // (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x // slower — it dominated fill latency because resolveTextEntryElement re-runs this on // each verify/repair poll once the focused field reference goes stale). // Prefer the smallest matching field so nested editable controls win over large containers. - let candidates = [ + candidates = [ app.textFields, app.secureTextFields, app.searchFields, @@ -371,16 +381,15 @@ extension RunnerTests { } return left.elementType.rawValue < right.elementType.rawValue } - matched = candidates.first }) if let exceptionMessage { NSLog( "AGENT_DEVICE_RUNNER_TEXT_INPUT_AT_POINT_IGNORED_EXCEPTION=%@", exceptionMessage ) - return nil + return [] } - return matched + return candidates } private func frameContainsPoint(_ frame: CGRect, _ point: CGPoint, tolerance: CGFloat) -> Bool { @@ -1019,6 +1028,14 @@ extension RunnerTests { return (wasVisible: true, dismissed: !visible, visible: visible) } + if tapKeyboardReturnControl(app: app, allowCoordinateFallback: true) { + sleepFor(0.2) + let visible = isKeyboardVisible(app: app) + if !visible { + return (wasVisible: true, dismissed: true, visible: false) + } + } + return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app)) #endif } @@ -1139,7 +1156,10 @@ extension RunnerTests { #endif } - private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool { + private func tapKeyboardReturnControl( + app: XCUIApplication, + allowCoordinateFallback: Bool = false + ) -> Bool { #if os(iOS) for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] { let candidates = [ @@ -1150,6 +1170,21 @@ extension RunnerTests { hittable.tap() return true } + if allowCoordinateFallback, + let keyboardFrame = visibleKeyboardFrame(app: app), + let framed = candidates.first(where: { + guard $0.exists else { return false } + let frame = $0.frame + return !frame.isEmpty && keyboardFrame.contains(CGPoint(x: frame.midX, y: frame.midY)) + }) { + let frame = framed.frame + switch tapAt(app: app, x: frame.midX, y: frame.midY) { + case .performed: + return true + case .unsupported: + return false + } + } } #endif return false diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift index abc771d84..2c4029c49 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift @@ -110,11 +110,25 @@ extension RunnerTests { #if os(tvOS) return .unsupported("element tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element") #else - element.tap() + let exceptionMessage = RunnerObjCExceptionCatcher.catchException({ + element.tap() + }) + if let exceptionMessage { + NSLog("AGENT_DEVICE_RUNNER_ELEMENT_TAP_IGNORED_EXCEPTION=%@", exceptionMessage) + if isPostTapElementDisappearance(exceptionMessage) { + return .performed + } + return .unsupported("element tap failed: \(exceptionMessage)") + } return .performed #endif } + private func isPostTapElementDisappearance(_ message: String) -> Bool { + message.contains("No matches found") + || message.contains("Failed to get matching snapshot") + } + private func selectFocusedTvElement(app: XCUIApplication, element: XCUIElement, action: String) -> RunnerInteractionOutcome? { #if os(tvOS) guard tvFocusedElementMatches(app: app, target: element) else { diff --git a/scripts/perf/scenario.ts b/scripts/perf/scenario.ts index 8c63ac08b..4d44b82e2 100644 --- a/scripts/perf/scenario.ts +++ b/scripts/perf/scenario.ts @@ -55,12 +55,14 @@ export function buildSettingsTour(p: ResolvedProfile, ctx: StepContext): Scenari // iOS: editable search field exists at root; fill it directly (freshRoot resets scroll). bat('fill search', 'fill', { command: 'fill', positionals: [s.searchFieldEditable, 'general'] }, { freshRoot: true }), bat('type', 'type', { command: 'type', positionals: ['wifi'] }), + bat('get editable text', 'get', { command: 'get', positionals: ['text', s.searchFieldEditable] }), ] : [ // Android: tap the search entry first to reveal the editable, then type/fill it. bat('press search field', 'press', { command: 'press', positionals: [s.searchField] }, { freshRoot: true }), bat('type', 'type', { command: 'type', positionals: ['wifi'] }), bat('fill search', 'fill', { command: 'fill', positionals: [s.searchFieldEditable, 'general'] }), + bat('get editable text', 'get', { command: 'get', positionals: ['text', s.searchFieldEditable] }), ]; return [ diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts index 83f922ea4..262f9119b 100644 --- a/src/commands/client-command-metadata.ts +++ b/src/commands/client-command-metadata.ts @@ -196,6 +196,7 @@ export const clientCommandMetadata = [ defineClientCommandMetadata('settings', { setting: requiredField(stringField()), state: requiredField(stringField()), + app: stringField(), latitude: numberField(), longitude: numberField(), permission: stringField(), diff --git a/src/commands/react-native/overlay.ts b/src/commands/react-native/overlay.ts index cae784c7a..e1e02a4f6 100644 --- a/src/commands/react-native/overlay.ts +++ b/src/commands/react-native/overlay.ts @@ -59,14 +59,18 @@ export function detectReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOver 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)); return { detected, diff --git a/src/compat/maestro/__tests__/replay-flow.test.ts b/src/compat/maestro/__tests__/replay-flow.test.ts index 327d6a46a..34cbd9834 100644 --- a/src/compat/maestro/__tests__/replay-flow.test.ts +++ b/src/compat/maestro/__tests__/replay-flow.test.ts @@ -91,6 +91,7 @@ env: assert.equal(parsed.actions[4]?.flags.holdMs, 3000); assert.equal(parsed.actions[1]?.flags.maestro?.allowNonHittableCoordinateFallback, true); assert.equal(parsed.actions[6]?.flags?.maestro?.allowNonHittableCoordinateFallback, true); + assert.equal(parsed.actions[10]?.flags.maestro?.allowAlreadyPastLoading, true); }); test('parseMaestroReplayFlow maps iOS openLink through the app id when available', () => { @@ -106,6 +107,7 @@ test('parseMaestroReplayFlow maps iOS openLink through the app id when available parsed.actions.map((entry) => [entry.command, entry.positionals]), [['open', ['com.callstack.agentdevicelab', 'exp://localhost:8082']]], ); + assert.equal(parsed.actions[0]?.flags.maestro?.prewarmRunnerBeforeOpen, true); }); test('parseMaestroReplayFlow maps Android openLink through the app id when available', () => { diff --git a/src/compat/maestro/__tests__/runtime-assertions.test.ts b/src/compat/maestro/__tests__/runtime-assertions.test.ts index b98d77d3a..4b0aee3d7 100644 --- a/src/compat/maestro/__tests__/runtime-assertions.test.ts +++ b/src/compat/maestro/__tests__/runtime-assertions.test.ts @@ -5,6 +5,7 @@ import { invokeMaestroAssertVisible, } from '../runtime-assertions.ts'; import type { DaemonRequest, DaemonResponse } from '../../../daemon/types.ts'; +import type { SnapshotState } from '../../../utils/snapshot.ts'; afterEach(() => { vi.restoreAllMocks(); @@ -36,16 +37,7 @@ test('invokeMaestroAssertVisible takes a terminal snapshot when the last miss st ok: true, data: { createdAt: 2, - nodes: [ - { - index: 1, - ref: 'e1', - type: 'android.widget.TextView', - label: 'Details is preloaded!', - rect: { x: 120, y: 900, width: 300, height: 60 }, - depth: 8, - }, - ], + nodes: [node('Details is preloaded!')], }, }; }, @@ -83,16 +75,7 @@ test('invokeMaestroAssertVisible retries transient snapshot failures until a lat ok: true, data: { createdAt: 2, - nodes: [ - { - index: 1, - ref: 'e1', - type: 'android.widget.TextView', - label: 'Ready', - rect: { x: 10, y: 20, width: 120, height: 40 }, - depth: 8, - }, - ], + nodes: [node('Ready')], }, }; }, @@ -110,6 +93,168 @@ test('invokeMaestroAssertVisible retries transient snapshot failures until a lat } }); +test('invokeMaestroAssertVisible dismisses React Native overlays before retrying native iOS wait', async () => { + const calls: Array<[string, string[] | undefined]> = []; + let waits = 0; + const response = await invokeMaestroAssertVisible({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'ios' }, + }, + positionals: ['label="Ready" || text="Ready" || id="Ready"', '60000'], + invoke: async (req): Promise => { + calls.push([req.command, req.positionals]); + if (req.command === 'wait') { + waits += 1; + if (waits === 1) { + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'wait timed out for text: Ready. Current surface: Uncaught error', + }, + }; + } + 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.deepEqual(calls, [ + ['wait', ['Ready', '60000']], + ['react-native', ['dismiss-overlay']], + ['wait', ['Ready', '60000']], + ]); +}); + +test('invokeMaestroAssertVisible uses snapshot resolution for short iOS assertions', async () => { + const calls: Array<[string, string[] | undefined]> = []; + const response = await invokeMaestroAssertVisible({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'ios' }, + }, + positionals: ['label="Ready" || text="Ready" || id="Ready"', '1000'], + invoke: async (req): Promise => { + calls.push([req.command, req.positionals]); + if (req.command === 'snapshot') { + return { + ok: true, + data: snapshot([node('Ready')]), + }; + } + return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; + }, + }); + + assert.equal(response.ok, true); + assert.deepEqual(calls, [['snapshot', []]]); +}); + +test('invokeMaestroAssertVisible dismisses React Native overlays during snapshot assertions', async () => { + const calls: Array<[string, string[] | undefined]> = []; + let snapshots = 0; + const response = await invokeMaestroAssertVisible({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'ios' }, + }, + positionals: ['label="Ready" || text="Ready" || id="Ready"', '1000'], + invoke: async (req): Promise => { + calls.push([req.command, req.positionals]); + if (req.command === 'snapshot') { + snapshots += 1; + return { + ok: true, + data: snapshot( + snapshots === 1 + ? [ + node('Ready'), + node('Runtime Error', { + index: 2, + ref: 'e2', + rect: { x: 0, y: 0, width: 390, height: 80 }, + }), + node('Minimize', { + index: 3, + ref: 'e3', + type: 'Button', + rect: { x: 300, y: 20, width: 80, height: 44 }, + }), + ] + : [node('Ready')], + snapshots, + ), + }; + } + 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', []], + ]); +}); + +test('invokeMaestroAssertVisible fails fast when a RedBox has no dismiss target', async () => { + const calls: Array<[string, string[] | undefined]> = []; + const response = await invokeMaestroAssertVisible({ + baseReq: { + token: 't', + session: 's', + flags: { platform: 'ios' }, + }, + positionals: ['label="Ready" || text="Ready" || id="Ready"', '1000'], + invoke: async (req): Promise => { + calls.push([req.command, req.positionals]); + if (req.command === 'snapshot') { + return { + ok: true, + data: snapshot([ + node("Uncaught (in promise): Error: Unable to download asset from url: 'x'", { + type: 'Other', + rect: { x: 0, y: 0, width: 390, height: 80 }, + }), + ]), + }; + } + if (req.command === 'react-native') { + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'React Native overlay detected, but no safe dismiss target was found', + }, + }; + } + return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; + }, + }); + + assert.equal(response.ok, false); + if (!response.ok) { + assert.match(response.error.message, /React Native overlay is covering app content/); + } + assert.deepEqual(calls, [ + ['snapshot', []], + ['react-native', ['dismiss-overlay']], + ]); +}); + test('invokeMaestroAssertNotVisible passes after a slow hidden sample exhausts the timeout', async () => { vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(3500); @@ -159,17 +304,7 @@ test('invokeMaestroAssertNotVisible ignores matched nodes without visible rects' ok: true, data: { createdAt: 1, - nodes: [ - { - index: 1, - ref: 'e1', - type: 'android.widget.TextView', - label: '📌', - value: '📌', - enabled: true, - depth: 21, - }, - ], + nodes: [node('📌', { value: '📌', enabled: true, depth: 21, rect: undefined })], }, }), }); @@ -181,6 +316,25 @@ test('invokeMaestroAssertNotVisible ignores matched nodes without visible rects' } }); +function snapshot(nodes: SnapshotState['nodes'], createdAt = 1): SnapshotState { + return { createdAt, nodes }; +} + +function node( + label: string, + overrides: Partial = {}, +): SnapshotState['nodes'][number] { + return { + index: 1, + ref: 'e1', + type: 'android.widget.TextView', + label, + rect: { x: 20, y: 80, width: 120, height: 40 }, + depth: 8, + ...overrides, + }; +} + test('invokeMaestroAssertNotVisible accepts timeout overrides for short extended waits', async () => { vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValueOnce(300); diff --git a/src/compat/maestro/__tests__/runtime-interactions.test.ts b/src/compat/maestro/__tests__/runtime-interactions.test.ts index bbde33420..719eb239c 100644 --- a/src/compat/maestro/__tests__/runtime-interactions.test.ts +++ b/src/compat/maestro/__tests__/runtime-interactions.test.ts @@ -11,33 +11,45 @@ test('invokeMaestroTapOn resolves mutating taps from the current raw snapshot', const selector = 'label="Article by Gandalf" || text="Article by Gandalf" || id="Article by Gandalf"'; - const clicks: string[][] = []; - let snapshots = 0; - const response = await invokeMaestroTapOn({ - baseReq: { - token: 'test', - session: 'nav', - flags: { platform: 'ios' }, - }, - positionals: [selector], - invoke: async (req: DaemonRequest): Promise => { - if (req.command === 'snapshot') { - snapshots += 1; - return { ok: true, data: currentBreadcrumbSnapshot() }; - } - if (req.command === 'click') { - clicks.push(req.positionals ?? []); - return { ok: true, data: {} }; - } - return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; - }, - }); + const { response, clicks, snapshots } = await runTapOn(selector, () => + currentBreadcrumbSnapshot(), + ); expect(response.ok).toBe(true); expect(snapshots).toBe(1); expect(clicks).toEqual([['86', '89']]); }); +test('invokeMaestroTapOn taps resolved iOS buttons by coordinates', async () => { + const { response, clicks } = await runTapOn( + 'label="Pop to top" || text="Pop to top" || id="Pop to top"', + () => buttonSnapshot('Pop to top'), + ); + + expect(response.ok).toBe(true); + expect(clicks).toEqual([['201', '149']]); +}); + +test('invokeMaestroTapOn uses React Native overlay dismissal for overlay controls', async () => { + const { response, commands } = await runTapOn( + 'label="Dismiss" || text="Dismiss" || id="Dismiss"', + () => buttonSnapshot('Dismiss'), + ); + + 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']]); +}); + test('invokeMaestroSwipeScreen maps horizontal directional swipes to native gesture presets', async () => { const gestures: string[][] = []; const response = await invokeMaestroSwipeScreen({ @@ -185,6 +197,44 @@ function currentBreadcrumbSnapshot(): SnapshotState { }; } +async function runTapOn( + selector: string, + readSnapshot: (snapshotIndex: number) => SnapshotState, +): Promise<{ + response: DaemonResponse; + commands: string[]; + clicks: string[][]; + snapshots: number; +}> { + const commands: string[] = []; + const clicks: string[][] = []; + let snapshots = 0; + const response = await invokeMaestroTapOn({ + baseReq: { + token: 'test', + session: 'nav', + flags: { platform: 'ios' }, + }, + positionals: [selector], + invoke: async (req: DaemonRequest): Promise => { + commands.push(req.command); + if (req.command === 'snapshot') { + 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: {} }; + } + return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } }; + }, + }); + return { response, commands, clicks, snapshots }; +} + function fullScreenSnapshot(width: number, height: number): SnapshotState { return { createdAt: Date.now(), @@ -209,6 +259,81 @@ function fullScreenSnapshot(width: number, height: number): SnapshotState { }; } +function buttonSnapshot(label: string): SnapshotState { + return { + createdAt: Date.now(), + nodes: [ + appNode(), + windowNode(), + { + index: 2, + ref: 'e3', + type: 'Button', + label, + depth: 4, + parentIndex: 1, + rect: { x: 142, y: 128.66666412353516, width: 118, height: 40 }, + }, + ], + }; +} + +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 appNode(): SnapshotState['nodes'][number] { return { index: 0, diff --git a/src/compat/maestro/__tests__/runtime-targets.test.ts b/src/compat/maestro/__tests__/runtime-targets.test.ts index 5bdf8c886..e5b3a0f23 100644 --- a/src/compat/maestro/__tests__/runtime-targets.test.ts +++ b/src/compat/maestro/__tests__/runtime-targets.test.ts @@ -59,6 +59,64 @@ test('resolveMaestroNodeFromSnapshot blocks taps on app content behind React Nat }); }); +test('resolveVisibleMaestroNodeFromSnapshot ignores hidden React Native overlay controls', () => { + const snapshot = makeReactNativeOverlaySnapshot(); + snapshot.nodes = snapshot.nodes.map((node) => + node.label === 'Dismiss' || node.label === 'Minimize' + ? { ...node, visibleToUser: false } + : node, + ); + + const appContent = resolveVisibleMaestroNodeFromSnapshot( + snapshot, + 'label="Article title" || text="Article title" || id="Article title"', + 'android', + { referenceWidth: 1080, referenceHeight: 2340 }, + ); + + expect(appContent).toMatchObject({ + ok: true, + node: expect.objectContaining({ label: 'Article title' }), + }); +}); + +test('resolveVisibleMaestroNodeFromSnapshot blocks content behind control-less RedBox text', () => { + const snapshot: SnapshotState = { + createdAt: Date.now(), + nodes: [ + { + index: 1, + ref: 'e1', + type: 'StaticText', + label: 'Article title', + rect: { x: 24, y: 120, width: 200, height: 44 }, + depth: 4, + }, + { + index: 2, + ref: 'e2', + type: 'Other', + label: + "Uncaught (in promise): Error: Unable to download asset from url: 'http://localhost:8081/assets/icon.ttf'", + rect: { x: 0, y: 0, width: 402, height: 100 }, + depth: 2, + }, + ], + }; + + const target = resolveVisibleMaestroNodeFromSnapshot( + snapshot, + 'label="Article title" || text="Article title" || id="Article title"', + 'ios', + { referenceWidth: 402, referenceHeight: 874 }, + ); + + expect(target).toMatchObject({ + ok: false, + message: expect.stringContaining('React Native overlay is covering app content'), + }); +}); + test('resolveMaestroNodeFromSnapshot does not match plain text as a substring', () => { const snapshot: SnapshotState = { createdAt: Date.now(), @@ -96,12 +154,19 @@ test('resolveMaestroNodeFromSnapshot does not match plain text as a substring', 'android', { referenceWidth: 1080, referenceHeight: 2340 }, ); - const composite = resolveVisibleMaestroNodeFromSnapshot( + const compositeAssertion = resolveVisibleMaestroNodeFromSnapshot( snapshot, 'label="Albums" || text="Albums" || id="Albums"', 'ios', { referenceWidth: 402, referenceHeight: 874 }, ); + const compositeTap = resolveMaestroNodeFromSnapshot( + snapshot, + 'label="Albums" || text="Albums" || id="Albums"', + {}, + 'ios', + { referenceWidth: 402, referenceHeight: 874 }, + ); expect(plain).toMatchObject({ ok: false, @@ -111,7 +176,11 @@ test('resolveMaestroNodeFromSnapshot does not match plain text as a substring', ok: true, node: expect.objectContaining({ label: 'Push feed' }), }); - expect(composite).toMatchObject({ + expect(compositeAssertion).toMatchObject({ + ok: false, + message: expect.stringContaining('Albums'), + }); + expect(compositeTap).toMatchObject({ ok: true, node: expect.objectContaining({ label: 'Albums, back' }), }); diff --git a/src/compat/maestro/command-mapper.ts b/src/compat/maestro/command-mapper.ts index 83c8b642f..484f8d4d2 100644 --- a/src/compat/maestro/command-mapper.ts +++ b/src/compat/maestro/command-mapper.ts @@ -155,7 +155,11 @@ function convertOpenLink( const rawLink = readOpenLink(value, name); const url = resolveMaestroString(rawLink, context); if ((context.platform === 'ios' || context.platform === 'android') && config.appId) { - return action('open', [resolveMaestroString(requireAppId(config, name), context), url]); + return action( + 'open', + [resolveMaestroString(requireAppId(config, name), context), url], + context.platform === 'ios' ? { maestro: { prewarmRunnerBeforeOpen: true } } : undefined, + ); } return action('open', [url]); } diff --git a/src/compat/maestro/interactions.ts b/src/compat/maestro/interactions.ts index 591753a63..9695c769e 100644 --- a/src/compat/maestro/interactions.ts +++ b/src/compat/maestro/interactions.ts @@ -144,7 +144,11 @@ export function convertExtendedWaitUntil( if (value.notVisible !== undefined) { return [action(MAESTRO_RUNTIME_COMMAND.assertNotVisible, [selector, timeoutMs])]; } - return [action(MAESTRO_RUNTIME_COMMAND.assertVisible, [selector, timeoutMs])]; + return [ + action(MAESTRO_RUNTIME_COMMAND.assertVisible, [selector, timeoutMs], { + maestro: { allowAlreadyPastLoading: true }, + }), + ]; } export function convertScroll(value: unknown): SessionAction { diff --git a/src/compat/maestro/runtime-assertions.ts b/src/compat/maestro/runtime-assertions.ts index ee8a0d7fb..cb2bd2cf1 100644 --- a/src/compat/maestro/runtime-assertions.ts +++ b/src/compat/maestro/runtime-assertions.ts @@ -5,6 +5,7 @@ import type { SnapshotState } from '../../utils/snapshot.ts'; import { sleep } from '../../utils/timeouts.ts'; import { captureMaestroRawSnapshot, + dismissReactNativeOverlayIfPresent, errorResponse, rememberMaestroVisibleContext, readSnapshotState, @@ -12,6 +13,7 @@ import { type ReplayBaseRequest, } from './runtime-support.ts'; import { + extractMaestroVisibleTextQuery, readMaestroSelectorPlatform, resolveVisibleMaestroNodeFromSnapshot, } from './runtime-targets.ts'; @@ -22,11 +24,17 @@ const MAESTRO_ASSERTION_POLICY = { assertVisiblePollMs: 250, assertNotVisiblePollMs: 250, defaultAssertNotVisibleTimeoutMs: 3000, + minNativeVisibleWaitTimeoutMs: 30000, } as const; type MaestroVisibilitySample = | { visible: true; response: DaemonResponse } - | { visible: false; response: DaemonResponse; infrastructureFailure: boolean }; + | { + visible: false; + response: DaemonResponse; + infrastructureFailure: boolean; + snapshot?: SnapshotState; + }; export async function invokeMaestroAssertVisible(params: { baseReq: ReplayBaseRequest; @@ -40,17 +48,69 @@ export async function invokeMaestroAssertVisible(params: { }); if (!args.ok) return args.response; + const nativeWaitQuery = readNativeVisibleWaitQuery(params.baseReq, args.selector, args.timeoutMs); + if (nativeWaitQuery) { + const nativeStartedAt = Date.now(); + let nativeResponse = await params.invoke({ + ...params.baseReq, + command: 'wait', + positionals: [nativeWaitQuery, String(args.timeoutMs)], + }); + if (!nativeResponse.ok && shouldRetryNativeWaitAfterOverlayDismiss(nativeResponse)) { + const overlayResponse = await dismissReactNativeOverlayIfPresent(params); + if (overlayResponse) { + nativeResponse = await params.invoke({ + ...params.baseReq, + command: 'wait', + positionals: [nativeWaitQuery, String(args.timeoutMs)], + }); + } + } + if (!nativeResponse.ok) return nativeResponse; + return visibleAssertionResponse( + { + ok: true, + data: { + selector: args.selector, + nativeWait: true, + query: nativeWaitQuery, + response: nativeResponse.data, + }, + }, + args.selector, + nativeStartedAt, + ); + } + // Native wait/is cannot replace this loop: wait only proves existence, while // is requires unique resolution and does not apply Maestro overlay filtering. const startedAt = Date.now(); const deadlineMs = args.timeoutMs + MAESTRO_ASSERTION_POLICY.assertVisibleGraceMs; let lastResponse: DaemonResponse | undefined; + let lastSnapshot: SnapshotState | 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; + lastSnapshot = sample.snapshot; + if (!dismissedOverlay && shouldRetrySnapshotAssertionAfterOverlayDismiss(sample.response)) { + const overlayResponse = await dismissReactNativeOverlayIfPresent(params); + if (overlayResponse) { + dismissedOverlay = true; + continue; + } + return sample.response; + } + if ( + params.baseReq.flags?.maestro?.allowAlreadyPastLoading === true && + lastSnapshot && + isAlreadyPastLoadingState(args.selector, lastSnapshot) + ) { + return alreadyPastLoadingResponse(args.selector, args.timeoutMs, startedAt); + } const elapsedMs = Date.now() - startedAt; if (elapsedMs >= deadlineMs) { @@ -79,6 +139,50 @@ export async function invokeMaestroAssertVisible(params: { ); } +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 { + return ( + !response.ok && + response.error.code === 'COMMAND_FAILED' && + response.error.message.includes('React Native overlay') + ); +} + +function readNativeVisibleWaitQuery( + baseReq: ReplayBaseRequest, + selector: string, + timeoutMs: number, +): string | null { + if (baseReq.flags?.platform !== 'ios') return null; + if (baseReq.flags?.maestro?.allowAlreadyPastLoading === true) return null; + if (timeoutMs < MAESTRO_ASSERTION_POLICY.minNativeVisibleWaitTimeoutMs) return null; + return extractMaestroVisibleTextQuery(selector); +} + +function alreadyPastLoadingResponse( + selector: string, + timeoutMs: number, + startedAt: number, +): DaemonResponse { + return { + ok: true, + data: { + selector, + alreadyPastLoading: true, + waitedMs: Date.now() - startedAt, + timeoutMs, + }, + }; +} + function readVisibilityAssertionArgs( positionals: string[], options: { command: string; defaultTimeoutMs: number }, @@ -133,6 +237,7 @@ async function readMaestroVisibilitySample( visible: false, response: errorResponse('COMMAND_FAILED', target.message, { selector }), infrastructureFailure: false, + snapshot, }; } rememberMaestroVisibleContext(params.scope, selector); @@ -153,6 +258,32 @@ async function readMaestroVisibilitySample( }; } +function isAlreadyPastLoadingState(selector: string, snapshot: SnapshotState): boolean { + const query = normalizeLoadingText(extractMaestroVisibleTextQuery(selector)); + if (!isLoadingText(query)) return false; + + const currentTexts = snapshot.nodes + .flatMap((node) => [node.label, node.value, node.identifier]) + .filter((value): value is string => Boolean(value?.trim())) + .map((value) => normalizeLoadingText(value)); + + if (currentTexts.some((text) => text.includes('something went wrong'))) return false; + return currentTexts.some((text) => text !== query && !isLoadingText(text)); +} + +function normalizeLoadingText(value: string | null | undefined): string { + return ( + value + ?.trim() + .toLowerCase() + .replace(/\.\.\./g, '...') ?? '' + ); +} + +function isLoadingText(value: string): boolean { + return value === 'loading' || value === 'loading...' || value === 'loading…'; +} + function visibleAssertionResponse( response: DaemonResponse, selector: string, diff --git a/src/compat/maestro/runtime-interactions.ts b/src/compat/maestro/runtime-interactions.ts index cf1e8ed46..0c7d3530d 100644 --- a/src/compat/maestro/runtime-interactions.ts +++ b/src/compat/maestro/runtime-interactions.ts @@ -13,6 +13,7 @@ import { pointForMaestroTapOnTarget, swipeCoordinatesFromTarget } from './runtim import { captureMaestroRawSnapshot, clearMaestroVisibleContext, + dismissReactNativeOverlayIfPresent, errorResponse, readCachedMaestroReferenceFrame, readMaestroVisibleContext, @@ -427,10 +428,27 @@ async function invokeMaestroSnapshotTapOn( selector: string, options: MaestroTapOnOptions, ): Promise<{ response: DaemonResponse; targetResolved: boolean }> { - const target = await resolveMaestroSnapshotTarget(params, selector, options, 'tapOn', { + let target = await resolveMaestroSnapshotTarget(params, selector, options, 'tapOn', { promoteTapTarget: true, }); + if (!target.ok && isReactNativeOverlayBlockedResponse(target.response)) { + const overlayResponse = await dismissReactNativeOverlayIfPresent(params); + if (overlayResponse?.ok) clearMaestroVisibleContext(params.scope); + if (overlayResponse) { + target = await resolveMaestroSnapshotTarget(params, selector, options, 'tapOn', { + promoteTapTarget: true, + }); + } + } if (!target.ok) return { response: target.response, targetResolved: false }; + const overlayResponse = await maybeDismissReactNativeOverlayTapTarget(params, selector); + if (overlayResponse) { + if (overlayResponse.ok) clearMaestroVisibleContext(params.scope); + return { + response: overlayResponse, + targetResolved: true, + }; + } const point = pointForMaestroTapOnTarget( target.target, extractMaestroVisibleTextQuery(selector) !== null, @@ -464,6 +482,23 @@ async function invokeMaestroSnapshotTapOn( }; } +async function maybeDismissReactNativeOverlayTapTarget( + params: MaestroTapOnParams, + selector: string, +): Promise { + const query = extractMaestroVisibleTextQuery(selector)?.trim().toLowerCase(); + if (query !== 'dismiss' && query !== 'minimize' && query !== 'close') return null; + return await dismissReactNativeOverlayIfPresent(params); +} + +function isReactNativeOverlayBlockedResponse(response: DaemonResponse): boolean { + return ( + !response.ok && + response.error.code === 'ELEMENT_NOT_FOUND' && + response.error.message.includes('React Native overlay is covering app content') + ); +} + async function invokeMaestroFuzzyTapOn( params: MaestroTapOnParams, query: string, diff --git a/src/compat/maestro/runtime-support.ts b/src/compat/maestro/runtime-support.ts index f58f3f1ab..56df6f35a 100644 --- a/src/compat/maestro/runtime-support.ts +++ b/src/compat/maestro/runtime-support.ts @@ -86,6 +86,19 @@ export function clearMaestroVisibleContext(scope: ReplayVarScope | undefined): v if (scope) maestroVisibleContextCache.delete(scope); } +export async function dismissReactNativeOverlayIfPresent(params: { + baseReq: ReplayBaseRequest; + invoke: MaestroRuntimeInvoke; +}): Promise { + const response = await params.invoke({ + ...params.baseReq, + command: 'react-native', + positionals: ['dismiss-overlay'], + }); + if (response.ok && response.data?.dismissed === true) return response; + return null; +} + function rememberMaestroReferenceFrame(scope: ReplayVarScope, data: unknown): void { const snapshot = readSnapshotState(data); const frame = getSnapshotReferenceFrame(snapshot); diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index d5b63d992..3f92efbb5 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -55,6 +55,10 @@ type MaestroMatchResolutionOptions = { preferredContext?: MaestroPreferredContext; }; +type MaestroSelectorMatchOptions = { + allowLeadingCompositeLabelMatch?: boolean; +}; + type ReactNativeOverlayFilterResult = { matches: SnapshotNode[]; blockedByReactNativeOverlay: boolean; @@ -109,7 +113,7 @@ export function resolveMaestroNodeFromSnapshot( return { ok: false, message: visibleMatchesResult.blockedByReactNativeOverlay - ? `Maestro selector matched ${matches.length} element(s), but React Native overlay is covering app content: ${selector}` + ? `React Native overlay is covering app content: ${selector}` : matches.length > 0 && visibleMatchesResult.matches.length === 0 ? `Maestro selector matched ${matches.length} element(s), but none were visible: ${selector}` : `Maestro selector did not match index ${index}: ${selector}`, @@ -153,7 +157,9 @@ export function resolveVisibleMaestroNodeFromSnapshot( platform: Platform, frame: TouchReferenceFrame | undefined, ): { ok: true; node: SnapshotNode; rect: Rect; matches: number } | { ok: false; message: string } { - const matches = findMaestroSelectorMatches(snapshot, selector, platform); + const matches = findMaestroSelectorMatches(snapshot, selector, platform, { + allowLeadingCompositeLabelMatch: false, + }); const visibleMatchesResult = filterVisibleMaestroMatches({ nodes: snapshot.nodes, matches, @@ -170,11 +176,10 @@ export function resolveVisibleMaestroNodeFromSnapshot( if (!target) { return { ok: false, - message: - matches.length > 0 - ? visibleMatchesResult.blockedByReactNativeOverlay - ? `Maestro selector matched ${matches.length} element(s), but React Native overlay is covering app content: ${selector}` - : `Maestro selector matched ${matches.length} element(s), but none were visible: ${selector}` + message: visibleMatchesResult.blockedByReactNativeOverlay + ? `React Native overlay is covering app content: ${selector}` + : matches.length > 0 + ? `Maestro selector matched ${matches.length} element(s), but none were visible: ${selector}` : `Maestro selector did not match: ${selector}`, }; } @@ -200,7 +205,11 @@ function filterVisibleMaestroMatches(params: { platform: params.platform, }).pass, ); - const overlayFilter = filterReactNativeOverlayBlockedMatches(params.nodes, visibleMatches); + const overlayFilter = filterReactNativeOverlayBlockedMatches( + params.nodes, + visibleMatches, + params.platform, + ); return { matches: overlayFilter.matches, blockedByReactNativeOverlay: overlayFilter.blockedByReactNativeOverlay, @@ -210,6 +219,7 @@ function filterVisibleMaestroMatches(params: { function filterReactNativeOverlayBlockedMatches( nodes: SnapshotState['nodes'], matches: SnapshotNode[], + platform: Platform, ): ReactNativeOverlayFilterResult { const overlay = detectReactNativeOverlay(nodes); if (!overlay.detected) { @@ -218,11 +228,26 @@ function filterReactNativeOverlayBlockedMatches( if (!overlay.redBox) { return { matches, blockedByReactNativeOverlay: false }; } - const overlayNodeIndexes = new Set( - [...overlay.dismissNodes, ...overlay.minimizeNodes, ...overlay.collapsedNodes].map( - (node) => node.index, - ), + const visibleOverlayControls = [ + ...overlay.dismissNodes, + ...overlay.minimizeNodes, + ...overlay.collapsedNodes, + ].filter( + (node) => + evaluateIsPredicate({ + predicate: 'visible', + node, + nodes, + platform, + }).pass, ); + if (visibleOverlayControls.length === 0) { + if (overlay.redBox && !hasReactNativeOverlayDismissCandidates(overlay)) { + return { matches: [], blockedByReactNativeOverlay: true }; + } + return { matches, blockedByReactNativeOverlay: false }; + } + const overlayNodeIndexes = new Set(visibleOverlayControls.map((node) => node.index)); const overlayMatches = matches.filter((node) => overlayNodeIndexes.has(node.index)); return { matches: overlayMatches, @@ -230,6 +255,18 @@ 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'; } @@ -252,11 +289,12 @@ function findMaestroSelectorMatches( snapshot: SnapshotState, selectorExpression: string, platform: Platform, + options: MaestroSelectorMatchOptions = {}, ): SnapshotNode[] { const chain = parseSelectorChain(selectorExpression); for (const selector of chain.selectors) { const matches = snapshot.nodes.filter((node) => - matchesMaestroSelector(node, selector, platform), + matchesMaestroSelector(node, selector, platform, options), ); if (matches.length > 0) return matches; } @@ -286,17 +324,23 @@ function matchesMaestroSelector( node: SnapshotNode, selector: Selector, platform: Platform, + options: MaestroSelectorMatchOptions, ): boolean { if (matchesSelector(node, selector, platform)) return true; - return selector.terms.every((term) => matchesMaestroTerm(node, term, platform)); + return selector.terms.every((term) => matchesMaestroTerm(node, term, platform, options)); } -function matchesMaestroTerm(node: SnapshotNode, term: SelectorTerm, platform: Platform): boolean { +function matchesMaestroTerm( + node: SnapshotNode, + term: SelectorTerm, + platform: Platform, + options: MaestroSelectorMatchOptions, +): boolean { if (typeof term.value !== 'string' || !isMaestroRegexTextKey(term.key)) { return matchesSelector(node, { raw: term.key, terms: [term] }, platform); } const value = readMaestroTextTermValue(node, term.key); - return textEqualsOrRegex(value, term.value); + return textEqualsOrRegex(value, term.value, options); } function isMaestroRegexTextKey(key: SelectorTerm['key']): key is 'id' | 'label' | 'text' | 'value' { @@ -313,12 +357,21 @@ function readMaestroTextTermValue( return extractNodeText(node); } -function textEqualsOrRegex(value: string | undefined, query: string): boolean { +function textEqualsOrRegex( + value: string | undefined, + query: string, + options: MaestroSelectorMatchOptions = {}, +): boolean { const text = value ?? ''; const normalizedText = normalizeText(text); const normalizedQuery = normalizeText(query); if (normalizedText === normalizedQuery) return true; - if (isLeadingCompositeLabelMatch(normalizedText, normalizedQuery)) return true; + if ( + options.allowLeadingCompositeLabelMatch !== false && + isLeadingCompositeLabelMatch(normalizedText, normalizedQuery) + ) { + return true; + } if (!looksLikeMaestroRegex(query)) return false; try { return new RegExp(query).test(text); diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index a89921d71..f979a38e1 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -6,7 +6,9 @@ import type { SessionSurface } from './session-surface.ts'; export type MaestroRuntimeFlags = { allowNonHittableCoordinateFallback?: boolean; + allowAlreadyPastLoading?: boolean; optional?: boolean; + prewarmRunnerBeforeOpen?: boolean; runScriptEnv?: Record; }; diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index 60dfe075f..615905d28 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -34,6 +34,7 @@ function isRunnerNativeSelectorKey(key: string): key is DirectIosSelectorTarget[ export function isDirectIosSelectorFallbackError(error: unknown): boolean { const appError = asAppError(error); + if (appError.code === 'ELEMENT_NOT_FOUND') return true; if (appError.code !== 'COMMAND_FAILED') return false; const message = appError.message.toLowerCase(); return ( diff --git a/src/daemon/handlers/__tests__/interaction-read.test.ts b/src/daemon/handlers/__tests__/interaction-read.test.ts index 07e8ce3f5..a9c64f0a0 100644 --- a/src/daemon/handlers/__tests__/interaction-read.test.ts +++ b/src/daemon/handlers/__tests__/interaction-read.test.ts @@ -33,13 +33,19 @@ describe('readTextForNode', () => { beforeEach(() => mockDispatch.mockClear()); it('returns snapshot text without a backend read for non-editable nodes', async () => { - const text = await readTextForNode({ ...baseParams, node: node({ type: 'button', label: 'General' }) }); + const text = await readTextForNode({ + ...baseParams, + node: node({ type: 'button', label: 'General' }), + }); expect(text).toBe('General'); expect(mockDispatch).not.toHaveBeenCalled(); }); it('still re-reads via the backend for editable text inputs (live value may exceed snapshot)', async () => { - const text = await readTextForNode({ ...baseParams, node: node({ type: 'textfield', value: 'snap' }) }); + const text = await readTextForNode({ + ...baseParams, + node: node({ type: 'textfield', value: 'snap' }), + }); expect(mockDispatch).toHaveBeenCalledOnce(); expect(text).toBe('backend-text'); }); @@ -50,7 +56,10 @@ describe('readTextForNode', () => { }); it('returns snapshot text without a backend read when the node has no resolvable center', async () => { - const text = await readTextForNode({ ...baseParams, node: node({ type: 'button', label: 'General', rect: undefined }) }); + const text = await readTextForNode({ + ...baseParams, + node: node({ type: 'button', label: 'General', rect: undefined }), + }); expect(text).toBe('General'); expect(mockDispatch).not.toHaveBeenCalled(); }); diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index a4e594141..3ba595a5c 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -303,6 +303,77 @@ test('get text simple iOS id selector uses runner query without snapshot', async expect(recorded?.result?.selectorChain).toEqual(['id="field-name"']); }); +test('get text iOS label selector uses snapshot disambiguation instead of runner query', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'get-text-ios-label-selector'; + sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + mockDispatch.mockResolvedValue({ + backend: 'xctest', + nodes: [ + { + index: 0, + depth: 0, + type: 'Application', + rect: { x: 0, y: 0, width: 393, height: 852 }, + enabled: true, + hittable: true, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'Cell', + label: 'General', + rect: { x: 0, y: 100, width: 393, height: 48 }, + enabled: true, + hittable: true, + }, + { + index: 2, + depth: 2, + parentIndex: 1, + type: 'Button', + label: 'General', + rect: { x: 0, y: 100, width: 393, height: 48 }, + enabled: true, + hittable: true, + }, + { + index: 3, + depth: 2, + parentIndex: 1, + type: 'StaticText', + label: 'General', + rect: { x: 18, y: 112, width: 80, height: 24 }, + enabled: true, + hittable: false, + }, + ], + }); + + const response = await handleInteractionCommands({ + req: { + token: 't', + session: sessionName, + command: 'get', + positionals: ['text', 'label="General"'], + flags: {}, + }, + sessionName, + sessionStore, + contextFromFlags, + }); + + expect(response?.ok).toBe(true); + expect(mockRunIosRunnerCommand).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch.mock.calls[0]?.[1]).toBe('snapshot'); + if (response?.ok) { + expect(response.data?.text).toBe('General'); + expect(response.data?.selector).toBe('label="General"'); + } +}); + test('get text simple iOS id selector does not snapshot-fallback on ambiguous runner match', async () => { const sessionStore = makeSessionStore(); const sessionName = 'get-text-ios-direct-selector-ambiguous'; @@ -502,64 +573,70 @@ test('click simple iOS selector forwards Maestro non-hittable coordinate fallbac } }); -test('click simple iOS id selector falls back to snapshot coordinates when direct tap fails', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-direct-selector-fallback'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); - - mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { - if (command === 'press' && (context as Record)?.directElementSelector) { - throw new AppError('COMMAND_FAILED', 'fetch failed'); - } - if (command === 'snapshot') { - return { - nodes: attachRefs([ - { - index: 0, - type: 'Window', - rect: { x: 0, y: 0, width: 390, height: 844 }, - }, - { - index: 1, - parentIndex: 0, - type: 'XCUIElementTypeButton', - identifier: 'submit', - rect: { x: 20, y: 80, width: 120, height: 40 }, - enabled: true, - hittable: true, - }, - ]), - backend: 'xctest', - }; - } - if (command === 'press') { - return { x: Number(positionals[0]), y: Number(positionals[1]), pressed: true }; - } - return {}; - }); +test.each([ + ['transport failure', new AppError('COMMAND_FAILED', 'fetch failed')], + ['runner element miss', new AppError('ELEMENT_NOT_FOUND', 'element not found')], +])( + 'click simple iOS id selector falls back to snapshot coordinates on %s', + async (label, error) => { + const sessionStore = makeSessionStore(); + const sessionName = `ios-direct-selector-fallback-${label}`; + sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { + if (command === 'press' && (context as Record)?.directElementSelector) { + throw error; + } + if (command === 'snapshot') { + return { + nodes: attachRefs([ + { + index: 0, + type: 'Window', + rect: { x: 0, y: 0, width: 390, height: 844 }, + }, + { + index: 1, + parentIndex: 0, + type: 'XCUIElementTypeButton', + identifier: 'submit', + rect: { x: 20, y: 80, width: 120, height: 40 }, + enabled: true, + hittable: true, + }, + ]), + backend: 'xctest', + }; + } + if (command === 'press') { + return { x: Number(positionals[0]), y: Number(positionals[1]), pressed: true }; + } + return {}; + }); - const response = await handleInteractionCommands({ - req: { - token: 't', - session: sessionName, - command: 'click', - positionals: ['id="submit"'], - flags: {}, - }, - sessionName, - sessionStore, - contextFromFlags, - }); + const response = await handleInteractionCommands({ + req: { + token: 't', + session: sessionName, + command: 'click', + positionals: ['id="submit"'], + flags: {}, + }, + sessionName, + sessionStore, + contextFromFlags, + }); - expect(response?.ok).toBe(true); - const pressCalls = mockDispatch.mock.calls.filter((call) => call[1] === 'press'); - expect(pressCalls.length).toBe(2); - expect(pressCalls[0]?.[2]).toEqual([]); - expect(pressCalls[1]?.[2]).toEqual(['80', '100']); - if (response?.ok) { - expect(response.data?.selectorChain).toContain('id="submit"'); - } -}); + expect(response?.ok).toBe(true); + const pressCalls = mockDispatch.mock.calls.filter((call) => call[1] === 'press'); + expect(pressCalls.length).toBe(2); + expect(pressCalls[0]?.[2]).toEqual([]); + expect(pressCalls[1]?.[2]).toEqual(['80', '100']); + if (response?.ok) { + expect(response.data?.selectorChain).toContain('id="submit"'); + } + }, +); test('click simple iOS id selector does not snapshot-fallback on ambiguous runner match', async () => { const sessionStore = makeSessionStore(); diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index 2a94f6128..60ca98c12 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -1058,6 +1058,38 @@ test('runReplayScriptFile treats absent Maestro extendedWaitUntil.notVisible tar assert.equal(calls[0]?.flags?.noRecord, true); }); +test('runReplayScriptFile treats passed loading extendedWaitUntil as success', async () => { + const { response } = await runReplayFixture({ + label: 'maestro-extended-wait-loading-already-past', + script: [ + 'appId: demo.app', + '---', + '- extendedWaitUntil:', + ' visible: Loading…', + ' timeout: 1', + '', + ].join('\n'), + flags: { replayBackend: 'maestro' }, + invoke: async () => ({ + ok: true, + data: { + createdAt: 1, + nodes: [ + { + index: 1, + label: 'Suspend', + type: 'Button', + rect: { x: 16, y: 120, width: 120, height: 48 }, + visibleToUser: true, + }, + ], + }, + }), + }); + + assert.equal(response.ok, true); +}); + test('runReplayScriptFile retries Maestro fuzzy tapOn without raw selector fallback', async () => { const calls: CapturedInvocation[] = []; let snapshotAttempts = 0; diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 96dc49c17..0a2dbade5 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -1998,6 +1998,69 @@ test('open iOS app session prewarms runner session when app bundle id is known', ); }); +test('open iOS Maestro app link waits for runner prewarm before launching app', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-maestro-open-link'; + const events: string[] = []; + let finishPrewarm: (() => void) | undefined; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'ios', + id: 'ios-device-1', + name: 'iPhone Device', + kind: 'device', + booted: true, + }), + appBundleId: 'com.example.previous', + appName: 'Previous App', + }); + + mockPrewarmIosRunnerSession.mockImplementation( + () => + new Promise((resolve) => { + events.push('prewarm-start'); + finishPrewarm = () => { + events.push('prewarm-finish'); + resolve(); + }; + }), + ); + mockDispatch.mockImplementation(async (_device, command) => { + events.push(`dispatch:${command}`); + return {}; + }); + + const responsePromise = handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['com.example.app', 'rne://screen-layout'], + flags: { + maestro: { prewarmRunnerBeforeOpen: true }, + }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + await vi.waitFor(() => expect(events).toEqual(['prewarm-start'])); + + finishPrewarm?.(); + const response = await responsePromise; + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(events).toEqual(['prewarm-start', 'prewarm-finish', 'dispatch:open']); + expect((response as any).data?.timing).toMatchObject({ + runnerPrewarmKind: 'session', + runnerPrewarmScheduled: true, + runnerPrewarmWaited: true, + }); +}); + test('open iOS URL without app bundle id skips runner prewarm', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index ae1689151..a80537539 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -918,6 +918,34 @@ test('settings rejects unsupported iOS physical devices', async () => { } }); +test('settings clear-app-state dispatches explicit app id without an active app session', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-clear-state'; + sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + + const response = await handleSnapshotCommands({ + req: { + token: 't', + session: sessionName, + command: 'settings', + positionals: ['clear-app-state', 'org.reactnavigation.playground'], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(mockDispatch).toHaveBeenCalledWith( + iosSimulatorDevice, + 'settings', + ['clear-app-state', 'clear', 'org.reactnavigation.playground'], + undefined, + expect.objectContaining({ appBundleId: 'org.reactnavigation.playground' }), + ); +}); + test('settings usage hint documents canonical faceid states', async () => { const sessionStore = makeSessionStore(); const response = await handleSnapshotCommands({ diff --git a/src/daemon/handlers/interaction-read.ts b/src/daemon/handlers/interaction-read.ts index cc416757f..1263ab1ff 100644 --- a/src/daemon/handlers/interaction-read.ts +++ b/src/daemon/handlers/interaction-read.ts @@ -31,11 +31,7 @@ export async function readTextForNode(params: { // Restricted to iOS because other backends read differently — macOS helper and Linux reads // are value-first (AXValue/title/description), unlike the label-first snapshot readable text, // so skipping their backend read would change the returned text. - if ( - device.platform === 'ios' && - fallbackText && - !prefersValueForReadableText(node.type ?? '') - ) { + if (device.platform === 'ios' && fallbackText && !prefersValueForReadableText(node.type ?? '')) { return fallbackText; } diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index bb56a8470..03fe3b302 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -184,6 +184,12 @@ async function completeOpenCommand(params: { timing.runnerPrewarmScheduled = true; runnerPrewarm = prewarmIosRunnerSession(device, runnerPrewarmOptions); } + if (runnerPrewarm && req.flags?.maestro?.prewarmRunnerBeforeOpen === true) { + const runnerPrewarmStartedAtMs = Date.now(); + await runnerPrewarm; + timing.runnerPrewarmWaited = true; + timing.runnerPrewarmDurationMs = Math.max(0, Date.now() - runnerPrewarmStartedAtMs); + } const openStartedAtMs = Date.now(); await dispatchCommand(device, 'open', openPositionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, sessionAppBundleId), @@ -200,12 +206,12 @@ async function completeOpenCommand(params: { openPositionals, }); timing.launchUrlDurationMs = Math.max(0, Date.now() - launchUrlStartedAtMs); - if (shouldRelaunch && runnerPrewarm) { + if (shouldRelaunch && runnerPrewarm && timing.runnerPrewarmWaited !== true) { const runnerPrewarmStartedAtMs = Date.now(); await runnerPrewarm; timing.runnerPrewarmWaited = true; timing.runnerPrewarmDurationMs = Math.max(0, Date.now() - runnerPrewarmStartedAtMs); - } else if (runnerPrewarm) { + } else if (runnerPrewarm && timing.runnerPrewarmWaited !== true) { timing.runnerPrewarmWaited = false; } sessionAppBundleId = await inferAndroidPackageAfterOpen(device, openTarget, sessionAppBundleId); diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index f636c463d..1339ce754 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -14,6 +14,7 @@ import { errorResponse, type DaemonFailureResponse } from './response.ts'; type ParsedSettingsArgs = { setting: string; state: string; + appBundleId?: string; permissionTarget?: string; latitude?: string; longitude?: string; @@ -34,6 +35,17 @@ export function parseSettingsArgs( const setting = req.positionals?.[0]?.toLowerCase(); const state = req.positionals?.[1]?.toLowerCase(); const permissionTarget = req.positionals?.[2]?.toLowerCase(); + if (setting === 'clear-app-state') { + const appBundleId = state === 'clear' ? req.positionals?.[2] : req.positionals?.[1]; + return { + ok: true, + parsed: { + setting, + state: 'clear', + appBundleId, + }, + }; + } if ( !setting || !state || @@ -58,7 +70,14 @@ export async function handleSettingsCommand( params: HandleSettingsCommandParams, ): Promise { const { req, logPath, sessionStore, session, device, parsed } = params; - const { setting, state, permissionTarget, latitude, longitude } = parsed; + const { + setting, + state, + appBundleId: parsedAppBundleId, + permissionTarget, + latitude, + longitude, + } = parsed; if (!isCommandSupportedOnDevice('settings', device)) { return errorResponse('UNSUPPORTED_OPERATION', 'settings is not supported on this device'); } @@ -66,14 +85,16 @@ export async function handleSettingsCommand( return errorResponse('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); } - const appBundleId = session?.appBundleId; + const appBundleId = parsedAppBundleId ?? session?.appBundleId; // Settings positional layout for dispatch: setting, state, command payload, appBundleId. const positionals = - setting === 'permission' - ? [setting, state, permissionTarget ?? '', req.positionals?.[3] ?? '', appBundleId ?? ''] - : setting === 'location' && state === 'set' - ? [setting, state, latitude ?? '', longitude ?? '', appBundleId ?? ''] - : [setting, state, appBundleId ?? '']; + setting === 'clear-app-state' + ? [setting, state, appBundleId ?? ''] + : setting === 'permission' + ? [setting, state, permissionTarget ?? '', req.positionals?.[3] ?? '', appBundleId ?? ''] + : setting === 'location' && state === 'set' + ? [setting, state, latitude ?? '', longitude ?? '', appBundleId ?? ''] + : [setting, state, appBundleId ?? '']; const data = await dispatchCommand(device, 'settings', positionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, appBundleId, session?.trace?.outPath), }); diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 39b9d133e..0fb4c3f9b 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -277,9 +277,17 @@ async function dispatchDirectIosSelectorGet( property: 'text' | 'attrs', selectorExpression: string, ): Promise { - const directQuery = await resolveDirectIosSelectorQuery(params, selectorExpression); - if (isDirectIosSelectorErrorResult(directQuery)) return directQuery.response; - if (!directQuery) return null; + const session = params.sessionStore.get(params.sessionName); + const selector = readSimpleIosSelectorTarget({ session, selectorExpression }); + if (!session || !selector) return null; + // get text intentionally disambiguates label/text/value triplets from snapshots; the runner + // direct query rejects those ambiguous matches before the shared selector resolver can rank them. + if (property === 'text' && selector.key !== 'id') return null; + + const result = await queryDirectIosSelectorOrFallback(params, session, selector); + if (isDirectIosSelectorErrorResult(result)) return result.response; + if (!result) return null; + const directQuery = { session, selector, result }; const payload = buildDirectIosGetResult(property, directQuery.selector.raw, directQuery.result); if (!payload) return null; recordIfSession( diff --git a/src/platforms/ios/__tests__/runner-command-retry.test.ts b/src/platforms/ios/__tests__/runner-command-retry.test.ts index 7274e4714..88fc9a395 100644 --- a/src/platforms/ios/__tests__/runner-command-retry.test.ts +++ b/src/platforms/ios/__tests__/runner-command-retry.test.ts @@ -81,6 +81,31 @@ test('mutating commands retry startup sessions with stale bundle cleanup', async assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], freshSession); }); +test('mutating commands restart stale sessions when readiness preflight fails before command send', async () => { + const staleSession = makeRunnerSession({ port: 8100, ready: true }); + const freshSession = makeRunnerSession({ port: 8101, ready: false }); + + mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'fetch failed', { + runnerReadinessPreflightFailed: true, + }), + ) + .mockResolvedValueOnce({ message: 'tapped' }); + + const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); + + assert.deepEqual(result, { message: 'tapped' }); + assert.equal(mockEnsureRunnerSession.mock.calls.length, 2); + assert.deepEqual(mockInvalidateRunnerSession.mock.calls[0], [ + staleSession, + 'runner_readiness_preflight_failed_before_command_send', + ]); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], freshSession); +}); + test('mutating commands do not restart or replay after command send failure', async () => { const session = makeRunnerSession({ port: 8100, ready: true }); diff --git a/src/platforms/ios/__tests__/runner-session.test.ts b/src/platforms/ios/__tests__/runner-session.test.ts index a8423843b..0a96d5c60 100644 --- a/src/platforms/ios/__tests__/runner-session.test.ts +++ b/src/platforms/ios/__tests__/runner-session.test.ts @@ -178,6 +178,30 @@ test('runner session skips readiness preflight for tap commands after a recent s assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); }); +test('runner session keeps readiness preflight for selector taps after a recent successful response', async () => { + const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() }); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); + + const result = await executeRunnerCommandWithSession( + IOS_SIMULATOR, + session, + { + command: 'tap', + selectorKey: 'label', + selectorValue: 'Navigate to article', + appBundleId: 'com.example.demo', + }, + '/tmp/runner.log', + 30_000, + ); + + assert.deepEqual(result, { tapped: true }); + assert.equal(mockWaitForRunner.mock.calls.length, 1); + assert.deepEqual(mockWaitForRunner.mock.calls[0]?.[2], { command: 'uptime' }); + assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1); +}); + test('runner session skips readiness preflight for tapSeries after a recent successful response', async () => { const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() }); mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true })); diff --git a/src/platforms/ios/runner-client.ts b/src/platforms/ios/runner-client.ts index ed0b1c3bb..73aae1f1b 100644 --- a/src/platforms/ios/runner-client.ts +++ b/src/platforms/ios/runner-client.ts @@ -149,6 +149,33 @@ async function executeRunnerCommand( throw retryErr; } } + if (session && isRunnerReadinessPreflightError(appErr) && isRetryableRunnerError(appErr)) { + assertRunnerRequestActive(options.requestId); + await invalidateRunnerSession( + session, + 'runner_readiness_preflight_failed_before_command_send', + ); + session = await ensureRunnerSession(device, { ...options, cleanStaleBundles: true }); + try { + return await executeRunnerCommandWithSession( + device, + session, + command, + options.logPath, + RUNNER_STARTUP_TIMEOUT_MS, + signal, + ); + } catch (retryErr) { + const retryAppErr = + retryErr instanceof AppError + ? retryErr + : new AppError('COMMAND_FAILED', String(retryErr)); + if (isRetryableRunnerError(retryAppErr)) { + await invalidateRunnerSession(session, 'transport_error_after_retry_command_send'); + } + throw retryErr; + } + } if (!session && appErr.message.includes('Runner did not accept connection')) { await stopIosRunnerSession(device.id); } @@ -159,6 +186,10 @@ async function executeRunnerCommand( } } +function isRunnerReadinessPreflightError(error: AppError): boolean { + return error.details?.runnerReadinessPreflightFailed === true; +} + export { resolveRunnerDestination, resolveRunnerBuildDestination, diff --git a/src/platforms/ios/runner-session.ts b/src/platforms/ios/runner-session.ts index 4eeba57e3..45e04c73a 100644 --- a/src/platforms/ios/runner-session.ts +++ b/src/platforms/ios/runner-session.ts @@ -449,21 +449,25 @@ export async function executeRunnerCommandWithSession( const readinessTimeoutMs = session.ready ? Math.min(RUNNER_READY_PREFLIGHT_TIMEOUT_MS, deadline.remainingMs()) : Math.min(RUNNER_STARTUP_TIMEOUT_MS, deadline.remainingMs()); - const readinessResponse = await withDiagnosticTimer( - 'ios_runner_readiness_preflight', - async () => - await waitForRunner( - device, - session.port, - { command: 'uptime' }, - logPath, - readinessTimeoutMs, - session, - signal, - ), - { command: command.command, sessionReady: session.ready, timeoutMs: readinessTimeoutMs }, - ); - await parseRunnerResponse(readinessResponse, session, logPath); + try { + const readinessResponse = await withDiagnosticTimer( + 'ios_runner_readiness_preflight', + async () => + await waitForRunner( + device, + session.port, + { command: 'uptime' }, + logPath, + readinessTimeoutMs, + session, + signal, + ), + { command: command.command, sessionReady: session.ready, timeoutMs: readinessTimeoutMs }, + ); + await parseRunnerResponse(readinessResponse, session, logPath); + } catch (error) { + throw markRunnerReadinessPreflightError(error); + } } else { emitDiagnostic({ level: 'debug', @@ -540,6 +544,7 @@ function shouldPreflightMutatingRunnerCommand( ): boolean { if (!session.ready) return true; if (command.command !== 'tap' && command.command !== 'tapSeries') return true; + if (command.selectorKey !== undefined) return true; const lastSuccessAt = session.lastSuccessfulRunnerResponseAtMs; return ( lastSuccessAt === undefined || @@ -547,6 +552,27 @@ function shouldPreflightMutatingRunnerCommand( ); } +function markRunnerReadinessPreflightError(error: unknown): AppError { + const appErr = + error instanceof AppError + ? error + : new AppError( + 'COMMAND_FAILED', + error instanceof Error ? error.message : String(error), + undefined, + error, + ); + return new AppError( + appErr.code, + appErr.message, + { + ...(appErr.details ?? {}), + runnerReadinessPreflightFailed: true, + }, + appErr.cause ?? error, + ); +} + async function measureRunnerStartupStep( timings: Record, phase: string,