2026-02-28 13:38:59 +01:00
|
|
|
import XCTest
|
|
|
|
|
|
2026-06-25 07:38:21 +02:00
|
|
|
#if os(macOS)
|
|
|
|
|
import CoreGraphics
|
|
|
|
|
#endif
|
|
|
|
|
|
2026-06-14 05:24:31 -04:00
|
|
|
private enum RunnerInterfaceOrientation {
|
|
|
|
|
static let unknown = 0
|
|
|
|
|
static let portrait = 1
|
|
|
|
|
static let portraitUpsideDown = 2
|
|
|
|
|
static let landscapeRight = 3
|
|
|
|
|
static let landscapeLeft = 4
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 13:38:59 +01:00
|
|
|
extension RunnerTests {
|
2026-07-13 13:16:38 +02:00
|
|
|
enum PlannedGestureExecution: Equatable {
|
|
|
|
|
case fastSwipe
|
|
|
|
|
case sampled
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum SynthesizedDragProfile {
|
|
|
|
|
case continuous
|
|
|
|
|
case fastSwipe
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 13:38:41 +01:00
|
|
|
struct TouchVisualizationFrame {
|
|
|
|
|
let x: Double
|
|
|
|
|
let y: Double
|
|
|
|
|
let referenceWidth: Double
|
|
|
|
|
let referenceHeight: Double
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct DragVisualizationFrame {
|
|
|
|
|
let x: Double
|
|
|
|
|
let y: Double
|
|
|
|
|
let x2: Double
|
|
|
|
|
let y2: Double
|
|
|
|
|
let referenceWidth: Double
|
|
|
|
|
let referenceHeight: Double
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:36:48 +02:00
|
|
|
struct DragPoints {
|
|
|
|
|
let x: Double
|
|
|
|
|
let y: Double
|
|
|
|
|
let x2: Double
|
|
|
|
|
let y2: Double
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 11:06:57 +02:00
|
|
|
struct SynthesizedDragPlan {
|
|
|
|
|
let points: DragPoints
|
2026-07-08 17:15:42 +02:00
|
|
|
let context: SynthesizedCoordinateContext
|
|
|
|
|
|
|
|
|
|
var referenceFrame: CGRect {
|
|
|
|
|
context.referenceFrame
|
|
|
|
|
}
|
2026-07-08 11:06:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:36:48 +02:00
|
|
|
struct SelectorElementMatch {
|
|
|
|
|
let element: XCUIElement?
|
|
|
|
|
let isAmbiguous: Bool
|
2026-05-27 21:45:51 +02:00
|
|
|
let usedNonHittableFallback: Bool
|
2026-05-19 11:36:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-28 13:38:59 +01:00
|
|
|
func performBackGesture(app: XCUIApplication) {
|
2026-05-12 15:32:21 +07:00
|
|
|
if pressTvRemote(.menu) {
|
2026-02-28 13:38:59 +01:00
|
|
|
return
|
|
|
|
|
}
|
2026-05-12 15:32:21 +07:00
|
|
|
performCoordinateBackGesture(app: app)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func performCoordinateBackGesture(app: XCUIApplication) {
|
|
|
|
|
#if !os(tvOS)
|
2026-02-28 13:38:59 +01:00
|
|
|
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
|
|
|
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
|
|
|
|
|
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
|
|
|
|
start.press(forDuration: 0.05, thenDragTo: end)
|
2026-05-12 15:32:21 +07:00
|
|
|
#endif
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-27 10:18:00 +01:00
|
|
|
func performSystemBackAction(app: XCUIApplication) -> Bool {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
return false
|
|
|
|
|
#else
|
2026-05-12 15:32:21 +07:00
|
|
|
if pressTvRemote(.menu) {
|
2026-03-27 10:18:00 +01:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
performBackGesture(app: app)
|
|
|
|
|
return true
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 13:38:59 +01:00
|
|
|
func performAppSwitcherGesture(app: XCUIApplication) {
|
2026-05-12 15:32:21 +07:00
|
|
|
if pressTvRemote(.home) {
|
|
|
|
|
sleepFor(resolveTvRemoteDoublePressDelay())
|
|
|
|
|
_ = pressTvRemote(.home)
|
2026-02-28 13:38:59 +01:00
|
|
|
return
|
|
|
|
|
}
|
2026-05-12 15:32:21 +07:00
|
|
|
performCoordinateAppSwitcherGesture(app: app)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func performCoordinateAppSwitcherGesture(app: XCUIApplication) {
|
|
|
|
|
#if !os(tvOS)
|
2026-02-28 13:38:59 +01:00
|
|
|
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
|
|
|
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))
|
|
|
|
|
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
|
|
|
|
|
start.press(forDuration: 0.6, thenDragTo: end)
|
2026-05-12 15:32:21 +07:00
|
|
|
#endif
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func pressHomeButton() {
|
2026-03-23 10:22:55 +01:00
|
|
|
#if os(macOS)
|
|
|
|
|
return
|
|
|
|
|
#else
|
2026-05-12 15:32:21 +07:00
|
|
|
if pressTvRemote(.home) {
|
2026-02-28 13:38:59 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
XCUIDevice.shared.press(.home)
|
2026-03-23 10:22:55 +01:00
|
|
|
#endif
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 20:34:56 +02:00
|
|
|
func rotateDevice(to orientationName: String) -> Bool {
|
2026-06-30 21:30:46 +02:00
|
|
|
#if os(macOS) || os(tvOS) || os(visionOS)
|
2026-04-01 20:34:56 +02:00
|
|
|
return false
|
|
|
|
|
#else
|
|
|
|
|
switch orientationName {
|
|
|
|
|
case "portrait":
|
|
|
|
|
XCUIDevice.shared.orientation = .portrait
|
|
|
|
|
case "portrait-upside-down":
|
|
|
|
|
XCUIDevice.shared.orientation = .portraitUpsideDown
|
|
|
|
|
case "landscape-left":
|
|
|
|
|
XCUIDevice.shared.orientation = .landscapeLeft
|
|
|
|
|
case "landscape-right":
|
|
|
|
|
XCUIDevice.shared.orientation = .landscapeRight
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
sleepFor(0.2)
|
|
|
|
|
return true
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 13:38:59 +01:00
|
|
|
func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
|
|
|
|
|
let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text)
|
|
|
|
|
let element = app.descendants(matching: .any).matching(predicate).firstMatch
|
|
|
|
|
return element.exists ? element : nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 21:45:51 +02:00
|
|
|
func findElement(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
selectorKey: String,
|
|
|
|
|
selectorValue: String,
|
2026-07-15 21:26:42 +02:00
|
|
|
allowNonHittableFallback: Bool = false,
|
|
|
|
|
expectedPoint: CGPoint? = nil
|
2026-05-27 21:45:51 +02:00
|
|
|
) -> SelectorElementMatch {
|
2026-05-19 11:36:48 +02:00
|
|
|
let value = selectorValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
guard !value.isEmpty else {
|
2026-05-27 21:45:51 +02:00
|
|
|
return SelectorElementMatch(element: nil, isAmbiguous: false, usedNonHittableFallback: false)
|
2026-05-19 11:36:48 +02:00
|
|
|
}
|
|
|
|
|
let predicate: NSPredicate
|
|
|
|
|
switch selectorKey {
|
|
|
|
|
case "id":
|
|
|
|
|
predicate = NSPredicate(format: "identifier ==[c] %@", value)
|
|
|
|
|
case "label":
|
|
|
|
|
predicate = NSPredicate(format: "label ==[c] %@", value)
|
|
|
|
|
case "value":
|
|
|
|
|
predicate = NSPredicate(format: "value ==[c] %@", value)
|
|
|
|
|
case "text":
|
|
|
|
|
predicate = NSPredicate(format: "label ==[c] %@ OR identifier ==[c] %@ OR value ==[c] %@", value, value, value)
|
|
|
|
|
default:
|
2026-05-27 21:45:51 +02:00
|
|
|
return SelectorElementMatch(element: nil, isAmbiguous: false, usedNonHittableFallback: false)
|
2026-05-19 11:36:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var matchedElement: XCUIElement?
|
2026-05-27 21:45:51 +02:00
|
|
|
var nonHittableElement: XCUIElement?
|
2026-05-19 11:36:48 +02:00
|
|
|
let matches = app.descendants(matching: .any).matching(predicate).allElementsBoundByIndex
|
|
|
|
|
for element in matches where element.exists {
|
2026-07-15 21:26:42 +02:00
|
|
|
if let expectedPoint, !element.frame.contains(expectedPoint) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-05-27 21:45:51 +02:00
|
|
|
if !element.isHittable {
|
|
|
|
|
if allowNonHittableFallback && hasTappableFrame(app: app, element: element) {
|
|
|
|
|
guard nonHittableElement == nil else {
|
|
|
|
|
return SelectorElementMatch(element: nil, isAmbiguous: true, usedNonHittableFallback: false)
|
|
|
|
|
}
|
|
|
|
|
nonHittableElement = element
|
|
|
|
|
}
|
2026-05-19 11:36:48 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
guard matchedElement == nil else {
|
2026-05-27 21:45:51 +02:00
|
|
|
return SelectorElementMatch(element: nil, isAmbiguous: true, usedNonHittableFallback: false)
|
2026-05-19 11:36:48 +02:00
|
|
|
}
|
|
|
|
|
matchedElement = element
|
|
|
|
|
}
|
2026-05-27 21:45:51 +02:00
|
|
|
if let matchedElement {
|
|
|
|
|
return SelectorElementMatch(element: matchedElement, isAmbiguous: false, usedNonHittableFallback: false)
|
|
|
|
|
}
|
|
|
|
|
return SelectorElementMatch(
|
|
|
|
|
element: nonHittableElement,
|
|
|
|
|
isAmbiguous: false,
|
|
|
|
|
usedNonHittableFallback: nonHittableElement != nil
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 15:11:55 +02:00
|
|
|
// Maestro-compat gate for the non-hittable coordinate fallback: an element
|
|
|
|
|
// with no frame at all cannot be coordinate-tapped, otherwise the decision
|
|
|
|
|
// is the shared TapPointPolicy center-in-frame rule (golden parity table
|
|
|
|
|
// with the TS twin). app.frame is the frame source here — replay taps
|
|
|
|
|
// resolved bounds Maestro-style, so the union frame is intentional.
|
2026-05-27 21:45:51 +02:00
|
|
|
private func hasTappableFrame(app: XCUIApplication, element: XCUIElement) -> Bool {
|
|
|
|
|
let frame = element.frame
|
|
|
|
|
if frame.isEmpty {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-07-04 15:11:55 +02:00
|
|
|
return TapPointPolicy.isAllowed(elementFrame: frame, windowFrame: app.frame)
|
2026-05-19 11:36:48 +02:00
|
|
|
}
|
|
|
|
|
|
fix: honor wait budgets, preserve the daemon on polling timeouts, refuse off-screen selector taps (#1075)
* fix: agent-UX fixes from Bluesky dogfooding
Four fixes found by driving the Bluesky dev build end to end as an agent:
1. wait honors its user-supplied budget in the daemon request envelope.
The wait timeout travels as a positional, so the client never extended
the request timeout: any 'wait ... 180000' died at the 90s default.
resolveDaemonRequestTimeoutMs now parses wait positionals (shared
src/core parser) and extends the envelope to budget + 30s margin.
2. wait/find timeouts no longer reset the daemon. They are repeated
snapshot captures, sharing snapshot's stalled-bridge failure mode, but
sat on the daemon-reset path — one timed-out wait destroyed every
session the daemon owned (observed live: wait timeout -> runner
killed -> SESSION_NOT_FOUND on the next command).
3. Off-screen selector targets are refused instead of silently tapped.
'tap label=Explore' against Bluesky's closed drawer reported success
while tapping (-161, 265) — coordinates that cannot land:
- selector disambiguation now prefers candidates whose CENTER lies in
the root viewport (isNodeVisibleOnScreen; edge-grazing containers
poke fractions of a pixel into the viewport and must not count),
so 'press label=Profile' picks the visible tab over the drawer item;
- selector-resolved interactions get the same off-screen guard refs
already had (reason: offscreen_selector, with a scroll/open hint);
- the runner's direct selector tap refuses matches whose center is
outside the main window frame (app.frame unions transformed
subtrees, so a closed drawer inflates it) with ELEMENT_OFFSCREEN,
which falls back to the tree-based path.
4. The non-hittable 'may have had no visible effect' hint is dropped when
--verify evidence proves the interactive tree changed — the warning
sat directly next to data contradicting it.
Live-verified against Bluesky on the iPhone 17 Pro simulator: offscreen
tap -> offscreen_selector error; ambiguous Profile -> on-screen tab
(355, 817); 100s wait survives the old 90s envelope with the session
intact.
* refactor: dedupe resolved-target tails and off-screen guards, decompose disambiguation
Fallow follow-up on the dogfood fixes: extract the shared ref/selector
resolved-node tail (describeResolvedInteractionNode), the shared
off-screen check-and-throw skeleton (throwIfOffscreenInteractionTarget —
per-caller messages/hints preserved), and the candidate-accumulation
step out of analyzeSelectorMatches. No behavior change; suites and the
fallow gate are green.
2026-07-04 13:08:46 +02:00
|
|
|
// The tappable on-screen viewport. app.frame is unsuitable: it unions
|
|
|
|
|
// transformed subtrees, so a closed drawer at negative x inflates it and
|
|
|
|
|
// out-of-window coordinates still pass containment. Falls back to app.frame
|
|
|
|
|
// when no window frame is readable.
|
|
|
|
|
func onScreenWindowFrame(app: XCUIApplication) -> CGRect {
|
|
|
|
|
let window = app.windows.element(boundBy: 0)
|
|
|
|
|
if window.exists {
|
|
|
|
|
let frame = window.frame
|
|
|
|
|
if !frame.isEmpty {
|
|
|
|
|
return frame
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return app.frame
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:36:48 +02:00
|
|
|
func queryElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> Response {
|
|
|
|
|
let match = findElement(app: app, selectorKey: selectorKey, selectorValue: selectorValue)
|
|
|
|
|
if match.isAmbiguous {
|
|
|
|
|
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
|
|
|
|
|
}
|
|
|
|
|
guard let element = match.element else {
|
|
|
|
|
return Response(ok: true, data: DataPayload(found: false, nodes: []))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
let valueText = String(describing: element.value ?? "")
|
|
|
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
let node = SnapshotNode(
|
|
|
|
|
index: 0,
|
|
|
|
|
type: elementTypeName(element.elementType),
|
|
|
|
|
label: label.isEmpty ? nil : label,
|
|
|
|
|
identifier: identifier.isEmpty ? nil : identifier,
|
|
|
|
|
value: valueText.isEmpty ? nil : valueText,
|
|
|
|
|
rect: snapshotRect(from: element.frame),
|
|
|
|
|
enabled: element.isEnabled,
|
|
|
|
|
focused: nil,
|
|
|
|
|
selected: element.isSelected ? true : nil,
|
|
|
|
|
hittable: element.isHittable,
|
|
|
|
|
depth: 0,
|
|
|
|
|
parentIndex: nil,
|
|
|
|
|
hiddenContentAbove: nil,
|
|
|
|
|
hiddenContentBelow: nil
|
|
|
|
|
)
|
|
|
|
|
return Response(
|
|
|
|
|
ok: true,
|
|
|
|
|
data: DataPayload(
|
|
|
|
|
text: readableText(for: element),
|
|
|
|
|
found: true,
|
|
|
|
|
nodes: [node]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 17:13:31 +01:00
|
|
|
func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? {
|
|
|
|
|
let point = CGPoint(x: x, y: y)
|
2026-06-01 14:04:27 +02:00
|
|
|
let textInputCandidates = textInputCandidatesAt(app: app, point: point)
|
|
|
|
|
for element in textInputCandidates where prefersExpandedTextRead(element) {
|
|
|
|
|
if let text = readableText(for: element) {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 17:13:31 +01:00
|
|
|
let candidates = app.descendants(matching: .any).allElementsBoundByIndex
|
|
|
|
|
.filter { element in
|
|
|
|
|
element.exists && !element.frame.isEmpty && element.frame.contains(point)
|
|
|
|
|
}
|
|
|
|
|
.sorted { left, right in
|
|
|
|
|
let leftArea = max(1, left.frame.width * left.frame.height)
|
|
|
|
|
let rightArea = max(1, right.frame.width * right.frame.height)
|
|
|
|
|
if leftArea != rightArea {
|
|
|
|
|
return leftArea < rightArea
|
|
|
|
|
}
|
|
|
|
|
if left.frame.minY != right.frame.minY {
|
|
|
|
|
return left.frame.minY < right.frame.minY
|
|
|
|
|
}
|
|
|
|
|
if left.frame.minX != right.frame.minX {
|
|
|
|
|
return left.frame.minX < right.frame.minX
|
|
|
|
|
}
|
|
|
|
|
return left.elementType.rawValue < right.elementType.rawValue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for element in candidates where prefersExpandedTextRead(element) {
|
|
|
|
|
if let text = readableText(for: element) {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for element in candidates {
|
|
|
|
|
if let text = readableText(for: element) {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 14:57:56 +01:00
|
|
|
func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
|
2026-06-01 14:04:27 +02:00
|
|
|
return textInputCandidatesAt(app: app, point: CGPoint(x: x, y: y)).first
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] {
|
refactor(ios): safely(tag:default:) wrapper for the catch-log-and-default band-aid (#660)
Consolidate the repeated `var x = default; catchException({ x = expr }); if let m {
NSLog("..._IGNORED_EXCEPTION=%@", m); return default }; return x` shape used around
exception-prone XCUITest queries into one generic helper (RunnerTests+Exceptions.swift):
func safely<T>(_ tag:, _ fallback:, _ block:) -> T
func safely<T>(_ tag:, _ block: () -> T?) -> T? // nil-default convenience
11 uniform sites adopt it: SystemModal (4), Snapshot (2), TextEntry (2), Interaction (3).
Each drops from ~7-12 lines to 1-3. The NSLog format is preserved byte-for-byte via the tag arg
(tag "MODAL_QUERY" -> "AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@"), so the
silently-logged-and-continued path keeps its searchable format and now has a single place to add
per-tag exception telemetry.
Left inline by design (not the uniform pattern): the silent `_ = catchException` KVC reads
(elementHasFocus, snapshotHasFocus), the throw-on-AX snapshot path, executeOnMainSafely's
retry-driving catch, and the bespoke pressKeyboardReturn fallback / performElementTap branches
whose result depends on the exception message.
Behavior-preserving: same defaults, same log output, same returned values. catchException is
non-escaping, so the inout capture in safeIsActionableCandidate is preserved.
Verified: xcodebuild build-for-testing -> TEST BUILD SUCCEEDED.
2026-06-01 21:25:43 -05:00
|
|
|
safely("TEXT_INPUT_AT_POINT", []) {
|
perf+fix(ios): faster text entry (readiness + typed-query field resolve) + fix fill mis-navigation (#633)
* perf(ios): early-exit text-entry readiness when the keyboard is visible
The XCUITest text-entry focus/readiness loops keyed their fast-exit on
focusedTextInput(), which is intentionally hardcoded to return nil on iOS (focus
predicates are stale there). As a result stabilizeTextInputBeforeTyping always
burned its full focusTimeout (0.4s) and waitForTextEntryReadiness burned its full
readinessTimeout (2.0s) in the normal case where the software keyboard appears —
~2.4s of dead wait before a single keystroke on every type/fill.
The software keyboard becoming visible is the reliable iOS readiness signal, so
both loops now return as soon as isKeyboardVisible() is true. The warmup-first-char
echo check and post-type verify/repair remain as drop safety nets.
Measured on iPhone 17 sim (Settings search field), median type time:
25 chars: 3342ms -> 1379ms (2.4x)
52 chars: 3969ms -> 2190ms
313 chars: 10.3s -> 8.6s (remainder is genuine per-char XCUITest typing)
Reliability unchanged: 64/65 trials exact (incl. a 50-word lorem ipsum, verified
by read-back + screenshot); the lone miss triggered the existing verify/repair.
* fix(ios): don't clear an already-empty text field (fixes fill mis-navigation)
clearTextInput unconditionally ran moveCaretToEnd (an edge-tap computed from the
element frame) + a 24-key delete burst, even when the field was empty. On a field
that repositions on focus — e.g. the Settings search bar jumping bottom->top and
revealing a 'Suggestions' list — that edge-tap used a stale frame and landed on an
adjacent row (Developer), navigating away instead of clearing. fill (replace) into
the search field went to the Developer pane (0/3 correct).
Skip the clear entirely when the field's value is already empty (placeholder
treated as empty): replacing into an empty field is a no-op, and skipping avoids
the stray edge-tap. fill into the Settings search now types correctly and stays
put: 5/5 exact (read-back + screenshot).
* perf(ios): resolve text fields via typed queries, not full-tree enumeration
textInputAt used app.descendants(.any).allElementsBoundByIndex (snapshots EVERY
element) to find the text input at a point. fill drove this repeatedly: once it has
coordinates, resolveTextEntryElement re-runs textInputAt on every verify/repair poll
iteration whenever the focused-field reference goes stale (e.g. the Settings search
bar repositioning bottom->top), so the full-tree enum dominated fill latency.
Query the text-input element types directly (app.textFields/secureTextFields/
searchFields/textViews) instead. Same matches, but XCUITest resolves typed queries
without snapshotting the whole tree. Measured (iPhone 17 sim, warm runner): fill 25
chars ~14.5s -> ~4.5s (3.2x), 6/6 exact. Same primitive #632 killed for get text.
* fix(ios): address review — focus-change gate + secure-field clear
Review P1 (focus race): the isKeyboardVisible early-exit in stabilizeTextInputBeforeTyping
and waitForTextEntryReadiness fired the instant the keyboard was visible — but when it was
ALREADY up from a previous field (back-to-back fills), that is before first-responder moves
to the newly-tapped field, so app.typeText could target the old field. Gate the fast-path on
a keyboard hidden->visible TRANSITION via a shared keyboardBecameVisible(wasVisibleAtEntry:)
helper; when the keyboard was already up, fall back to the settle/timeout (the prior, correct
behavior) instead of the ~2.4s dead wait the fresh case avoids.
Review P1 (F2): clearTextInput used editableTextValue(...) ?? "" and skipped clearing on
empty — but editableTextValue returns nil for secure (and unknown) fields, so secure fields
were NEVER cleared and replace concatenated stale+new. Distinguish nil (clear) from "" (skip).
Device-validated: fresh fill fast-path preserved + exact; a second fill with the keyboard
already up still types into the correct field and replaces (not concatenates).
2026-05-31 15:08:57 +02:00
|
|
|
// Query the text-input element types directly instead of enumerating the entire tree
|
|
|
|
|
// (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x
|
|
|
|
|
// slower — it dominated fill latency because resolveTextEntryElement re-runs this on
|
|
|
|
|
// each verify/repair poll once the focused field reference goes stale).
|
2026-03-27 14:57:56 +01:00
|
|
|
// Prefer the smallest matching field so nested editable controls win over large containers.
|
refactor(ios): safely(tag:default:) wrapper for the catch-log-and-default band-aid (#660)
Consolidate the repeated `var x = default; catchException({ x = expr }); if let m {
NSLog("..._IGNORED_EXCEPTION=%@", m); return default }; return x` shape used around
exception-prone XCUITest queries into one generic helper (RunnerTests+Exceptions.swift):
func safely<T>(_ tag:, _ fallback:, _ block:) -> T
func safely<T>(_ tag:, _ block: () -> T?) -> T? // nil-default convenience
11 uniform sites adopt it: SystemModal (4), Snapshot (2), TextEntry (2), Interaction (3).
Each drops from ~7-12 lines to 1-3. The NSLog format is preserved byte-for-byte via the tag arg
(tag "MODAL_QUERY" -> "AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@"), so the
silently-logged-and-continued path keeps its searchable format and now has a single place to add
per-tag exception telemetry.
Left inline by design (not the uniform pattern): the silent `_ = catchException` KVC reads
(elementHasFocus, snapshotHasFocus), the throw-on-AX snapshot path, executeOnMainSafely's
retry-driving catch, and the bespoke pressKeyboardReturn fallback / performElementTap branches
whose result depends on the exception message.
Behavior-preserving: same defaults, same log output, same returned values. catchException is
non-escaping, so the inout capture in safeIsActionableCandidate is preserved.
Verified: xcodebuild build-for-testing -> TEST BUILD SUCCEEDED.
2026-06-01 21:25:43 -05:00
|
|
|
[
|
perf+fix(ios): faster text entry (readiness + typed-query field resolve) + fix fill mis-navigation (#633)
* perf(ios): early-exit text-entry readiness when the keyboard is visible
The XCUITest text-entry focus/readiness loops keyed their fast-exit on
focusedTextInput(), which is intentionally hardcoded to return nil on iOS (focus
predicates are stale there). As a result stabilizeTextInputBeforeTyping always
burned its full focusTimeout (0.4s) and waitForTextEntryReadiness burned its full
readinessTimeout (2.0s) in the normal case where the software keyboard appears —
~2.4s of dead wait before a single keystroke on every type/fill.
The software keyboard becoming visible is the reliable iOS readiness signal, so
both loops now return as soon as isKeyboardVisible() is true. The warmup-first-char
echo check and post-type verify/repair remain as drop safety nets.
Measured on iPhone 17 sim (Settings search field), median type time:
25 chars: 3342ms -> 1379ms (2.4x)
52 chars: 3969ms -> 2190ms
313 chars: 10.3s -> 8.6s (remainder is genuine per-char XCUITest typing)
Reliability unchanged: 64/65 trials exact (incl. a 50-word lorem ipsum, verified
by read-back + screenshot); the lone miss triggered the existing verify/repair.
* fix(ios): don't clear an already-empty text field (fixes fill mis-navigation)
clearTextInput unconditionally ran moveCaretToEnd (an edge-tap computed from the
element frame) + a 24-key delete burst, even when the field was empty. On a field
that repositions on focus — e.g. the Settings search bar jumping bottom->top and
revealing a 'Suggestions' list — that edge-tap used a stale frame and landed on an
adjacent row (Developer), navigating away instead of clearing. fill (replace) into
the search field went to the Developer pane (0/3 correct).
Skip the clear entirely when the field's value is already empty (placeholder
treated as empty): replacing into an empty field is a no-op, and skipping avoids
the stray edge-tap. fill into the Settings search now types correctly and stays
put: 5/5 exact (read-back + screenshot).
* perf(ios): resolve text fields via typed queries, not full-tree enumeration
textInputAt used app.descendants(.any).allElementsBoundByIndex (snapshots EVERY
element) to find the text input at a point. fill drove this repeatedly: once it has
coordinates, resolveTextEntryElement re-runs textInputAt on every verify/repair poll
iteration whenever the focused-field reference goes stale (e.g. the Settings search
bar repositioning bottom->top), so the full-tree enum dominated fill latency.
Query the text-input element types directly (app.textFields/secureTextFields/
searchFields/textViews) instead. Same matches, but XCUITest resolves typed queries
without snapshotting the whole tree. Measured (iPhone 17 sim, warm runner): fill 25
chars ~14.5s -> ~4.5s (3.2x), 6/6 exact. Same primitive #632 killed for get text.
* fix(ios): address review — focus-change gate + secure-field clear
Review P1 (focus race): the isKeyboardVisible early-exit in stabilizeTextInputBeforeTyping
and waitForTextEntryReadiness fired the instant the keyboard was visible — but when it was
ALREADY up from a previous field (back-to-back fills), that is before first-responder moves
to the newly-tapped field, so app.typeText could target the old field. Gate the fast-path on
a keyboard hidden->visible TRANSITION via a shared keyboardBecameVisible(wasVisibleAtEntry:)
helper; when the keyboard was already up, fall back to the settle/timeout (the prior, correct
behavior) instead of the ~2.4s dead wait the fresh case avoids.
Review P1 (F2): clearTextInput used editableTextValue(...) ?? "" and skipped clearing on
empty — but editableTextValue returns nil for secure (and unknown) fields, so secure fields
were NEVER cleared and replace concatenated stale+new. Distinguish nil (clear) from "" (skip).
Device-validated: fresh fill fast-path preserved + exact; a second fill with the keyboard
already up still types into the correct field and replaces (not concatenates).
2026-05-31 15:08:57 +02:00
|
|
|
app.textFields,
|
|
|
|
|
app.secureTextFields,
|
|
|
|
|
app.searchFields,
|
|
|
|
|
app.textViews,
|
|
|
|
|
]
|
|
|
|
|
.flatMap { $0.allElementsBoundByIndex }
|
2026-03-27 14:57:56 +01:00
|
|
|
.filter { element in
|
|
|
|
|
guard element.exists else { return false }
|
perf+fix(ios): faster text entry (readiness + typed-query field resolve) + fix fill mis-navigation (#633)
* perf(ios): early-exit text-entry readiness when the keyboard is visible
The XCUITest text-entry focus/readiness loops keyed their fast-exit on
focusedTextInput(), which is intentionally hardcoded to return nil on iOS (focus
predicates are stale there). As a result stabilizeTextInputBeforeTyping always
burned its full focusTimeout (0.4s) and waitForTextEntryReadiness burned its full
readinessTimeout (2.0s) in the normal case where the software keyboard appears —
~2.4s of dead wait before a single keystroke on every type/fill.
The software keyboard becoming visible is the reliable iOS readiness signal, so
both loops now return as soon as isKeyboardVisible() is true. The warmup-first-char
echo check and post-type verify/repair remain as drop safety nets.
Measured on iPhone 17 sim (Settings search field), median type time:
25 chars: 3342ms -> 1379ms (2.4x)
52 chars: 3969ms -> 2190ms
313 chars: 10.3s -> 8.6s (remainder is genuine per-char XCUITest typing)
Reliability unchanged: 64/65 trials exact (incl. a 50-word lorem ipsum, verified
by read-back + screenshot); the lone miss triggered the existing verify/repair.
* fix(ios): don't clear an already-empty text field (fixes fill mis-navigation)
clearTextInput unconditionally ran moveCaretToEnd (an edge-tap computed from the
element frame) + a 24-key delete burst, even when the field was empty. On a field
that repositions on focus — e.g. the Settings search bar jumping bottom->top and
revealing a 'Suggestions' list — that edge-tap used a stale frame and landed on an
adjacent row (Developer), navigating away instead of clearing. fill (replace) into
the search field went to the Developer pane (0/3 correct).
Skip the clear entirely when the field's value is already empty (placeholder
treated as empty): replacing into an empty field is a no-op, and skipping avoids
the stray edge-tap. fill into the Settings search now types correctly and stays
put: 5/5 exact (read-back + screenshot).
* perf(ios): resolve text fields via typed queries, not full-tree enumeration
textInputAt used app.descendants(.any).allElementsBoundByIndex (snapshots EVERY
element) to find the text input at a point. fill drove this repeatedly: once it has
coordinates, resolveTextEntryElement re-runs textInputAt on every verify/repair poll
iteration whenever the focused-field reference goes stale (e.g. the Settings search
bar repositioning bottom->top), so the full-tree enum dominated fill latency.
Query the text-input element types directly (app.textFields/secureTextFields/
searchFields/textViews) instead. Same matches, but XCUITest resolves typed queries
without snapshotting the whole tree. Measured (iPhone 17 sim, warm runner): fill 25
chars ~14.5s -> ~4.5s (3.2x), 6/6 exact. Same primitive #632 killed for get text.
* fix(ios): address review — focus-change gate + secure-field clear
Review P1 (focus race): the isKeyboardVisible early-exit in stabilizeTextInputBeforeTyping
and waitForTextEntryReadiness fired the instant the keyboard was visible — but when it was
ALREADY up from a previous field (back-to-back fills), that is before first-responder moves
to the newly-tapped field, so app.typeText could target the old field. Gate the fast-path on
a keyboard hidden->visible TRANSITION via a shared keyboardBecameVisible(wasVisibleAtEntry:)
helper; when the keyboard was already up, fall back to the settle/timeout (the prior, correct
behavior) instead of the ~2.4s dead wait the fresh case avoids.
Review P1 (F2): clearTextInput used editableTextValue(...) ?? "" and skipped clearing on
empty — but editableTextValue returns nil for secure (and unknown) fields, so secure fields
were NEVER cleared and replace concatenated stale+new. Distinguish nil (clear) from "" (skip).
Device-validated: fresh fill fast-path preserved + exact; a second fill with the keyboard
already up still types into the correct field and replaces (not concatenates).
2026-05-31 15:08:57 +02:00
|
|
|
let frame = element.frame
|
|
|
|
|
return !frame.isEmpty && frameContainsPoint(frame, point, tolerance: 2)
|
2026-03-27 14:57:56 +01:00
|
|
|
}
|
|
|
|
|
.sorted { left, right in
|
|
|
|
|
let leftArea = max(1, left.frame.width * left.frame.height)
|
|
|
|
|
let rightArea = max(1, right.frame.width * right.frame.height)
|
|
|
|
|
if leftArea != rightArea {
|
|
|
|
|
return leftArea < rightArea
|
|
|
|
|
}
|
|
|
|
|
if left.frame.minY != right.frame.minY {
|
|
|
|
|
return left.frame.minY < right.frame.minY
|
|
|
|
|
}
|
|
|
|
|
if left.frame.minX != right.frame.minX {
|
|
|
|
|
return left.frame.minX < right.frame.minX
|
|
|
|
|
}
|
|
|
|
|
return left.elementType.rawValue < right.elementType.rawValue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 21:45:51 +02:00
|
|
|
private func frameContainsPoint(_ frame: CGRect, _ point: CGPoint, tolerance: CGFloat) -> Bool {
|
|
|
|
|
point.x >= frame.minX - tolerance
|
|
|
|
|
&& point.x <= frame.maxX + tolerance
|
|
|
|
|
&& point.y >= frame.minY - tolerance
|
|
|
|
|
&& point.y <= frame.maxY + tolerance
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 11:49:30 +01:00
|
|
|
func isKeyboardVisible(app: XCUIApplication) -> Bool {
|
2026-05-19 11:36:48 +02:00
|
|
|
return visibleKeyboardFrame(app: app) != nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 11:49:30 +01:00
|
|
|
func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
|
|
|
|
|
let wasVisible = isKeyboardVisible(app: app)
|
|
|
|
|
guard wasVisible else {
|
|
|
|
|
return (wasVisible: false, dismissed: false, visible: false)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 15:32:21 +07:00
|
|
|
#if os(tvOS)
|
|
|
|
|
_ = pressTvRemote(.menu)
|
|
|
|
|
sleepFor(0.2)
|
|
|
|
|
let visible = isKeyboardVisible(app: app)
|
|
|
|
|
return (wasVisible: true, dismissed: !visible, visible: visible)
|
|
|
|
|
#else
|
2026-03-27 11:49:30 +01:00
|
|
|
if tapKeyboardDismissControl(app: app) {
|
|
|
|
|
sleepFor(0.2)
|
|
|
|
|
let visible = isKeyboardVisible(app: app)
|
|
|
|
|
return (wasVisible: true, dismissed: !visible, visible: visible)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
|
2026-05-12 15:32:21 +07:00
|
|
|
#endif
|
2026-03-27 11:49:30 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-27 21:45:51 +02:00
|
|
|
func pressKeyboardReturn(app: XCUIApplication) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
|
|
|
|
|
#if os(tvOS)
|
|
|
|
|
return (wasVisible: false, pressed: pressTvRemote(.select), visible: false)
|
|
|
|
|
#elseif os(iOS)
|
|
|
|
|
let wasVisible = isKeyboardVisible(app: app)
|
|
|
|
|
if tapKeyboardReturnControl(app: app) {
|
|
|
|
|
sleepFor(0.2)
|
|
|
|
|
return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var typed = false
|
|
|
|
|
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
|
|
|
app.typeText(XCUIKeyboardKey.return.rawValue)
|
|
|
|
|
typed = true
|
|
|
|
|
})
|
|
|
|
|
if let exceptionMessage {
|
|
|
|
|
NSLog(
|
|
|
|
|
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_IGNORED_EXCEPTION=%@",
|
|
|
|
|
exceptionMessage
|
|
|
|
|
)
|
|
|
|
|
if let singleTarget = singleTextEntryElement(app: app) {
|
|
|
|
|
return pressKeyboardReturn(on: singleTarget, app: app, wasVisible: wasVisible)
|
|
|
|
|
}
|
|
|
|
|
return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
|
|
|
|
|
}
|
|
|
|
|
sleepFor(0.2)
|
|
|
|
|
return (wasVisible: wasVisible, pressed: typed, visible: isKeyboardVisible(app: app))
|
|
|
|
|
#else
|
|
|
|
|
return (wasVisible: false, pressed: false, visible: false)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func pressKeyboardReturn(
|
|
|
|
|
on element: XCUIElement,
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
wasVisible: Bool
|
|
|
|
|
) -> (wasVisible: Bool, pressed: Bool, visible: Bool) {
|
2026-06-27 13:48:38 +02:00
|
|
|
#if os(iOS)
|
2026-05-27 21:45:51 +02:00
|
|
|
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
|
|
|
element.tap()
|
|
|
|
|
element.typeText(XCUIKeyboardKey.return.rawValue)
|
|
|
|
|
})
|
|
|
|
|
if let exceptionMessage {
|
|
|
|
|
NSLog(
|
|
|
|
|
"AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TARGET_IGNORED_EXCEPTION=%@",
|
|
|
|
|
exceptionMessage
|
|
|
|
|
)
|
|
|
|
|
return (wasVisible: wasVisible, pressed: false, visible: isKeyboardVisible(app: app))
|
|
|
|
|
}
|
|
|
|
|
sleepFor(0.2)
|
|
|
|
|
return (wasVisible: wasVisible, pressed: true, visible: isKeyboardVisible(app: app))
|
2026-06-27 13:48:38 +02:00
|
|
|
#else
|
|
|
|
|
return (wasVisible: wasVisible, pressed: false, visible: false)
|
|
|
|
|
#endif
|
2026-05-27 21:45:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
|
|
|
|
|
#if os(iOS)
|
refactor(ios): safely(tag:default:) wrapper for the catch-log-and-default band-aid (#660)
Consolidate the repeated `var x = default; catchException({ x = expr }); if let m {
NSLog("..._IGNORED_EXCEPTION=%@", m); return default }; return x` shape used around
exception-prone XCUITest queries into one generic helper (RunnerTests+Exceptions.swift):
func safely<T>(_ tag:, _ fallback:, _ block:) -> T
func safely<T>(_ tag:, _ block: () -> T?) -> T? // nil-default convenience
11 uniform sites adopt it: SystemModal (4), Snapshot (2), TextEntry (2), Interaction (3).
Each drops from ~7-12 lines to 1-3. The NSLog format is preserved byte-for-byte via the tag arg
(tag "MODAL_QUERY" -> "AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@"), so the
silently-logged-and-continued path keeps its searchable format and now has a single place to add
per-tag exception telemetry.
Left inline by design (not the uniform pattern): the silent `_ = catchException` KVC reads
(elementHasFocus, snapshotHasFocus), the throw-on-AX snapshot path, executeOnMainSafely's
retry-driving catch, and the bespoke pressKeyboardReturn fallback / performElementTap branches
whose result depends on the exception message.
Behavior-preserving: same defaults, same log output, same returned values. catchException is
non-escaping, so the inout capture in safeIsActionableCandidate is preserved.
Verified: xcodebuild build-for-testing -> TEST BUILD SUCCEEDED.
2026-06-01 21:25:43 -05:00
|
|
|
let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) {
|
|
|
|
|
app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
|
2026-05-27 21:45:51 +02:00
|
|
|
guard element.exists else { return false }
|
|
|
|
|
switch element.elementType {
|
|
|
|
|
case .textField, .secureTextField, .searchField, .textView:
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return matches.count == 1 ? matches[0] : nil
|
|
|
|
|
#else
|
|
|
|
|
return nil
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 11:49:30 +01:00
|
|
|
private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
|
2026-05-12 15:32:21 +07:00
|
|
|
#if os(tvOS)
|
|
|
|
|
return false
|
|
|
|
|
#else
|
2026-05-19 11:36:48 +02:00
|
|
|
guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-04-28 21:20:09 -04:00
|
|
|
for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
|
2026-03-27 11:49:30 +01:00
|
|
|
let candidates = [
|
|
|
|
|
app.keyboards.buttons[label],
|
|
|
|
|
app.keyboards.keys[label],
|
2026-04-28 21:20:09 -04:00
|
|
|
app.keyboards.toolbars.buttons[label],
|
2026-03-27 11:49:30 +01:00
|
|
|
]
|
|
|
|
|
if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
|
|
|
|
|
hittable.tap()
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-04-28 21:20:09 -04:00
|
|
|
|
|
|
|
|
let toolbarButtonPredicate = NSPredicate(
|
|
|
|
|
format: "label == %@ OR identifier == %@",
|
|
|
|
|
label,
|
|
|
|
|
label
|
|
|
|
|
)
|
2026-06-30 14:08:41 +02:00
|
|
|
let toolbarButtons = app.descendants(matching: .button)
|
2026-04-28 21:20:09 -04:00
|
|
|
.matching(toolbarButtonPredicate)
|
|
|
|
|
.allElementsBoundByIndex
|
|
|
|
|
if let hittable = toolbarButtons.first(where: {
|
|
|
|
|
$0.exists && $0.isHittable && isKeyboardAccessoryControl($0, keyboardFrame: keyboardFrame)
|
|
|
|
|
}) {
|
|
|
|
|
hittable.tap()
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-03-27 11:49:30 +01:00
|
|
|
}
|
|
|
|
|
return false
|
2026-05-12 15:32:21 +07:00
|
|
|
#endif
|
2026-03-27 11:49:30 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-30 14:08:41 +02:00
|
|
|
private func tapKeyboardReturnControl(app: XCUIApplication) -> Bool {
|
2026-05-27 21:45:51 +02:00
|
|
|
#if os(iOS)
|
|
|
|
|
for label in ["return", "Return", "Enter", "Go", "Search", "Next", "Done", "Send", "Join"] {
|
|
|
|
|
let candidates = [
|
|
|
|
|
app.keyboards.buttons[label],
|
|
|
|
|
app.keyboards.keys[label],
|
|
|
|
|
]
|
|
|
|
|
if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
|
|
|
|
|
hittable.tap()
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 21:20:09 -04:00
|
|
|
private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
|
|
|
|
|
let frame = element.frame
|
|
|
|
|
guard !frame.isEmpty && !keyboardFrame.isEmpty else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return frame.intersects(keyboardFrame) || abs(frame.maxY - keyboardFrame.minY) <= 80
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 17:13:31 +01:00
|
|
|
private func readableText(for element: XCUIElement) -> String? {
|
|
|
|
|
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
let valueText = String(describing: element.value ?? "")
|
|
|
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
switch element.elementType {
|
|
|
|
|
case .textField, .secureTextField, .searchField, .textView:
|
|
|
|
|
if !valueText.isEmpty { return valueText }
|
|
|
|
|
if !label.isEmpty { return label }
|
|
|
|
|
return identifier.isEmpty ? nil : identifier
|
|
|
|
|
default:
|
|
|
|
|
if !label.isEmpty { return label }
|
|
|
|
|
if !valueText.isEmpty { return valueText }
|
|
|
|
|
return identifier.isEmpty ? nil : identifier
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func prefersExpandedTextRead(_ element: XCUIElement) -> Bool {
|
|
|
|
|
switch element.elementType {
|
|
|
|
|
case .textField, .secureTextField, .searchField, .textView:
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 13:38:59 +01:00
|
|
|
func findScopeElement(app: XCUIApplication, scope: String) -> XCUIElement? {
|
|
|
|
|
let predicate = NSPredicate(
|
|
|
|
|
format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@",
|
|
|
|
|
scope,
|
|
|
|
|
scope
|
|
|
|
|
)
|
|
|
|
|
let element = app.descendants(matching: .any).matching(predicate).firstMatch
|
|
|
|
|
return element.exists ? element : nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 15:32:21 +07:00
|
|
|
func tapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
|
|
|
if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "tap") {
|
|
|
|
|
return outcome
|
|
|
|
|
}
|
|
|
|
|
return performCoordinateTap(app: app, x: x, y: y)
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 16:01:52 +01:00
|
|
|
func mouseClickAt(app: XCUIApplication, x: Double, y: Double, button: String) throws {
|
2026-05-12 15:32:21 +07:00
|
|
|
#if os(macOS)
|
2026-03-23 16:01:52 +01:00
|
|
|
let coordinate = interactionCoordinate(app: app, x: x, y: y)
|
2026-05-12 15:32:21 +07:00
|
|
|
switch button {
|
|
|
|
|
case "primary":
|
|
|
|
|
coordinate.tap()
|
|
|
|
|
case "secondary":
|
|
|
|
|
coordinate.rightClick()
|
|
|
|
|
case "middle":
|
|
|
|
|
throw NSError(
|
|
|
|
|
domain: "AgentDeviceRunner",
|
|
|
|
|
code: 1,
|
|
|
|
|
userInfo: [NSLocalizedDescriptionKey: "middle mouse button is not supported"]
|
|
|
|
|
)
|
|
|
|
|
default:
|
2026-03-23 16:01:52 +01:00
|
|
|
throw NSError(
|
|
|
|
|
domain: "AgentDeviceRunner",
|
|
|
|
|
code: 1,
|
2026-05-12 15:32:21 +07:00
|
|
|
userInfo: [NSLocalizedDescriptionKey: "unsupported mouse button: \(button)"]
|
2026-03-23 16:01:52 +01:00
|
|
|
)
|
2026-05-12 15:32:21 +07:00
|
|
|
}
|
|
|
|
|
#elseif os(tvOS)
|
|
|
|
|
throw NSError(
|
|
|
|
|
domain: "AgentDeviceRunner",
|
|
|
|
|
code: 1,
|
|
|
|
|
userInfo: [NSLocalizedDescriptionKey: "mouseClick is not supported on tvOS"]
|
|
|
|
|
)
|
|
|
|
|
#else
|
|
|
|
|
throw NSError(
|
|
|
|
|
domain: "AgentDeviceRunner",
|
|
|
|
|
code: 1,
|
|
|
|
|
userInfo: [NSLocalizedDescriptionKey: "mouseClick is only supported on macOS"]
|
|
|
|
|
)
|
|
|
|
|
#endif
|
2026-03-23 16:01:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 07:38:21 +02:00
|
|
|
func desktopScrollAt(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
x: Double,
|
|
|
|
|
y: Double,
|
|
|
|
|
direction: String,
|
|
|
|
|
pixels: Double,
|
|
|
|
|
durationMs: Double?
|
|
|
|
|
) throws {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
guard let events = desktopScrollWheelDeltaEvents(
|
|
|
|
|
direction: direction,
|
|
|
|
|
pixels: pixels,
|
|
|
|
|
durationMs: durationMs
|
|
|
|
|
) else {
|
|
|
|
|
throw NSError(
|
|
|
|
|
domain: "AgentDeviceRunner",
|
|
|
|
|
code: 1,
|
|
|
|
|
userInfo: [NSLocalizedDescriptionKey: "unsupported desktop scroll direction: \(direction)"]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let coordinate = interactionCoordinate(app: app, x: x, y: y)
|
|
|
|
|
let interval = desktopScrollEventIntervalSeconds(durationMs: durationMs, eventCount: events.count)
|
|
|
|
|
for (index, deltas) in events.enumerated() {
|
|
|
|
|
// Keep desktop scrolling on XCTest's coordinate API so macOS owns wheel synthesis, natural
|
|
|
|
|
// scrolling preference handling, and cursor placement instead of posting raw CGEvents.
|
|
|
|
|
coordinate.scroll(
|
|
|
|
|
byDeltaX: CGFloat(deltas.horizontal),
|
|
|
|
|
deltaY: CGFloat(deltas.vertical)
|
|
|
|
|
)
|
|
|
|
|
if interval > 0 && index < events.count - 1 {
|
|
|
|
|
Thread.sleep(forTimeInterval: interval)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#elseif os(tvOS)
|
|
|
|
|
throw NSError(
|
|
|
|
|
domain: "AgentDeviceRunner",
|
|
|
|
|
code: 1,
|
|
|
|
|
userInfo: [NSLocalizedDescriptionKey: "desktopScroll is not supported on tvOS"]
|
|
|
|
|
)
|
|
|
|
|
#else
|
|
|
|
|
throw NSError(
|
|
|
|
|
domain: "AgentDeviceRunner",
|
|
|
|
|
code: 1,
|
|
|
|
|
userInfo: [NSLocalizedDescriptionKey: "desktopScroll is only supported on macOS"]
|
|
|
|
|
)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func desktopScrollWheelDeltas(direction: String, pixels: Double) -> (vertical: Int32, horizontal: Int32)? {
|
|
|
|
|
let magnitude = Int32(max(1, min(Double(Int32.max), pixels.rounded())))
|
|
|
|
|
switch direction {
|
|
|
|
|
case "up":
|
|
|
|
|
return (vertical: magnitude, horizontal: 0)
|
|
|
|
|
case "down":
|
|
|
|
|
return (vertical: -magnitude, horizontal: 0)
|
|
|
|
|
case "left":
|
|
|
|
|
return (vertical: 0, horizontal: magnitude)
|
|
|
|
|
case "right":
|
|
|
|
|
return (vertical: 0, horizontal: -magnitude)
|
|
|
|
|
default:
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func desktopScrollWheelDeltaEvents(
|
|
|
|
|
direction: String,
|
|
|
|
|
pixels: Double,
|
|
|
|
|
durationMs: Double?
|
|
|
|
|
) -> [(vertical: Int32, horizontal: Int32)]? {
|
|
|
|
|
guard let totalDeltas = desktopScrollWheelDeltas(direction: direction, pixels: pixels) else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
let magnitude = max(abs(Int(totalDeltas.vertical)), abs(Int(totalDeltas.horizontal)))
|
|
|
|
|
let duration = max(0, durationMs ?? 0)
|
|
|
|
|
let requestedEventCount = duration > 0 ? Int(ceil(duration / 16.0)) : 1
|
|
|
|
|
let eventCount = max(1, min(magnitude, requestedEventCount))
|
|
|
|
|
guard eventCount > 1 else {
|
|
|
|
|
return [totalDeltas]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if totalDeltas.vertical != 0 {
|
|
|
|
|
return distributeDesktopScrollDelta(totalDeltas.vertical, eventCount: eventCount)
|
|
|
|
|
.map { (vertical: $0, horizontal: 0) }
|
|
|
|
|
}
|
|
|
|
|
return distributeDesktopScrollDelta(totalDeltas.horizontal, eventCount: eventCount)
|
|
|
|
|
.map { (vertical: 0, horizontal: $0) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func desktopScrollEventIntervalSeconds(durationMs: Double?, eventCount: Int) -> TimeInterval {
|
|
|
|
|
guard let durationMs, durationMs > 0, eventCount > 1 else { return 0 }
|
|
|
|
|
return (durationMs / 1000.0) / Double(eventCount - 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func distributeDesktopScrollDelta(_ delta: Int32, eventCount: Int) -> [Int32] {
|
|
|
|
|
let sign: Int32 = delta < 0 ? -1 : 1
|
|
|
|
|
let magnitude = abs(Int(delta))
|
|
|
|
|
let base = magnitude / eventCount
|
|
|
|
|
let remainder = magnitude % eventCount
|
|
|
|
|
return (0..<eventCount).map { index in
|
|
|
|
|
sign * Int32(base + (index < remainder ? 1 : 0))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 15:32:21 +07:00
|
|
|
func doubleTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
|
|
|
if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "double tap") {
|
|
|
|
|
guard case .performed = outcome else { return outcome }
|
|
|
|
|
sleepFor(0.1)
|
|
|
|
|
_ = pressTvRemote(.select)
|
|
|
|
|
return .performed
|
|
|
|
|
}
|
|
|
|
|
return performCoordinateDoubleTap(app: app, x: x, y: y)
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-12 15:32:21 +07:00
|
|
|
func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
|
|
|
|
|
if let outcome = longSelectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), duration: duration) {
|
|
|
|
|
return outcome
|
|
|
|
|
}
|
|
|
|
|
return performCoordinateLongPress(app: app, x: x, y: y, duration: duration)
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func dragAt(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
x: Double,
|
|
|
|
|
y: Double,
|
|
|
|
|
x2: Double,
|
|
|
|
|
y2: Double,
|
|
|
|
|
holdDuration: TimeInterval
|
2026-05-12 15:32:21 +07:00
|
|
|
) -> RunnerInteractionOutcome {
|
|
|
|
|
// tvOS has no coordinate drag. Preserve the direction as a focus move.
|
|
|
|
|
let dx = x2 - x
|
|
|
|
|
let dy = y2 - y
|
|
|
|
|
let button: TvRemoteButton = abs(dx) > abs(dy)
|
|
|
|
|
? (dx > 0 ? .right : .left)
|
|
|
|
|
: (dy > 0 ? .down : .up)
|
|
|
|
|
if pressTvRemote(button) {
|
|
|
|
|
return .performed
|
|
|
|
|
}
|
|
|
|
|
return performCoordinateDrag(app: app, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-14 05:24:31 -04:00
|
|
|
/// Rotates an interface-oriented point into the device-native (portrait) space the
|
|
|
|
|
/// synthesized event path consumes — synthesized events skip XCTest's orientation
|
|
|
|
|
/// handling, so without this a landscape tap lands in the wrong place.
|
|
|
|
|
func nativeSynthesizedPoint(
|
|
|
|
|
orientedX x: Double,
|
|
|
|
|
orientedY y: Double,
|
|
|
|
|
in frame: CGRect,
|
|
|
|
|
interfaceOrientation: Int
|
|
|
|
|
) -> CGPoint {
|
|
|
|
|
let localX = x - Double(frame.minX)
|
|
|
|
|
let localY = y - Double(frame.minY)
|
|
|
|
|
let width = Double(frame.width)
|
|
|
|
|
let height = Double(frame.height)
|
|
|
|
|
switch interfaceOrientation {
|
|
|
|
|
case RunnerInterfaceOrientation.landscapeRight:
|
|
|
|
|
return CGPoint(x: height - localY, y: localX)
|
|
|
|
|
case RunnerInterfaceOrientation.landscapeLeft:
|
|
|
|
|
return CGPoint(x: localY, y: width - localX)
|
|
|
|
|
case RunnerInterfaceOrientation.portraitUpsideDown:
|
|
|
|
|
return CGPoint(x: width - localX, y: height - localY)
|
|
|
|
|
default: // portrait or unknown
|
|
|
|
|
return CGPoint(x: localX, y: localY)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Rotates an interface-oriented translation vector into the same native
|
|
|
|
|
/// coordinate space as `nativeSynthesizedPoint`.
|
|
|
|
|
func nativeSynthesizedVector(
|
|
|
|
|
orientedDx dx: Double,
|
|
|
|
|
orientedDy dy: Double,
|
|
|
|
|
interfaceOrientation: Int
|
|
|
|
|
) -> CGVector {
|
|
|
|
|
switch interfaceOrientation {
|
|
|
|
|
case RunnerInterfaceOrientation.landscapeRight:
|
|
|
|
|
return CGVector(dx: -dy, dy: dx)
|
|
|
|
|
case RunnerInterfaceOrientation.landscapeLeft:
|
|
|
|
|
return CGVector(dx: dy, dy: -dx)
|
|
|
|
|
case RunnerInterfaceOrientation.portraitUpsideDown:
|
|
|
|
|
return CGVector(dx: -dx, dy: -dy)
|
|
|
|
|
default: // portrait or unknown
|
|
|
|
|
return CGVector(dx: dx, dy: dy)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 04:59:13 -07:00
|
|
|
func synthesizedDragAt(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
x: Double,
|
|
|
|
|
y: Double,
|
|
|
|
|
x2: Double,
|
|
|
|
|
y2: Double,
|
2026-07-08 11:06:57 +02:00
|
|
|
durationMs: Double,
|
2026-07-13 13:16:38 +02:00
|
|
|
profile: SynthesizedDragProfile = .continuous,
|
2026-07-08 17:15:42 +02:00
|
|
|
context: SynthesizedCoordinateContext? = nil
|
2026-06-03 04:59:13 -07:00
|
|
|
) -> RunnerInteractionOutcome {
|
|
|
|
|
#if os(iOS)
|
2026-07-08 11:06:57 +02:00
|
|
|
guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite else {
|
|
|
|
|
return .unsupported(
|
|
|
|
|
message: "synthesized coordinate drag requires finite coordinates",
|
|
|
|
|
hint: "Retry with finite x, y, x2, and y2 values."
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-14 05:24:31 -04:00
|
|
|
let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
|
2026-07-08 17:15:42 +02:00
|
|
|
guard let context = context ?? synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.synthesizedDrag)) else {
|
2026-07-08 11:06:57 +02:00
|
|
|
return .unsupported(
|
|
|
|
|
message: "synthesized coordinate drag could not resolve a finite screen frame",
|
|
|
|
|
hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-07-08 17:15:42 +02:00
|
|
|
let frame = context.referenceFrame
|
2026-06-14 05:24:31 -04:00
|
|
|
let start = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: frame, interfaceOrientation: orientation)
|
|
|
|
|
let end = nativeSynthesizedPoint(orientedX: x2, orientedY: y2, in: frame, interfaceOrientation: orientation)
|
2026-07-13 13:16:38 +02:00
|
|
|
let message = switch profile {
|
|
|
|
|
case .continuous:
|
|
|
|
|
RunnerSynthesizedGesture.synthesizeContinuousDrag(
|
2026-07-10 16:41:54 +02:00
|
|
|
withApplication: app,
|
|
|
|
|
x: Double(start.x),
|
|
|
|
|
y: Double(start.y),
|
|
|
|
|
x2: Double(end.x),
|
|
|
|
|
y2: Double(end.y),
|
|
|
|
|
durationMs: durationMs
|
|
|
|
|
)
|
2026-07-13 13:16:38 +02:00
|
|
|
case .fastSwipe:
|
|
|
|
|
RunnerSynthesizedGesture.synthesizeSwipe(
|
2026-07-10 16:41:54 +02:00
|
|
|
withApplication: app,
|
|
|
|
|
x: Double(start.x),
|
|
|
|
|
y: Double(start.y),
|
|
|
|
|
x2: Double(end.x),
|
|
|
|
|
y2: Double(end.y),
|
|
|
|
|
durationMs: durationMs
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if let message {
|
2026-06-03 04:59:13 -07:00
|
|
|
return .unsupported(
|
|
|
|
|
message: message,
|
2026-07-08 11:06:57 +02:00
|
|
|
hint: "Private XCTest event synthesis is required for AX-free coordinate drag on iOS; update Xcode if this persists."
|
2026-06-03 04:59:13 -07:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return .performed
|
|
|
|
|
#elseif os(tvOS)
|
|
|
|
|
return .unsupported(
|
|
|
|
|
message: "coordinate drag is not supported on tvOS",
|
|
|
|
|
hint: "tvOS has no coordinate input; use remote-driven swipe/scroll to move focus instead."
|
|
|
|
|
)
|
|
|
|
|
#else
|
|
|
|
|
return .unsupported(
|
|
|
|
|
message: "coordinate drag is not supported on macOS",
|
|
|
|
|
hint: "macOS automation has no touchscreen; use mouse-driven interactions instead."
|
|
|
|
|
)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 17:15:42 +02:00
|
|
|
func synthesizedTapAt(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
x: Double,
|
|
|
|
|
y: Double,
|
|
|
|
|
context: SynthesizedCoordinateContext? = nil
|
|
|
|
|
) -> RunnerInteractionOutcome {
|
2026-06-07 11:17:27 +02:00
|
|
|
#if os(iOS)
|
2026-07-08 11:06:57 +02:00
|
|
|
guard x.isFinite, y.isFinite else {
|
|
|
|
|
return .unsupported(
|
|
|
|
|
message: "synthesized coordinate tap requires finite coordinates",
|
|
|
|
|
hint: "Retry with finite x and y values."
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-14 05:24:31 -04:00
|
|
|
let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
|
2026-07-08 17:15:42 +02:00
|
|
|
guard let context = context ?? synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.coordinateTap)) else {
|
2026-07-08 11:06:57 +02:00
|
|
|
return .unsupported(
|
|
|
|
|
message: "synthesized coordinate tap could not resolve a finite screen frame",
|
|
|
|
|
hint: "Retry after the app is foregrounded, or use a plain screenshot to choose coordinates."
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-07-08 17:15:42 +02:00
|
|
|
let frame = context.referenceFrame
|
2026-07-08 11:06:57 +02:00
|
|
|
let point = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: frame, interfaceOrientation: orientation)
|
2026-06-07 11:17:27 +02:00
|
|
|
if let message = RunnerSynthesizedGesture.synthesizeTap(
|
|
|
|
|
withApplication: app,
|
2026-06-14 05:24:31 -04:00
|
|
|
x: Double(point.x),
|
|
|
|
|
y: Double(point.y)
|
2026-06-07 11:17:27 +02:00
|
|
|
) {
|
|
|
|
|
return .unsupported(
|
|
|
|
|
message: message,
|
|
|
|
|
hint: "Falling back to XCTest coordinate tap may be slower and can still need a healthy accessibility tree."
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return .performed
|
|
|
|
|
#elseif os(tvOS)
|
|
|
|
|
return .unsupported(
|
|
|
|
|
message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
|
|
|
|
|
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
|
|
|
|
|
)
|
|
|
|
|
#else
|
|
|
|
|
return .unsupported(
|
|
|
|
|
message: "synthesized coordinate tap is not supported on macOS",
|
|
|
|
|
hint: "macOS automation has no touchscreen; use mouse-driven interactions instead."
|
|
|
|
|
)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:36:48 +02:00
|
|
|
func keyboardAvoidingDragPoints(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
x: Double,
|
|
|
|
|
y: Double,
|
|
|
|
|
x2: Double,
|
|
|
|
|
y2: Double
|
|
|
|
|
) -> DragPoints {
|
|
|
|
|
let original = DragPoints(x: x, y: y, x2: x2, y2: y2)
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
|
|
|
|
|
return original
|
|
|
|
|
}
|
|
|
|
|
let minX = min(x, x2)
|
|
|
|
|
let minY = min(y, y2)
|
|
|
|
|
let gestureBounds = CGRect(
|
|
|
|
|
x: CGFloat(minX),
|
|
|
|
|
y: CGFloat(minY),
|
|
|
|
|
width: CGFloat(max(abs(x2 - x), 1)),
|
|
|
|
|
height: CGFloat(max(abs(y2 - y), 1))
|
|
|
|
|
)
|
|
|
|
|
guard gestureBounds.intersects(keyboardFrame) else {
|
|
|
|
|
return original
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let window = app.windows.firstMatch
|
|
|
|
|
let appFrame = window.exists && !window.frame.isEmpty ? window.frame : app.frame
|
|
|
|
|
guard !appFrame.isEmpty else {
|
|
|
|
|
return original
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let padding: Double = 12
|
|
|
|
|
let targetMaxY = Double(keyboardFrame.minY) - padding
|
|
|
|
|
let currentMaxY = max(y, y2)
|
|
|
|
|
let shift = currentMaxY - targetMaxY
|
|
|
|
|
guard shift > 0 else {
|
|
|
|
|
return original
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let adjustedY = y - shift
|
|
|
|
|
let adjustedY2 = y2 - shift
|
|
|
|
|
guard min(adjustedY, adjustedY2) >= Double(appFrame.minY) + padding else {
|
|
|
|
|
return original
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
NSLog(
|
|
|
|
|
"AGENT_DEVICE_RUNNER_KEYBOARD_AVOIDING_DRAG from=(%.1f,%.1f)->(%.1f,%.1f) adjusted=(%.1f,%.1f)->(%.1f,%.1f) keyboardMinY=%.1f",
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
x2,
|
|
|
|
|
y2,
|
|
|
|
|
x,
|
|
|
|
|
adjustedY,
|
|
|
|
|
x2,
|
|
|
|
|
adjustedY2,
|
|
|
|
|
Double(keyboardFrame.minY)
|
|
|
|
|
)
|
|
|
|
|
return DragPoints(x: x, y: adjustedY, x2: x2, y2: adjustedY2)
|
|
|
|
|
#else
|
|
|
|
|
return original
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 13:38:41 +01:00
|
|
|
func resolvedTouchVisualizationFrame(app: XCUIApplication, x: Double, y: Double) -> TouchVisualizationFrame {
|
|
|
|
|
let appFrame = app.frame
|
|
|
|
|
let referenceFrame = resolvedTouchReferenceFrame(app: app, appFrame: appFrame)
|
|
|
|
|
let originX = appFrame.isEmpty ? referenceFrame.minX : appFrame.minX
|
|
|
|
|
let originY = appFrame.isEmpty ? referenceFrame.minY : appFrame.minY
|
|
|
|
|
return TouchVisualizationFrame(
|
|
|
|
|
x: originX + x,
|
|
|
|
|
y: originY + y,
|
|
|
|
|
referenceWidth: referenceFrame.width,
|
|
|
|
|
referenceHeight: referenceFrame.height
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func resolvedDragVisualizationFrame(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
x: Double,
|
|
|
|
|
y: Double,
|
|
|
|
|
x2: Double,
|
|
|
|
|
y2: Double
|
|
|
|
|
) -> DragVisualizationFrame {
|
|
|
|
|
let start = resolvedTouchVisualizationFrame(app: app, x: x, y: y)
|
|
|
|
|
let end = resolvedTouchVisualizationFrame(app: app, x: x2, y: y2)
|
|
|
|
|
return DragVisualizationFrame(
|
|
|
|
|
x: start.x,
|
|
|
|
|
y: start.y,
|
|
|
|
|
x2: end.x,
|
|
|
|
|
y2: end.y,
|
|
|
|
|
referenceWidth: start.referenceWidth,
|
|
|
|
|
referenceHeight: start.referenceHeight
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 12:06:57 +01:00
|
|
|
func resolvedTouchReferenceFrame(app: XCUIApplication, appFrame: CGRect) -> CGRect {
|
2026-03-24 13:38:41 +01:00
|
|
|
let window = app.windows.firstMatch
|
2026-05-19 11:36:48 +02:00
|
|
|
if window.exists {
|
|
|
|
|
let windowFrame = window.frame
|
|
|
|
|
if !windowFrame.isEmpty {
|
|
|
|
|
return frameAvoidingKeyboard(app: app, frame: windowFrame)
|
|
|
|
|
}
|
2026-03-24 13:38:41 +01:00
|
|
|
}
|
|
|
|
|
if !appFrame.isEmpty {
|
2026-05-19 11:36:48 +02:00
|
|
|
return frameAvoidingKeyboard(app: app, frame: appFrame)
|
2026-03-24 13:38:41 +01:00
|
|
|
}
|
|
|
|
|
return CGRect(x: 0, y: 0, width: 0, height: 0)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:36:48 +02:00
|
|
|
private func frameAvoidingKeyboard(app: XCUIApplication, frame: CGRect) -> CGRect {
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
guard let keyboardFrame = visibleKeyboardFrame(app: app), !frame.isEmpty else {
|
|
|
|
|
return frame
|
|
|
|
|
}
|
|
|
|
|
let intersection = frame.intersection(keyboardFrame)
|
|
|
|
|
guard !intersection.isNull && intersection.height > 0 else {
|
|
|
|
|
return frame
|
|
|
|
|
}
|
|
|
|
|
let keyboardCoverage = intersection.width / max(frame.width, 1)
|
|
|
|
|
guard keyboardCoverage >= 0.5 else {
|
|
|
|
|
return frame
|
|
|
|
|
}
|
|
|
|
|
let safeHeight = keyboardFrame.minY - frame.minY
|
|
|
|
|
guard safeHeight >= frame.height * 0.25 else {
|
|
|
|
|
return frame
|
|
|
|
|
}
|
|
|
|
|
return CGRect(x: frame.minX, y: frame.minY, width: frame.width, height: safeHeight)
|
|
|
|
|
#else
|
|
|
|
|
return frame
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
|
|
|
|
|
#if os(iOS)
|
refactor(ios): safely(tag:default:) wrapper for the catch-log-and-default band-aid (#660)
Consolidate the repeated `var x = default; catchException({ x = expr }); if let m {
NSLog("..._IGNORED_EXCEPTION=%@", m); return default }; return x` shape used around
exception-prone XCUITest queries into one generic helper (RunnerTests+Exceptions.swift):
func safely<T>(_ tag:, _ fallback:, _ block:) -> T
func safely<T>(_ tag:, _ block: () -> T?) -> T? // nil-default convenience
11 uniform sites adopt it: SystemModal (4), Snapshot (2), TextEntry (2), Interaction (3).
Each drops from ~7-12 lines to 1-3. The NSLog format is preserved byte-for-byte via the tag arg
(tag "MODAL_QUERY" -> "AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@"), so the
silently-logged-and-continued path keeps its searchable format and now has a single place to add
per-tag exception telemetry.
Left inline by design (not the uniform pattern): the silent `_ = catchException` KVC reads
(elementHasFocus, snapshotHasFocus), the throw-on-AX snapshot path, executeOnMainSafely's
retry-driving catch, and the bespoke pressKeyboardReturn fallback / performElementTap branches
whose result depends on the exception message.
Behavior-preserving: same defaults, same log output, same returned values. catchException is
non-escaping, so the inout capture in safeIsActionableCandidate is preserved.
Verified: xcodebuild build-for-testing -> TEST BUILD SUCCEEDED.
2026-06-01 21:25:43 -05:00
|
|
|
return safely("KEYBOARD_FRAME") {
|
2026-05-19 11:36:48 +02:00
|
|
|
let keyboard = app.keyboards.firstMatch
|
refactor(ios): safely(tag:default:) wrapper for the catch-log-and-default band-aid (#660)
Consolidate the repeated `var x = default; catchException({ x = expr }); if let m {
NSLog("..._IGNORED_EXCEPTION=%@", m); return default }; return x` shape used around
exception-prone XCUITest queries into one generic helper (RunnerTests+Exceptions.swift):
func safely<T>(_ tag:, _ fallback:, _ block:) -> T
func safely<T>(_ tag:, _ block: () -> T?) -> T? // nil-default convenience
11 uniform sites adopt it: SystemModal (4), Snapshot (2), TextEntry (2), Interaction (3).
Each drops from ~7-12 lines to 1-3. The NSLog format is preserved byte-for-byte via the tag arg
(tag "MODAL_QUERY" -> "AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@"), so the
silently-logged-and-continued path keeps its searchable format and now has a single place to add
per-tag exception telemetry.
Left inline by design (not the uniform pattern): the silent `_ = catchException` KVC reads
(elementHasFocus, snapshotHasFocus), the throw-on-AX snapshot path, executeOnMainSafely's
retry-driving catch, and the bespoke pressKeyboardReturn fallback / performElementTap branches
whose result depends on the exception message.
Behavior-preserving: same defaults, same log output, same returned values. catchException is
non-escaping, so the inout capture in safeIsActionableCandidate is preserved.
Verified: xcodebuild build-for-testing -> TEST BUILD SUCCEEDED.
2026-06-01 21:25:43 -05:00
|
|
|
guard keyboard.exists else { return nil }
|
2026-05-19 11:36:48 +02:00
|
|
|
let keyboardFrame = keyboard.frame
|
refactor(ios): safely(tag:default:) wrapper for the catch-log-and-default band-aid (#660)
Consolidate the repeated `var x = default; catchException({ x = expr }); if let m {
NSLog("..._IGNORED_EXCEPTION=%@", m); return default }; return x` shape used around
exception-prone XCUITest queries into one generic helper (RunnerTests+Exceptions.swift):
func safely<T>(_ tag:, _ fallback:, _ block:) -> T
func safely<T>(_ tag:, _ block: () -> T?) -> T? // nil-default convenience
11 uniform sites adopt it: SystemModal (4), Snapshot (2), TextEntry (2), Interaction (3).
Each drops from ~7-12 lines to 1-3. The NSLog format is preserved byte-for-byte via the tag arg
(tag "MODAL_QUERY" -> "AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@"), so the
silently-logged-and-continued path keeps its searchable format and now has a single place to add
per-tag exception telemetry.
Left inline by design (not the uniform pattern): the silent `_ = catchException` KVC reads
(elementHasFocus, snapshotHasFocus), the throw-on-AX snapshot path, executeOnMainSafely's
retry-driving catch, and the bespoke pressKeyboardReturn fallback / performElementTap branches
whose result depends on the exception message.
Behavior-preserving: same defaults, same log output, same returned values. catchException is
non-escaping, so the inout capture in safeIsActionableCandidate is preserved.
Verified: xcodebuild build-for-testing -> TEST BUILD SUCCEEDED.
2026-06-01 21:25:43 -05:00
|
|
|
guard !keyboardFrame.isEmpty else { return nil }
|
|
|
|
|
return keyboardFrame
|
2026-05-19 11:36:48 +02:00
|
|
|
}
|
|
|
|
|
#else
|
|
|
|
|
return nil
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 11:06:57 +02:00
|
|
|
func axFreeSynthesizedDragPlan(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
x: Double,
|
|
|
|
|
y: Double,
|
|
|
|
|
x2: Double,
|
|
|
|
|
y2: Double,
|
2026-07-08 17:15:42 +02:00
|
|
|
context: SynthesizedCoordinateContext? = nil
|
2026-07-08 11:06:57 +02:00
|
|
|
) -> SynthesizedDragPlan? {
|
|
|
|
|
#if os(iOS)
|
2026-07-08 17:15:42 +02:00
|
|
|
let context = context ?? synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.synthesizedDrag))
|
2026-07-08 11:06:57 +02:00
|
|
|
guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite,
|
2026-07-08 17:15:42 +02:00
|
|
|
let context
|
2026-07-08 11:06:57 +02:00
|
|
|
else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-07-08 17:15:42 +02:00
|
|
|
let points = keyboardAvoidingSynthesizedDragPoints(
|
|
|
|
|
app: app,
|
|
|
|
|
x: x,
|
|
|
|
|
y: y,
|
|
|
|
|
x2: x2,
|
|
|
|
|
y2: y2,
|
|
|
|
|
context: context
|
|
|
|
|
)
|
2026-07-08 11:06:57 +02:00
|
|
|
return SynthesizedDragPlan(
|
|
|
|
|
points: points,
|
2026-07-08 17:15:42 +02:00
|
|
|
context: context
|
2026-07-08 11:06:57 +02:00
|
|
|
)
|
|
|
|
|
#else
|
|
|
|
|
return nil
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func axFreeDragVisualizationFrame(
|
|
|
|
|
x: Double,
|
|
|
|
|
y: Double,
|
|
|
|
|
x2: Double,
|
|
|
|
|
y2: Double,
|
|
|
|
|
referenceFrame: CGRect
|
|
|
|
|
) -> DragVisualizationFrame {
|
|
|
|
|
return DragVisualizationFrame(
|
|
|
|
|
x: x,
|
|
|
|
|
y: y,
|
|
|
|
|
x2: x2,
|
|
|
|
|
y2: y2,
|
|
|
|
|
referenceWidth: Double(referenceFrame.width),
|
|
|
|
|
referenceHeight: Double(referenceFrame.height)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 17:15:42 +02:00
|
|
|
func synthesizedCoordinateContext(policy: SynthesizedGesturePolicy) -> SynthesizedCoordinateContext? {
|
2026-07-08 11:06:57 +02:00
|
|
|
#if os(iOS)
|
2026-07-08 17:15:42 +02:00
|
|
|
let health = runnerAccessibilityHealth
|
|
|
|
|
guard let referenceFrame = synthesizedScreenshotReferenceFrame(
|
|
|
|
|
screenshotSize: { XCUIScreen.main.screenshot().image.size }
|
|
|
|
|
) else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return SynthesizedCoordinateContext(
|
|
|
|
|
referenceFrame: referenceFrame,
|
|
|
|
|
keyboardPolicy: policy.keyboardPolicy,
|
|
|
|
|
fallbackPolicy: policy.fallbackPolicy,
|
|
|
|
|
accessibilityHealth: health
|
2026-07-08 11:06:57 +02:00
|
|
|
)
|
|
|
|
|
#else
|
|
|
|
|
return nil
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 17:15:42 +02:00
|
|
|
func synthesizedScreenshotReferenceFrame(screenshotSize: () -> CGSize) -> CGRect? {
|
|
|
|
|
let screenshotSize = screenshotSize()
|
2026-07-08 11:06:57 +02:00
|
|
|
guard screenshotSize.width.isFinite, screenshotSize.height.isFinite,
|
|
|
|
|
screenshotSize.width > 0,
|
|
|
|
|
screenshotSize.height > 0
|
|
|
|
|
else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return CGRect(x: 0, y: 0, width: screenshotSize.width, height: screenshotSize.height)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 17:15:42 +02:00
|
|
|
func synthesizedFrameAvoidingKeyboardWhenAllowed(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
context: SynthesizedCoordinateContext
|
|
|
|
|
) -> CGRect {
|
2026-07-08 11:06:57 +02:00
|
|
|
#if os(iOS)
|
2026-07-08 17:15:42 +02:00
|
|
|
guard context.allowsKeyboardProbe else { return context.referenceFrame }
|
|
|
|
|
return frameAvoidingKeyboard(app: app, frame: context.referenceFrame)
|
2026-07-08 11:06:57 +02:00
|
|
|
#else
|
2026-07-08 17:15:42 +02:00
|
|
|
return context.referenceFrame
|
2026-07-08 11:06:57 +02:00
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func keyboardAvoidingSynthesizedDragPoints(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
x: Double,
|
|
|
|
|
y: Double,
|
|
|
|
|
x2: Double,
|
2026-07-08 17:15:42 +02:00
|
|
|
y2: Double,
|
|
|
|
|
context: SynthesizedCoordinateContext
|
2026-07-08 11:06:57 +02:00
|
|
|
) -> DragPoints {
|
|
|
|
|
#if os(iOS)
|
2026-07-08 17:15:42 +02:00
|
|
|
guard context.allowsKeyboardProbe else {
|
2026-07-08 11:06:57 +02:00
|
|
|
return DragPoints(x: x, y: y, x2: x2, y2: y2)
|
|
|
|
|
}
|
|
|
|
|
return keyboardAvoidingDragPoints(app: app, x: x, y: y, x2: x2, y2: y2)
|
|
|
|
|
#else
|
|
|
|
|
return DragPoints(x: x, y: y, x2: x2, y2: y2)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 12:06:57 +01:00
|
|
|
func swipe(app: XCUIApplication, direction: String) -> DragVisualizationFrame? {
|
2026-02-28 13:38:59 +01:00
|
|
|
if performTvRemoteSwipeIfAvailable(direction: direction) {
|
2026-03-27 12:06:57 +01:00
|
|
|
let frame = resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
|
|
|
|
|
let midX = frame.midX
|
|
|
|
|
let midY = frame.midY
|
|
|
|
|
return DragVisualizationFrame(
|
|
|
|
|
x: midX,
|
|
|
|
|
y: midY,
|
|
|
|
|
x2: midX,
|
|
|
|
|
y2: midY,
|
|
|
|
|
referenceWidth: frame.width,
|
|
|
|
|
referenceHeight: frame.height
|
|
|
|
|
)
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|
2026-03-27 12:06:57 +01:00
|
|
|
return nil
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-27 12:06:57 +01:00
|
|
|
private func performTvRemoteSwipeIfAvailable(direction: String) -> Bool {
|
2026-02-28 13:38:59 +01:00
|
|
|
switch direction {
|
2026-03-27 12:06:57 +01:00
|
|
|
case "up":
|
2026-05-12 15:32:21 +07:00
|
|
|
return pressTvRemote(.up)
|
2026-03-27 12:06:57 +01:00
|
|
|
case "down":
|
2026-05-12 15:32:21 +07:00
|
|
|
return pressTvRemote(.down)
|
2026-03-27 12:06:57 +01:00
|
|
|
case "left":
|
2026-05-12 15:32:21 +07:00
|
|
|
return pressTvRemote(.left)
|
2026-03-27 12:06:57 +01:00
|
|
|
case "right":
|
2026-05-12 15:32:21 +07:00
|
|
|
return pressTvRemote(.right)
|
2026-03-27 12:06:57 +01:00
|
|
|
default:
|
|
|
|
|
return false
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 13:16:38 +02:00
|
|
|
func plannedGestureValidationError(_ plan: RunnerGesturePlan) -> String? {
|
|
|
|
|
guard plan.topology == "single" || plan.topology == "two" else {
|
|
|
|
|
return "planned gesture topology must be single or two"
|
|
|
|
|
}
|
|
|
|
|
let supportedIntent = plan.topology == "single"
|
|
|
|
|
? plan.intent == "fling" || plan.intent == "pan"
|
|
|
|
|
: plan.intent == "pan" || plan.intent == "pinch" || plan.intent == "rotate"
|
|
|
|
|
|| plan.intent == "transform"
|
|
|
|
|
guard supportedIntent else { return "planned gesture has unsupported intent for its topology" }
|
2026-07-15 21:26:42 +02:00
|
|
|
if plan.topology == "single" {
|
|
|
|
|
guard plan.executionProfile == "endpoint-hold" || plan.executionProfile == "timed-pan" else {
|
|
|
|
|
return "single-pointer gesture requires a supported execution profile"
|
|
|
|
|
}
|
|
|
|
|
} else if plan.executionProfile != nil {
|
|
|
|
|
return "multi-touch gesture cannot define a single-pointer execution profile"
|
|
|
|
|
}
|
2026-07-13 13:16:38 +02:00
|
|
|
guard plan.durationMs.isFinite, plan.durationMs >= 16, plan.durationMs <= 10_000 else {
|
|
|
|
|
return "planned gesture durationMs must be between 16 and 10000"
|
|
|
|
|
}
|
|
|
|
|
let viewport = plan.viewport
|
|
|
|
|
guard viewport.x.isFinite, viewport.y.isFinite, viewport.width.isFinite,
|
|
|
|
|
viewport.height.isFinite, viewport.width > 0, viewport.height > 0
|
|
|
|
|
else {
|
|
|
|
|
return "planned gesture viewport must be finite and positive"
|
|
|
|
|
}
|
|
|
|
|
let expectedPointerCount = plan.topology == "single" ? 1 : 2
|
|
|
|
|
guard plan.pointers.count == expectedPointerCount else {
|
|
|
|
|
return "planned gesture pointer count does not match topology"
|
|
|
|
|
}
|
|
|
|
|
for (index, pointer) in plan.pointers.enumerated() where pointer.pointerId != index {
|
|
|
|
|
return "planned gesture requires ordered pointer ids"
|
|
|
|
|
}
|
|
|
|
|
let firstSamples = plan.pointers[0].samples
|
|
|
|
|
guard firstSamples.count >= 2 else { return "planned pointer paths require at least two samples" }
|
|
|
|
|
for pointer in plan.pointers {
|
|
|
|
|
guard pointer.samples.count == firstSamples.count else {
|
|
|
|
|
return "planned pointer paths require matching samples"
|
|
|
|
|
}
|
|
|
|
|
var previousOffset = -1.0
|
|
|
|
|
for (index, sample) in pointer.samples.enumerated() {
|
|
|
|
|
guard sample.offsetMs.isFinite,
|
|
|
|
|
sample.offsetMs == firstSamples[index].offsetMs,
|
|
|
|
|
sample.offsetMs > previousOffset
|
|
|
|
|
else {
|
|
|
|
|
return "planned pointer sample offsets must match and strictly increase"
|
|
|
|
|
}
|
|
|
|
|
let point = sample.point
|
|
|
|
|
guard point.x.isFinite, point.y.isFinite,
|
|
|
|
|
point.x >= viewport.x,
|
|
|
|
|
point.x <= viewport.x + viewport.width,
|
|
|
|
|
point.y >= viewport.y,
|
|
|
|
|
point.y <= viewport.y + viewport.height
|
|
|
|
|
else {
|
|
|
|
|
return "planned pointer sample lies outside the viewport"
|
|
|
|
|
}
|
|
|
|
|
previousOffset = sample.offsetMs
|
|
|
|
|
}
|
|
|
|
|
guard pointer.samples.first?.offsetMs == 0,
|
|
|
|
|
pointer.samples.last?.offsetMs == plan.durationMs
|
|
|
|
|
else {
|
|
|
|
|
return "planned pointer paths must start at 0 and end at durationMs"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if plan.topology == "two" {
|
|
|
|
|
guard let firstStart = firstSamples.first?.point,
|
|
|
|
|
let secondStart = plan.pointers[1].samples.first?.point,
|
|
|
|
|
hypot(firstStart.x - secondStart.x, firstStart.y - secondStart.y) > 0
|
|
|
|
|
else {
|
|
|
|
|
return "planned pointer paths require a positive initial span"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
2026-05-12 15:32:21 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-13 13:16:38 +02:00
|
|
|
func plannedGestureExecution(for plan: RunnerGesturePlan) -> PlannedGestureExecution {
|
2026-07-15 21:26:42 +02:00
|
|
|
plan.topology == "single" && plan.executionProfile == "endpoint-hold"
|
|
|
|
|
? .fastSwipe
|
|
|
|
|
: .sampled
|
2026-05-22 18:01:58 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-13 13:16:38 +02:00
|
|
|
func sampledPlannedGesture(
|
2026-05-22 18:01:58 +02:00
|
|
|
app: XCUIApplication,
|
2026-07-13 13:16:38 +02:00
|
|
|
plan: RunnerGesturePlan
|
2026-05-22 18:01:58 +02:00
|
|
|
) -> RunnerInteractionOutcome {
|
|
|
|
|
#if os(iOS)
|
2026-06-14 05:24:31 -04:00
|
|
|
let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
|
2026-07-13 13:16:38 +02:00
|
|
|
// The portable planner and validation use this exact viewport. Using app.frame here can
|
|
|
|
|
// diverge when XCTest unions transformed/off-screen descendants into the application frame.
|
|
|
|
|
let frame = CGRect(
|
|
|
|
|
x: plan.viewport.x,
|
|
|
|
|
y: plan.viewport.y,
|
|
|
|
|
width: plan.viewport.width,
|
|
|
|
|
height: plan.viewport.height
|
|
|
|
|
)
|
|
|
|
|
let pointerSamples: [[[String: NSNumber]]] = plan.pointers.map { pointer in
|
|
|
|
|
pointer.samples.map { sample in
|
|
|
|
|
let point = nativeSynthesizedPoint(
|
|
|
|
|
orientedX: sample.point.x,
|
|
|
|
|
orientedY: sample.point.y,
|
|
|
|
|
in: frame,
|
|
|
|
|
interfaceOrientation: orientation
|
|
|
|
|
)
|
|
|
|
|
return [
|
|
|
|
|
"x": NSNumber(value: Double(point.x)),
|
|
|
|
|
"y": NSNumber(value: Double(point.y)),
|
|
|
|
|
"offsetMs": NSNumber(value: sample.offsetMs),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let message = RunnerSynthesizedGesture.synthesizeGesture(
|
2026-05-26 09:58:33 +02:00
|
|
|
withApplication: app,
|
2026-07-13 13:16:38 +02:00
|
|
|
pointerSamples: pointerSamples
|
2026-05-26 09:58:33 +02:00
|
|
|
) {
|
2026-06-01 19:30:36 +02:00
|
|
|
return .unsupported(
|
|
|
|
|
message: message,
|
2026-07-13 13:16:38 +02:00
|
|
|
hint: "This gesture uses private XCTest event-synthesis APIs; rebuild the runner with a supported Xcode if this persists."
|
2026-06-01 19:30:36 +02:00
|
|
|
)
|
2026-05-26 09:58:33 +02:00
|
|
|
}
|
|
|
|
|
return .performed
|
2026-05-22 18:01:58 +02:00
|
|
|
#elseif os(tvOS)
|
2026-06-01 19:30:36 +02:00
|
|
|
return .unsupported(
|
2026-07-13 13:16:38 +02:00
|
|
|
message: "two-finger gestures are not supported on tvOS",
|
|
|
|
|
hint: "tvOS has no touch input; use remote-driven navigation."
|
|
|
|
|
)
|
|
|
|
|
#elseif os(visionOS)
|
|
|
|
|
return .unsupported(
|
|
|
|
|
message: "two-finger touch gestures are not supported on visionOS",
|
|
|
|
|
hint: "The current XCTest synthesizer supports iOS and iPadOS touch simulators only."
|
2026-06-01 19:30:36 +02:00
|
|
|
)
|
2026-05-22 18:01:58 +02:00
|
|
|
#else
|
2026-06-01 19:30:36 +02:00
|
|
|
return .unsupported(
|
2026-07-13 13:16:38 +02:00
|
|
|
message: "two-finger gestures are not supported on macOS",
|
|
|
|
|
hint: "macOS automation has no multi-touch input; run on an iOS simulator."
|
2026-06-01 19:30:36 +02:00
|
|
|
)
|
2026-05-22 18:01:58 +02:00
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 10:22:55 +01:00
|
|
|
private func interactionRoot(app: XCUIApplication) -> XCUIElement {
|
|
|
|
|
let windows = app.windows.allElementsBoundByIndex
|
|
|
|
|
if let window = windows.first(where: { $0.exists && !$0.frame.isEmpty }) {
|
|
|
|
|
return window
|
|
|
|
|
}
|
|
|
|
|
return app
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 15:32:21 +07:00
|
|
|
private func performCoordinateTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
|
|
|
#if os(tvOS)
|
2026-06-01 19:30:36 +02:00
|
|
|
return .unsupported(
|
|
|
|
|
message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
|
|
|
|
|
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
|
|
|
|
|
)
|
2026-05-12 15:32:21 +07:00
|
|
|
#else
|
|
|
|
|
interactionCoordinate(app: app, x: x, y: y).tap()
|
|
|
|
|
return .performed
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func performCoordinateDoubleTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
|
|
|
#if os(tvOS)
|
2026-06-01 19:30:36 +02:00
|
|
|
return .unsupported(
|
|
|
|
|
message: "coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
|
|
|
|
|
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
|
|
|
|
|
)
|
2026-05-12 15:32:21 +07:00
|
|
|
#else
|
|
|
|
|
interactionCoordinate(app: app, x: x, y: y).doubleTap()
|
|
|
|
|
return .performed
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func performCoordinateLongPress(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
|
|
|
|
|
#if os(tvOS)
|
2026-06-01 19:30:36 +02:00
|
|
|
return .unsupported(
|
|
|
|
|
message: "coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element",
|
|
|
|
|
hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then long-select it."
|
|
|
|
|
)
|
2026-05-12 15:32:21 +07:00
|
|
|
#else
|
|
|
|
|
interactionCoordinate(app: app, x: x, y: y).press(forDuration: duration)
|
|
|
|
|
return .performed
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func performCoordinateDrag(
|
|
|
|
|
app: XCUIApplication,
|
|
|
|
|
x: Double,
|
|
|
|
|
y: Double,
|
|
|
|
|
x2: Double,
|
|
|
|
|
y2: Double,
|
|
|
|
|
holdDuration: TimeInterval
|
|
|
|
|
) -> RunnerInteractionOutcome {
|
|
|
|
|
#if os(tvOS)
|
2026-06-01 19:30:36 +02:00
|
|
|
return .unsupported(
|
|
|
|
|
message: "coordinate drag is not supported on tvOS",
|
|
|
|
|
hint: "tvOS has no coordinate input; use remote-driven swipe/scroll to move focus instead."
|
|
|
|
|
)
|
2026-05-12 15:32:21 +07:00
|
|
|
#else
|
|
|
|
|
let start = interactionCoordinate(app: app, x: x, y: y)
|
|
|
|
|
let end = interactionCoordinate(app: app, x: x2, y: y2)
|
|
|
|
|
start.press(forDuration: holdDuration, thenDragTo: end)
|
|
|
|
|
return .performed
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#if !os(tvOS)
|
2026-03-23 10:22:55 +01:00
|
|
|
private func interactionCoordinate(app: XCUIApplication, x: Double, y: Double) -> XCUICoordinate {
|
2026-05-21 12:42:08 +02:00
|
|
|
#if os(iOS)
|
|
|
|
|
let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
|
|
|
return origin.withOffset(CGVector(dx: x, dy: y))
|
|
|
|
|
#else
|
2026-03-23 10:22:55 +01:00
|
|
|
let root = interactionRoot(app: app)
|
|
|
|
|
let origin = root.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
|
|
|
let rootFrame = root.frame
|
|
|
|
|
let offsetX = x - Double(rootFrame.origin.x)
|
|
|
|
|
let offsetY = y - Double(rootFrame.origin.y)
|
|
|
|
|
return origin.withOffset(CGVector(dx: offsetX, dy: offsetY))
|
2026-05-21 12:42:08 +02:00
|
|
|
#endif
|
2026-03-23 10:22:55 +01:00
|
|
|
}
|
2026-05-12 15:32:21 +07:00
|
|
|
#endif
|
2026-03-23 10:22:55 +01:00
|
|
|
|
2026-06-29 15:43:15 +02:00
|
|
|
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
|
2026-06-14 05:24:31 -04:00
|
|
|
// Identity in portrait/unknown, 90° per landscape, 180° upside-down.
|
|
|
|
|
func testNativeSynthesizedPointRotatesByInterfaceOrientation() {
|
|
|
|
|
let portrait = CGRect(x: 0, y: 0, width: 834, height: 1210)
|
|
|
|
|
let landscape = CGRect(x: 0, y: 0, width: 1210, height: 834)
|
|
|
|
|
let offsetLandscape = CGRect(x: 10, y: 20, width: 1210, height: 834)
|
|
|
|
|
// (frame, UIInterfaceOrientation, expected native point) for a tap at (170, 268).
|
|
|
|
|
let cases: [(CGRect, Int, CGPoint)] = [
|
|
|
|
|
(portrait, RunnerInterfaceOrientation.portrait, CGPoint(x: 170, y: 268)),
|
|
|
|
|
(landscape, RunnerInterfaceOrientation.landscapeRight, CGPoint(x: 566, y: 170)),
|
|
|
|
|
(landscape, RunnerInterfaceOrientation.landscapeLeft, CGPoint(x: 268, y: 1040)),
|
|
|
|
|
(portrait, RunnerInterfaceOrientation.portraitUpsideDown, CGPoint(x: 664, y: 942)),
|
|
|
|
|
(portrait, RunnerInterfaceOrientation.unknown, CGPoint(x: 170, y: 268)),
|
|
|
|
|
]
|
|
|
|
|
for (frame, orientation, expected) in cases {
|
|
|
|
|
XCTAssertEqual(
|
|
|
|
|
nativeSynthesizedPoint(orientedX: 170, orientedY: 268, in: frame, interfaceOrientation: orientation),
|
|
|
|
|
expected,
|
|
|
|
|
"interfaceOrientation \(orientation)"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
XCTAssertEqual(
|
|
|
|
|
nativeSynthesizedPoint(
|
|
|
|
|
orientedX: 180,
|
|
|
|
|
orientedY: 288,
|
|
|
|
|
in: offsetLandscape,
|
|
|
|
|
interfaceOrientation: RunnerInterfaceOrientation.landscapeLeft
|
|
|
|
|
),
|
|
|
|
|
CGPoint(x: 268, y: 1040),
|
|
|
|
|
"non-zero frame origin is localized before rotation"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testNativeSynthesizedVectorRotatesByInterfaceOrientation() {
|
|
|
|
|
let cases: [(Int, CGVector)] = [
|
|
|
|
|
(RunnerInterfaceOrientation.portrait, CGVector(dx: 40, dy: -20)),
|
|
|
|
|
(RunnerInterfaceOrientation.landscapeRight, CGVector(dx: 20, dy: 40)),
|
|
|
|
|
(RunnerInterfaceOrientation.landscapeLeft, CGVector(dx: -20, dy: -40)),
|
|
|
|
|
(RunnerInterfaceOrientation.portraitUpsideDown, CGVector(dx: -40, dy: 20)),
|
|
|
|
|
(RunnerInterfaceOrientation.unknown, CGVector(dx: 40, dy: -20)),
|
|
|
|
|
]
|
|
|
|
|
for (orientation, expected) in cases {
|
|
|
|
|
let vector = nativeSynthesizedVector(orientedDx: 40, orientedDy: -20, interfaceOrientation: orientation)
|
|
|
|
|
XCTAssertEqual(vector.dx, expected.dx, "dx interfaceOrientation \(orientation)")
|
|
|
|
|
XCTAssertEqual(vector.dy, expected.dy, "dy interfaceOrientation \(orientation)")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-25 07:38:21 +02:00
|
|
|
|
2026-07-08 17:15:42 +02:00
|
|
|
func testSynthesizedScreenshotReferenceFrameUsesScreenshotSize() throws {
|
|
|
|
|
let resolved = try XCTUnwrap(
|
|
|
|
|
synthesizedScreenshotReferenceFrame(screenshotSize: { CGSize(width: 430, height: 932) })
|
2026-07-08 11:06:57 +02:00
|
|
|
)
|
|
|
|
|
|
2026-07-08 17:15:42 +02:00
|
|
|
XCTAssertEqual(resolved, CGRect(x: 0, y: 0, width: 430, height: 932))
|
2026-07-08 11:06:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-08 17:15:42 +02:00
|
|
|
func testSynthesizedScreenshotReferenceFrameRejectsInvalidSize() {
|
2026-07-08 11:06:57 +02:00
|
|
|
XCTAssertNil(
|
2026-07-08 17:15:42 +02:00
|
|
|
synthesizedScreenshotReferenceFrame(
|
|
|
|
|
screenshotSize: { CGSize(width: CGFloat.infinity, height: 932) }
|
2026-07-08 11:06:57 +02:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 13:16:38 +02:00
|
|
|
func testPlannedMultiTouchGestureAcceptsMatchingInBoundsTrajectories() throws {
|
|
|
|
|
let plan = try JSONDecoder().decode(
|
|
|
|
|
RunnerGesturePlan.self,
|
|
|
|
|
from: Data(
|
|
|
|
|
#"{"topology":"two","intent":"pan","durationMs":32,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":80,"y":80}},{"offsetMs":16,"point":{"x":90,"y":85}},{"offsetMs":32,"point":{"x":100,"y":90}}]},{"pointerId":1,"samples":[{"offsetMs":0,"point":{"x":80,"y":120}},{"offsetMs":16,"point":{"x":90,"y":125}},{"offsetMs":32,"point":{"x":100,"y":130}}]}]}"#.utf8
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertNil(plannedGestureValidationError(plan))
|
2026-07-15 21:26:42 +02:00
|
|
|
XCTAssertEqual(plannedGestureExecution(for: plan), .sampled)
|
2026-07-13 13:16:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testPlannedMultiTouchGestureRejectsMismatchedOffsets() throws {
|
|
|
|
|
let plan = try JSONDecoder().decode(
|
|
|
|
|
RunnerGesturePlan.self,
|
|
|
|
|
from: Data(
|
|
|
|
|
#"{"topology":"two","intent":"transform","durationMs":32,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":80,"y":80}},{"offsetMs":32,"point":{"x":100,"y":90}}]},{"pointerId":1,"samples":[{"offsetMs":0,"point":{"x":80,"y":120}},{"offsetMs":31,"point":{"x":100,"y":130}}]}]}"#.utf8
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(
|
|
|
|
|
plannedGestureValidationError(plan),
|
|
|
|
|
"planned pointer sample offsets must match and strictly increase"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testSinglePointerFlingUsesFastSwipeExecution() throws {
|
|
|
|
|
let plan = try JSONDecoder().decode(
|
|
|
|
|
RunnerGesturePlan.self,
|
|
|
|
|
from: Data(
|
2026-07-15 21:26:42 +02:00
|
|
|
#"{"topology":"single","intent":"fling","executionProfile":"endpoint-hold","durationMs":100,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":100,"point":{"x":40,"y":150}}]}]}"#.utf8
|
2026-07-13 13:16:38 +02:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(plannedGestureExecution(for: plan), .fastSwipe)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testSinglePointerTimedPanUsesSampledExecution() throws {
|
|
|
|
|
let plan = try JSONDecoder().decode(
|
|
|
|
|
RunnerGesturePlan.self,
|
|
|
|
|
from: Data(
|
2026-07-15 21:26:42 +02:00
|
|
|
#"{"topology":"single","intent":"pan","executionProfile":"timed-pan","durationMs":500,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":250,"point":{"x":100,"y":150}},{"offsetMs":500,"point":{"x":40,"y":150}}]}]}"#.utf8
|
2026-07-13 13:16:38 +02:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(plannedGestureExecution(for: plan), .sampled)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:26:42 +02:00
|
|
|
func testSinglePointerEndpointHoldUsesFastSwipeExecution() throws {
|
|
|
|
|
let plan = try JSONDecoder().decode(
|
|
|
|
|
RunnerGesturePlan.self,
|
|
|
|
|
from: Data(
|
|
|
|
|
#"{"topology":"single","intent":"pan","executionProfile":"endpoint-hold","durationMs":500,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":500,"point":{"x":40,"y":150}}]}]}"#.utf8
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertNil(plannedGestureValidationError(plan))
|
|
|
|
|
XCTAssertEqual(plannedGestureExecution(for: plan), .fastSwipe)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testSinglePointerGestureRejectsMissingExecutionProfile() throws {
|
|
|
|
|
let plan = try JSONDecoder().decode(
|
|
|
|
|
RunnerGesturePlan.self,
|
|
|
|
|
from: Data(
|
|
|
|
|
#"{"topology":"single","intent":"pan","durationMs":500,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":500,"point":{"x":40,"y":150}}]}]}"#.utf8
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(
|
|
|
|
|
plannedGestureValidationError(plan),
|
|
|
|
|
"single-pointer gesture requires a supported execution profile"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 07:38:21 +02:00
|
|
|
func testDesktopScrollWheelDeltasMapDirections() throws {
|
|
|
|
|
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "up", pixels: 120)).vertical, 120)
|
|
|
|
|
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "down", pixels: 120)).vertical, -120)
|
|
|
|
|
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "left", pixels: 120)).horizontal, 120)
|
|
|
|
|
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "right", pixels: 120)).horizontal, -120)
|
|
|
|
|
XCTAssertNil(desktopScrollWheelDeltas(direction: "diagonal", pixels: 120))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testDesktopScrollWheelDeltaEventsHonorDurationAndPreservePixels() throws {
|
|
|
|
|
let events = try XCTUnwrap(desktopScrollWheelDeltaEvents(direction: "down", pixels: 200, durationMs: 50))
|
|
|
|
|
XCTAssertEqual(events.count, 4)
|
|
|
|
|
XCTAssertEqual(events.map(\.vertical).reduce(0, +), -200)
|
|
|
|
|
XCTAssertEqual(events.map(\.horizontal).reduce(0, +), 0)
|
|
|
|
|
XCTAssertEqual(desktopScrollEventIntervalSeconds(durationMs: 50, eventCount: events.count), 0.05 / 3.0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testDesktopScrollWheelDeltaEventsKeepInstantScrollSingleEvent() throws {
|
|
|
|
|
let events = try XCTUnwrap(desktopScrollWheelDeltaEvents(direction: "down", pixels: 200, durationMs: 0))
|
|
|
|
|
XCTAssertEqual(events.count, 1)
|
|
|
|
|
XCTAssertEqual(events.first?.vertical, -200)
|
|
|
|
|
}
|
2026-06-29 15:43:15 +02:00
|
|
|
#endif
|
2026-02-28 13:38:59 +01:00
|
|
|
}
|