@@ -33,6 +33,11 @@ final class RunnerTests: XCTestCase {
3333 private let maxSnapshotElements = 600
3434 private let fastSnapshotLimit = 300
3535 private let mainThreadExecutionTimeout : TimeInterval = 30
36+ private let retryCooldown : TimeInterval = 0.2
37+ private let postSnapshotInteractionDelay : TimeInterval = 0.2
38+ private let firstInteractionAfterActivateDelay : TimeInterval = 0.25
39+ private var needsPostSnapshotInteractionDelay = false
40+ private var needsFirstInteractionDelay = false
3641 private let interactiveTypes : Set < XCUIElement . ElementType > = [
3742 . button,
3843 . cell,
@@ -241,36 +246,51 @@ final class RunnerTests: XCTestCase {
241246 }
242247
243248 private func executeOnMainSafely( command: Command ) throws -> Response {
244- var response : Response ?
245- var swiftError : Error ?
246- let exceptionMessage = RunnerObjCExceptionCatcher . catchException ( {
247- do {
248- response = try self . executeOnMain ( command: command)
249- } catch {
250- swiftError = error
249+ var hasRetried = false
250+ while true {
251+ var response : Response ?
252+ var swiftError : Error ?
253+ let exceptionMessage = RunnerObjCExceptionCatcher . catchException ( {
254+ do {
255+ response = try self . executeOnMain ( command: command)
256+ } catch {
257+ swiftError = error
258+ }
259+ } )
260+
261+ if let exceptionMessage {
262+ currentApp = nil
263+ currentBundleId = nil
264+ if !hasRetried, shouldRetryCommand ( command. command) {
265+ hasRetried = true
266+ sleepFor ( retryCooldown)
267+ continue
268+ }
269+ throw NSError (
270+ domain: RunnerErrorDomain . exception,
271+ code: RunnerErrorCode . objcException,
272+ userInfo: [ NSLocalizedDescriptionKey: exceptionMessage]
273+ )
251274 }
252- } )
253-
254- if let exceptionMessage {
255- currentApp = nil
256- currentBundleId = nil
257- throw NSError (
258- domain: RunnerErrorDomain . exception,
259- code: RunnerErrorCode . objcException,
260- userInfo: [ NSLocalizedDescriptionKey: exceptionMessage]
261- )
262- }
263- if let swiftError {
264- throw swiftError
265- }
266- guard let response else {
267- throw NSError (
268- domain: RunnerErrorDomain . general,
269- code: RunnerErrorCode . commandReturnedNoResponse,
270- userInfo: [ NSLocalizedDescriptionKey: " command returned no response " ]
271- )
275+ if let swiftError {
276+ throw swiftError
277+ }
278+ guard let response else {
279+ throw NSError (
280+ domain: RunnerErrorDomain . general,
281+ code: RunnerErrorCode . commandReturnedNoResponse,
282+ userInfo: [ NSLocalizedDescriptionKey: " command returned no response " ]
283+ )
284+ }
285+ if !hasRetried, shouldRetryCommand ( command. command) , shouldRetryResponse ( response) {
286+ hasRetried = true
287+ currentApp = nil
288+ currentBundleId = nil
289+ sleepFor ( retryCooldown)
290+ continue
291+ }
292+ return response
272293 }
273- return response
274294 }
275295
276296 private func executeOnMain( command: Command ) throws -> Response {
@@ -310,6 +330,22 @@ final class RunnerTests: XCTestCase {
310330 }
311331 }
312332
333+ if isInteractionCommand ( command. command) {
334+ if let bundleId = requestedBundleId, activeApp. state != . runningForeground {
335+ activeApp = activateTarget ( bundleId: bundleId, reason: " interaction_foreground_guard " )
336+ } else if requestedBundleId == nil , activeApp. state != . runningForeground {
337+ app. activate ( )
338+ activeApp = app
339+ }
340+ if !activeApp. waitForExistence ( timeout: 2 ) {
341+ if let bundleId = requestedBundleId {
342+ return Response ( ok: false , error: ErrorPayload ( message: " app ' \( bundleId) ' is not available " ) )
343+ }
344+ return Response ( ok: false , error: ErrorPayload ( message: " runner app is not available " ) )
345+ }
346+ applyInteractionStabilizationIfNeeded ( )
347+ }
348+
313349 switch command. command {
314350 case . shutdown:
315351 return Response ( ok: true , data: DataPayload ( message: " shutdown " ) )
@@ -427,8 +463,10 @@ final class RunnerTests: XCTestCase {
427463 raw: command. raw ?? false ,
428464 )
429465 if options. raw {
466+ needsPostSnapshotInteractionDelay = true
430467 return Response ( ok: true , data: snapshotRaw ( app: activeApp, options: options) )
431468 }
469+ needsPostSnapshotInteractionDelay = true
432470 return Response ( ok: true , data: snapshotFast ( app: activeApp, options: options) )
433471 case . back:
434472 if tapNavigationBack ( app: activeApp) {
@@ -490,9 +528,50 @@ final class RunnerTests: XCTestCase {
490528 target. activate ( )
491529 currentApp = target
492530 currentBundleId = bundleId
531+ needsFirstInteractionDelay = true
493532 return target
494533 }
495534
535+ private func shouldRetryCommand( _ command: CommandType ) -> Bool {
536+ switch command {
537+ case . tap, . longPress, . drag:
538+ return true
539+ default :
540+ return false
541+ }
542+ }
543+
544+ private func shouldRetryResponse( _ response: Response ) -> Bool {
545+ guard response. ok == false else { return false }
546+ guard let message = response. error? . message. lowercased ( ) else { return false }
547+ return message. contains ( " is not available " )
548+ }
549+
550+ private func isInteractionCommand( _ command: CommandType ) -> Bool {
551+ switch command {
552+ case . tap, . longPress, . drag, . type, . swipe, . back, . appSwitcher, . pinch:
553+ return true
554+ default :
555+ return false
556+ }
557+ }
558+
559+ private func applyInteractionStabilizationIfNeeded( ) {
560+ if needsPostSnapshotInteractionDelay {
561+ sleepFor ( postSnapshotInteractionDelay)
562+ needsPostSnapshotInteractionDelay = false
563+ }
564+ if needsFirstInteractionDelay {
565+ sleepFor ( firstInteractionAfterActivateDelay)
566+ needsFirstInteractionDelay = false
567+ }
568+ }
569+
570+ private func sleepFor( _ delay: TimeInterval ) {
571+ guard delay > 0 else { return }
572+ usleep ( useconds_t ( delay * 1_000_000 ) )
573+ }
574+
496575 private func tapNavigationBack( app: XCUIApplication ) -> Bool {
497576 let buttons = app. navigationBars. buttons. allElementsBoundByIndex
498577 if let back = buttons. first ( where: { $0. isHittable } ) {
0 commit comments