diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m index 109c610949..420e806ae7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m @@ -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; @@ -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 *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; @@ -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"; diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAlertButtonChoice.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAlertButtonChoice.swift new file mode 100644 index 0000000000..c32f22918e --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAlertButtonChoice.swift @@ -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() + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index fc5d205bbd..af23abf8c5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -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) @@ -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) @@ -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 { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AlertObservation.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AlertObservation.swift index 6ef66572c9..a010810149 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AlertObservation.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AlertObservation.swift @@ -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 )) } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index fcb0d942cc..d2f63502d6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -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" diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertButtonChoiceTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertButtonChoiceTests.swift new file mode 100644 index 0000000000..f8862aa6d5 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertButtonChoiceTests.swift @@ -0,0 +1,179 @@ +import XCTest + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { + func testAlertAcceptTreatsOpenAsAffirmative() { + XCTAssertTrue(RunnerAlertButtonChoice.isAcceptButton("Open")) + } + + func testAlertDismissPicksTheTrackingPromptDeclineLabel() { + // The App Tracking Transparency prompt, as iOS words it in English (#3627). + XCTAssertEqual( + RunnerAlertButtonChoice.buttonIndex(for: ["Ask App Not to Track", "Allow"], action: "dismiss"), + 0 + ) + XCTAssertEqual( + RunnerAlertButtonChoice.buttonIndex(for: ["Ask App Not to Track", "Allow"], action: "accept"), + 1 + ) + // The list entry alone decides this shape: without it neither label is a recognized decline + // and the choice must refuse rather than guess. + XCTAssertEqual( + RunnerAlertButtonChoice.buttonIndex(for: ["Ask App Not to Track", "Learn More"], action: "dismiss"), + 0 + ) + } + + func testAlertDismissRefusesUnrecognizedDeclineCopyInsteadOfGuessing() { + // No fallback position is safe: every label-based guess at an unknown decline can land on a + // localized or reworded affirmative, which is the #3627 failure. Unknown wording is reached + // by `alert get` plus `snapshot`/`press`, which name the button instead of inferring it. + for labels in [ + ["Keep my activity private", "Allow"], + ["Not this time", "Allow Tracking"], + ["More info", "Allow", "OK"], + ["Accept all", "OK"], + ["Agree to Terms", "OK"], + ["I accept", "OK"], + ["Accept terms", "Continue"] + ] { + XCTAssertNil( + RunnerAlertButtonChoice.buttonIndex(for: labels, action: "dismiss"), + "dismiss must fail without a tap rather than guess through \(labels)" + ) + } + } + + func testAlertDismissRefusesAPromptWhoseLabelsNeitherListRecognizes() { + // A fully localized prompt: iOS localizes both buttons, so no label is claimed by either + // list, no position identifies the decline, and a guess could authorize (#3627). + XCTAssertNil( + RunnerAlertButtonChoice.buttonIndex( + for: ["Demander à l’app de ne pas suivre", "Autoriser le suivi"], + action: "dismiss" + ), + "dismiss must fail without a tap rather than guess through a localized prompt" + ) + } + + func testAlertDismissClosesOnlyAPopupMarkerSurfaceThroughItsLastElement() { + // The marker is the app's own declaration that the surface closes the popover, so that + // surface keeps base's close-by-last-element behavior. No consent prompt can carry it, so + // nothing else gains a positional tap. + XCTAssertEqual( + RunnerAlertButtonChoice.buttonIndex(for: ["Close popover"], action: "dismiss", popupMarkerSurface: true), + 0 + ) + XCTAssertEqual( + RunnerAlertButtonChoice.buttonIndex( + for: ["Details", "Close popover"], action: "dismiss", popupMarkerSurface: true + ), + 1 + ) + XCTAssertNil( + RunnerAlertButtonChoice.buttonIndex(for: ["Close popover"], action: "dismiss", popupMarkerSurface: false), + "an unlabeled-by-the-list prompt without the marker still refuses" + ) + } + + func testAlertDismissRefusesAPromptWhoseEveryButtonIsAffirmative() { + for labels in [["Allow"], ["Allow", "OK"], ["Yes", "Continue", "Done"]] { + XCTAssertNil( + RunnerAlertButtonChoice.buttonIndex(for: labels, action: "dismiss"), + "dismiss must fail without a tap rather than authorize through \(labels)" + ) + } + } + + /// The known-decline branches stay exact: a listed decline resolves by position first, and + /// accept takes the label its own list claims even when a decline is present. + func testAlertDismissStillPicksTheKnownCancelLabelAndAcceptStillTakesOK() { + XCTAssertEqual( + RunnerAlertButtonChoice.buttonIndex(for: ["Cancel", "OK"], action: "dismiss"), + 0 + ) + XCTAssertEqual( + RunnerAlertButtonChoice.buttonIndex(for: ["Don’t Allow", "Allow"], action: "dismiss"), + 0 + ) + XCTAssertEqual( + RunnerAlertButtonChoice.buttonIndex(for: ["Cancel", "OK"], action: "accept"), + 1 + ) + } + + /// accept never activates a listed decline: alone it is no acceptance, and with a choice it + /// takes only a label its own list claims. + func testAlertAcceptKeepsItsGuardsOnTheDeclineLabelsItMustNotTake() { + // A lone declining button is not an acceptance, and with a choice, accept takes the first + // label the accept list claims. + XCTAssertNil(RunnerAlertButtonChoice.buttonIndex(for: ["Ask App Not to Track"], action: "accept")) + XCTAssertNil(RunnerAlertButtonChoice.buttonIndex(for: ["Cancel"], action: "accept")) + XCTAssertEqual( + RunnerAlertButtonChoice.buttonIndex(for: ["Not now", "Confirm purchase"], action: "accept"), + 1 + ) + } +} +#endif + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) +extension RunnerTests { + /// The reporter's scenario end to end: `alert dismiss` on the App Tracking Transparency prompt, + /// whose affirmative button is the last one. The command's own report was `dismissed` either + /// way, so the canary — which button the app actually answered — is the assertion that matters + /// (#3627). + @MainActor + func testAlertDismissAnswersTheTrackingPromptDeclineAndNeverAuthorizes() throws { + app.launchArguments = ["--agent-device-alert-decline-choice"] + app.launch() + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + app.terminate() + } + XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: appExistenceTimeout)) + // Resolution is a pure read; a contended hosted simulator can spend far more than the 10 s + // command timeout on it, so it runs under the sibling fixtures' resolution allowance. + let alert = try XCTUnwrap( + resolveAlert(app: app, deadline: Date().addingTimeInterval(RunnerTests.alertResolutionAllowance)) + ) + XCTAssertEqual(alert.buttons.map(\.label).last, "Allow", "the fixture keeps the affirmative button last") + + let response = handleAlert( + alert, + action: "dismiss", + deadline: Date().addingTimeInterval(RunnerTests.alertActivationDeadline) + ) + + XCTAssertTrue(response.ok, String(describing: response.error)) + XCTAssertEqual(app.staticTexts["agent-device-alert-decline-answer"].label, "Declined") + } + + /// A prompt carrying no button the decline list recognizes has no decline to name: `dismiss` + /// must refuse with the typed code and tap nothing, and the canary proves the prompt stayed + /// unanswered (#3627). + @MainActor + func testAlertDismissRefusesAnAffirmativeOnlyPromptWithoutActivatingIt() throws { + app.launchArguments = ["--agent-device-alert-decline-choice", "--agent-device-alert-affirmative-only"] + 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(RunnerTests.alertResolutionAllowance)) + ) + + let response = handleAlert( + alert, + action: "dismiss", + deadline: Date().addingTimeInterval(RunnerTests.alertActivationDeadline) + ) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "ALERT_BUTTON_NOT_FOUND") + XCTAssertEqual(app.staticTexts["agent-device-alert-decline-answer"].label, "Unanswered") + } +} +#endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift index 42f706b2c9..00419f9847 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift @@ -8,13 +8,8 @@ extension RunnerTests { XCTAssertEqual(app.state, .notRunning) XCTAssertNil(resolveAlert(app: app, deadline: Date().addingTimeInterval(1))) } - - func testAlertAcceptTreatsOpenAsAffirmative() { - XCTAssertTrue(isAcceptButton("Open")) - } } #endif - #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) extension RunnerTests { @MainActor @@ -55,6 +50,21 @@ extension RunnerTests { XCTAssertTrue(alert.buttons.contains { $0.identifier == "agent-device-close-popover" }) } + /// `alert dismiss` on the marker window must still name its close button: the marker is the + /// app's own decline declaration, so the choice reaches it even though "Close popover" is on + /// no list (#3627 left this surface at base behavior). + @MainActor + func testAlertDismissOnADismissPopupWindowChoosesThePopoverCloseButton() throws { + launchCrowdedScreen(extraArguments: ["--agent-device-dismiss-popup-window"]) + defer { terminateCrowdedScreen() } + + let alert = try XCTUnwrap( + resolveAlert(app: app, deadline: Date().addingTimeInterval(RunnerTests.defaultAlertCommandTimeout)) + ) + let button = try XCTUnwrap(chooseAlertButton(alert, action: "dismiss")) + XCTAssertEqual(button.identifier, "agent-device-close-popover") + } + private func launchCrowdedScreen(extraArguments: [String]) { app.launchArguments = ["--agent-device-crowded-screen"] + extraArguments app.launch() diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 4fc748c1fd..7bf37cbdb3 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -205,6 +205,12 @@ "trigger": "structured runner reply with code TEXT_INPUT_PROBE_UNAVAILABLE: a coordinate fill refused before its focus tap because the pre-tap input lookup could not answer", "dispatched": "no" }, + { + "id": "ios-runner.reply.ALERT_BUTTON_NOT_FOUND", + "producer": "ios-runner", + "trigger": "structured runner reply with code ALERT_BUTTON_NOT_FOUND: the accept/dismiss button choice refused before any activation (#3627)", + "dispatched": "no" + }, { "id": "ios-runner.reply.APP_SCREEN_WINDOW_UNRESOLVED", "producer": "ios-runner", diff --git a/packages/contracts/src/alert-contract.ts b/packages/contracts/src/alert-contract.ts index 018d5ceb3c..b7af81b93a 100644 --- a/packages/contracts/src/alert-contract.ts +++ b/packages/contracts/src/alert-contract.ts @@ -12,6 +12,14 @@ export const ALERT_ACTION_RETRY_MS = 2_000; export const ALERT_NOT_FOUND_RUNNER_CODE = 'ALERT_NOT_FOUND'; export const ALERT_NOT_FOUND_REASON = 'alert-not-found'; +/** + * The Apple XCUITest runner refused an `accept`/`dismiss` because no button on the alert fits + * the action: `dismiss` found no label on its decline list, and tapping anything else could + * answer an affirmative (#3627). The refusal is made in the button choice, before any + * activation, so it discloses `dispatched: no` like the other replies that ran nothing. + */ +export const ALERT_BUTTON_NOT_FOUND_RUNNER_CODE = 'ALERT_BUTTON_NOT_FOUND'; + export const ALERT_ACTIONS = ['get', 'accept', 'dismiss', 'wait'] as const; export type AlertAction = (typeof ALERT_ACTIONS)[number]; diff --git a/packages/platform-apple/src/runner/__tests__/index-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/index-dispatch-disclosure.test.ts index 7e66b4dfb9..3db43476a1 100644 --- a/packages/platform-apple/src/runner/__tests__/index-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/index-dispatch-disclosure.test.ts @@ -171,6 +171,7 @@ const DRIVERS: Record Promise> = { 'ios-runner.reply.NO_TEXT_INPUT_AT_POINT': () => replyFailure('NO_TEXT_INPUT_AT_POINT'), 'ios-runner.reply.TEXT_INPUT_PROBE_UNAVAILABLE': () => replyFailure('TEXT_INPUT_PROBE_UNAVAILABLE'), + 'ios-runner.reply.ALERT_BUTTON_NOT_FOUND': () => replyFailure('ALERT_BUTTON_NOT_FOUND'), 'ios-runner.reply.APP_SCREEN_WINDOW_UNRESOLVED': () => replyFailure('APP_SCREEN_WINDOW_UNRESOLVED'), 'ios-runner.reply.APP_SCREEN_UNRESOLVED': () => replyFailure('APP_SCREEN_UNRESOLVED'), diff --git a/packages/platform-apple/src/runner/runner-contract.ts b/packages/platform-apple/src/runner/runner-contract.ts index f29118d7ee..98769aed7c 100644 --- a/packages/platform-apple/src/runner/runner-contract.ts +++ b/packages/platform-apple/src/runner/runner-contract.ts @@ -5,7 +5,10 @@ import { type DispatchDisclosure, } from '@agent-device/kernel/errors'; import crypto from 'node:crypto'; -import { ALERT_NOT_FOUND_RUNNER_CODE } from '@agent-device/contracts/alert-contract'; +import { + ALERT_BUTTON_NOT_FOUND_RUNNER_CODE, + ALERT_NOT_FOUND_RUNNER_CODE, +} from '@agent-device/contracts/alert-contract'; import type { DeviceRotation } from '@agent-device/contracts/device'; import type { SnapshotPreferredBackend } from '@agent-device/kernel/snapshot'; import type { ClickButton } from '@agent-device/contracts/click-button'; @@ -319,6 +322,7 @@ const RUNNER_ERROR_CODE_DISPATCH: ReadonlyMap = new [ALERT_NOT_FOUND_RUNNER_CODE, 'no'], [NO_TEXT_INPUT_AT_POINT_RUNNER_CODE, 'no'], [TEXT_INPUT_PROBE_UNAVAILABLE_RUNNER_CODE, 'no'], + [ALERT_BUTTON_NOT_FOUND_RUNNER_CODE, 'no'], ...[...RUNNER_SCREEN_CAPTURE_REFUSAL_RUNNER_CODES].map((code) => [code, 'no'] as const), ]); diff --git a/src/commands/capture/alert.ts b/src/commands/capture/alert.ts index f07dc964a0..2ffffb133f 100644 --- a/src/commands/capture/alert.ts +++ b/src/commands/capture/alert.ts @@ -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. Each iOS XCTest execution activates once, then only observes. Inspect again after an unconfirmed action; never assume a timeout means nothing happened.'; + '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. On the Apple XCUITest runner, dismiss taps only a recognized decline label (Cancel, Don\'t Allow, Ask App Not to Track, and similar) or the close element of a popover whose accessibility marker reads exactly "dismiss popup"; when no button fits the action, accept and dismiss answer ALERT_BUTTON_NOT_FOUND having tapped nothing — read labels with alert get, then press the button by label. Inspect again after an unconfirmed action; never assume a timeout means nothing happened.'; const alertCommandMetadata = defineFieldCommandMetadata( ALERT_COMMAND_NAME, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index aaf2069bda..c7260dcb4b 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -387,6 +387,7 @@ agent-device alert dismiss - `alert` (same as `alert get`) inspects or handles system alerts on iOS simulator, macOS desktop, and Android permission dialogs. On iOS it also covers app-owned modal popups with native blocking markers. - Use `alert get` for a quick check, and `alert wait ` only when a prompt may appear after async work. Don't use `settings permission` to answer a dialog already on screen; it is for setup before a flow. - `accept` and `dismiss` are sent once on every platform and never replayed, even if the response is lost. On iOS the runner activates the button once, then only observes until the alert disappears, changes, or the deadline expires. A changed alert does not prove a permission was granted; verify the app outcome. +- On the Apple XCUITest runner, `dismiss` activates only a button its decline list recognizes (`Cancel`, `Don't Allow`, `Ask App Not to Track`, and similar English labels), or the close button of a popover whose native marker reads "dismiss popup". It never taps anything else: unknown, reworded, or fully localized decline copy fails with `error.code: ALERT_BUTTON_NOT_FOUND` and `details.dispatched: no`, tapping nothing, rather than guessing at an affirmative ([#3627](https://github.com/callstack/agent-device/issues/3627)). A button choice that finds no button to name answers with that code and `dispatched: no` for `accept` too. To answer a prompt with unrecognized wording, read the labels with `alert get`, then press the button by label through `snapshot` + `press @ref`. The macOS helper's `dismiss` keeps its own rules (the app's cancel-button attribute, its label list, and a last-button fallback). - An unreadable or ambiguous capture after the action fails with `error.details.runnerErrorCode: ALERT_CONFIRMATION_UNAVAILABLE`; an expired runner deadline fails with `ALERT_DEADLINE_EXCEEDED`. Neither proves the alert is gone or that nothing happened. Run `alert get` before acting again. - iOS runner refusals carry `error.details.reason`: `runner_busy` means the runner ran nothing (`dispatched: no`); `runner_main_thread_timeout` means the action may still land (`dispatched: unknown`). - If a sheet or permission prompt is visible but `alert` reports none, treat it as app UI: run `snapshot -i -s ""`, then `press @ref`. Android alert support is snapshot-derived, and not every iOS simulator permission surface is a native alert.