diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 657606bb8..de94f9d66 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 \ diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index ea62b202d..8b43c1993 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,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 } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index abb6737b1..98dc676f9 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 081d1c861..3d234c21f 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()