Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@
#import <UIKit/UIKit.h>
#import <UserNotifications/UserNotifications.h>

// 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.
static UIAlertController *RunnerAlertDeclineChoiceFixture(BOOL affirmativeOnly, UILabel *answer) {
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;
answer.text = button[1];
}]];
}
return alert;
}

// A plain text field whose accessibility value is the masked rendering of its text. This is how
// a React Native `secureTextEntry` input reaches XCTest: `.textField`, not `.secureTextField`,
// with a value of bullets (tester-army/e2e#872).
Expand Down Expand Up @@ -354,7 +377,7 @@ - (void)presentAlertFixtureReplacement:(BOOL)replacement {
for (NSString *buttonTitle in @[@"Cancel", @"OK"]) {
UIAlertActionStyle style = [buttonTitle isEqualToString:@"Cancel"]
? UIAlertActionStyleCancel : UIAlertActionStyleDefault;
[alert addAction:[UIAlertAction actionWithTitle:buttonTitle style:style handler:^(UIAlertAction *action) {
UIAlertAction *button = [UIAlertAction actionWithTitle:buttonTitle style:style handler:^(UIAlertAction *action) {
(void)action;
if (!replacement) {
self.alertActivationBusyAnswer.text = self.alertActivationBusyBackstop != nil
Expand All @@ -373,33 +396,17 @@ - (void)presentAlertFixtureReplacement:(BOOL)replacement {
[self presentAlertFixtureReplacement:YES];
Comment thread
thymikee marked this conversation as resolved.
Resolved
}];
}
}]];
}];
button.enabled = ![arguments containsObject:@"--agent-device-alert-replacement-disabled-buttons"];
[alert addAction:button];
}
[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];
}]];
}
UIAlertController *alert = RunnerAlertDeclineChoiceFixture(affirmativeOnly, self.alertDeclineAnswer);
[self presentViewController:alert animated:NO completion:nil];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,16 @@ enum RunnerAlertObservation {
enum RunnerAlertVerification: Equatable {
case disappeared
case presentationChanged
case stillVisible
case unconfirmed
case timedOut

static func verify(
Comment thread
thymikee marked this conversation as resolved.
Resolved
original: AlertSurfacePresentation,
observation: RunnerAlertObservation
) -> RunnerAlertVerification {
) -> RunnerAlertVerification? {
Comment thread
thymikee marked this conversation as resolved.
Resolved
switch observation {
case .visible(let current):
return original == current ? .stillVisible : .presentationChanged
return original == current ? nil : .presentationChanged
case .absent:
return .disappeared
case .unavailable:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@
struct RunnerAlert {
let root: XCUIElement
let ownerApp: XCUIApplication
let buttons: [XCUIElement]
let actions: [XCUIElement]
let source: AlertSurfaceSource
/// The window hosting a SpringBoard modal; nil routes through `ownerApp`.
let window: XCUIElement?
}

enum AlertResolution {
case absent
/// A blocking system modal is on screen but its remote host could not be read.
/// Required alert acquisition did not establish presence or absence.
case unresolved
case notActionable
case resolved(RunnerAlert)
}

Expand All @@ -26,78 +27,70 @@
"No alert button was activated. Inspect the current alert before deciding on an action."

static let alertUnresolvedHint =
"A system prompt is visible but alert could not read it. Inspect it with screenshot, then snapshot -i, and press @ref on the visible button."
"Alert could not finish reading the current prompt state. Inspect it with screenshot, then snapshot -i, and press @ref on the visible button."

static func alertCommandTimeout(timeoutMs: Double?) -> TimeInterval {
guard let timeoutMs, timeoutMs.isFinite else { return defaultAlertCommandTimeout }
return max(0.001, timeoutMs / 1000)
}

/// The alert answer for a blocking system modal. Nil means no modal is on screen, so the active
/// app's own alert surfaces are read next.
static func alertResolution(
forSystemModal resolution: BlockingSystemModalResolution,
resolved: (ResolvedBlockingSystemModal) -> AlertResolution
) -> AlertResolution? {
switch resolution {
case .resolved(let modal):
return resolved(modal)
case .unresolved:
return .unresolved
case .absent:
return nil
}
}

@MainActor
func resolveAlert(app activeApp: XCUIApplication, deadline: Date) -> AlertResolution {
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
if let override = alertResolutionOverrideForTesting {
return override(deadline)
}
#endif
do {
#if !os(macOS)
if let resolution = Self.alertResolution(
forSystemModal: resolveBlockingSystemModal(deadline: deadline),
resolved: { modal in
runnerAlert(
root: modal.root,
ownerApp: modal.ownerApp,
actions: modal.actions,
source: .blockingSystemModal,
window: modal.window
)
let systemResolution = resolveBlockingSystemModal(deadline: deadline)
switch systemResolution {
case .absent: break
case .resolved, .unresolved, .notActionable: return systemResolution
}
) {
return resolution
}
#endif
let state = activeApp.state
guard state != .notRunning && state != .unknown else {
let state = try readModalValue(deadline: deadline) { activeApp.state }
guard state != .unknown else { return .unresolved }
guard state != .notRunning else { return .absent }
if let alert = try firstExistingElement(in: readModalElements(deadline: deadline) {
Comment thread
thymikee marked this conversation as resolved.
Resolved
activeApp.alerts.allElementsBoundByIndex
}, deadline: deadline) {
return try resolveAppAlert(root: alert, ownerApp: activeApp, source: .appAlert, deadline: deadline)
}
if let dialog = try firstExistingElement(in: readModalElements(deadline: deadline) {
activeApp.otherElements.matching(identifier: AlertSurface.safariDialogIdentifier).allElementsBoundByIndex
}, deadline: deadline) {
return try resolveAppAlert(root: dialog, ownerApp: activeApp, source: .safariDialog, deadline: deadline)
}
if let popup = try firstDismissPopupWindow(in: activeApp, deadline: deadline) {
return try resolveAppAlert(root: popup, ownerApp: activeApp, source: .dismissPopup, deadline: deadline)
}
return .absent
} catch {
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
return .unresolved
}
// Guard the query: when a remote-hosted modal (e.g. the AccessorySetupKit
// picker) was just dismissed, the dismissal re-check re-enters here with the
// now-gone host as `activeApp`, and its `alerts` query raises
// kAXErrorServerNotFound. safeElementsQuery absorbs it and reports no alert.
if let alert = firstExistingElement(in: safeElementsQuery { activeApp.alerts.allElementsBoundByIndex }) {
return runnerAlert(root: alert, ownerApp: activeApp, actions: actionableElements(in: alert), source: .appAlert)
}
if let dialog = firstExistingElement(in: safeElementsQuery {
activeApp.otherElements.matching(identifier: AlertSurface.safariDialogIdentifier).allElementsBoundByIndex
}) {
return runnerAlert(root: dialog, ownerApp: activeApp, actions: actionableElements(in: dialog), source: .safariDialog)
}
if let popup = firstDismissPopupWindow(in: activeApp) {
return runnerAlert(root: popup, ownerApp: activeApp, actions: actionableElements(in: popup), source: .dismissPopup)
}
return .absent
}

private func resolveAppAlert(
root: XCUIElement, ownerApp: XCUIApplication, source: AlertSurfaceSource, deadline: Date
) throws -> AlertResolution {
let actions = try readModalActions(in: root, deadline: deadline)
guard !actions.isEmpty else { return .notActionable }
return .resolved(RunnerAlert(root: root, ownerApp: ownerApp, actions: actions, source: source, window: nil))
}

@MainActor
func handleAlert(_ alert: RunnerAlert, action: String, deadline: Date) throws -> Response {
if action == "accept" || action == "dismiss" {
guard let button = chooseAlertButton(alert, action: action) else {
let selectedButton: XCUIElement?
do {
selectedButton = try chooseAlertButton(alert, action: action, deadline: deadline)
} catch ModalReadFailure.deadlineExceeded {
return alertVerificationResponse(.timedOut, action: action, activated: false)
} catch {
return alertUnresolvedResponse()
}
guard let button = selectedButton 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(
Expand Down Expand Up @@ -149,22 +142,28 @@
}
while true {
sleepFor(min(0.2, max(0, deadline.timeIntervalSinceNow)))
let verification = RunnerAlertVerification.verify(
guard let verification = RunnerAlertVerification.verify(
original: original,
observation: observeAlert(
in: alert.ownerApp,
source: alert.source,
observesApplicationRoot: observesApplicationRoot,
deadline: deadline
)
)
if verification == .stillVisible { continue }
) else { continue }
return alertVerificationResponse(verification, action: action, activated: true)
}
}

let title = preferredAlertTitle(alert.root, buttons: alert.buttons)
let items = alert.buttons.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines) }
let items: [String]
Comment thread
thymikee marked this conversation as resolved.
Resolved
do {
items = try readModalValue(deadline: deadline) {
alert.actions.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines) }
}
} catch {
Comment thread
thymikee marked this conversation as resolved.
Resolved
return alertUnresolvedResponse()
}

Check warning on line 165 in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

minor: A spent deadline on the `alert get` read reports ALERT\_UNRESOLVED instead of ALERT\_DEADLINE\_EXCEEDED

In `handleAlert`, the two post-resolution reads classify a spent deadline differently: - `chooseAlertButton` failure is split: `catch ModalReadFailure.deadlineExceeded` → `alertVerificationResponse(.timedOut, ...)` (`ALERT_DEADLINE_EXCEEDED`), other errors → `alertUnresolvedResponse()` (RunnerTests+Alert.swift:85-92). - the `get` read of the button labels catches everything: `catch { return alertUnresolvedResponse() }` (RunnerTests+Alert.swift:158-165). The website copy this PR just rewrote states "an expired runner deadline during action or confirmation after the alert is resolved fails with `ALERT_DEADLINE_EXCEEDED`", and the runner already maps this exact case to `ALERT_DEADLINE_EXCEEDED` in the sibling branch (its own comment at line 102/109 reuses `.timedOut` for a pre-activation spent deadline). Concretely: `alert get` whose deadline expires while `readModalValue` is reading `alert.actions` labels answers `ALERT_UNRESOLVED`, i.e. a retriability/inspection hint, where the accurate code is the deadline one. Mirror the accept/dismiss classification so the two reads cannot drift.
let title = safely("ALERT_TITLE", "Alert") { preferredAlertTitle(alert.root, buttons: alert.actions) }
return Response(
ok: true,
data: DataPayload(
Expand Down Expand Up @@ -283,32 +282,28 @@
operation()
}

private func runnerAlert(
root: XCUIElement,
ownerApp: XCUIApplication,
actions: [XCUIElement],
source: AlertSurfaceSource,
window: XCUIElement? = nil
) -> AlertResolution {
let buttons = actions.filter { isEnabledElement($0) }
guard !buttons.isEmpty else {
return .absent
}
return .resolved(RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons, source: source, window: window))
func alertUnresolvedResponse() -> Response {
Comment thread
thymikee marked this conversation as resolved.
Resolved
Response(ok: false, error: ErrorPayload(
code: RunnerWireErrorCode.alertUnresolved,
message: "alert could not finish reading the current prompt state",
hint: Self.alertUnresolvedHint
))
}

private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
elements.first { isVisibleElement($0) }
private func firstExistingElement(in elements: [XCUIElement], deadline: Date) throws -> XCUIElement? {
try elements.first { element in
try readModalValue(deadline: deadline) { isVisibleElement(element) }
}
}

Check notice on line 297 in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

nit: `alertUnresolvedResponse()` is missing a blank line before the following private helper

The extracted `alertUnresolvedResponse()` ends at RunnerTests+Alert.swift:291 and `private func firstExistingElement` starts on line 293, leaving no blank line between declarations, while every other pair of declarations in this file is separated. No functional impact; the same file's other new helpers (`readModalValue`, `readModalElements`, `readModalActions` in RunnerTests+SystemModal.swift) are all blank-line separated.

/// The marker is matched inside XCTest's query, so the screen is read once per query. Reading each
/// descendant instead costs one round trip per element, and on a screen whose tree changes while
/// it is read (a loading web view) each vanished element adds XCTest's retry cycle. `containing`
/// also matches a window that is itself the marker.
private func firstDismissPopupWindow(in app: XCUIApplication) -> XCUIElement? {
firstExistingElement(in: safeElementsQuery {
private func firstDismissPopupWindow(in app: XCUIApplication, deadline: Date) throws -> XCUIElement? {
try firstExistingElement(in: readModalElements(deadline: deadline) {
app.windows.containing(Self.dismissPopupMarker).allElementsBoundByIndex
})
}, deadline: deadline)
}

private static let dismissPopupMarker = NSPredicate(
Expand All @@ -319,17 +314,21 @@

// 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? {
func chooseAlertButton(_ alert: RunnerAlert, action: String, deadline: Date) throws -> XCUIElement? {
Comment thread
thymikee marked this conversation as resolved.
Resolved
let buttons = try readModalValue(deadline: deadline) {
alert.actions.filter { $0.exists && $0.isEnabled }
}
let labels = try readModalValue(deadline: deadline) { buttons.map(\.label) }
guard let index = RunnerAlertButtonChoice.buttonIndex(
for: alert.buttons.map(\.label),
for: labels,
action: action,
popupMarkerSurface: alert.source == .dismissPopup,
vocabulary: .system
) else {
return nil
}
return alert.buttons[index]
return buttons[index]
}

Check warning on line 331 in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

minor: Disabled buttons are now offered as tap candidates and reported as `hittable` in the blocking-modal snapshot

`chooseAlertButton` filters to enabled controls (RunnerTests+Alert.swift:317-331), and `handleAlert` taps `buttons[index]` with no further enabled check — the old `runnerAlert` never handed it a disabled element because `runnerAlert` filtered on `isEnabledElement` and answered `.absent` when none remained. `readModalActions` (RunnerTests+SystemModal.swift:175-194) now keeps `exists && isHittable` regardless of `isEnabled`, and `testAlertWithNoEnabledButtonsStaysResolved` only covers the all-disabled case, where the choice refuses before any activation. The untested middle case is a mixed alert — an enabled decline plus a disabled affirmative (the common 'confirm disabled until the checkbox is ticked' sheet), or a control whose `isEnabled` flips to false after resolution while `isHittable` stays true. `RunnerAlertButtonChoice.buttonIndex` ranks on labels only, so `accept` picks the disabled affirmative, and `activateAlertButton` synthesises a tap at its midX/midY into a non-tracking control; `captureAlertPresentation` then sees an unchanged surface and, per the `.stillVisible` removal, the command rides the deadline to `ALERT_DEADLINE_EXCEEDED` with `activated: true` disclosed. `snapshotForBlockingSystemModal` also publishes these nodes with `hittableOverride: true` (RunnerTests+SystemModal.swift:40-52), so a `snapshot -i` off the system-modal probe states `enabled: false, hittable: true` for a control `press @ref` cannot actuate.

/// The alert's own title: the first non-empty static text inside the alert that is neither a
/// button nor alert chrome, then the alert element's own label, then the first labelled
Expand Down Expand Up @@ -384,10 +383,6 @@
element.exists && !element.frame.isNull && !element.frame.isEmpty
}

private func isEnabledElement(_ element: XCUIElement) -> Bool {
safely("ALERT_BUTTON_ENABLED", false) { element.exists && element.isEnabled }
}

// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,6 @@ extension RunnerTests {
case .timedOut:
Comment thread
thymikee marked this conversation as resolved.
Resolved
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"
Expand Down
Loading
Loading