Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ @interface AgentDeviceRunnerViewController : UIViewController
@property(nonatomic, assign) NSUInteger maskedTogglePresses;
@property(nonatomic, strong) UILabel *alertActionStatus;
@property(nonatomic, strong) UILabel *alertActivationBusyAnswer;
@property(nonatomic, strong) UILabel *alertDeclineAnswer;
@property(nonatomic, assign) NSUInteger firstAlertActions;
@property(nonatomic, assign) NSUInteger replacementAlertActions;
@property(nonatomic, strong) UILabel *textEntryWriteBackStatus;
Expand Down Expand Up @@ -392,8 +393,39 @@ - (void)presentAlertFixtureReplacement:(BOOL)replacement {
[self presentViewController:alert animated:NO completion:nil];
}

// Mirrors the App Tracking Transparency prompt's real button order: the affirmative button is
// last (#3627). The answer label records which button the app actually took, so a test asserts
// the app's answer instead of the command's own report. `--agent-device-alert-affirmative-only`
// shows the same alert with no decline button at all.
- (void)presentAlertDeclineChoiceFixture {
BOOL affirmativeOnly =
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-affirmative-only"];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"“Runner” Would Like to Track Your Activity"
message:@"Track your activity across other companies' apps and websites."
preferredStyle:UIAlertControllerStyleAlert];
NSArray<NSArray *> *buttons = affirmativeOnly
? @[ @[@"Allow", @"Authorized"], @[@"OK", @"Authorized"] ]
: @[ @[@"Ask App Not to Track", @"Declined"], @[@"Allow", @"Authorized"] ];
for (NSArray *button in buttons) {
[alert addAction:[UIAlertAction actionWithTitle:button[0]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *action) {
(void)action;
self.alertDeclineAnswer.text = button[1];
}]];
}
[self presentViewController:alert animated:NO completion:nil];
}

- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
if (!self.alertFixtureStarted &&
([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-decline-choice"] ||
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-affirmative-only"])) {
self.alertFixtureStarted = YES;
[self presentAlertDeclineChoiceFixture];
}
if (!self.alertFixtureStarted &&
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-replacement-regression"]) {
self.alertFixtureStarted = YES;
Expand Down Expand Up @@ -575,6 +607,20 @@ - (void)viewDidLoad {
[self updateAlertActionStatus];
}

if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-decline-choice"] ||
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-affirmative-only"]) {
UILabel *declineAnswer = [[UILabel alloc] init];
declineAnswer.text = @"Unanswered";
declineAnswer.accessibilityIdentifier = @"agent-device-alert-decline-answer";
declineAnswer.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:declineAnswer];
[NSLayoutConstraint activateConstraints:@[
[declineAnswer.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[declineAnswer.topAnchor constraintEqualToAnchor:label.bottomAnchor constant:24],
]];
self.alertDeclineAnswer = declineAnswer;
}

if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-alert-activation-busy"]) {
UILabel *busyAnswer = [[UILabel alloc] init];
busyAnswer.text = @"Unanswered";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation

/// Which of an alert's buttons an `accept` or `dismiss` activates, decided on the button labels
/// alone, so the rule is checkable without a screen.
///
/// `dismiss` activates only a label `isDismissButton` recognizes. An unknown decline cannot be
/// identified from labels or button position: iOS localizes prompts unevenly, so one prompt can
/// pair a localized decline with a still-English affirmative, and a positional guess would tap
/// that affirmative and record an authorization (#3627). An unrecognized decline is therefore
/// refused with `ALERT_BUTTON_NOT_FOUND` and no tap.
///
/// The one exception is a dismiss-popup surface itself: its native accessibility marker is the
/// app's own declaration that the surface closes the popover, so `popupMarkerSurface` lets
/// `dismiss` take the last actionable element of that window. A consent prompt can never carry
/// that marker.
enum RunnerAlertButtonChoice {
static func buttonIndex(for labels: [String], action: String, popupMarkerSurface: Bool = false) -> Int? {
if action == "accept" {
if let accept = labels.firstIndex(where: { isAcceptButton($0) }) {
return accept
}
// A lone button is the only way out of the prompt, so accept takes it unless it declines.
return labels.count == 1 && !isDismissButton(labels[0]) ? 0 : nil
}
if let decline = labels.firstIndex(where: { isDismissButton($0) }) {
return decline
}
return popupMarkerSurface ? labels.indices.last : nil
}

static func isAcceptButton(_ label: String) -> Bool {
let normalized = normalize(label)
return [
"ok",
"allow",
"yes",
"continue",
"done",
"open",
"open settings"
].contains(normalized) || normalized.hasPrefix("confirm")
}

static func isDismissButton(_ label: String) -> Bool {
[
"cancel",
"close",
"dismiss",
"don't allow",
"don’t allow",
"not now",
"no",
"keep browsing",
"later",
"ask app not to track"
].contains(normalize(label))
}

private static func normalize(_ label: String) -> String {
label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ extension RunnerTests {

static let defaultAlertCommandTimeout: TimeInterval = 10

/// One recovery sentence for every alert failure that activated nothing, so the refusal in
/// `handleAlert` and the unactivated `alertVerificationResponse` branches cannot drift apart.
static let alertNoButtonActivatedHint =
"No alert button was activated. Inspect the current alert before deciding on an action."

static func alertCommandTimeout(timeoutMs: Double?) -> TimeInterval {
guard let timeoutMs, timeoutMs.isFinite else { return defaultAlertCommandTimeout }
return max(0.001, timeoutMs / 1000)
Expand Down Expand Up @@ -58,8 +63,14 @@ extension RunnerTests {
@MainActor
func handleAlert(_ alert: RunnerAlert, action: String, deadline: Date) -> Response {
if action == "accept" || action == "dismiss" {
guard let button = chooseAlertButton(alert.buttons, action: action) else {
return Response(ok: false, error: ErrorPayload(message: "alert \(action) button not found"))
guard let button = chooseAlertButton(alert, action: action) else {
// The choice refused before any activation, so this discloses dispatched: no like the
// runner's other pre-run refusals.
return Response(ok: false, error: ErrorPayload(
code: RunnerWireErrorCode.alertButtonNotFound,
message: "alert \(action) button not found",
hint: RunnerTests.alertNoButtonActivatedHint
))
}
guard Date() < deadline else {
return alertVerificationResponse(.timedOut, action: action, activated: false)
Expand Down Expand Up @@ -246,42 +257,17 @@ extension RunnerTests {
)
private static let dismissPopupMarkerText = NSPredicate(format: "SELF MATCHES[c] %@", dismissPopupMarkerPattern)

private func chooseAlertButton(_ buttons: [XCUIElement], action: String) -> XCUIElement? {
if action == "accept" {
if let accept = buttons.first(where: { isAcceptButton($0.label) }) {
return accept
}
return buttons.count == 1 && !isDismissButton(buttons[0].label) ? buttons[0] : nil
// Internal, not private: the dismiss-popup wiring is pinned by
// RunnerTests+AlertTests, and the label rule itself by RunnerAlertButtonChoice.
func chooseAlertButton(_ alert: RunnerAlert, action: String) -> XCUIElement? {
guard let index = RunnerAlertButtonChoice.buttonIndex(
for: alert.buttons.map(\.label),
action: action,
popupMarkerSurface: alert.source == .dismissPopup
) else {
return nil
}

return buttons.first(where: { isDismissButton($0.label) }) ?? buttons.last
}

func isAcceptButton(_ label: String) -> Bool {
let normalized = label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return [
"ok",
"allow",
"yes",
"continue",
"done",
"open",
"open settings"
].contains(normalized) || normalized.hasPrefix("confirm")
}

private func isDismissButton(_ label: String) -> Bool {
[
"cancel",
"close",
"dismiss",
"don't allow",
"don’t allow",
"not now",
"no",
"keep browsing",
"later"
].contains(label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
return alert.buttons[index]
}

private func preferredAlertTitle(_ element: XCUIElement, buttons: [XCUIElement]) -> String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ extension RunnerTests {
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."
: RunnerTests.alertNoButtonActivatedHint
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@ final class RunnerTests: XCTestCase {
/// `NO_TEXT_INPUT_AT_POINT` refuses a coordinate fill whose point identified no text input.
/// `TEXT_INPUT_PROBE_UNAVAILABLE` refuses one whose input lookup could not answer on the degraded
/// channel, so the runner cannot tell whether an input is there.
/// `ALERT_BUTTON_NOT_FOUND` is the alert button choice refusing before any activation, so it
/// discloses dispatched: no.
enum RunnerWireErrorCode {
static let mainThreadTimeout = "MAIN_THREAD_TIMEOUT"
static let appNotRunning = "APP_NOT_RUNNING"
static let noTextInputAtPoint = "NO_TEXT_INPUT_AT_POINT"
static let textInputProbeUnavailable = "TEXT_INPUT_PROBE_UNAVAILABLE"
static let alertButtonNotFound = "ALERT_BUTTON_NOT_FOUND"
}

static let springboardBundleId = "com.apple.springboard"
Expand Down
Loading
Loading