Skip to content

Commit 0d07d9c

Browse files
committed
fix: harden runner interactions after snapshot
1 parent a2bd557 commit 0d07d9c

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 {
@@ -309,6 +329,22 @@ final class RunnerTests: XCTestCase {
309329
}
310330
}
311331

332+
if isInteractionCommand(command.command) {
333+
if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
334+
activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
335+
} else if requestedBundleId == nil, activeApp.state != .runningForeground {
336+
app.activate()
337+
activeApp = app
338+
}
339+
if !activeApp.waitForExistence(timeout: 2) {
340+
if let bundleId = requestedBundleId {
341+
return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
342+
}
343+
return Response(ok: false, error: ErrorPayload(message: "runner app is not available"))
344+
}
345+
applyInteractionStabilizationIfNeeded()
346+
}
347+
312348
switch command.command {
313349
case .shutdown:
314350
return Response(ok: true, data: DataPayload(message: "shutdown"))
@@ -389,8 +425,10 @@ final class RunnerTests: XCTestCase {
389425
raw: command.raw ?? false,
390426
)
391427
if options.raw {
428+
needsPostSnapshotInteractionDelay = true
392429
return Response(ok: true, data: snapshotRaw(app: activeApp, options: options))
393430
}
431+
needsPostSnapshotInteractionDelay = true
394432
return Response(ok: true, data: snapshotFast(app: activeApp, options: options))
395433
case .back:
396434
if tapNavigationBack(app: activeApp) {
@@ -452,9 +490,50 @@ final class RunnerTests: XCTestCase {
452490
target.activate()
453491
currentApp = target
454492
currentBundleId = bundleId
493+
needsFirstInteractionDelay = true
455494
return target
456495
}
457496

497+
private func shouldRetryCommand(_ command: CommandType) -> Bool {
498+
switch command {
499+
case .tap, .longPress, .drag:
500+
return true
501+
default:
502+
return false
503+
}
504+
}
505+
506+
private func shouldRetryResponse(_ response: Response) -> Bool {
507+
guard response.ok == false else { return false }
508+
guard let message = response.error?.message.lowercased() else { return false }
509+
return message.contains("is not available")
510+
}
511+
512+
private func isInteractionCommand(_ command: CommandType) -> Bool {
513+
switch command {
514+
case .tap, .longPress, .drag, .type, .swipe, .back, .appSwitcher, .pinch:
515+
return true
516+
default:
517+
return false
518+
}
519+
}
520+
521+
private func applyInteractionStabilizationIfNeeded() {
522+
if needsPostSnapshotInteractionDelay {
523+
sleepFor(postSnapshotInteractionDelay)
524+
needsPostSnapshotInteractionDelay = false
525+
}
526+
if needsFirstInteractionDelay {
527+
sleepFor(firstInteractionAfterActivateDelay)
528+
needsFirstInteractionDelay = false
529+
}
530+
}
531+
532+
private func sleepFor(_ delay: TimeInterval) {
533+
guard delay > 0 else { return }
534+
usleep(useconds_t(delay * 1_000_000))
535+
}
536+
458537
private func tapNavigationBack(app: XCUIApplication) -> Bool {
459538
let buttons = app.navigationBars.buttons.allElementsBoundByIndex
460539
if let back = buttons.first(where: { $0.isHittable }) {

0 commit comments

Comments
 (0)