Skip to content

Commit 33db118

Browse files
committed
fix: harden iOS runner command execution
1 parent 040b518 commit 33db118

5 files changed

Lines changed: 125 additions & 14 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@
401401
STRING_CATALOG_GENERATE_SYMBOLS = NO;
402402
SWIFT_APPROACHABLE_CONCURRENCY = YES;
403403
SWIFT_EMIT_LOC_STRINGS = NO;
404+
SWIFT_OBJC_BRIDGING_HEADER = "AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h";
404405
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
405406
SWIFT_VERSION = 5.0;
406407
TARGETED_DEVICE_FAMILY = "1,2";
@@ -422,6 +423,7 @@
422423
STRING_CATALOG_GENERATE_SYMBOLS = NO;
423424
SWIFT_APPROACHABLE_CONCURRENCY = YES;
424425
SWIFT_EMIT_LOC_STRINGS = NO;
426+
SWIFT_OBJC_BRIDGING_HEADER = "AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h";
425427
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
426428
SWIFT_VERSION = 5.0;
427429
TARGETED_DEVICE_FAMILY = "1,2";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
#import "RunnerObjCExceptionCatcher.h"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#import <Foundation/Foundation.h>
2+
3+
NS_ASSUME_NONNULL_BEGIN
4+
5+
@interface RunnerObjCExceptionCatcher : NSObject
6+
7+
+ (NSString * _Nullable)catchException:(NS_NOESCAPE dispatch_block_t)tryBlock;
8+
9+
@end
10+
11+
NS_ASSUME_NONNULL_END
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#import "RunnerObjCExceptionCatcher.h"
2+
3+
@implementation RunnerObjCExceptionCatcher
4+
5+
+ (NSString * _Nullable)catchException:(NS_NOESCAPE dispatch_block_t)tryBlock {
6+
@try {
7+
tryBlock();
8+
return nil;
9+
} @catch (NSException *exception) {
10+
NSString *reason = exception.reason ?: @"Unhandled XCTest exception";
11+
return [NSString stringWithFormat:@"%@: %@", exception.name, reason];
12+
}
13+
}
14+
15+
@end

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)