From 0d89661f18791377aa6bf298a583a717fce1a4d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 11 Sep 2026 19:24:49 +0200 Subject: [PATCH 1/3] fix(ios): gate alert activation on a fresh hittable read A snapshot can surface an alert button before the owning app has made it hittable, and a starved host widens that window. The single, never-repeated activation tapped into that gap, dropping the button press, riding an unchanged alert to ALERT_DEADLINE_EXCEEDED with First actions: 0, and flaking the alert-replacement runner regressions under CI contention. Wait for a fresh exists+isHittable read before the one activation; still activates at most once. --- .../RunnerTests+Alert.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index ea62b202d9..efc4cf211b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -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 @@ -222,6 +225,26 @@ 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. + private func waitUntilAlertButtonHittable(_ button: XCUIElement, deadline: Date) -> Bool { + while Date() < deadline { + var hittable = false + _ = RunnerObjCExceptionCatcher.catchException({ + hittable = button.exists && button.isHittable + }) + if hittable { + return true + } + sleepFor(min(0.1, max(0, deadline.timeIntervalSinceNow))) + } + return false + } + private func isDismissPopupMarker(_ label: String) -> Bool { label.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("dismiss popup") == .orderedSame } From 7bbb1488ca8c6cba332a00b9758b4301d7fc7a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 12 Sep 2026 14:26:03 +0200 Subject: [PATCH 2/3] fix(ios): recheck the deadline after the alert hittable probe The hittable read is a synchronous query that a starved host can complete past the command deadline. It previously handed back true unconditionally, so handleAlert tapped once more after the budget was already gone. Only a read that lands before the deadline buys back the single activation. Route the read through a unit-test-overridable probe and add a regression that completes the probe past the deadline and asserts, via the fixture's own action counter, that no button is activated. --- .../RunnerTests+Alert.swift | 25 +++++++++++++------ .../RunnerTests.swift | 1 + .../RunnerTests+AlertObservationTests.swift | 23 +++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index efc4cf211b..8b43c19930 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -230,21 +230,32 @@ extension RunnerTests { // 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. + // 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 { - var hittable = false - _ = RunnerObjCExceptionCatcher.catchException({ - hittable = button.exists && button.isHittable - }) - if hittable { - return true + 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 } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index abb6737b1b..98dc676f92 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -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. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertObservationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertObservationTests.swift index 081d1c8616..3d234c21f6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertObservationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertObservationTests.swift @@ -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() From d1e1d1e344e5a4d34971812570c7c437f12f527a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 12 Sep 2026 14:26:04 +0200 Subject: [PATCH 3/3] chore(gates): select the late-hittable-probe alert regression --- .github/workflows/ios.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 657606bb8d..de94f9d663 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -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 \