Skip to content

Commit 2413aeb

Browse files
committed
fix(ios): confirm alerts without repeating activation
1 parent 80997b6 commit 2413aeb

9 files changed

Lines changed: 354 additions & 89 deletions

File tree

.github/workflows/ios.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ jobs:
169169
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testCachedTargetInvalidationClearsProcessBoundState \
170170
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertResolutionCannotBypassRequestedDeadline \
171171
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertAcceptTreatsOpenAsAffirmative \
172+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertAcceptDoesNotActivateAReplacementWithASharedButton \
173+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDismissDoesNotActivateAReplacementWithTheSameTitle \
174+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt \
175+
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testAlertDeadlineBeforeActivationLeavesTheOriginalUntouched \
172176
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testSystemModalProbeSliceSharesAndClampsToPlanDeadline \
173177
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied \
174178
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBoundedSystemModalProbeTimeoutRecoversThenReleasesOnDrain \

apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,62 @@ int main(int argc, const char *argv[]) {
5858
#import <UIKit/UIKit.h>
5959

6060
@interface AgentDeviceRunnerViewController : UIViewController
61+
@property(nonatomic, strong) UILabel *alertActionStatus;
62+
@property(nonatomic, assign) NSUInteger firstAlertActions;
63+
@property(nonatomic, assign) NSUInteger replacementAlertActions;
64+
@property(nonatomic, assign) BOOL alertFixtureStarted;
6165
@end
6266

6367
@implementation AgentDeviceRunnerViewController
6468

69+
#if TARGET_OS_IOS
70+
- (void)updateAlertActionStatus {
71+
self.alertActionStatus.text = [NSString stringWithFormat:@"First actions: %lu; replacement actions: %lu",
72+
(unsigned long)self.firstAlertActions,
73+
(unsigned long)self.replacementAlertActions];
74+
}
75+
76+
- (void)presentAlertFixtureReplacement:(BOOL)replacement {
77+
NSArray<NSString *> *arguments = NSProcessInfo.processInfo.arguments;
78+
BOOL sameTitle = [arguments containsObject:@"--agent-device-alert-same-title"];
79+
BOOL sameBody = [arguments containsObject:@"--agent-device-alert-same-body"];
80+
NSString *title = replacement && !sameTitle ? @"Next confirmation" : @"First confirmation";
81+
NSString *body = replacement && !sameBody ? @"Second request" : @"First request";
82+
UIAlertController *alert = [UIAlertController alertControllerWithTitle:title
83+
message:body
84+
preferredStyle:UIAlertControllerStyleAlert];
85+
__weak UIAlertController *weakAlert = alert;
86+
for (NSString *buttonTitle in @[@"Cancel", @"OK"]) {
87+
UIAlertActionStyle style = [buttonTitle isEqualToString:@"Cancel"]
88+
? UIAlertActionStyleCancel : UIAlertActionStyleDefault;
89+
[alert addAction:[UIAlertAction actionWithTitle:buttonTitle style:style handler:^(UIAlertAction *action) {
90+
(void)action;
91+
if (replacement) {
92+
self.replacementAlertActions += 1;
93+
} else {
94+
self.firstAlertActions += 1;
95+
}
96+
[self updateAlertActionStatus];
97+
if (!replacement) {
98+
[weakAlert dismissViewControllerAnimated:NO completion:^{
99+
[self presentAlertFixtureReplacement:YES];
100+
}];
101+
}
102+
}]];
103+
}
104+
[self presentViewController:alert animated:NO completion:nil];
105+
}
106+
107+
- (void)viewDidAppear:(BOOL)animated {
108+
[super viewDidAppear:animated];
109+
if (!self.alertFixtureStarted &&
110+
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-replacement-regression"]) {
111+
self.alertFixtureStarted = YES;
112+
[self presentAlertFixtureReplacement:NO];
113+
}
114+
}
115+
#endif
116+
65117
- (void)agentDeviceTextEntryDidChange:(UITextField *)textField {
66118
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-disappear-after-input"] &&
67119
textField.text.length > 0) {
@@ -88,6 +140,12 @@ - (void)viewDidLoad {
88140

89141
// Keep the fixture behind a launch argument so normal runner snapshots remain unchanged.
90142
#if TARGET_OS_IOS
143+
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-replacement-regression"]) {
144+
self.alertActionStatus = label;
145+
label.accessibilityIdentifier = @"agent-device-alert-actions";
146+
[self updateAlertActionStatus];
147+
}
148+
91149
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-regression"]) {
92150
UITextField *textField = [[UITextField alloc] init];
93151
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
struct RunnerAlertPresentation: Equatable {
2+
let title: String
3+
let content: [String]
4+
let buttons: [String]
5+
}
6+
7+
enum RunnerAlertObservation {
8+
case visible(RunnerAlertPresentation)
9+
case absent
10+
case unavailable
11+
case deadlineExceeded
12+
}
13+
14+
enum RunnerAlertVerification: Equatable {
15+
case disappeared
16+
case presentationChanged
17+
case stillVisible
18+
case unconfirmed
19+
case timedOut
20+
21+
static func verify(
22+
original: RunnerAlertPresentation,
23+
observation: RunnerAlertObservation
24+
) -> RunnerAlertVerification {
25+
switch observation {
26+
case .visible(let current):
27+
return original == current ? .stillVisible : .presentationChanged
28+
case .absent:
29+
return .disappeared
30+
case .unavailable:
31+
return .unconfirmed
32+
case .deadlineExceeded:
33+
return .timedOut
34+
}
35+
}
36+
}

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

Lines changed: 21 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -55,50 +55,34 @@ extension RunnerTests {
5555
guard let button = chooseAlertButton(alert.buttons, action: action) else {
5656
return Response(ok: false, error: ErrorPayload(message: "alert \(action) button not found"))
5757
}
58-
let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
59-
let actionButtonLabel = button.label.trimmingCharacters(in: .whitespacesAndNewlines)
60-
let actionButtonFrame = button.frame
58+
guard Date() < deadline else {
59+
return alertVerificationResponse(.timedOut, action: action, activated: false)
60+
}
61+
guard let original = captureAlertPresentation(alert.root) else {
62+
return alertVerificationResponse(.unconfirmed, action: action, activated: false)
63+
}
64+
let observesApplicationRoot = alert.root.elementType == .application
65+
guard Date() < deadline else {
66+
return alertVerificationResponse(.timedOut, action: action, activated: false)
67+
}
6168
let outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
6269
if let response = unsupportedResponse(for: outcome) {
6370
return response
6471
}
65-
sleepFor(0.2)
66-
if alertStillVisible(
67-
in: alert.ownerApp,
68-
source: alert.source,
69-
previousTitle: previousTitle,
70-
actionButtonLabel: actionButtonLabel,
71-
deadline: deadline
72-
) {
73-
if !actionButtonFrame.isNull && !actionButtonFrame.isEmpty {
74-
let coordinateOutcome = tapAt(
75-
app: alert.ownerApp,
76-
x: actionButtonFrame.midX,
77-
y: actionButtonFrame.midY
78-
)
79-
if let response = unsupportedResponse(for: coordinateOutcome) {
80-
return response
81-
}
82-
sleepFor(0.2)
83-
}
84-
}
85-
if alertStillVisible(
86-
in: alert.ownerApp,
87-
source: alert.source,
88-
previousTitle: previousTitle,
89-
actionButtonLabel: actionButtonLabel,
90-
deadline: deadline
91-
) {
92-
return Response(
93-
ok: false,
94-
error: ErrorPayload(
95-
code: "INTERACTION_FAILED",
96-
message: "alert \(action) did not dismiss the visible alert",
97-
hint: "The alert button was found but the system still reports the alert after tapping it."
72+
while true {
73+
sleepFor(min(0.2, max(0, deadline.timeIntervalSinceNow)))
74+
let verification = RunnerAlertVerification.verify(
75+
original: original,
76+
observation: observeAlert(
77+
in: alert.ownerApp,
78+
source: alert.source,
79+
observesApplicationRoot: observesApplicationRoot,
80+
deadline: deadline
9881
)
9982
)
83+
if verification == .stillVisible { continue }
84+
return alertVerificationResponse(verification, action: action, activated: true)
10085
}
101-
return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
10286
}
10387

10488
return Response(
@@ -135,57 +119,6 @@ extension RunnerTests {
135119
return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons, source: source)
136120
}
137121

138-
private func alertStillVisible(
139-
in ownerApp: XCUIApplication,
140-
source: RunnerAlertSource,
141-
previousTitle: String,
142-
actionButtonLabel: String,
143-
deadline: Date
144-
) -> Bool {
145-
guard Date() < deadline,
146-
let current = resolveAlert(source: source, app: ownerApp, deadline: deadline)
147-
else {
148-
return false
149-
}
150-
let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons)
151-
if previousTitle == currentTitle {
152-
return true
153-
}
154-
return current.buttons.contains { button in
155-
button.label.trimmingCharacters(in: .whitespacesAndNewlines) == actionButtonLabel
156-
}
157-
}
158-
159-
private func resolveAlert(
160-
source: RunnerAlertSource,
161-
app: XCUIApplication,
162-
deadline: Date
163-
) -> RunnerAlert? {
164-
switch source {
165-
case .blockingSystemModal:
166-
#if os(macOS)
167-
return nil
168-
#else
169-
guard case .resolved(let modal) = resolveBlockingSystemModal(deadline: deadline) else {
170-
return nil
171-
}
172-
return runnerAlert(modal)
173-
#endif
174-
case .appAlert:
175-
guard let alert = firstExistingElement(
176-
in: safeElementsQuery { app.alerts.allElementsBoundByIndex }
177-
) else {
178-
return nil
179-
}
180-
return runnerAlert(root: alert, ownerApp: app, source: .appAlert)
181-
case .dismissPopup:
182-
guard let popup = firstDismissPopupWindow(in: app) else {
183-
return nil
184-
}
185-
return runnerAlert(root: popup, ownerApp: app, source: .dismissPopup)
186-
}
187-
}
188-
189122
private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
190123
elements.first { isVisibleElement($0) }
191124
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import XCTest
2+
3+
extension RunnerTests {
4+
func captureAlertPresentation(_ element: XCUIElement) -> RunnerAlertPresentation? {
5+
var presentation: RunnerAlertPresentation?
6+
_ = RunnerObjCExceptionCatcher.catchException {
7+
guard let snapshot = try? element.snapshot() else { return }
8+
presentation = self.alertPresentation(snapshot)
9+
}
10+
return presentation
11+
}
12+
13+
func observeAlert(
14+
in ownerApp: XCUIApplication,
15+
source: RunnerAlertSource,
16+
observesApplicationRoot: Bool,
17+
deadline: Date
18+
) -> RunnerAlertObservation {
19+
guard Date() < deadline else { return .deadlineExceeded }
20+
var observation = RunnerAlertObservation.unavailable
21+
_ = RunnerObjCExceptionCatcher.catchException {
22+
switch ownerApp.state {
23+
case .notRunning:
24+
observation = .absent
25+
return
26+
case .runningForeground, .runningBackground, .runningBackgroundSuspended:
27+
break
28+
case .unknown:
29+
return
30+
@unknown default:
31+
return
32+
}
33+
guard Date() < deadline, let snapshot = try? ownerApp.snapshot(),
34+
!snapshot.children.isEmpty, !snapshot.frame.isNull, !snapshot.frame.isEmpty else { return }
35+
if observesApplicationRoot {
36+
observation = .visible(self.alertPresentation(snapshot))
37+
return
38+
}
39+
let candidates = self.alertSnapshots(in: snapshot, source: source, viewport: snapshot.frame)
40+
guard candidates.count <= 1 else { return }
41+
observation = candidates.first.map { .visible(self.alertPresentation($0)) } ?? .absent
42+
}
43+
return Date() < deadline ? observation : .deadlineExceeded
44+
}
45+
46+
private func alertSnapshots(
47+
in snapshot: XCUIElementSnapshot,
48+
source: RunnerAlertSource,
49+
viewport: CGRect
50+
) -> [XCUIElementSnapshot] {
51+
let frame = snapshot.frame
52+
let visible = !frame.isNull && !frame.isEmpty && viewport.contains(CGPoint(x: frame.midX, y: frame.midY))
53+
let matches: Bool
54+
switch source {
55+
case .blockingSystemModal:
56+
matches = snapshot.elementType == .alert || snapshot.elementType == .sheet
57+
case .appAlert:
58+
matches = snapshot.elementType == .alert
59+
case .dismissPopup:
60+
matches = snapshot.elementType == .window && containsDismissPopupMarker(snapshot)
61+
}
62+
if matches && visible { return [snapshot] }
63+
return snapshot.children.flatMap { alertSnapshots(in: $0, source: source, viewport: viewport) }
64+
}
65+
66+
private func containsDismissPopupMarker(_ snapshot: XCUIElementSnapshot) -> Bool {
67+
[snapshot.label, snapshot.identifier].contains {
68+
$0.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("dismiss popup") == .orderedSame
69+
} || snapshot.children.contains { containsDismissPopupMarker($0) }
70+
}
71+
72+
private func alertPresentation(_ snapshot: XCUIElementSnapshot) -> RunnerAlertPresentation {
73+
var content: [String] = []
74+
var buttons: [String] = []
75+
func collect(_ node: XCUIElementSnapshot) {
76+
let label = node.label.trimmingCharacters(in: .whitespacesAndNewlines)
77+
if actionableTypes.contains(node.elementType) {
78+
buttons.append(label)
79+
} else if !label.isEmpty {
80+
content.append(label)
81+
}
82+
node.children.forEach(collect)
83+
}
84+
snapshot.children.forEach(collect)
85+
return RunnerAlertPresentation(
86+
title: snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines),
87+
content: content,
88+
buttons: buttons
89+
)
90+
}
91+
92+
func alertVerificationResponse(
93+
_ verification: RunnerAlertVerification,
94+
action: String,
95+
activated: Bool
96+
) -> Response {
97+
let code: String
98+
let message: String
99+
switch verification {
100+
case .disappeared, .presentationChanged:
101+
return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
102+
case .timedOut:
103+
code = "ALERT_DEADLINE_EXCEEDED"
104+
message = "alert \(action) exhausted its deadline"
105+
case .stillVisible:
106+
code = "INTERACTION_FAILED"
107+
message = "alert \(action) still observes an unchanged alert presentation"
108+
case .unconfirmed:
109+
code = "ALERT_CONFIRMATION_UNAVAILABLE"
110+
message = "alert \(action) could not read the alert presentation"
111+
}
112+
return Response(ok: false, error: ErrorPayload(
113+
code: code,
114+
message: message,
115+
hint: activated
116+
? "The button was activated once. Inspect the current alert before deciding on another action."
117+
: "No alert button was activated. Inspect the current alert before deciding on an action."
118+
))
119+
}
120+
}

0 commit comments

Comments
 (0)