Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ jobs:
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDismissDoesNotActivateAReplacementWithTheSameTitle \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDeadlineBeforeActivationLeavesTheOriginalUntouched \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertHittableProbeCompletingAfterDeadlineLeavesTheOriginalUntouched \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ extension RunnerTests {
guard Date() < deadline else {
return alertVerificationResponse(.timedOut, action: action, activated: false)
}
guard waitUntilAlertButtonHittable(button, deadline: deadline) else {
return alertVerificationResponse(.timedOut, action: action, activated: false)
}
let outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
if let response = unsupportedResponse(for: outcome) {
return response
Expand Down Expand Up @@ -222,6 +225,37 @@ extension RunnerTests {
return enabled
}

// A snapshot can expose an alert's button a beat before the owning app has made it
// hittable, and a starved host delays the app's layout and hit-testing further. This
// activation is not repeated, so a tap issued into that window is dropped, the
// presentation never changes, and the whole budget rides an unchanged alert to
// `ALERT_DEADLINE_EXCEEDED` with no button ever activated. Spend the deadline waiting
// for a fresh hittable read instead of spending it on a dropped tap. The hittable read
// is itself a synchronous query a starved host can complete past the deadline, so a read
// that lands late forfeits rather than buys back the one activation.
private func waitUntilAlertButtonHittable(_ button: XCUIElement, deadline: Date) -> Bool {
while Date() < deadline {
if probeAlertButtonHittable(button, deadline: deadline) {
return Date() < deadline
}
sleepFor(min(0.1, max(0, deadline.timeIntervalSinceNow)))
}
return false
}

private func probeAlertButtonHittable(_ button: XCUIElement, deadline: Date) -> Bool {
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
if let override = alertButtonHittabilityProbeOverrideForTesting {
return override(deadline)
}
#endif
var hittable = false
_ = RunnerObjCExceptionCatcher.catchException({
hittable = button.exists && button.isHittable
})
return hittable
}

private func isDismissPopupMarker(_ label: String) -> Bool {
label.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("dismiss popup") == .orderedSame
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ final class RunnerTests: XCTestCase {
var systemModalProbeOverrideForTesting: ((Date) -> DataPayload?)?
var blockingSystemModalPresenceOverrideForTesting: Bool?
var alertResolutionOverrideForTesting: ((Date) -> RunnerAlert?)?
var alertButtonHittabilityProbeOverrideForTesting: ((Date) -> Bool)?
#endif
// Observability for the record(_:) suppression below: how many AX-broken-screen snapshot
// issues this session muted, so wedge investigations see the volume without grepping logs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ extension RunnerTests {
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 0; replacement actions: 0")
}

func testAlertHittableProbeCompletingAfterDeadlineLeavesTheOriginalUntouched() throws {
app.launchArguments = ["--agent-device-alert-replacement-regression"]
app.launch()
defer {
alertButtonHittabilityProbeOverrideForTesting = nil
invalidateCachedTarget(reason: "unit_test_cleanup")
app.terminate()
}
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
let alert = try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(10)))
alertButtonHittabilityProbeOverrideForTesting = { probeDeadline in
while Date() < probeDeadline {
Thread.sleep(forTimeInterval: min(0.02, max(0, probeDeadline.timeIntervalSinceNow)))
}
return true
}
let response = handleAlert(alert, action: "accept", deadline: Date().addingTimeInterval(1))
XCTAssertFalse(response.ok)
XCTAssertEqual(response.error?.code, "ALERT_DEADLINE_EXCEEDED")
XCTAssertTrue(app.alerts.firstMatch.exists)
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 0; replacement actions: 0")
}

private func assertReplacementAlertUntouched(action: String, arguments: [String], confirmed: Bool) throws {
app.launchArguments = ["--agent-device-alert-replacement-regression"] + arguments
app.launch()
Expand Down
Loading