Skip to content

Commit 8fde634

Browse files
committed
fix: harden runner interactions after snapshot
1 parent 1ce2b8f commit 8fde634

1 file changed

Lines changed: 107 additions & 28 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 107 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
@@ -310,6 +330,22 @@ final class RunnerTests: XCTestCase {
310330
}
311331
}
312332

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()
347+
}
348+
313349
switch command.command {
314350
case .shutdown:
315351
return Response(ok: true, data: DataPayload(message: "shutdown"))
@@ -427,8 +463,10 @@ final class RunnerTests: XCTestCase {
427463
raw: command.raw ?? false,
428464
)
429465
if options.raw {
466+
needsPostSnapshotInteractionDelay = true
430467
return Response(ok: true, data: snapshotRaw(app: activeApp, options: options))
431468
}
469+
needsPostSnapshotInteractionDelay = true
432470
return Response(ok: true, data: snapshotFast(app: activeApp, options: options))
433471
case .back:
434472
if tapNavigationBack(app: activeApp) {
@@ -490,9 +528,50 @@ final class RunnerTests: XCTestCase {
490528
target.activate()
491529
currentApp = target
492530
currentBundleId = bundleId
531+
needsFirstInteractionDelay = true
493532
return target
494533
}
495534

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+
496575
private func tapNavigationBack(app: XCUIApplication) -> Bool {
497576
let buttons = app.navigationBars.buttons.allElementsBoundByIndex
498577
if let back = buttons.first(where: { $0.isHittable }) {

0 commit comments

Comments
 (0)