mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
e58cbcdb5f
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).
269 lines
8.3 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|
|
}
|