@devicelab/maestro-runner-darwin-arm64 1.1.28 → 1.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +30 -3
  2. package/bin/maestro-runner +0 -0
  3. package/drivers/android/devicelab-android-driver-test.apk +0 -0
  4. package/drivers/android/devicelab-android-driver.apk +0 -0
  5. package/drivers/ios/DevicelabIOSRunner/DevicelabIOSRunnerUITests/ATTRIBUTION.md +16 -0
  6. package/drivers/ios/DevicelabIOSRunner/DevicelabIOSRunnerUITests/RunnerTests+Lifecycle.swift +33 -0
  7. package/drivers/ios/DevicelabIOSRunner/DevicelabIOSRunnerUITests/RunnerTests+Snapshot.swift +195 -88
  8. package/drivers/ios/DevicelabIOSRunner/DevicelabIOSRunnerUITests/RunnerTests.swift +3 -0
  9. package/drivers/ios/WebDriverAgent/CHANGELOG.md +202 -0
  10. package/drivers/ios/WebDriverAgent/README.md +18 -9
  11. package/drivers/ios/WebDriverAgent/Scripts/ci/build-real.sh +6 -10
  12. package/drivers/ios/WebDriverAgent/Scripts/fetch-prebuilt-wda.mjs +3 -2
  13. package/drivers/ios/WebDriverAgent/WebDriverAgent.xcodeproj/project.pbxproj +119 -0
  14. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCAXClient_iOS+FBSnapshotReqParams.m +8 -0
  15. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.h +3 -0
  16. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.m +30 -1
  17. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIApplication+FBTouchAction.h +53 -0
  18. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIApplication+FBTouchAction.m +124 -0
  19. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIDevice+FBHelpers.h +6 -0
  20. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIDevice+FBHelpers.m +100 -23
  21. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIDevice+FBHinge.h +41 -0
  22. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIDevice+FBHinge.m +215 -0
  23. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIDevice+FBRotation.m +8 -3
  24. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIElement+FBFind.m +20 -7
  25. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m +110 -50
  26. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIElement+FBTyping.h +7 -0
  27. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIElement+FBTyping.m +20 -0
  28. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Categories/XCUIElement+FBUtilities.m +14 -11
  29. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Commands/FBCustomCommands.m +86 -10
  30. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Commands/FBElementCommands.m +57 -7
  31. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Commands/FBSessionCommands.m +3 -2
  32. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Info.plist +2 -2
  33. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Routing/FBHTTPServer.m +39 -16
  34. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Routing/FBScreenRecordingRequest.m +7 -2
  35. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Routing/FBSession.h +22 -0
  36. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Routing/FBSession.m +78 -12
  37. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Routing/FBTCPSocket.h +5 -1
  38. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Routing/FBTCPSocket.m +10 -1
  39. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Routing/FBWebServer.m +4 -1
  40. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBActiveAppDetectionPoint.h +2 -2
  41. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBActiveAppDetectionPoint.m +20 -0
  42. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBConfiguration.h +8 -0
  43. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBConfiguration.m +2 -0
  44. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBHIDOperations.h +41 -0
  45. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBHIDOperations.m +244 -0
  46. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBImageProcessor.m +3 -1
  47. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBMathUtils.h +11 -0
  48. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBMathUtils.m +38 -0
  49. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBMjpegServer.m +5 -3
  50. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBScreen.h +17 -0
  51. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBScreen.m +76 -1
  52. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBScreenshot.h +2 -2
  53. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBScreenshot.m +6 -2
  54. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBSettings.h +2 -0
  55. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBSettings.m +2 -0
  56. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBSettingsHandler.m +48 -0
  57. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m +121 -13
  58. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/FBXPath.m +181 -128
  59. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/LRUCache/LRUCache.m +8 -5
  60. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/Utilities/LRUCache/LRUCacheNode.h +1 -1
  61. package/drivers/ios/WebDriverAgent/WebDriverAgentLib/WebDriverAgentLib.h +1 -0
  62. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationApp/COORDINATE_PROBE.md +3 -1
  63. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.m +18 -0
  64. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationApp/Classes/SceneDelegate.m +33 -0
  65. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationApp/Classes/ViewController.m +26 -7
  66. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m +52 -0
  67. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/FBDisplayGestureTests.m +541 -0
  68. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/FBElementSwipingTests.m +95 -0
  69. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/FBImageProcessorTests.m +34 -0
  70. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/FBScreenTests.m +392 -0
  71. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/FBSessionIntegrationTests.m +97 -0
  72. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/FBTypingTest.m +33 -0
  73. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/FBW3CTouchActionsIntegrationTests.m +126 -0
  74. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/XCUIApplicationHelperTests.m +111 -0
  75. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/XCUIDeviceHelperTests.m +97 -0
  76. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/XCUIDeviceRotationTests.m +53 -0
  77. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests/XCUIElementFBFindTests.m +113 -0
  78. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests_tvOS/FBTVFocusIntegrationTests.m +21 -0
  79. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/IntegrationTests_watchOS/WDAAppLifecycleIntegrationTests.swift +12 -0
  80. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/UnitTests/FBCustomCommandsTests.m +61 -0
  81. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/UnitTests/FBElementUtilitiesTests.m +40 -0
  82. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/UnitTests/FBHTTPServerSessionTests.m +22 -4
  83. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/UnitTests/FBHTTPServerTests.m +199 -5
  84. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/UnitTests/FBLRUCacheTests.m +34 -0
  85. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/UnitTests/FBMathUtilsTests.m +45 -0
  86. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/UnitTests/FBSessionTests.m +201 -0
  87. package/drivers/ios/WebDriverAgent/WebDriverAgentTests/UnitTests/FBXPathTests.m +101 -0
  88. package/drivers/ios/WebDriverAgent/docs/hinge-angle.md +54 -0
  89. package/drivers/ios/WebDriverAgent/docs/multiple-displays.md +168 -0
  90. package/drivers/ios/WebDriverAgent/lib/webdriveragent.ts +1 -2
  91. package/drivers/ios/WebDriverAgent/lib/xcodebuild.ts +58 -10
  92. package/drivers/ios/WebDriverAgent/package.json +8 -15
  93. package/drivers/ios/WebDriverAgent/test/functional/helpers/display-settings.ts +49 -0
  94. package/drivers/ios/WebDriverAgent/test/functional/helpers/simulator.ts +7 -7
  95. package/drivers/ios/WebDriverAgent/test/functional/webdriveragent-e2e.spec.ts +4 -0
  96. package/drivers/ios/WebDriverAgent/test/unit/xcodebuild.spec.ts +230 -0
  97. package/drivers/ios/WebDriverAgent/tsconfig.json +2 -1
  98. package/drivers/ios/devicelab-ios-agent/README.md +12 -0
  99. package/drivers/ios/devicelab-ios-agent/device/DevicelabIOSAgent.app/DevicelabIOSAgent +0 -0
  100. package/drivers/ios/devicelab-ios-agent/device/DevicelabIOSAgentUITests-Runner.app/PlugIns/DevicelabIOSAgentUITests.xctest/DevicelabIOSAgentUITests +0 -0
  101. package/drivers/ios/devicelab-ios-agent/device/manifest.json +3 -3
  102. package/drivers/ios/devicelab-ios-agent/simulator/DevicelabIOSAgent.app/DevicelabIOSAgent +0 -0
  103. package/drivers/ios/devicelab-ios-agent/simulator/DevicelabIOSAgentUITests-Runner.app/PlugIns/DevicelabIOSAgentUITests.xctest/DevicelabIOSAgentUITests +0 -0
  104. package/drivers/ios/devicelab-ios-agent/simulator/manifest.json +3 -3
  105. package/package.json +1 -1
  106. package/drivers/ios/WebDriverAgent/.editorconfig +0 -11
  107. package/drivers/ios/WebDriverAgent/.github/ISSUE_TEMPLATE.md +0 -4
  108. package/drivers/ios/WebDriverAgent/.github/actions/xcode-setup/action.yml +0 -32
  109. package/drivers/ios/WebDriverAgent/.github/dependabot.yml +0 -22
  110. package/drivers/ios/WebDriverAgent/.github/workflows/functional-test.yml +0 -103
  111. package/drivers/ios/WebDriverAgent/.github/workflows/pr-title.yml +0 -10
  112. package/drivers/ios/WebDriverAgent/.github/workflows/publish.js.yml +0 -112
  113. package/drivers/ios/WebDriverAgent/.github/workflows/unit-test.yml +0 -32
  114. package/drivers/ios/WebDriverAgent/.github/workflows/wda-package.yml +0 -114
  115. package/drivers/ios/WebDriverAgent/.github/workflows/wda-tests.yml +0 -246
  116. package/drivers/ios/WebDriverAgent/oxfmt.config.mjs +0 -6
  117. package/drivers/ios/WebDriverAgent/oxlint.config.mjs +0 -6
  118. package/drivers/ios/WebDriverAgent/release.config.mjs +0 -14
package/README.md CHANGED
@@ -20,7 +20,9 @@
20
20
  [![npm](https://img.shields.io/npm/v/maestro-runner?label=npm&color=cb3837)](https://www.npmjs.com/package/maestro-runner)
21
21
  [![CI](https://github.com/devicelab-dev/maestro-runner/actions/workflows/ci.yml/badge.svg)](https://github.com/devicelab-dev/maestro-runner/actions/workflows/ci.yml)
22
22
  [![codecov](https://codecov.io/gh/devicelab-dev/maestro-runner/branch/main/graph/badge.svg)](https://codecov.io/gh/devicelab-dev/maestro-runner)
23
- [![Go Report Card](https://goreportcard.com/badge/github.com/devicelab-dev/maestro-runner?v=2)](https://goreportcard.com/report/github.com/devicelab-dev/maestro-runner)
23
+ [![last release](https://img.shields.io/github/release-date/devicelab-dev/maestro-runner?label=last%20release)](https://github.com/devicelab-dev/maestro-runner/releases)
24
+ [![platforms](https://img.shields.io/badge/platforms-Android%20%7C%20iOS%20%7C%20Web-3a9d5c)](https://open.devicelab.dev/maestro-runner/docs/getting-started)
25
+ [![docs](https://img.shields.io/badge/docs-open.devicelab.dev-17a2b8)](https://open.devicelab.dev/maestro-runner/docs/getting-started)
24
26
 
25
27
  <b><a href="https://open.devicelab.dev/maestro-runner">Documentation</a></b> | <b><a href="#install">Get Started</a></b> | <b><a href="https://open.devicelab.dev/maestro-runner/docs/cli-reference">CLI Reference</a></b> | <b><a href="https://open.devicelab.dev/maestro-runner/docs/flow-commands">Flow Commands</a></b> | <b><a href="CONTRIBUTING.md">Contributing</a></b>
26
28
 
@@ -75,6 +77,14 @@ maestro-runner --driver appium --appium-url <server-url> test flow.yaml # Appium
75
77
  maestro-runner test --parallel 3 flows/ # Parallel on 3 devices
76
78
  ```
77
79
 
80
+ ## AI coding agents
81
+
82
+ ```bash
83
+ maestro-runner skills install # writes ~/.claude/skills/maestro-runner/SKILL.md
84
+ ```
85
+
86
+ Installs a skill that tells Claude Code (and other agents that read `SKILL.md`) how to write Maestro flows, run them with maestro-runner, inspect the screen and read the reports. `--dir` writes it elsewhere (`.claude/skills/maestro-runner` in a repository), `--print` prints it. The file carries the binary's version and is replaced when you install from a newer release.
87
+
78
88
  ## Key Features
79
89
 
80
90
  - **Zero migration** — Runs your existing Maestro YAML flows as-is, no changes needed
@@ -94,7 +104,7 @@ maestro-runner test --parallel 3 flows/ # Parall
94
104
  - **scrollUntilVisible** — Native scroll implementation that reliably finds off-screen elements
95
105
  - **Relative selectors** — Find elements by position: below, above, leftOf, rightOf, childOf
96
106
  - **JavaScript scripting** — Embedded JS runtime with HTTP client for dynamic test logic, no external dependencies
97
- - **Configurable timeouts** — Per-command and per-flow timeouts, `--wait-for-idle-timeout 0` to disable
107
+ - **Configurable timeouts** — Per-command and per-flow timeouts, `--find-timeout 3000` to make a wrong selector fail in 3 s instead of 17 s, `--wait-for-idle-timeout 0` to disable
98
108
  - **Lightweight** — Single binary, no JVM required
99
109
 
100
110
  ## Supported Platforms & Drivers
@@ -104,7 +114,7 @@ maestro-runner test --parallel 3 flows/ # Parall
104
114
  | **DeviceLab** | Android | Default since v1.1.28. On-device WebSocket driver, ~2x faster than UIAutomator2. |
105
115
  | **UIAutomator2** | Android | `--driver uiautomator2`. Direct connection to the device, no external server needed. |
106
116
  | **DeviceLab** | iOS | Default since v1.1.28. The DeviceLab iOS agent, on simulators and real iPhones (real iPhones need `--team-id`). `--driver devicelab-legacy` keeps the previous runner. |
107
- | **WDA (WebDriverAgent)** | iOS | `--driver wda`. Supports simulators and physical devices. |
117
+ | **WDA (WebDriverAgent)** | iOS | `--driver wda`. Supports simulators and physical devices. `--reuse-wda` leaves WebDriverAgent running between runs and reuses it. |
108
118
  | **Browser (CDP)** | Web | `--platform web`. Chrome/Chromium automation via Chrome DevTools Protocol. |
109
119
  | **Appium** | Android & iOS | `--driver appium`. For cloud testing providers and existing Appium infrastructure. |
110
120
 
@@ -181,6 +191,23 @@ maestro-runner --driver appium --appium-url <HUB_URL> --caps caps.json test flow
181
191
  - **[TestingBot](docs/cloud-providers/testingbot.md)** — Setup guide for running on TestingBot's real device cloud
182
192
  - **[Sauce Labs](docs/cloud-providers/saucelabs.md)** — Setup guide for running on Sauce Labs Appium cloud
183
193
 
194
+ ## Telemetry
195
+
196
+ maestro-runner sends one anonymous usage event at the end of each `test` run:
197
+ counts and versions only, such as runner version, platform, driver, device
198
+ kind, flow and step counts, failure kinds and duration. It holds no machine or
199
+ user id, no flow or file names, selectors, app ids, URLs, environment values or
200
+ error messages, and nothing is written on your machine. Projects are told
201
+ apart by a one-way hash of the list of flow files you run; the names
202
+ themselves are never sent.
203
+
204
+ Turn it off with `MAESTRO_RUNNER_NO_TELEMETRY=1` or `DO_NOT_TRACK=1`. Set
205
+ `MAESTRO_RUNNER_TELEMETRY_DEBUG=1` to print the event instead of sending it.
206
+ [Telemetry](https://open.devicelab.dev/maestro-runner/docs/telemetry) lists every field.
207
+
208
+ The update check is separate: `MAESTRO_RUNNER_NO_UPDATE_CHECK=1` turns it off,
209
+ and on CI the notice is not printed.
210
+
184
211
  ## Contributing
185
212
 
186
213
  See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
Binary file
@@ -63,6 +63,22 @@ The only intentional local edits are:
63
63
  60 → 240 chars/sec (matches WDA's `FBTypeText` default). RN inputs
64
64
  accept this rate without dropping characters.
65
65
 
66
+ 8. `RunnerTests+Snapshot.swift` collapsed tab fallback: ported from
67
+ upstream's fix for wide sliders wedging the runner (agent-device #3776,
68
+ commit 92db564a3, MIT), adapted to this file. The fallback reads its
69
+ candidates in one predicate query instead of every descendant one
70
+ attribute at a time, starts that query only while the snapshot's 8 s
71
+ processing budget still covers what the root snapshot took (otherwise the
72
+ snapshot reports `truncated`), and no longer treats a slider as a
73
+ collapsed container. Candidate `hittable` comes from geometry instead of
74
+ a per-element XCTest call.
75
+ 9. `RunnerTests+Lifecycle.swift` `activateTarget`: when the target app was
76
+ in the background, wait (at most 2 s) for SpringBoard to go quiescent
77
+ after `activate()`. The idea comes from WebDriverAgent's
78
+ `-[XCUIApplication fb_activate]` (appium/appium-ios PR #82, Apache License
79
+ 2.0): after a resume the next tap could report success without reaching
80
+ the app.
81
+
66
82
  The wire-protocol contract (`RunnerTests+Models.swift` Command/Response shapes,
67
83
  HTTP/JSON transport in `RunnerTests+Transport.swift`, dispatch behaviour in
68
84
  `RunnerTests+CommandExecution.swift`) is **agent-device's contract** — the
@@ -237,14 +237,47 @@ extension RunnerTests {
237
237
  target.state.rawValue,
238
238
  reason
239
239
  )
240
+ let resuming = isBackgrounded(target.state)
240
241
  // activate avoids terminating and relaunching the target app
241
242
  target.activate()
243
+ if resuming {
244
+ waitForSpringBoardAfterResume()
245
+ }
242
246
  currentApp = target
243
247
  currentBundleId = bundleId
244
248
  needsFirstInteractionDelay = true
245
249
  return target
246
250
  }
247
251
 
252
+ /// True for an app that is running but not on screen: activating it is a
253
+ /// resume, not a launch.
254
+ func isBackgrounded(_ state: XCUIApplication.State) -> Bool {
255
+ #if os(iOS)
256
+ return state == .runningBackground || state == .runningBackgroundSuspended
257
+ #else
258
+ return false
259
+ #endif
260
+ }
261
+
262
+ /// Waits, capped at resumeSpringBoardIdleTimeout, for SpringBoard to go
263
+ /// idle with its animations done. After a backgrounded app is brought back
264
+ /// the app itself can already look idle while SpringBoard is still
265
+ /// finishing the switch, and the next tap was reported as performed but
266
+ /// never reached the app. Waiting on SpringBoard, only on this resume path,
267
+ /// is the fix WebDriverAgent took (appium/appium-ios#82, Apache-2.0).
268
+ func waitForSpringBoardAfterResume() {
269
+ let started = ProcessInfo.processInfo.systemUptime
270
+ let outcome = RunnerXCTestTimeouts.waitForQuiescence(
271
+ of: springboard,
272
+ timeout: resumeSpringBoardIdleTimeout
273
+ )
274
+ NSLog(
275
+ "AGENT_DEVICE_RUNNER_RESUME_WAIT outcome=%d waited_ms=%.0f",
276
+ outcome.rawValue,
277
+ (ProcessInfo.processInfo.systemUptime - started) * 1000
278
+ )
279
+ }
280
+
248
281
  func withTemporaryScrollIdleTimeoutIfSupported(
249
282
  _ target: XCUIApplication,
250
283
  operation: () -> Void
@@ -20,6 +20,13 @@ extension RunnerTests {
20
20
  // captureRootSnapshot retries a slow root snapshot at XCTest's full
21
21
  // timeout, so this bound cannot cut a large tree short.
22
22
  private static let snapshotRequestTimeout: TimeInterval = 3.0
23
+ // How long one fast snapshot may run before the collapsed tab fallback's
24
+ // query no longer starts (agent-device's tree slice, #3776). XCTest cannot
25
+ // cancel a query once it runs on the main thread, so the check is made
26
+ // before it starts.
27
+ private static let collapsedTabProcessingBudget: TimeInterval = 8.0
28
+ // The least time a query is admitted with (agent-device's sweep budget).
29
+ private static let minimumQueryBudget: TimeInterval = 0.1
23
30
 
24
31
  private struct SnapshotTraversalContext {
25
32
  // The app actually read. A no-bundle snapshot can move to the app in
@@ -27,6 +34,9 @@ extension RunnerTests {
27
34
  let app: XCUIApplication
28
35
  let queryRoot: XCUIElement
29
36
  let rootSnapshot: XCUIElementSnapshot
37
+ // How long reading rootSnapshot took: what one more query over the
38
+ // whole tree is expected to cost.
39
+ let rootReadDuration: TimeInterval
30
40
  let viewport: CGRect
31
41
  let occlusion: OcclusionIndex
32
42
  let maxDepth: Int
@@ -173,6 +183,7 @@ extension RunnerTests {
173
183
  }
174
184
 
175
185
  func snapshotFast(app requested: XCUIApplication, options: SnapshotOptions) -> Response {
186
+ let processingDeadline = Date().addingTimeInterval(Self.collapsedTabProcessingBudget)
176
187
  if let blocking = blockingSystemAlertSnapshot() {
177
188
  return Response(ok: true, data: blocking)
178
189
  }
@@ -192,20 +203,22 @@ extension RunnerTests {
192
203
  }
193
204
  let app = context.app
194
205
 
195
- var cachedDescendantElements: [XCUIElement]?
196
- func collapsedTabDescendants() -> [XCUIElement] {
197
- if let cachedDescendantElements {
198
- return cachedDescendantElements
199
- }
200
- let fetched = safeSnapshotElementsQuery {
201
- context.queryRoot.descendants(matching: .any).allElementsBoundByIndex
206
+ var nodes: [SnapshotNode] = []
207
+ var truncated = false
208
+
209
+ // Read at most once per snapshot, and only when a collapsed container
210
+ // asks. A query that could not start leaves the tree marked truncated.
211
+ var cachedCollapsedTabCandidates: [CollapsedTabCandidate]?
212
+ func collapsedTabCandidatesOnce() -> [CollapsedTabCandidate] {
213
+ if let cachedCollapsedTabCandidates {
214
+ return cachedCollapsedTabCandidates
202
215
  }
203
- cachedDescendantElements = fetched
204
- return fetched
216
+ let candidates = collapsedTabCandidates(context: context, processingDeadline: processingDeadline)
217
+ if candidates == nil { truncated = true }
218
+ cachedCollapsedTabCandidates = candidates ?? []
219
+ return candidates ?? []
205
220
  }
206
221
 
207
- var nodes: [SnapshotNode] = []
208
- var truncated = false
209
222
  let rootEvaluation = evaluateSnapshot(context.rootSnapshot, in: context)
210
223
  nodes.append(
211
224
  makeSnapshotNode(
@@ -220,7 +233,8 @@ extension RunnerTests {
220
233
  let didTruncateFallback = appendCollapsedTabFallbackNodes(
221
234
  to: &nodes,
222
235
  containerSnapshot: context.rootSnapshot,
223
- resolveElements: collapsedTabDescendants,
236
+ resolveCandidates: collapsedTabCandidatesOnce,
237
+ viewport: context.viewport,
224
238
  depth: 1,
225
239
  parentIndex: 0,
226
240
  nodeLimit: fastSnapshotLimit
@@ -301,7 +315,8 @@ extension RunnerTests {
301
315
  let didTruncateFallback = appendCollapsedTabFallbackNodes(
302
316
  to: &nodes,
303
317
  containerSnapshot: snapshot,
304
- resolveElements: collapsedTabDescendants,
318
+ resolveCandidates: collapsedTabCandidatesOnce,
319
+ viewport: context.viewport,
305
320
  depth: visibleDepth + 1,
306
321
  parentIndex: index,
307
322
  nodeLimit: nodeLimit
@@ -504,6 +519,7 @@ extension RunnerTests {
504
519
  options: SnapshotOptions
505
520
  ) -> SnapshotTraversalContext? {
506
521
  var app = requested
522
+ var readStarted = Date()
507
523
  var captured = captureRootSnapshot(app: app, options: options)
508
524
  if captured == nil, options.followsScreen, app.state != .runningForeground {
509
525
  // The app left the screen while its snapshot was in flight. A
@@ -512,18 +528,21 @@ extension RunnerTests {
512
528
  let front = foregroundTarget(app)
513
529
  if front !== app {
514
530
  app = front
531
+ readStarted = Date()
515
532
  captured = captureRootSnapshot(app: app, options: options)
516
533
  }
517
534
  }
518
535
  guard let (queryRoot, rootSnapshot) = captured else {
519
536
  return nil
520
537
  }
538
+ let rootReadDuration = Date().timeIntervalSince(readStarted)
521
539
 
522
540
  let viewport = snapshotViewport(app: app, rootSnapshot: queryRoot === app ? rootSnapshot : nil)
523
541
  return SnapshotTraversalContext(
524
542
  app: app,
525
543
  queryRoot: queryRoot,
526
544
  rootSnapshot: rootSnapshot,
545
+ rootReadDuration: rootReadDuration,
527
546
  viewport: viewport,
528
547
  occlusion: OcclusionIndex(root: rootSnapshot, viewport: viewport, isOccluding: isOccludingType),
529
548
  maxDepth: options.depth ?? Int.max
@@ -634,7 +653,12 @@ extension RunnerTests {
634
653
  }
635
654
 
636
655
  private func snapshotValueText(_ snapshot: XCUIElementSnapshot) -> String? {
637
- guard let value = snapshot.value else { return nil }
656
+ Self.valueText(snapshot.value)
657
+ }
658
+
659
+ /// An element's value as a node publishes it; blank reads as no value.
660
+ private static func valueText(_ value: Any?) -> String? {
661
+ guard let value else { return nil }
638
662
  let text = String(describing: value).trimmingCharacters(in: .whitespacesAndNewlines)
639
663
  return text.isEmpty ? nil : text
640
664
  }
@@ -687,14 +711,16 @@ extension RunnerTests {
687
711
  private func appendCollapsedTabFallbackNodes(
688
712
  to nodes: inout [SnapshotNode],
689
713
  containerSnapshot: XCUIElementSnapshot,
690
- resolveElements: () -> [XCUIElement],
714
+ resolveCandidates: () -> [CollapsedTabCandidate],
715
+ viewport: CGRect,
691
716
  depth: Int,
692
717
  parentIndex: Int,
693
718
  nodeLimit: Int
694
719
  ) -> Bool {
695
720
  let fallbackNodes = collapsedTabFallbackNodes(
696
721
  for: containerSnapshot,
697
- resolveElements: resolveElements,
722
+ resolveCandidates: resolveCandidates,
723
+ viewport: viewport,
698
724
  startingIndex: nodes.count,
699
725
  depth: depth,
700
726
  parentIndex: parentIndex
@@ -706,26 +732,131 @@ extension RunnerTests {
706
732
  return fallbackNodes.count > remaining
707
733
  }
708
734
 
735
+ /// One live element a collapsed tab container may adopt, as the matching
736
+ /// query's predicate read it.
737
+ private struct CollapsedTabCandidate {
738
+ let type: XCUIElement.ElementType
739
+ let frame: CGRect
740
+ let label: String
741
+ let identifier: String
742
+ let valueText: String?
743
+ let enabled: Bool
744
+ let focused: Bool
745
+ let selected: Bool
746
+ }
747
+
748
+ // Ported from agent-device #3776 (MIT): a wide slider qualified for this
749
+ // fallback, which then read every live descendant of the app one attribute
750
+ // at a time on the XCTest main thread — one round trip each, which nothing
751
+ // can cancel — so a crowded screen kept the runner busy until it was
752
+ // treated as wedged. Candidates now come from one query whose predicate
753
+ // applies the type, geometry and content rules and copies each match's
754
+ // attributes as XCTest evaluates it, and the query starts only while the
755
+ // time left before processingDeadline covers what the root snapshot took.
756
+ // nil means it did not start.
757
+ private func collapsedTabCandidates(
758
+ context: SnapshotTraversalContext,
759
+ processingDeadline: Date
760
+ ) -> [CollapsedTabCandidate]? {
761
+ let containerFrames = Self.snapshotDescendants(of: context.rootSnapshot)
762
+ .filter { isCollapsedTabContainer($0) }
763
+ .map(\.frame)
764
+ if containerFrames.isEmpty { return [] }
765
+ let expectedCost = max(Self.minimumQueryBudget, context.rootReadDuration)
766
+ guard processingDeadline.timeIntervalSinceNow >= expectedCost else {
767
+ NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_TAB_FALLBACK_QUERY_NOT_STARTED")
768
+ return nil
769
+ }
770
+ var candidates: [CollapsedTabCandidate] = []
771
+ let predicate = NSPredicate { object, _ in
772
+ guard let attributes = object as? XCUIElementAttributes,
773
+ Self.hasCollapsedTabContent(attributes),
774
+ containerFrames.contains(where: {
775
+ Self.isCollapsedTabCandidate(type: attributes.elementType, frame: attributes.frame, in: $0)
776
+ })
777
+ else {
778
+ return false
779
+ }
780
+ candidates.append(self.collapsedTabCandidate(attributes))
781
+ return true
782
+ }
783
+ let matches = safeSnapshotElementsQuery {
784
+ context.queryRoot.descendants(matching: .any).matching(predicate).allElementsBoundByIndex
785
+ }
786
+ return matches.isEmpty ? [] : candidates
787
+ }
788
+
789
+ private func collapsedTabCandidate(_ attributes: XCUIElementAttributes) -> CollapsedTabCandidate {
790
+ var focused = false
791
+ _ = RunnerObjCExceptionCatcher.catchException({
792
+ if let value = (attributes as? NSObject)?.value(forKey: "hasFocus") as? Bool {
793
+ focused = value
794
+ }
795
+ })
796
+ return CollapsedTabCandidate(
797
+ type: attributes.elementType,
798
+ frame: attributes.frame,
799
+ label: attributes.label.trimmingCharacters(in: .whitespacesAndNewlines),
800
+ identifier: attributes.identifier.trimmingCharacters(in: .whitespacesAndNewlines),
801
+ valueText: Self.valueText(attributes.value),
802
+ enabled: attributes.isEnabled,
803
+ focused: focused,
804
+ selected: attributes.isSelected
805
+ )
806
+ }
807
+
808
+ /// Every descendant of `root` in its captured snapshot, without `root`.
809
+ private static func snapshotDescendants(of root: XCUIElementSnapshot) -> [XCUIElementSnapshot] {
810
+ var descendants: [XCUIElementSnapshot] = []
811
+ var stack = Array(root.children.reversed())
812
+ while let snapshot = stack.popLast() {
813
+ descendants.append(snapshot)
814
+ stack.append(contentsOf: snapshot.children.reversed())
815
+ }
816
+ return descendants
817
+ }
818
+
819
+ /// A collapsed tab container's item: a candidate type, smaller than the
820
+ /// container, and centred inside it.
821
+ private static func isCollapsedTabCandidate(
822
+ type: XCUIElement.ElementType,
823
+ frame: CGRect,
824
+ in containerFrame: CGRect
825
+ ) -> Bool {
826
+ guard collapsedTabCandidateTypes.contains(type), !frame.isNull, !frame.isEmpty else {
827
+ return false
828
+ }
829
+ if frame.equalTo(containerFrame) { return false }
830
+ let area = max(CGFloat(1), frame.width * frame.height)
831
+ let containerArea = max(CGFloat(1), containerFrame.width * containerFrame.height)
832
+ return area < containerArea * 0.9 && containerFrame.contains(CGPoint(x: frame.midX, y: frame.midY))
833
+ }
834
+
835
+ /// Whether an element names something a flow can address: a label,
836
+ /// identifier or value that is not blank.
837
+ private static func hasCollapsedTabContent(_ attributes: XCUIElementAttributes) -> Bool {
838
+ [attributes.label, attributes.identifier]
839
+ .contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
840
+ || valueText(attributes.value) != nil
841
+ }
842
+
709
843
  private func collapsedTabFallbackNodes(
710
844
  for containerSnapshot: XCUIElementSnapshot,
711
- resolveElements: () -> [XCUIElement],
845
+ resolveCandidates: () -> [CollapsedTabCandidate],
846
+ viewport: CGRect,
712
847
  startingIndex: Int,
713
848
  depth: Int,
714
849
  parentIndex: Int
715
850
  ) -> [SnapshotNode] {
716
- if !containerSnapshot.children.isEmpty { return [] }
717
- guard shouldExpandCollapsedTabContainer(containerSnapshot) else { return [] }
851
+ guard isCollapsedTabContainer(containerSnapshot) else { return [] }
718
852
  let containerFrame = containerSnapshot.frame
719
- if containerFrame.isNull || containerFrame.isEmpty { return [] }
720
853
 
721
- // Collapsed tab containers should be rare, so a full descendant scan is acceptable once per
722
- // snapshot as a fallback for XCTest omitting the tab children from the snapshot tree.
723
- let elements = resolveElements()
724
- let candidates = elements.compactMap { element in
854
+ let candidates = resolveCandidates().compactMap { candidate in
725
855
  collapsedTabCandidateNode(
726
- element: element,
856
+ candidate: candidate,
727
857
  containerSnapshot: containerSnapshot,
728
- containerFrame: containerFrame
858
+ containerFrame: containerFrame,
859
+ viewport: viewport
729
860
  )
730
861
  }
731
862
  .sorted { left, right in
@@ -772,64 +903,41 @@ extension RunnerTests {
772
903
  }
773
904
 
774
905
  private func collapsedTabCandidateNode(
775
- element: XCUIElement,
906
+ candidate: CollapsedTabCandidate,
776
907
  containerSnapshot: XCUIElementSnapshot,
777
- containerFrame: CGRect
908
+ containerFrame: CGRect,
909
+ viewport: CGRect
778
910
  ) -> SnapshotNode? {
779
- var node: SnapshotNode?
780
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
781
- if !element.exists { return }
782
- let elementType = element.elementType
783
- if !Self.collapsedTabCandidateTypes.contains(elementType) { return }
784
- let frame = element.frame
785
- if frame.isNull || frame.isEmpty { return }
786
- if frame.equalTo(containerFrame) { return }
787
- let area = max(CGFloat(1), frame.width * frame.height)
788
- let containerArea = max(CGFloat(1), containerFrame.width * containerFrame.height)
789
- if area >= containerArea * 0.9 { return }
790
- let center = CGPoint(x: frame.midX, y: frame.midY)
791
- if !containerFrame.contains(center) { return }
792
-
793
- let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
794
- let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
795
- let valueText = snapshotValueText(element)
796
- let hasContent = !label.isEmpty || !identifier.isEmpty || valueText != nil
797
- if !hasContent { return }
798
- if sameSemanticElement(
799
- containerSnapshot: containerSnapshot,
800
- elementType: elementType,
801
- label: label,
802
- identifier: identifier
803
- ) {
804
- return
805
- }
806
-
807
- node = SnapshotNode(
808
- index: 0,
809
- type: elementTypeName(elementType),
810
- label: label.isEmpty ? nil : label,
811
- identifier: identifier.isEmpty ? nil : identifier,
812
- value: valueText,
813
- placeholderValue: nil,
814
- rect: snapshotRect(from: frame),
815
- enabled: element.isEnabled,
816
- focused: elementHasFocus(element) ? true : nil,
817
- selected: element.isSelected ? true : nil,
818
- hittable: element.isHittable,
819
- depth: 0,
820
- parentIndex: nil,
821
- hiddenContentAbove: nil,
822
- hiddenContentBelow: nil
823
- )
824
- })
825
- if let exceptionMessage {
826
- NSLog(
827
- "AGENT_DEVICE_RUNNER_SNAPSHOT_TAB_FALLBACK_IGNORED_EXCEPTION=%@",
828
- exceptionMessage
829
- )
911
+ if !Self.isCollapsedTabCandidate(type: candidate.type, frame: candidate.frame, in: containerFrame) {
912
+ return nil
913
+ }
914
+ if sameSemanticElement(
915
+ containerSnapshot: containerSnapshot,
916
+ elementType: candidate.type,
917
+ label: candidate.label,
918
+ identifier: candidate.identifier
919
+ ) {
830
920
  return nil
831
921
  }
832
- return node
922
+ return SnapshotNode(
923
+ index: 0,
924
+ type: elementTypeName(candidate.type),
925
+ label: candidate.label.isEmpty ? nil : candidate.label,
926
+ identifier: candidate.identifier.isEmpty ? nil : candidate.identifier,
927
+ value: candidate.valueText,
928
+ placeholderValue: nil,
929
+ rect: snapshotRect(from: candidate.frame),
930
+ enabled: candidate.enabled,
931
+ focused: candidate.focused ? true : nil,
932
+ selected: candidate.selected ? true : nil,
933
+ // Read from geometry: asking XCTest would be one more round trip per
934
+ // item, the cost this fallback no longer pays.
935
+ hittable: candidate.enabled && isVisibleInViewport(candidate.frame, viewport),
936
+ depth: 0,
937
+ parentIndex: nil,
938
+ hiddenContentAbove: nil,
939
+ hiddenContentBelow: nil
940
+ )
833
941
  }
834
942
 
835
943
  private func snapshotHasFocus(_ snapshot: XCUIElementSnapshot) -> Bool {
@@ -846,24 +954,23 @@ extension RunnerTests {
846
954
  return snapshot.isSelected
847
955
  }
848
956
 
849
- private func shouldExpandCollapsedTabContainer(_ snapshot: XCUIElementSnapshot) -> Bool {
957
+ /// A wide tab bar or segmented control that XCTest reports without
958
+ /// children. Sliders no longer qualify (agent-device #3776): a wide slider
959
+ /// has no items to adopt, and qualifying sent every screen with one
960
+ /// through the descendant query.
961
+ private func isCollapsedTabContainer(_ snapshot: XCUIElementSnapshot) -> Bool {
962
+ if !snapshot.children.isEmpty { return false }
850
963
  let frame = snapshot.frame
851
964
  if frame.isNull || frame.isEmpty { return false }
852
965
  if frame.width < max(CGFloat(160), frame.height * 1.75) { return false }
853
966
  switch snapshot.elementType {
854
- case .tabBar, .segmentedControl, .slider:
967
+ case .tabBar, .segmentedControl:
855
968
  return true
856
969
  default:
857
970
  return false
858
971
  }
859
972
  }
860
973
 
861
- private func snapshotValueText(_ element: XCUIElement) -> String? {
862
- let text = String(describing: element.value ?? "")
863
- .trimmingCharacters(in: .whitespacesAndNewlines)
864
- return text.isEmpty ? nil : text
865
- }
866
-
867
974
  private func sameSemanticElement(
868
975
  containerSnapshot: XCUIElementSnapshot,
869
976
  elementType: XCUIElement.ElementType,
@@ -58,6 +58,9 @@ final class RunnerTests: XCTestCase {
58
58
  // a caller polling snapshots during settle paid it on each following
59
59
  // interaction.
60
60
  let firstInteractionAfterActivateDelay: TimeInterval = 0.1
61
+ // The most a resume (activating a backgrounded app) waits for SpringBoard
62
+ // to go idle. WebDriverAgent saw lost taps with no wait and none with 2 s.
63
+ let resumeSpringBoardIdleTimeout: TimeInterval = 2.0
61
64
  // The idle command's cap when the caller gives none, and the most it may
62
65
  // ask for: well under mainThreadExecutionTimeout, so a wait can never be
63
66
  // mistaken for a hung main thread.