fix: handle platform alerts (#562)

* fix: handle Android platform alerts

* fix: handle iOS alerts in runner

* chore: type alert metadata

* refactor: colocate Android alert handling
This commit is contained in:
Michał Pierzchała
2026-05-20 14:51:17 +02:00
committed by GitHub
parent 896adcc625
commit f71371ebb8
24 changed files with 1330 additions and 187 deletions
@@ -0,0 +1,155 @@
import XCTest
extension RunnerTests {
struct RunnerAlert {
let root: XCUIElement
let ownerApp: XCUIApplication
let buttons: [XCUIElement]
}
func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? {
if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) {
return runnerAlert(root: alert, ownerApp: activeApp)
}
if let popup = firstDismissPopupWindow(in: activeApp) {
return runnerAlert(root: popup, ownerApp: activeApp)
}
#if os(macOS)
return nil
#else
if let systemModal = firstBlockingSystemModal(in: springboard) {
return runnerAlert(root: systemModal, ownerApp: springboard)
}
return nil
#endif
}
func handleAlert(_ alert: RunnerAlert, action: String) -> 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"))
}
let outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
if let response = unsupportedResponse(for: outcome) {
return response
}
return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
}
return Response(
ok: true,
data: DataPayload(
message: preferredAlertTitle(alert.root, buttons: alert.buttons),
items: alert.buttons.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines) }
)
)
}
private func runnerAlert(root: XCUIElement, ownerApp: XCUIApplication) -> RunnerAlert? {
let buttons = actionableElements(in: root).filter { isEnabledElement($0) }
guard !buttons.isEmpty else {
return nil
}
return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons)
}
private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
elements.first { isVisibleElement($0) }
}
private func firstDismissPopupWindow(in app: XCUIApplication) -> XCUIElement? {
safeElementsQuery {
app.windows.allElementsBoundByIndex
}.first { window in
if !isVisibleElement(window) { return false }
if isDismissPopupMarker(window.label) || isDismissPopupMarker(window.identifier) {
return true
}
return safeElementsQuery {
window.descendants(matching: .any).allElementsBoundByIndex
}.contains { descendant in
isDismissPopupMarker(descendant.label) || isDismissPopupMarker(descendant.identifier)
}
}
}
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
}
return buttons.first(where: { isDismissButton($0.label) }) ?? buttons.last
}
private func isAcceptButton(_ label: String) -> Bool {
let normalized = label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return [
"ok",
"allow",
"yes",
"continue",
"done",
"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())
}
private func preferredAlertTitle(_ element: XCUIElement, buttons: [XCUIElement]) -> String {
let buttonLabels = Set(buttons.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() })
let descendants = element.descendants(matching: .any).allElementsBoundByIndex
for descendant in descendants {
let text = descendant.label.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty ||
isGenericAlertLabel(text) ||
buttonLabels.contains(text.lowercased()) ||
descendant.elementType == .navigationBar ||
actionableTypes.contains(descendant.elementType)
{
continue
}
return text
}
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
return label.isEmpty || isGenericAlertLabel(label) ? "Alert" : label
}
private func isGenericAlertLabel(_ label: String) -> Bool {
let normalized = label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return isDismissPopupMarker(normalized) ||
normalized.hasPrefix("vertical scroll bar") ||
normalized.hasPrefix("horizontal scroll bar") ||
normalized == "tab bar"
}
private func isVisibleElement(_ element: XCUIElement) -> Bool {
element.exists && !element.frame.isNull && !element.frame.isEmpty
}
private func isEnabledElement(_ element: XCUIElement) -> Bool {
var enabled = false
_ = RunnerObjCExceptionCatcher.catchException({
enabled = element.exists && element.isEnabled
})
return enabled
}
private func isDismissPopupMarker(_ label: String) -> Bool {
label.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare("dismiss popup") == .orderedSame
}
}
@@ -13,7 +13,7 @@ extension RunnerTests {
return (gestureStartUptimeMs, currentUptimeMs())
}
private func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
switch outcome {
case .performed:
return nil
@@ -731,32 +731,10 @@ extension RunnerTests {
)
case .alert:
let action = (command.action ?? "get").lowercased()
let alert = activeApp.alerts.firstMatch
if !alert.exists {
guard let alert = resolveAlert(app: activeApp) else {
return Response(ok: false, error: ErrorPayload(message: "alert not found"))
}
if action == "accept" {
guard let button = alert.buttons.allElementsBoundByIndex.first else {
return Response(ok: false, error: ErrorPayload(message: "alert accept button not found"))
}
let outcome = activateElement(app: activeApp, element: button, action: "alert accept")
if let response = unsupportedResponse(for: outcome) {
return response
}
return Response(ok: true, data: DataPayload(message: "accepted"))
}
if action == "dismiss" {
guard let button = alert.buttons.allElementsBoundByIndex.last else {
return Response(ok: false, error: ErrorPayload(message: "alert dismiss button not found"))
}
let outcome = activateElement(app: activeApp, element: button, action: "alert dismiss")
if let response = unsupportedResponse(for: outcome) {
return response
}
return Response(ok: true, data: DataPayload(message: "dismissed"))
}
let buttonLabels = alert.buttons.allElementsBoundByIndex.map { $0.label }
return Response(ok: true, data: DataPayload(message: alert.label, items: buttonLabels))
return handleAlert(alert, action: action)
case .pinch:
guard let scale = command.scale, scale > 0 else {
return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
@@ -46,7 +46,7 @@ extension RunnerTests {
#endif
}
private func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
let disableSafeProbe = RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_SAFE_MODAL_PROBE")
let queryElements: (() -> [XCUIElement]) -> [XCUIElement] = { fetch in
if disableSafeProbe {
@@ -76,7 +76,7 @@ extension RunnerTests {
return nil
}
private func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
var elements: [XCUIElement] = []
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
elements = fetch()
@@ -120,7 +120,7 @@ extension RunnerTests {
return true
}
private func actionableElements(in element: XCUIElement) -> [XCUIElement] {
func actionableElements(in element: XCUIElement) -> [XCUIElement] {
var seen = Set<String>()
var actions: [XCUIElement] = []
let descendants = safeElementsQuery {