Skip to content

Commit f94c74f

Browse files
committed
fix(runner): bound XCTest's idle wait around alert activation
An alert button is read as hittable before it is tapped, so XCTest's own wait for the app to idle before synthesizing that tap adds nothing the runner has not already checked. Its default outlives the command, so the tap lands after the caller was told the alert timed out (#2546). The helper that bounds that wait was named for the scroll path that owned it; gesture, type and swipe already went through it, and alert activation now does too, so it is named for the wait it bounds. Each caller states which of XCTest's two waits it gives up: the gesture, type and swipe paths skip both, because their next step is a poll of their own, while alert activation drops only the pre-event wait. Its verification reads the alert this tap replaced, and an alert that dismisses in order to present an identical replacement passes through a moment with no alert at all — a first read landing in that moment reported a dismissal nothing had proved.
1 parent aba54b4 commit f94c74f

4 files changed

Lines changed: 52 additions & 9 deletions

File tree

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,16 @@ extension RunnerTests {
6868
guard waitUntilAlertButtonHittable(button, deadline: deadline) else {
6969
return alertVerificationResponse(.timedOut, action: action, activated: false)
7070
}
71-
let outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
71+
// The hittable read above is this activation's readiness gate, so XCTest's pre-synthesis wait
72+
// adds nothing and can cost more than the command has: the tap would land after the deadline
73+
// expired and the alert would be answered by a button the caller was told nothing about
74+
// (#2546). The post-tap settle stays, because the verification below reads the alert this tap
75+
// replaces; an alert that dismisses and presents an identical replacement passes through a
76+
// window with no alert, and a first read landing there reports a dismissal nothing proved.
77+
var outcome = RunnerInteractionOutcome.performed
78+
withBoundedInteractionIdleTimeoutIfSupported(alert.ownerApp, waits: .preEventSkipped) {
79+
outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
80+
}
7281
if let response = unsupportedResponse(for: outcome) {
7382
return response
7483
}

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ extension RunnerTests {
9797
var outcome = RunnerInteractionOutcome.performed
9898
let timing = measureGesture {
9999
if idleTimeout {
100-
withTemporaryScrollIdleTimeoutIfSupported(app) { outcome = action() }
100+
withBoundedInteractionIdleTimeoutIfSupported(app, waits: .bothSkipped) {
101+
outcome = action()
102+
}
101103
} else {
102104
outcome = action()
103105
}
@@ -2075,7 +2077,7 @@ extension RunnerTests {
20752077
return Response(ok: true, data: DataPayload(message: "remote pressed"))
20762078
case .type:
20772079
var response: Response?
2078-
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
2080+
withBoundedInteractionIdleTimeoutIfSupported(activeApp, waits: .bothSkipped) {
20792081
response = executeTypeCommand(activeApp: activeApp, command: command)
20802082
}
20812083
return response ?? Response(ok: false, error: ErrorPayload(message: "type produced no response"))
@@ -2087,7 +2089,7 @@ extension RunnerTests {
20872089
// keeps raw measureGesture and only routes the success payload through gestureResponse.
20882090
var executedFrame: DragVisualizationFrame?
20892091
let timing = measureGesture {
2090-
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
2092+
withBoundedInteractionIdleTimeoutIfSupported(activeApp, waits: .bothSkipped) {
20912093
executedFrame = swipe(app: activeApp, direction: direction)
20922094
}
20932095
}

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ func runnerCGImage(from image: RunnerImage) -> CGImage? {
2121
#endif
2222
}
2323

24+
/// Which of XCTest's two waits around one synthesized event a caller gives up.
25+
///
26+
/// The two waits answer to different callers. The pre-event wait is what #2546 bounds: the runner
27+
/// has already decided the interaction may be synthesized, and XCTest's default wait for the app to
28+
/// idle outlives a bounded command, so the event lands after the caller was told it failed. The
29+
/// post-event wait is the settle margin before the runner reads the app back, and a caller whose
30+
/// verdict is that next read cannot give it up.
31+
enum RunnerInteractionIdleWaits {
32+
/// Neither wait, for a caller whose next step is its own poll rather than a verdict read off this
33+
/// event: a scroll re-checks its own content, a text field was already located, a swipe has
34+
/// nothing to verify.
35+
case bothSkipped
36+
/// Pre-event wait dropped, post-event quiescence kept under the same bound, for a caller whose
37+
/// verdict is the state this event produced. Alert verification reads the alert the tap replaced,
38+
/// and a read taken mid-transition finds no alert and reports a dismissal nothing proved.
39+
case preEventSkipped
40+
}
41+
2442
extension RunnerTests {
2543
// MARK: - Recording
2644

@@ -209,8 +227,14 @@ extension RunnerTests {
209227
return target
210228
}
211229

212-
func withTemporaryScrollIdleTimeoutIfSupported(
230+
/// Bounds what XCTest waits around one synthesized event instead of letting it spend a command's
231+
/// whole deadline, keeping whichever settle the caller named in `waits`. Callers gate the
232+
/// interaction themselves first (a scroll needs no extra wait, a text field is located, an alert
233+
/// button is read as hittable), which is what the dropped pre-event wait replaces rather than a
234+
/// check the runner skips (#2546).
235+
func withBoundedInteractionIdleTimeoutIfSupported(
213236
_ target: XCUIApplication,
237+
waits: RunnerInteractionIdleWaits,
214238
operation: () -> Void
215239
) {
216240
let setter = NSSelectorFromString("setWaitForIdleTimeout:")
@@ -219,19 +243,20 @@ extension RunnerTests {
219243
? (target.value(forKey: "waitForIdleTimeout") as? NSNumber)
220244
: nil
221245
if supportsWaitForIdleTimeout {
222-
target.setValue(scrollInteractionIdleTimeoutDefault, forKey: "waitForIdleTimeout")
246+
target.setValue(interactionIdleTimeoutDefault, forKey: "waitForIdleTimeout")
223247
}
224248
defer {
225249
if let previous {
226250
target.setValue(previous.doubleValue, forKey: "waitForIdleTimeout")
227251
}
228252
}
229-
performWithQuiescenceSkippedIfSupported(target, operation: operation)
253+
performWithQuiescenceSkippedIfSupported(target, waits: waits, operation: operation)
230254
}
231255

232256
// Some apps never report post-gesture quiescence, even after XCTest has synthesized the event.
233257
private func performWithQuiescenceSkippedIfSupported(
234258
_ target: XCUIApplication,
259+
waits: RunnerInteractionIdleWaits,
235260
operation: () -> Void
236261
) {
237262
let selector = NSSelectorFromString("_performWithInteractionOptions:block:")
@@ -252,12 +277,19 @@ extension RunnerTests {
252277
)
253278
let skipPreEventQuiescence = UInt(1)
254279
let skipPostEventQuiescence = UInt(2)
280+
let options: UInt
281+
switch waits {
282+
case .bothSkipped:
283+
options = skipPreEventQuiescence | skipPostEventQuiescence
284+
case .preEventSkipped:
285+
options = skipPreEventQuiescence
286+
}
255287
withoutActuallyEscaping(operation) { escapableOperation in
256288
let block: @convention(block) () -> Void = escapableOperation
257289
performWithOptions(
258290
target,
259291
selector,
260-
skipPreEventQuiescence | skipPostEventQuiescence,
292+
options,
261293
block
262294
)
263295
}

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ final class RunnerTests: XCTestCase {
5959
let retryCooldown: TimeInterval = 0.2
6060
let postSnapshotInteractionDelay: TimeInterval = 0.2
6161
let firstInteractionAfterActivateDelay: TimeInterval = 0.25
62-
let scrollInteractionIdleTimeoutDefault: TimeInterval = 1.0
62+
let interactionIdleTimeoutDefault: TimeInterval = 1.0
6363
let tvRemoteDoublePressDelayDefault: TimeInterval = 0.0
6464
// Keep a periodic XCTest liveness marker in runner.log without flooding long-lived sessions.
6565
let xctestIdleKeepaliveInterval: TimeInterval = 60.0

0 commit comments

Comments
 (0)