Files
callstack__agent-device/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.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

269 lines
8.3 KiB
Swift

import XCTest
extension RunnerTests {
// MARK: - Blocking System Modal Snapshot
func blockingSystemAlertSnapshot(deadline: Date = .distantFuture) -> DataPayload? {
#if os(macOS)
return nil
#else
guard case .resolved(let resolvedModal) = resolveBlockingSystemModal(deadline: deadline) else {
return nil
}
let modal = resolvedModal.root
let actions = resolvedModal.actions
let title = preferredSystemModalTitle(modal)
guard let modalNode = safeMakeSnapshotNode(
element: modal,
index: 0,
type: "Alert",
labelOverride: title,
identifierOverride: modal.identifier,
depth: 0,
hittableOverride: true
) else {
return nil
}
var nodes: [SnapshotNode] = [modalNode]
for action in actions {
guard let actionNode = safeMakeSnapshotNode(
element: action,
index: nodes.count,
type: elementTypeName(action.elementType),
depth: 1,
parentIndex: 0,
hittableOverride: true
) else {
continue
}
nodes.append(actionNode)
}
// Action controls are the interaction contract. Informative text is useful context, but
// must not make a usable modal miss the bounded system-modal probe deadline.
let contentDeadline = deadline.addingTimeInterval(-0.5)
for content in informativeElements(in: modal, deadline: contentDeadline) {
guard Date() < contentDeadline,
let contentNode = safeMakeSnapshotNode(
element: content,
index: nodes.count,
type: elementTypeName(content.elementType),
depth: 1,
parentIndex: 0,
hittableOverride: false
)
else {
break
}
nodes.append(contentNode)
}
return DataPayload(nodes: nodes, truncated: false)
#endif
}
func firstBlockingSystemModal(
in springboard: XCUIApplication,
deadline: Date = .distantFuture
) -> XCUIElement? {
let disableSafeProbe = RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_SAFE_MODAL_PROBE")
let queryElements: (() -> [XCUIElement]) -> [XCUIElement] = { fetch in
if disableSafeProbe {
return fetch()
}
return self.safeElementsQuery(fetch)
}
let alerts = queryElements {
springboard.alerts.allElementsBoundByIndex
}
for alert in alerts {
if safeIsBlockingSystemModal(alert, in: springboard) {
return alert
}
}
// Don't start the second (sheet) enumeration once the shared probe deadline is spent (#1244).
if Date() >= deadline {
NSLog("AGENT_DEVICE_RUNNER_SYSTEM_MODAL_PROBE_DEADLINE stage=sheets")
return nil
}
let sheets = queryElements {
springboard.sheets.allElementsBoundByIndex
}
for sheet in sheets {
if safeIsBlockingSystemModal(sheet, in: springboard) {
return sheet
}
}
return nil
}
func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
safely("MODAL_QUERY", [], fetch)
}
private func safeIsBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
safely("MODAL_CHECK", false) { isBlockingSystemModal(element, in: springboard) }
}
private func isBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
guard element.exists else { return false }
let frame = element.frame
if frame.isNull || frame.isEmpty { return false }
let viewport = springboard.frame
if viewport.isNull || viewport.isEmpty { return false }
let center = CGPoint(x: frame.midX, y: frame.midY)
if !viewport.contains(center) { return false }
return true
}
func actionableElements(in element: XCUIElement) -> [XCUIElement] {
var seen = Set<String>()
var actions: [XCUIElement] = []
let descendants = actionableTypes.flatMap { modalDescendants(in: element, matching: $0) }
for candidate in descendants {
if !safeIsActionableCandidate(candidate, seen: &seen) { continue }
actions.append(candidate)
}
return actions
}
private func safeIsActionableCandidate(_ candidate: XCUIElement, seen: inout Set<String>) -> Bool {
safely("MODAL_ACTION", false) {
if !candidate.exists || !candidate.isHittable { return false }
if !actionableTypes.contains(candidate.elementType) { return false }
let frame = candidate.frame
if frame.isNull || frame.isEmpty { return false }
let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
if seen.contains(key) { return false }
seen.insert(key)
return true
}
}
private func informativeElements(in element: XCUIElement, deadline: Date) -> [XCUIElement] {
var seen = Set<String>()
var contents: [XCUIElement] = []
for type in readableSystemModalTypes {
guard Date() < deadline else { break }
for candidate in modalDescendants(in: element, matching: type, limit: 2) {
guard Date() < deadline else { return contents }
guard let key = safeInformativeElementKey(candidate) else { continue }
if seen.contains(key) { continue }
seen.insert(key)
contents.append(candidate)
}
}
return contents
}
private var readableSystemModalTypes: [XCUIElement.ElementType] {
[.staticText, .textView]
}
private func modalDescendants(
in element: XCUIElement,
matching type: XCUIElement.ElementType,
limit: Int? = nil
) -> [XCUIElement] {
let elements = safeElementsQuery {
element.descendants(matching: type).allElementsBoundByIndex
}
guard let limit else {
return elements
}
return Array(elements.prefix(limit))
}
private func safeInformativeElementKey(_ candidate: XCUIElement) -> String? {
safely("MODAL_CONTENT") { () -> String? in
let key = systemModalElementKey(candidate)
if !candidate.exists { return nil }
let frame = candidate.frame
if frame.isNull || frame.isEmpty { return nil }
let label = candidate.label.trimmingCharacters(in: .whitespacesAndNewlines)
if label.isEmpty { return nil }
return key
}
}
private func systemModalElementKey(_ element: XCUIElement) -> String {
let frame = element.frame
return "\(element.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(element.label)-\(element.identifier)"
}
private func preferredSystemModalTitle(_ element: XCUIElement) -> String {
let label = element.label
if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return label
}
let identifier = element.identifier
if !identifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return identifier
}
return "System Alert"
}
private func makeSnapshotNode(
element: XCUIElement,
index: Int,
type: String,
labelOverride: String? = nil,
identifierOverride: String? = nil,
depth: Int,
parentIndex: Int? = nil,
hittableOverride: Bool? = nil
) -> SnapshotNode {
let label = (labelOverride ?? element.label).trimmingCharacters(in: .whitespacesAndNewlines)
let identifier = (identifierOverride ?? element.identifier).trimmingCharacters(in: .whitespacesAndNewlines)
return SnapshotNode(
index: index,
type: type,
label: label.isEmpty ? nil : label,
identifier: identifier.isEmpty ? nil : identifier,
value: nil,
rect: snapshotRect(from: element.frame),
enabled: element.isEnabled,
focused: nil,
selected: nil,
hittable: hittableOverride ?? element.isHittable,
depth: depth,
parentIndex: parentIndex,
hiddenContentAbove: nil,
hiddenContentBelow: nil
)
}
private func safeMakeSnapshotNode(
element: XCUIElement,
index: Int,
type: String,
labelOverride: String? = nil,
identifierOverride: String? = nil,
depth: Int,
parentIndex: Int? = nil,
hittableOverride: Bool? = nil
) -> SnapshotNode? {
safely("MODAL_NODE") {
makeSnapshotNode(
element: element,
index: index,
type: type,
labelOverride: labelOverride,
identifierOverride: identifierOverride,
depth: depth,
parentIndex: parentIndex,
hittableOverride: hittableOverride
)
}
}
}