mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
committed by
GitHub
parent
896adcc625
commit
f71371ebb8
@@ -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
|
||||
}
|
||||
}
|
||||
+3
-25
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user