Skip to content

Commit fc10613

Browse files
authored
fix: harden iOS runner command execution (#72)
* fix: harden iOS runner command execution * fix: tighten iOS runner error semantics * fix: harden runner interactions after snapshot
1 parent 99cc684 commit fc10613

5 files changed

Lines changed: 223 additions & 21 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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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 *name = exception.name ?: @"NSException";
11+
NSString *reason = exception.reason ?: @"Unhandled XCTest exception";
12+
return [NSString stringWithFormat:@"%@: %@", name, reason];
13+
}
14+
}
15+
16+
@end

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 193 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ import XCTest
99
import Network
1010

1111
final 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

Comments
 (0)