@@ -20,6 +20,7 @@ final class RunnerTests: XCTestCase {
2020 private let maxRequestBytes = 2 * 1024 * 1024
2121 private let maxSnapshotElements = 600
2222 private let fastSnapshotLimit = 300
23+ private let mainThreadExecutionTimeout : TimeInterval = 30
2324 private let interactiveTypes : Set < XCUIElement . ElementType > = [
2425 . button,
2526 . cell,
@@ -49,7 +50,7 @@ final class RunnerTests: XCTestCase {
4950 ]
5051
5152 override func setUp( ) {
52- continueAfterFailure = false
53+ continueAfterFailure = true
5354 }
5455
5556 @MainActor
@@ -192,19 +193,26 @@ final class RunnerTests: XCTestCase {
192193
193194 private func execute( command: Command ) throws -> Response {
194195 if Thread . isMainThread {
195- return try executeOnMain ( command: command)
196+ return try executeOnMainSafely ( command: command)
196197 }
197198 var result : Result < Response , Error > ?
198199 let semaphore = DispatchSemaphore ( value: 0 )
199200 DispatchQueue . main. async {
200201 do {
201- result = . success( try self . executeOnMain ( command: command) )
202+ result = . success( try self . executeOnMainSafely ( command: command) )
202203 } catch {
203204 result = . failure( error)
204205 }
205206 semaphore. signal ( )
206207 }
207- semaphore. wait ( )
208+ let waitResult = semaphore. wait ( timeout: . now( ) + mainThreadExecutionTimeout)
209+ if waitResult == . timedOut {
210+ throw NSError (
211+ domain: " AgentDeviceRunner " ,
212+ code: 3 ,
213+ userInfo: [ NSLocalizedDescriptionKey: " main thread execution timed out " ]
214+ )
215+ }
208216 switch result {
209217 case . success( let response) :
210218 return response
@@ -215,24 +223,74 @@ final class RunnerTests: XCTestCase {
215223 }
216224 }
217225
226+ private func executeOnMainSafely( command: Command ) throws -> Response {
227+ var response : Response ?
228+ var swiftError : Error ?
229+ let exceptionMessage = RunnerObjCExceptionCatcher . catchException ( {
230+ do {
231+ response = try self . executeOnMain ( command: command)
232+ } catch {
233+ swiftError = error
234+ }
235+ } )
236+
237+ if let exceptionMessage {
238+ currentApp = nil
239+ currentBundleId = nil
240+ throw NSError (
241+ domain: " AgentDeviceRunner.NSException " ,
242+ code: 1 ,
243+ userInfo: [ NSLocalizedDescriptionKey: exceptionMessage]
244+ )
245+ }
246+ if let swiftError {
247+ throw swiftError
248+ }
249+ guard let response else {
250+ throw NSError (
251+ domain: " AgentDeviceRunner " ,
252+ code: 2 ,
253+ userInfo: [ NSLocalizedDescriptionKey: " command returned no response " ]
254+ )
255+ }
256+ return response
257+ }
258+
218259 private func executeOnMain( command: Command ) throws -> Response {
260+ if command. command == . shutdown {
261+ return Response ( ok: true , data: DataPayload ( message: " shutdown " ) )
262+ }
263+
219264 let normalizedBundleId = command. appBundleId?
220265 . trimmingCharacters ( in: . whitespacesAndNewlines)
221266 let requestedBundleId = ( normalizedBundleId? . isEmpty == true ) ? nil : normalizedBundleId
222- if let bundleId = requestedBundleId, currentBundleId != bundleId {
223- let target = XCUIApplication ( bundleIdentifier: bundleId)
224- NSLog ( " AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d " , bundleId, target. state. rawValue)
225- // activate avoids terminating and relaunching the target app
226- target. activate ( )
227- currentApp = target
228- currentBundleId = bundleId
229- } else if requestedBundleId == nil {
267+ if let bundleId = requestedBundleId {
268+ if currentBundleId != bundleId || currentApp == nil {
269+ _ = activateTarget ( bundleId: bundleId, reason: " bundle_changed " )
270+ }
271+ } else {
230272 // Do not reuse stale bundle targets when the caller does not explicitly request one.
231273 currentApp = nil
232274 currentBundleId = nil
233275 }
234- let activeApp = currentApp ?? app
235- _ = activeApp. waitForExistence ( timeout: 5 )
276+ var activeApp = currentApp ?? app
277+ if let bundleId = requestedBundleId, targetNeedsActivation ( activeApp) {
278+ activeApp = activateTarget ( bundleId: bundleId, reason: " stale_target " )
279+ } else if requestedBundleId == nil , targetNeedsActivation ( activeApp) {
280+ app. activate ( )
281+ activeApp = app
282+ }
283+
284+ if !activeApp. waitForExistence ( timeout: 5 ) {
285+ if let bundleId = requestedBundleId {
286+ activeApp = activateTarget ( bundleId: bundleId, reason: " missing_after_wait " )
287+ guard activeApp. waitForExistence ( timeout: 5 ) else {
288+ return Response ( ok: false , error: ErrorPayload ( message: " app ' \( bundleId) ' is not available " ) )
289+ }
290+ } else {
291+ return Response ( ok: false , error: ErrorPayload ( message: " runner app is not available " ) )
292+ }
293+ }
236294
237295 switch command. command {
238296 case . shutdown:
@@ -356,6 +414,30 @@ final class RunnerTests: XCTestCase {
356414 }
357415 }
358416
417+ private func targetNeedsActivation( _ target: XCUIApplication ) -> Bool {
418+ switch target. state {
419+ case . unknown, . notRunning, . runningBackground, . runningBackgroundSuspended:
420+ return true
421+ default :
422+ return false
423+ }
424+ }
425+
426+ private func activateTarget( bundleId: String , reason: String ) -> XCUIApplication {
427+ let target = XCUIApplication ( bundleIdentifier: bundleId)
428+ NSLog (
429+ " AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d reason=%@ " ,
430+ bundleId,
431+ target. state. rawValue,
432+ reason
433+ )
434+ // activate avoids terminating and relaunching the target app
435+ target. activate ( )
436+ currentApp = target
437+ currentBundleId = bundleId
438+ return target
439+ }
440+
359441 private func tapNavigationBack( app: XCUIApplication ) -> Bool {
360442 let buttons = app. navigationBars. buttons. allElementsBoundByIndex
361443 if let back = buttons. first ( where: { $0. isHittable } ) {
0 commit comments