Files
callstack__agent-device/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift
T
Michał Pierzchała e58cbcdb5f refactor: colocate native platform sources under android/, apple/, linux/ (#1273)
Move the scattered root-level native projects into per-platform folders and drop
the now-redundant platform prefix:

- android-ime-helper/        -> android/ime-helper/
- android-multitouch-helper/ -> android/multitouch-helper/
- android-snapshot-helper/   -> android/snapshot-helper/
- apple-runner/              -> apple/runner/
- macos-helper/              -> apple/macos-helper/
- src/platforms/linux/atspi-dump.py -> linux/atspi-dump.py

Only repo source paths move. Identity surfaces stay frozen so no user's runner
cache is invalidated on upgrade: the derived-cache key hashes source paths
relative to AgentDeviceRunner and excludes packageVersion, and the
~/.agent-device/{apple-runner,macos-helper} namespaces, the
agent-device-android-*-helper artifact/manifest/protocol names, the
AgentDeviceRunner Xcode project, and the `prepare ios-runner` CLI command are
unchanged. Updates build/package scripts, CI, package.json files+scripts,
ignore/attr/fallow configs, runtime path resolvers, and test fixtures.

Also: re-base repo-root-relative refs inside the moved apple/runner for the
added nesting level (gated XCUITest fixture walk + two doc links), and clean the
legacy dist/apple-runner packaged output so the relocated runner can't
double-ship into the wholesale-included dist (with a regression test).
2026-07-15 21:47:38 +02:00

289 lines
9.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import XCTest
extension RunnerTests {
enum RunnerAlertSource {
case blockingSystemModal
case appAlert
case dismissPopup
}
struct RunnerAlert {
let root: XCUIElement
let ownerApp: XCUIApplication
let buttons: [XCUIElement]
let source: RunnerAlertSource
}
static let defaultAlertCommandTimeout: TimeInterval = 10
static func alertCommandTimeout(timeoutMs: Double?) -> TimeInterval {
guard let timeoutMs, timeoutMs.isFinite else { return defaultAlertCommandTimeout }
return max(0.001, timeoutMs / 1000)
}
func resolveAlert(app activeApp: XCUIApplication, deadline: Date) -> RunnerAlert? {
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
if let override = alertResolutionOverrideForTesting {
return override(deadline)
}
#endif
#if !os(macOS)
switch resolveBlockingSystemModal(deadline: deadline) {
case .resolved(let modal):
return runnerAlert(modal)
case .unresolved:
return nil
case .absent:
break
}
#endif
// 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, source: .appAlert)
}
if let popup = firstDismissPopupWindow(in: activeApp) {
return runnerAlert(root: popup, ownerApp: activeApp, source: .dismissPopup)
}
return nil
}
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"))
}
let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
let actionButtonLabel = button.label.trimmingCharacters(in: .whitespacesAndNewlines)
let actionButtonFrame = button.frame
let outcome = activateElement(app: alert.ownerApp, element: button, action: "alert \(action)")
if let response = unsupportedResponse(for: outcome) {
return response
}
sleepFor(0.2)
if alertStillVisible(
in: alert.ownerApp,
source: alert.source,
previousTitle: previousTitle,
actionButtonLabel: actionButtonLabel,
deadline: deadline
) {
if !actionButtonFrame.isNull && !actionButtonFrame.isEmpty {
let coordinateOutcome = tapAt(
app: alert.ownerApp,
x: actionButtonFrame.midX,
y: actionButtonFrame.midY
)
if let response = unsupportedResponse(for: coordinateOutcome) {
return response
}
sleepFor(0.2)
}
}
if alertStillVisible(
in: alert.ownerApp,
source: alert.source,
previousTitle: previousTitle,
actionButtonLabel: actionButtonLabel,
deadline: deadline
) {
return Response(
ok: false,
error: ErrorPayload(
code: "INTERACTION_FAILED",
message: "alert \(action) did not dismiss the visible alert",
hint: "The alert button was found but the system still reports the alert after tapping it."
)
)
}
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(_ modal: ResolvedBlockingSystemModal) -> RunnerAlert? {
let buttons = modal.actions.filter { isEnabledElement($0) }
guard !buttons.isEmpty else {
return nil
}
return RunnerAlert(
root: modal.root,
ownerApp: modal.ownerApp,
buttons: buttons,
source: .blockingSystemModal
)
}
private func runnerAlert(
root: XCUIElement,
ownerApp: XCUIApplication,
source: RunnerAlertSource
) -> RunnerAlert? {
let buttons = actionableElements(in: root).filter { isEnabledElement($0) }
guard !buttons.isEmpty else {
return nil
}
return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons, source: source)
}
private func alertStillVisible(
in ownerApp: XCUIApplication,
source: RunnerAlertSource,
previousTitle: String,
actionButtonLabel: String,
deadline: Date
) -> Bool {
guard Date() < deadline,
let current = resolveAlert(source: source, app: ownerApp, deadline: deadline)
else {
return false
}
let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons)
if previousTitle == currentTitle {
return true
}
return current.buttons.contains { button in
button.label.trimmingCharacters(in: .whitespacesAndNewlines) == actionButtonLabel
}
}
private func resolveAlert(
source: RunnerAlertSource,
app: XCUIApplication,
deadline: Date
) -> RunnerAlert? {
switch source {
case .blockingSystemModal:
#if os(macOS)
return nil
#else
guard case .resolved(let modal) = resolveBlockingSystemModal(deadline: deadline) else {
return nil
}
return runnerAlert(modal)
#endif
case .appAlert:
guard let alert = firstExistingElement(
in: safeElementsQuery { app.alerts.allElementsBoundByIndex }
) else {
return nil
}
return runnerAlert(root: alert, ownerApp: app, source: .appAlert)
case .dismissPopup:
guard let popup = firstDismissPopupWindow(in: app) else {
return nil
}
return runnerAlert(root: popup, ownerApp: app, source: .dismissPopup)
}
}
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",
"dont 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
}
}