fix(ios): confirm alerts without repeating activation (#2326)

This commit is contained in:
Bills Booth
2026-09-06 04:00:02 -04:00
committed by GitHub
parent ed5e9736ec
commit 27a97ee619
9 changed files with 358 additions and 89 deletions
+4
View File
@@ -169,6 +169,10 @@ jobs:
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCachedTargetInvalidationClearsProcessBoundState \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertResolutionCannotBypassRequestedDeadline \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertAcceptTreatsOpenAsAffirmative \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertAcceptDoesNotActivateAReplacementWithASharedButton \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDismissDoesNotActivateAReplacementWithTheSameTitle \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDeadlineBeforeActivationLeavesTheOriginalUntouched \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \
@@ -58,10 +58,62 @@ int main(int argc, const char *argv[]) {
#import <UIKit/UIKit.h>
@interface AgentDeviceRunnerViewController : UIViewController
@property(nonatomic, strong) UILabel *alertActionStatus;
@property(nonatomic, assign) NSUInteger firstAlertActions;
@property(nonatomic, assign) NSUInteger replacementAlertActions;
@property(nonatomic, assign) BOOL alertFixtureStarted;
@end
@implementation AgentDeviceRunnerViewController
#if TARGET_OS_IOS
- (void)updateAlertActionStatus {
self.alertActionStatus.text = [NSString stringWithFormat:@"First actions: %lu; replacement actions: %lu",
(unsigned long)self.firstAlertActions,
(unsigned long)self.replacementAlertActions];
}
- (void)presentAlertFixtureReplacement:(BOOL)replacement {
NSArray<NSString *> *arguments = NSProcessInfo.processInfo.arguments;
BOOL sameTitle = [arguments containsObject:@"--agent-device-alert-same-title"];
BOOL sameBody = [arguments containsObject:@"--agent-device-alert-same-body"];
NSString *title = replacement && !sameTitle ? @"Next confirmation" : @"First confirmation";
NSString *body = replacement && !sameBody ? @"Second request" : @"First request";
UIAlertController *alert = [UIAlertController alertControllerWithTitle:title
message:body
preferredStyle:UIAlertControllerStyleAlert];
__weak UIAlertController *weakAlert = alert;
for (NSString *buttonTitle in @[@"Cancel", @"OK"]) {
UIAlertActionStyle style = [buttonTitle isEqualToString:@"Cancel"]
? UIAlertActionStyleCancel : UIAlertActionStyleDefault;
[alert addAction:[UIAlertAction actionWithTitle:buttonTitle style:style handler:^(UIAlertAction *action) {
(void)action;
if (replacement) {
self.replacementAlertActions += 1;
} else {
self.firstAlertActions += 1;
}
[self updateAlertActionStatus];
if (!replacement) {
[weakAlert dismissViewControllerAnimated:NO completion:^{
[self presentAlertFixtureReplacement:YES];
}];
}
}]];
}
[self presentViewController:alert animated:NO completion:nil];
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
if (!self.alertFixtureStarted &&
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-replacement-regression"]) {
self.alertFixtureStarted = YES;
[self presentAlertFixtureReplacement:NO];
}
}
#endif
- (void)agentDeviceTextEntryDidChange:(UITextField *)textField {
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-disappear-after-input"] &&
textField.text.length > 0) {
@@ -88,6 +140,12 @@ int main(int argc, const char *argv[]) {
// Keep the fixture behind a launch argument so normal runner snapshots remain unchanged.
#if TARGET_OS_IOS
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-replacement-regression"]) {
self.alertActionStatus = label;
label.accessibilityIdentifier = @"agent-device-alert-actions";
[self updateAlertActionStatus];
}
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-regression"]) {
UITextField *textField = [[UITextField alloc] init];
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
@@ -0,0 +1,36 @@
struct RunnerAlertPresentation: Equatable {
let title: String
let content: [String]
let buttons: [String]
}
enum RunnerAlertObservation {
case visible(RunnerAlertPresentation)
case absent
case unavailable
case deadlineExceeded
}
enum RunnerAlertVerification: Equatable {
case disappeared
case presentationChanged
case stillVisible
case unconfirmed
case timedOut
static func verify(
original: RunnerAlertPresentation,
observation: RunnerAlertObservation
) -> RunnerAlertVerification {
switch observation {
case .visible(let current):
return original == current ? .stillVisible : .presentationChanged
case .absent:
return .disappeared
case .unavailable:
return .unconfirmed
case .deadlineExceeded:
return .timedOut
}
}
}
@@ -55,50 +55,34 @@ extension RunnerTests {
guard let button = chooseAlertButton(alert.buttons, action: action) else {
return Response(ok: false, error: ErrorPayload(message: "alert \(action) button not found"))
}
let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
let actionButtonLabel = button.label.trimmingCharacters(in: .whitespacesAndNewlines)
let actionButtonFrame = button.frame
guard Date() < deadline else {
return alertVerificationResponse(.timedOut, action: action, activated: false)
}
guard let original = captureAlertPresentation(alert.root) else {
return alertVerificationResponse(.unconfirmed, action: action, activated: false)
}
let observesApplicationRoot = alert.root.elementType == .application
guard Date() < 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
}
sleepFor(0.2)
if alertStillVisible(
in: alert.ownerApp,
source: alert.source,
previousTitle: previousTitle,
actionButtonLabel: actionButtonLabel,
deadline: deadline
) {
if !actionButtonFrame.isNull && !actionButtonFrame.isEmpty {
let coordinateOutcome = tapAt(
app: alert.ownerApp,
x: actionButtonFrame.midX,
y: actionButtonFrame.midY
)
if let response = unsupportedResponse(for: coordinateOutcome) {
return response
}
sleepFor(0.2)
}
}
if alertStillVisible(
in: alert.ownerApp,
source: alert.source,
previousTitle: previousTitle,
actionButtonLabel: actionButtonLabel,
deadline: deadline
) {
return Response(
ok: false,
error: ErrorPayload(
code: "INTERACTION_FAILED",
message: "alert \(action) did not dismiss the visible alert",
hint: "The alert button was found but the system still reports the alert after tapping it."
while true {
sleepFor(min(0.2, max(0, deadline.timeIntervalSinceNow)))
let verification = RunnerAlertVerification.verify(
original: original,
observation: observeAlert(
in: alert.ownerApp,
source: alert.source,
observesApplicationRoot: observesApplicationRoot,
deadline: deadline
)
)
if verification == .stillVisible { continue }
return alertVerificationResponse(verification, action: action, activated: true)
}
return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
}
return Response(
@@ -135,57 +119,6 @@ extension RunnerTests {
return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons, source: source)
}
private func alertStillVisible(
in ownerApp: XCUIApplication,
source: RunnerAlertSource,
previousTitle: String,
actionButtonLabel: String,
deadline: Date
) -> Bool {
guard Date() < deadline,
let current = resolveAlert(source: source, app: ownerApp, deadline: deadline)
else {
return false
}
let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons)
if previousTitle == currentTitle {
return true
}
return current.buttons.contains { button in
button.label.trimmingCharacters(in: .whitespacesAndNewlines) == actionButtonLabel
}
}
private func resolveAlert(
source: RunnerAlertSource,
app: XCUIApplication,
deadline: Date
) -> RunnerAlert? {
switch source {
case .blockingSystemModal:
#if os(macOS)
return nil
#else
guard case .resolved(let modal) = resolveBlockingSystemModal(deadline: deadline) else {
return nil
}
return runnerAlert(modal)
#endif
case .appAlert:
guard let alert = firstExistingElement(
in: safeElementsQuery { app.alerts.allElementsBoundByIndex }
) else {
return nil
}
return runnerAlert(root: alert, ownerApp: app, source: .appAlert)
case .dismissPopup:
guard let popup = firstDismissPopupWindow(in: app) else {
return nil
}
return runnerAlert(root: popup, ownerApp: app, source: .dismissPopup)
}
}
private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
elements.first { isVisibleElement($0) }
}
@@ -0,0 +1,124 @@
import XCTest
extension RunnerTests {
func captureAlertPresentation(_ element: XCUIElement) -> RunnerAlertPresentation? {
var presentation: RunnerAlertPresentation?
_ = RunnerObjCExceptionCatcher.catchException {
guard let snapshot = try? element.snapshot() else { return }
presentation = self.alertPresentation(snapshot)
}
return presentation
}
func observeAlert(
in ownerApp: XCUIApplication,
source: RunnerAlertSource,
observesApplicationRoot: Bool,
deadline: Date
) -> RunnerAlertObservation {
guard Date() < deadline else { return .deadlineExceeded }
var observation = RunnerAlertObservation.unavailable
_ = RunnerObjCExceptionCatcher.catchException {
switch ownerApp.state {
case .notRunning:
observation = .absent
return
case .runningForeground, .runningBackground:
break
#if !os(macOS)
case .runningBackgroundSuspended:
break
#endif
case .unknown:
return
@unknown default:
return
}
guard Date() < deadline, let snapshot = try? ownerApp.snapshot(),
!snapshot.children.isEmpty, !snapshot.frame.isNull, !snapshot.frame.isEmpty else { return }
if observesApplicationRoot {
observation = .visible(self.alertPresentation(snapshot))
return
}
let candidates = self.alertSnapshots(in: snapshot, source: source, viewport: snapshot.frame)
guard candidates.count <= 1 else { return }
observation = candidates.first.map { .visible(self.alertPresentation($0)) } ?? .absent
}
return Date() < deadline ? observation : .deadlineExceeded
}
private func alertSnapshots(
in snapshot: XCUIElementSnapshot,
source: RunnerAlertSource,
viewport: CGRect
) -> [XCUIElementSnapshot] {
let frame = snapshot.frame
let visible = !frame.isNull && !frame.isEmpty && viewport.contains(CGPoint(x: frame.midX, y: frame.midY))
let matches: Bool
switch source {
case .blockingSystemModal:
matches = snapshot.elementType == .alert || snapshot.elementType == .sheet
case .appAlert:
matches = snapshot.elementType == .alert
case .dismissPopup:
matches = snapshot.elementType == .window && containsDismissPopupMarker(snapshot)
}
if matches && visible { return [snapshot] }
return snapshot.children.flatMap { alertSnapshots(in: $0, source: source, viewport: viewport) }
}
private func containsDismissPopupMarker(_ snapshot: XCUIElementSnapshot) -> Bool {
[snapshot.label, snapshot.identifier].contains {
$0.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("dismiss popup") == .orderedSame
} || snapshot.children.contains { containsDismissPopupMarker($0) }
}
private func alertPresentation(_ snapshot: XCUIElementSnapshot) -> RunnerAlertPresentation {
var content: [String] = []
var buttons: [String] = []
func collect(_ node: XCUIElementSnapshot) {
let label = node.label.trimmingCharacters(in: .whitespacesAndNewlines)
if actionableTypes.contains(node.elementType) {
buttons.append(label)
} else if !label.isEmpty {
content.append(label)
}
node.children.forEach(collect)
}
snapshot.children.forEach(collect)
return RunnerAlertPresentation(
title: snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines),
content: content,
buttons: buttons
)
}
func alertVerificationResponse(
_ verification: RunnerAlertVerification,
action: String,
activated: Bool
) -> Response {
let code: String
let message: String
switch verification {
case .disappeared, .presentationChanged:
return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
case .timedOut:
code = "ALERT_DEADLINE_EXCEEDED"
message = "alert \(action) exhausted its deadline"
case .stillVisible:
code = "INTERACTION_FAILED"
message = "alert \(action) still observes an unchanged alert presentation"
case .unconfirmed:
code = "ALERT_CONFIRMATION_UNAVAILABLE"
message = "alert \(action) could not read the alert presentation"
}
return Response(ok: false, error: ErrorPayload(
code: code,
message: message,
hint: activated
? "The button was activated once. Inspect the current alert before deciding on another action."
: "No alert button was activated. Inspect the current alert before deciding on an action."
))
}
}
@@ -0,0 +1,60 @@
import XCTest
extension RunnerTests {
#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)
func testAlertAcceptDoesNotActivateAReplacementWithASharedButton() throws {
try assertReplacementAlertUntouched(action: "accept", arguments: [], confirmed: true)
}
func testAlertDismissDoesNotActivateAReplacementWithTheSameTitle() throws {
try assertReplacementAlertUntouched(action: "dismiss", arguments: ["--agent-device-alert-same-title"], confirmed: true)
}
func testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt() throws {
try assertReplacementAlertUntouched(
action: "accept",
arguments: ["--agent-device-alert-same-title", "--agent-device-alert-same-body"],
confirmed: false
)
}
func testAlertDeadlineBeforeActivationLeavesTheOriginalUntouched() throws {
app.launchArguments = ["--agent-device-alert-replacement-regression"]
app.launch()
defer {
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)))
let response = handleAlert(alert, action: "accept", deadline: .distantPast)
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()
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
app.terminate()
}
XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout))
let command = try runnerCommandFixture(
#"{"command":"alert","commandId":"alert-replacement","action":"\#(action)","timeoutMs":10000}"#
)
let response = try executeOnMainPrepared(command: command, activeApp: app)
XCTAssertEqual(response.ok, confirmed, String(describing: response.error))
if !confirmed { XCTAssertEqual(response.error?.code, "ALERT_DEADLINE_EXCEEDED") }
XCTAssertTrue(app.alerts.firstMatch.exists, "the replacement must remain visible")
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 1; replacement actions: 0")
let current = try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(10)))
let inspection = handleAlert(current, action: "get", deadline: Date().addingTimeInterval(10))
XCTAssertTrue(inspection.ok)
XCTAssertEqual(inspection.data?.items?.sorted(), ["Cancel", "OK"])
XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 1; replacement actions: 0")
}
#endif
}
@@ -0,0 +1,52 @@
import XCTest
extension RunnerTests {
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
func testAlertVerificationDoesNotConfuseASharedButtonWithTheOriginalPresentation() {
let original = RunnerAlertPresentation(title: "First permission", content: ["First body"], buttons: ["Allow"])
let replacement = RunnerAlertPresentation(title: "Next permission", content: ["Next body"], buttons: ["Allow"])
XCTAssertEqual(
RunnerAlertVerification.verify(original: original, observation: .visible(replacement)),
.presentationChanged
)
}
func testAlertVerificationComparesMoreThanTheTitle() {
let original = RunnerAlertPresentation(title: "Confirmation", content: ["First body"], buttons: ["Cancel", "OK"])
let changedBody = RunnerAlertPresentation(title: "Confirmation", content: ["Next body"], buttons: ["Cancel", "OK"])
let changedButtons = RunnerAlertPresentation(title: "Confirmation", content: ["First body"], buttons: ["Not now", "OK"])
for replacement in [changedBody, changedButtons] {
XCTAssertEqual(
RunnerAlertVerification.verify(original: original, observation: .visible(replacement)),
.presentationChanged
)
}
}
func testAlertVerificationKeepsIdenticalOrTitlelessPresentationsUnconfirmed() {
for title in ["Confirmation", ""] {
let original = RunnerAlertPresentation(title: title, content: [], buttons: ["OK"])
XCTAssertEqual(
RunnerAlertVerification.verify(original: original, observation: .visible(original)),
.stillVisible
)
}
}
func testAlertVerificationRequiresProvenAbsence() {
let original = RunnerAlertPresentation(title: "Confirmation", content: [], buttons: ["OK"])
XCTAssertEqual(
RunnerAlertVerification.verify(original: original, observation: .absent),
.disappeared
)
XCTAssertEqual(
RunnerAlertVerification.verify(original: original, observation: .unavailable),
.unconfirmed
)
XCTAssertEqual(
RunnerAlertVerification.verify(original: original, observation: .deadlineExceeded),
.timedOut
)
}
#endif
}
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppError } from '@agent-device/kernel/errors';
const ALERT_COMMAND_NAME = 'alert';
const alertCommandDescription =
'Inspect, wait for, accept, or dismiss a platform alert. Use get before acting when the alert content matters; accept and dismiss change the active alert state.';
'Inspect, wait for, accept, or dismiss a platform alert. Use get before acting when the alert content matters; accept and dismiss change the active alert state. Each iOS XCTest execution activates once, then only observes. Inspect again after an unconfirmed action; never assume a timeout means nothing happened.';
const alertCommandMetadata = defineFieldCommandMetadata(
ALERT_COMMAND_NAME,
+2
View File
@@ -423,6 +423,8 @@ agent-device alert dismiss
- `alert` without an action is equivalent to `alert get`.
- `accept` and `dismiss` are sent once on every platform. A lost or unconfirmed response is reported as an error and never replayed; run `alert get` before acting again.
- Use `alert get` for an immediate cheap check. Use `alert wait <short-ms>` only when a prompt may appear after async work.
- Within an iOS XCTest execution, `accept` and `dismiss` activate the selected button once, then only observe until the alert disappears, its presentation changes, or the deadline expires. A shared button label never triggers a second coordinate tap. A changed presentation can be an updated original alert or a replacement; it does not prove a permission was granted. Verify the application outcome separately.
- An unreadable or ambiguous post-action capture fails with `error.details.runnerErrorCode: ALERT_CONFIRMATION_UNAVAILABLE`; an expired runner deadline uses `ALERT_DEADLINE_EXCEEDED` (the outer command watchdog can also report a timeout). Neither proves absence or that no action occurred. Identical-looking alerts remain unconfirmed. Inspect the current alert before deciding whether to act again.
- Android support is snapshot-derived. If `alert` reports no alert but a sheet is visible, treat it as app-owned UI and use `snapshot -i` plus `press` by visible label/ref.
- If an iOS permission sheet is visible in `snapshot` or `screenshot` but `alert accept` reports no alert, fall back to a scoped `snapshot -i -s "<visible label>"` plus `press @ref`; not every simulator permission surface is exposed as a native XCTest alert.