@@ -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 {
@@ -309,6 +329,22 @@ final class RunnerTests: XCTestCase {
309329 }
310330 }
311331
332+ if isInteractionCommand ( command. command) {
333+ if let bundleId = requestedBundleId, activeApp. state != . runningForeground {
334+ activeApp = activateTarget ( bundleId: bundleId, reason: " interaction_foreground_guard " )
335+ } else if requestedBundleId == nil , activeApp. state != . runningForeground {
336+ app. activate ( )
337+ activeApp = app
338+ }
339+ if !activeApp. waitForExistence ( timeout: 2 ) {
340+ if let bundleId = requestedBundleId {
341+ return Response ( ok: false , error: ErrorPayload ( message: " app ' \( bundleId) ' is not available " ) )
342+ }
343+ return Response ( ok: false , error: ErrorPayload ( message: " runner app is not available " ) )
344+ }
345+ applyInteractionStabilizationIfNeeded ( )
346+ }
347+
312348 switch command. command {
313349 case . shutdown:
314350 return Response ( ok: true , data: DataPayload ( message: " shutdown " ) )
@@ -389,8 +425,10 @@ final class RunnerTests: XCTestCase {
389425 raw: command. raw ?? false ,
390426 )
391427 if options. raw {
428+ needsPostSnapshotInteractionDelay = true
392429 return Response ( ok: true , data: snapshotRaw ( app: activeApp, options: options) )
393430 }
431+ needsPostSnapshotInteractionDelay = true
394432 return Response ( ok: true , data: snapshotFast ( app: activeApp, options: options) )
395433 case . back:
396434 if tapNavigationBack ( app: activeApp) {
@@ -452,9 +490,50 @@ final class RunnerTests: XCTestCase {
452490 target. activate ( )
453491 currentApp = target
454492 currentBundleId = bundleId
493+ needsFirstInteractionDelay = true
455494 return target
456495 }
457496
497+ private func shouldRetryCommand( _ command: CommandType ) -> Bool {
498+ switch command {
499+ case . tap, . longPress, . drag:
500+ return true
501+ default :
502+ return false
503+ }
504+ }
505+
506+ private func shouldRetryResponse( _ response: Response ) -> Bool {
507+ guard response. ok == false else { return false }
508+ guard let message = response. error? . message. lowercased ( ) else { return false }
509+ return message. contains ( " is not available " )
510+ }
511+
512+ private func isInteractionCommand( _ command: CommandType ) -> Bool {
513+ switch command {
514+ case . tap, . longPress, . drag, . type, . swipe, . back, . appSwitcher, . pinch:
515+ return true
516+ default :
517+ return false
518+ }
519+ }
520+
521+ private func applyInteractionStabilizationIfNeeded( ) {
522+ if needsPostSnapshotInteractionDelay {
523+ sleepFor ( postSnapshotInteractionDelay)
524+ needsPostSnapshotInteractionDelay = false
525+ }
526+ if needsFirstInteractionDelay {
527+ sleepFor ( firstInteractionAfterActivateDelay)
528+ needsFirstInteractionDelay = false
529+ }
530+ }
531+
532+ private func sleepFor( _ delay: TimeInterval ) {
533+ guard delay > 0 else { return }
534+ usleep ( useconds_t ( delay * 1_000_000 ) )
535+ }
536+
458537 private func tapNavigationBack( app: XCUIApplication ) -> Bool {
459538 let buttons = app. navigationBars. buttons. allElementsBoundByIndex
460539 if let back = buttons. first ( where: { $0. isHittable } ) {
0 commit comments