@@ -9,6 +9,18 @@ import XCTest
99import Network
1010
1111final class RunnerTests : XCTestCase {
12+ private enum RunnerErrorDomain {
13+ static let general = " AgentDeviceRunner "
14+ static let exception = " AgentDeviceRunner.NSException "
15+ }
16+
17+ private enum RunnerErrorCode {
18+ static let noResponseFromMainThread = 1
19+ static let commandReturnedNoResponse = 2
20+ static let mainThreadExecutionTimedOut = 3
21+ static let objcException = 1
22+ }
23+
1224 private static let springboardBundleId = " com.apple.springboard "
1325 private var listener : NWListener ?
1426 private var port : UInt16 = 0
@@ -20,6 +32,12 @@ final class RunnerTests: XCTestCase {
2032 private let maxRequestBytes = 2 * 1024 * 1024
2133 private let maxSnapshotElements = 600
2234 private let fastSnapshotLimit = 300
35+ 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
2341 private let interactiveTypes : Set < XCUIElement . ElementType > = [
2442 . button,
2543 . cell,
@@ -49,7 +67,7 @@ final class RunnerTests: XCTestCase {
4967 ]
5068
5169 override func setUp( ) {
52- continueAfterFailure = false
70+ continueAfterFailure = true
5371 }
5472
5573 @MainActor
@@ -192,53 +210,140 @@ final class RunnerTests: XCTestCase {
192210
193211 private func execute( command: Command ) throws -> Response {
194212 if Thread . isMainThread {
195- return try executeOnMain ( command: command)
213+ return try executeOnMainSafely ( command: command)
196214 }
197215 var result : Result < Response , Error > ?
198216 let semaphore = DispatchSemaphore ( value: 0 )
199217 DispatchQueue . main. async {
200218 do {
201- result = . success( try self . executeOnMain ( command: command) )
219+ result = . success( try self . executeOnMainSafely ( command: command) )
202220 } catch {
203221 result = . failure( error)
204222 }
205223 semaphore. signal ( )
206224 }
207- semaphore. wait ( )
225+ let waitResult = semaphore. wait ( timeout: . now( ) + mainThreadExecutionTimeout)
226+ if waitResult == . timedOut {
227+ // The main queue work may still be running; we stop waiting and report timeout.
228+ throw NSError (
229+ domain: RunnerErrorDomain . general,
230+ code: RunnerErrorCode . mainThreadExecutionTimedOut,
231+ userInfo: [ NSLocalizedDescriptionKey: " main thread execution timed out " ]
232+ )
233+ }
208234 switch result {
209235 case . success( let response) :
210236 return response
211237 case . failure( let error) :
212238 throw error
213239 case . none:
214- throw NSError ( domain: " AgentDeviceRunner " , code: 1 , userInfo: [ NSLocalizedDescriptionKey: " no response from main thread " ] )
240+ throw NSError (
241+ domain: RunnerErrorDomain . general,
242+ code: RunnerErrorCode . noResponseFromMainThread,
243+ userInfo: [ NSLocalizedDescriptionKey: " no response from main thread " ]
244+ )
245+ }
246+ }
247+
248+ private func executeOnMainSafely( command: Command ) throws -> Response {
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+ )
274+ }
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
215293 }
216294 }
217295
218296 private func executeOnMain( command: Command ) throws -> Response {
297+ if command. command == . shutdown {
298+ return Response ( ok: true , data: DataPayload ( message: " shutdown " ) )
299+ }
300+
219301 let normalizedBundleId = command. appBundleId?
220302 . trimmingCharacters ( in: . whitespacesAndNewlines)
221303 let requestedBundleId = ( normalizedBundleId? . isEmpty == true ) ? nil : normalizedBundleId
222- let switchedApp : Bool
223- if let bundleId = requestedBundleId, currentBundleId != bundleId {
224- let target = XCUIApplication ( bundleIdentifier: bundleId)
225- NSLog ( " AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d " , bundleId, target. state. rawValue)
226- // activate avoids terminating and relaunching the target app
227- target. activate ( )
228- currentApp = target
229- currentBundleId = bundleId
230- switchedApp = true
231- } else if requestedBundleId == nil {
304+ if let bundleId = requestedBundleId {
305+ if currentBundleId != bundleId || currentApp == nil {
306+ _ = activateTarget ( bundleId: bundleId, reason: " bundle_changed " )
307+ }
308+ } else {
232309 // Do not reuse stale bundle targets when the caller does not explicitly request one.
233310 currentApp = nil
234311 currentBundleId = nil
235- switchedApp = false
236- } else {
237- switchedApp = false
238312 }
239- let activeApp = currentApp ?? app
240- if switchedApp {
241- _ = activeApp. waitForExistence ( timeout: 5 )
313+
314+ var activeApp = currentApp ?? app
315+ if let bundleId = requestedBundleId, targetNeedsActivation ( activeApp) {
316+ activeApp = activateTarget ( bundleId: bundleId, reason: " stale_target " )
317+ } else if requestedBundleId == nil , targetNeedsActivation ( activeApp) {
318+ app. activate ( )
319+ activeApp = app
320+ }
321+
322+ if !activeApp. waitForExistence ( timeout: 5 ) {
323+ if let bundleId = requestedBundleId {
324+ activeApp = activateTarget ( bundleId: bundleId, reason: " missing_after_wait " )
325+ guard activeApp. waitForExistence ( timeout: 5 ) else {
326+ return Response ( ok: false , error: ErrorPayload ( message: " app ' \( bundleId) ' is not available " ) )
327+ }
328+ } else {
329+ return Response ( ok: false , error: ErrorPayload ( message: " runner app is not available " ) )
330+ }
331+ }
332+
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 ( )
242347 }
243348
244349 switch command. command {
@@ -358,8 +463,10 @@ final class RunnerTests: XCTestCase {
358463 raw: command. raw ?? false ,
359464 )
360465 if options. raw {
466+ needsPostSnapshotInteractionDelay = true
361467 return Response ( ok: true , data: snapshotRaw ( app: activeApp, options: options) )
362468 }
469+ needsPostSnapshotInteractionDelay = true
363470 return Response ( ok: true , data: snapshotFast ( app: activeApp, options: options) )
364471 case . back:
365472 if tapNavigationBack ( app: activeApp) {
@@ -400,6 +507,71 @@ final class RunnerTests: XCTestCase {
400507 }
401508 }
402509
510+ private func targetNeedsActivation( _ target: XCUIApplication ) -> Bool {
511+ switch target. state {
512+ case . unknown, . notRunning, . runningBackground, . runningBackgroundSuspended:
513+ return true
514+ default :
515+ return false
516+ }
517+ }
518+
519+ private func activateTarget( bundleId: String , reason: String ) -> XCUIApplication {
520+ let target = XCUIApplication ( bundleIdentifier: bundleId)
521+ NSLog (
522+ " AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d reason=%@ " ,
523+ bundleId,
524+ target. state. rawValue,
525+ reason
526+ )
527+ // activate avoids terminating and relaunching the target app
528+ target. activate ( )
529+ currentApp = target
530+ currentBundleId = bundleId
531+ needsFirstInteractionDelay = true
532+ return target
533+ }
534+
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+
403575 private func tapNavigationBack( app: XCUIApplication ) -> Bool {
404576 let buttons = app. navigationBars. buttons. allElementsBoundByIndex
405577 if let back = buttons. first ( where: { $0. isHittable } ) {
0 commit comments