zeno-mobile-runner 0.1.2

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 (213) hide show
  1. package/CHANGELOG.md +497 -0
  2. package/CONTRIBUTING.md +42 -0
  3. package/FEATURES.md +111 -0
  4. package/LICENSE +21 -0
  5. package/README.md +176 -0
  6. package/SECURITY.md +34 -0
  7. package/build.zig +38 -0
  8. package/build.zig.zon +7 -0
  9. package/clients/README.md +149 -0
  10. package/clients/go/README.md +24 -0
  11. package/clients/go/examples/fake-session/main.go +93 -0
  12. package/clients/go/go.mod +3 -0
  13. package/clients/go/zmr/client.go +432 -0
  14. package/clients/kotlin/README.md +35 -0
  15. package/clients/kotlin/build.gradle.kts +35 -0
  16. package/clients/kotlin/settings.gradle.kts +15 -0
  17. package/clients/kotlin/src/main/kotlin/dev/zmr/FakeSession.kt +86 -0
  18. package/clients/kotlin/src/main/kotlin/dev/zmr/ZmrClient.kt +67 -0
  19. package/clients/python/README.md +29 -0
  20. package/clients/python/examples/fake_session.py +48 -0
  21. package/clients/python/pyproject.toml +13 -0
  22. package/clients/python/zmr_client.py +202 -0
  23. package/clients/rust/Cargo.lock +107 -0
  24. package/clients/rust/Cargo.toml +10 -0
  25. package/clients/rust/README.md +19 -0
  26. package/clients/rust/examples/fake_session.rs +70 -0
  27. package/clients/rust/src/lib.rs +461 -0
  28. package/clients/swift/Package.swift +16 -0
  29. package/clients/swift/README.md +36 -0
  30. package/clients/swift/Sources/ZMRClient/ZMRClient.swift +114 -0
  31. package/clients/swift/Sources/ZMRFakeSession/main.swift +86 -0
  32. package/clients/typescript/README.md +34 -0
  33. package/clients/typescript/examples/fake-session.mjs +36 -0
  34. package/clients/typescript/index.d.ts +144 -0
  35. package/clients/typescript/index.mjs +192 -0
  36. package/clients/typescript/package.json +8 -0
  37. package/docs/adr/0001-agent-native-runner-boundary.md +31 -0
  38. package/docs/adr/0002-app-local-zmr-contract.md +39 -0
  39. package/docs/adr/0003-ios-simulator-xctest-shim.md +41 -0
  40. package/docs/adr/0004-benchmark-claims-and-baseline-collection.md +37 -0
  41. package/docs/adr/README.md +12 -0
  42. package/docs/ai-agents.md +154 -0
  43. package/docs/app-integration.md +330 -0
  44. package/docs/benchmarking.md +273 -0
  45. package/docs/client-installation.md +133 -0
  46. package/docs/clients.md +98 -0
  47. package/docs/config.md +175 -0
  48. package/docs/demo.md +259 -0
  49. package/docs/frameworks.md +72 -0
  50. package/docs/install.md +95 -0
  51. package/docs/npm.md +356 -0
  52. package/docs/protocol-fixtures/README.md +8 -0
  53. package/docs/protocol-fixtures/core-session.requests.jsonl +8 -0
  54. package/docs/protocol-fixtures/core-session.responses.jsonl +8 -0
  55. package/docs/protocol-versioning.md +65 -0
  56. package/docs/protocol.md +560 -0
  57. package/docs/scenario-authoring.md +88 -0
  58. package/docs/trace-privacy.md +88 -0
  59. package/docs/troubleshooting.md +256 -0
  60. package/examples/android-app-auth-probe.json +89 -0
  61. package/examples/android-app-error-state.json +13 -0
  62. package/examples/android-app-login-smoke.json +192 -0
  63. package/examples/android-app-onboarding.json +12 -0
  64. package/examples/android-app-referral-deep-link.json +12 -0
  65. package/examples/android-shim-smoke.json +19 -0
  66. package/examples/demo-failure.json +12 -0
  67. package/examples/demo-fake.json +14 -0
  68. package/examples/ios-dev-client-open-link.json +26 -0
  69. package/examples/ios-dev-client-route-snapshot.json +24 -0
  70. package/examples/ios-shim-smoke.json +23 -0
  71. package/examples/ios-smoke.json +9 -0
  72. package/go.work +3 -0
  73. package/npm/agents.mjs +183 -0
  74. package/npm/app-config.mjs +95 -0
  75. package/npm/build-zmr.mjs +21 -0
  76. package/npm/commands.mjs +104 -0
  77. package/npm/generated-files.mjs +50 -0
  78. package/npm/index.mjs +75 -0
  79. package/npm/init-app.mjs +80 -0
  80. package/npm/package-scripts.mjs +72 -0
  81. package/npm/postinstall.mjs +21 -0
  82. package/npm/scaffold.mjs +179 -0
  83. package/npm/scenarios.mjs +93 -0
  84. package/npm/setup.mjs +69 -0
  85. package/npm/wizard.mjs +117 -0
  86. package/npm/zmr.mjs +23 -0
  87. package/package.json +118 -0
  88. package/schemas/README.md +26 -0
  89. package/schemas/action-result.schema.json +27 -0
  90. package/schemas/capabilities-output.schema.json +98 -0
  91. package/schemas/devices-output.schema.json +25 -0
  92. package/schemas/doctor-output.schema.json +51 -0
  93. package/schemas/explain-output.schema.json +51 -0
  94. package/schemas/import-output.schema.json +23 -0
  95. package/schemas/init-output.schema.json +71 -0
  96. package/schemas/json-rpc.schema.json +55 -0
  97. package/schemas/release-manifest.schema.json +43 -0
  98. package/schemas/release-readiness-output.schema.json +127 -0
  99. package/schemas/run-output.schema.json +43 -0
  100. package/schemas/scenario.schema.json +128 -0
  101. package/schemas/schemas-output.schema.json +26 -0
  102. package/schemas/semantic-snapshot.schema.json +116 -0
  103. package/schemas/snapshot.schema.json +60 -0
  104. package/schemas/trace-event.schema.json +14 -0
  105. package/schemas/trace-manifest.schema.json +59 -0
  106. package/schemas/validate-output.schema.json +42 -0
  107. package/schemas/version-output.schema.json +23 -0
  108. package/schemas/zmr-config.schema.json +75 -0
  109. package/scripts/android-emulator.sh +126 -0
  110. package/scripts/assert-ios-physical-ready.sh +213 -0
  111. package/scripts/benchmark-command.sh +307 -0
  112. package/scripts/benchmark.sh +359 -0
  113. package/scripts/benchmark_gate.py +117 -0
  114. package/scripts/benchmark_result_row.py +88 -0
  115. package/scripts/compare-benchmarks.py +288 -0
  116. package/scripts/create-android-demo-app.sh +342 -0
  117. package/scripts/create-ios-demo-app.sh +261 -0
  118. package/scripts/demo-android-real.sh +232 -0
  119. package/scripts/demo-ios-real.sh +270 -0
  120. package/scripts/demo.sh +464 -0
  121. package/scripts/device-matrix.sh +338 -0
  122. package/scripts/ensure-ios-shim-target.rb +237 -0
  123. package/scripts/install-android-shim.sh +281 -0
  124. package/scripts/install-ios-shim.sh +589 -0
  125. package/scripts/pilot-gate.sh +560 -0
  126. package/scripts/release-readiness.py +838 -0
  127. package/scripts/release-readiness.sh +91 -0
  128. package/scripts/run-android-pilot.sh +561 -0
  129. package/scripts/run-ios-pilot.sh +509 -0
  130. package/shims/android/README.md +21 -0
  131. package/shims/android/ZMRShimInstrumentedTest.java +152 -0
  132. package/shims/android/protocol.md +18 -0
  133. package/shims/ios/README.md +50 -0
  134. package/shims/ios/ZMRShim.swift +110 -0
  135. package/shims/ios/ZMRShimUITestCase.swift +518 -0
  136. package/shims/ios/protocol.md +74 -0
  137. package/skills/zmr-mobile-testing/SKILL.md +127 -0
  138. package/src/android.zig +344 -0
  139. package/src/android_device_info.zig +99 -0
  140. package/src/android_emulator.zig +154 -0
  141. package/src/android_screen_recording.zig +112 -0
  142. package/src/android_shell.zig +112 -0
  143. package/src/bundle.zig +124 -0
  144. package/src/bundle_redaction.zig +272 -0
  145. package/src/bundle_tar.zig +123 -0
  146. package/src/cli_devices.zig +97 -0
  147. package/src/cli_doctor.zig +114 -0
  148. package/src/cli_import.zig +70 -0
  149. package/src/cli_info.zig +39 -0
  150. package/src/cli_init.zig +72 -0
  151. package/src/cli_output.zig +467 -0
  152. package/src/cli_run.zig +259 -0
  153. package/src/cli_serve.zig +287 -0
  154. package/src/cli_trace.zig +111 -0
  155. package/src/cli_validate.zig +41 -0
  156. package/src/command.zig +211 -0
  157. package/src/config.zig +305 -0
  158. package/src/config_diagnostics.zig +212 -0
  159. package/src/config_paths.zig +49 -0
  160. package/src/device_registry.zig +37 -0
  161. package/src/doctor.zig +412 -0
  162. package/src/doctor_hints.zig +52 -0
  163. package/src/errors.zig +55 -0
  164. package/src/fake_device.zig +163 -0
  165. package/src/health.zig +28 -0
  166. package/src/importer.zig +343 -0
  167. package/src/importer_json.zig +100 -0
  168. package/src/importer_model.zig +103 -0
  169. package/src/ios.zig +399 -0
  170. package/src/ios_devices.zig +219 -0
  171. package/src/ios_lifecycle.zig +72 -0
  172. package/src/ios_shim.zig +242 -0
  173. package/src/ios_snapshot.zig +20 -0
  174. package/src/json_fields.zig +80 -0
  175. package/src/json_rpc.zig +150 -0
  176. package/src/json_rpc_methods.zig +318 -0
  177. package/src/json_rpc_observation.zig +31 -0
  178. package/src/json_rpc_params.zig +52 -0
  179. package/src/json_rpc_protocol.zig +110 -0
  180. package/src/json_rpc_trace.zig +73 -0
  181. package/src/main.zig +131 -0
  182. package/src/mcp.zig +234 -0
  183. package/src/mcp_protocol.zig +64 -0
  184. package/src/mcp_trace.zig +83 -0
  185. package/src/report.zig +346 -0
  186. package/src/report_html.zig +63 -0
  187. package/src/report_values.zig +27 -0
  188. package/src/run_options.zig +152 -0
  189. package/src/runner.zig +280 -0
  190. package/src/runner_actions.zig +109 -0
  191. package/src/runner_config.zig +6 -0
  192. package/src/runner_diagnostics.zig +268 -0
  193. package/src/runner_events.zig +170 -0
  194. package/src/runner_native.zig +88 -0
  195. package/src/runner_waits.zig +300 -0
  196. package/src/scaffold.zig +472 -0
  197. package/src/scenario.zig +346 -0
  198. package/src/scenario_fields.zig +50 -0
  199. package/src/schema_registry.zig +53 -0
  200. package/src/selector.zig +84 -0
  201. package/src/semantic.zig +171 -0
  202. package/src/trace.zig +315 -0
  203. package/src/trace_json.zig +340 -0
  204. package/src/trace_summary.zig +218 -0
  205. package/src/trace_summary_diagnostic.zig +202 -0
  206. package/src/types.zig +120 -0
  207. package/src/uiautomator.zig +164 -0
  208. package/src/validation.zig +187 -0
  209. package/src/version.zig +22 -0
  210. package/viewer/app.js +373 -0
  211. package/viewer/index.html +126 -0
  212. package/viewer/parser.js +233 -0
  213. package/viewer/styles.css +585 -0
@@ -0,0 +1,18 @@
1
+ # Android Shim Protocol
2
+
3
+ The Android shim protocol is internal and may change before `v1.0.0`.
4
+
5
+ The first implementation should mirror the public ZMR action model:
6
+
7
+ - `snapshot`
8
+ - `tap`
9
+ - `type`
10
+ - `eraseText`
11
+ - `hideKeyboard`
12
+ - `swipe`
13
+ - `pressBack`
14
+ - `settle`
15
+ - `appState`
16
+
17
+ The Zig adapter must keep ADB/UI Automator fallback behavior so the runner can
18
+ still operate when the shim is not installed.
@@ -0,0 +1,50 @@
1
+ # ZMR iOS Shim
2
+
3
+ This directory contains the XCTest/XCUIAutomation shim scaffold used for
4
+ selector-grade iOS automation.
5
+
6
+ The public ZMR API remains the scenario file and JSON-RPC protocol. The shim
7
+ protocol is an internal local transport between the Zig runner and an app-local
8
+ UI test runner.
9
+
10
+ Current status:
11
+
12
+ - Zig-side command and snapshot mapping are covered in `src/ios_shim.zig`.
13
+ - `src/ios.zig` can run a configured shim command with one JSON request on
14
+ stdin and one JSON response on stdout.
15
+ - `scripts/install-ios-shim.sh` writes an app-local `.zmr/ios-shim` command and
16
+ copies the XCTest source files into the app repo for inclusion in a UI test
17
+ target.
18
+ - The generated command caches `xcodebuild build-for-testing` output and runs
19
+ selector commands through `test-without-building`, exchanging per-command
20
+ files under `.zmr/ios-shim-state/`. Set `ZMR_IOS_SHIM_FORCE_REBUILD=1` to
21
+ refresh the cached test bundle, or `ZMR_IOS_SHIM_ONESHOT=1` to force the
22
+ slower one-command XCTest fallback for debugging.
23
+ - The iOS adapter still uses `xcrun simctl` for simulator install, launch,
24
+ terminate, open link, screenshots, and logs. It uses `xcrun devicectl` for
25
+ physical-device lifecycle where Apple exposes a supported local command, and
26
+ uses the XCTest shim for physical-device screenshot artifacts.
27
+ - `.zmr/ensure-ios-shim-target.sh` can create or update the UI test target for
28
+ common Xcode project/workspace layouts through the Ruby `xcodeproj` gem.
29
+ Users can still add the generated Swift files manually when their project
30
+ layout needs custom handling.
31
+ - ZMR uses the shim as a native selector fast path for single-field tap, type,
32
+ erase-text, and wait/assert queries. Compound selectors stay on the portable
33
+ Zig observe-and-match path.
34
+ - Snapshot capture is intentionally bounded to common XCTest element families
35
+ and at most 256 nodes. This keeps traces fast and predictable for large
36
+ React Native, SwiftUI, and UIKit trees while preserving the controls agents
37
+ normally need for follow-up actions.
38
+
39
+ Support target:
40
+
41
+ - iOS simulators for full local artifacts: lifecycle, screenshots, logs,
42
+ selector actions, native selector waits, and bounded snapshots.
43
+ - Physical iOS devices for lifecycle and selector-grade XCTest automation,
44
+ subject to local signing, provisioning, Developer Mode, and Apple
45
+ `devicectl` availability. Screenshots use the XCTest shim; log artifact
46
+ capture remains simulator-first.
47
+ - XCTest/XCUIAutomation snapshots mapped into `UiNode`.
48
+ - Selector actions: tap, type, erase text, hide keyboard, swipe, and home/back
49
+ equivalent navigation.
50
+ - Clear state means best-effort app uninstall by bundle id.
@@ -0,0 +1,110 @@
1
+ import Foundation
2
+ import XCTest
3
+
4
+ struct ZMRShimCommand: Decodable {
5
+ let cmd: String
6
+ let selector: String?
7
+ let text: String?
8
+ let x: Int?
9
+ let y: Int?
10
+ let x1: Int?
11
+ let y1: Int?
12
+ let x2: Int?
13
+ let y2: Int?
14
+ let durationMs: UInt?
15
+ let maxChars: UInt?
16
+ }
17
+
18
+ struct ZMRShimBounds: Encodable {
19
+ let x: Int
20
+ let y: Int
21
+ let width: Int
22
+ let height: Int
23
+ }
24
+
25
+ struct ZMRShimNode: Encodable {
26
+ let id: String
27
+ let type: String
28
+ let label: String
29
+ let value: String
30
+ let identifier: String
31
+ let bounds: ZMRShimBounds
32
+ let enabled: Bool
33
+ let visible: Bool
34
+ let selected: Bool
35
+ }
36
+
37
+ enum ZMRShim {
38
+ static func snapshot(app: XCUIApplication) -> [ZMRShimNode] {
39
+ let queries: [(XCUIElement.ElementType, XCUIElementQuery)] = [
40
+ (.button, app.buttons),
41
+ (.staticText, app.staticTexts),
42
+ (.textField, app.textFields),
43
+ (.secureTextField, app.secureTextFields),
44
+ (.textView, app.textViews),
45
+ (.image, app.images),
46
+ (.switch, app.switches),
47
+ (.cell, app.cells),
48
+ (.scrollView, app.scrollViews),
49
+ (.table, app.tables),
50
+ (.collectionView, app.collectionViews)
51
+ ]
52
+
53
+ var nodes: [ZMRShimNode] = []
54
+ nodes.reserveCapacity(128)
55
+
56
+ for (type, query) in queries {
57
+ for element in query.allElementsBoundByIndex {
58
+ guard nodes.count < 256 else {
59
+ return nodes
60
+ }
61
+ nodes.append(node(index: nodes.count, type: type, element: element))
62
+ }
63
+ }
64
+
65
+ if nodes.isEmpty {
66
+ nodes.append(node(index: 0, type: .application, element: app))
67
+ }
68
+ return nodes
69
+ }
70
+
71
+ private static func node(index: Int, type: XCUIElement.ElementType, element: XCUIElement) -> ZMRShimNode {
72
+ let frame = element.frame
73
+ return ZMRShimNode(
74
+ id: stableId(index: index, element: element),
75
+ type: String(describing: type),
76
+ label: element.label,
77
+ value: elementValue(element),
78
+ identifier: element.identifier,
79
+ bounds: ZMRShimBounds(
80
+ x: Int(frame.origin.x),
81
+ y: Int(frame.origin.y),
82
+ width: Int(frame.size.width),
83
+ height: Int(frame.size.height)
84
+ ),
85
+ enabled: element.isEnabled,
86
+ visible: element.exists && !frame.isEmpty,
87
+ selected: element.isSelected
88
+ )
89
+ }
90
+
91
+ private static func elementValue(_ element: XCUIElement) -> String {
92
+ if let value = element.value as? String {
93
+ return value
94
+ }
95
+ if let value = element.value {
96
+ return String(describing: value)
97
+ }
98
+ return ""
99
+ }
100
+
101
+ private static func stableId(index: Int, element: XCUIElement) -> String {
102
+ if !element.identifier.isEmpty {
103
+ return "id:\(element.identifier)"
104
+ }
105
+ if !element.label.isEmpty {
106
+ return "label:\(element.label):\(index)"
107
+ }
108
+ return "index:\(index)"
109
+ }
110
+ }
@@ -0,0 +1,518 @@
1
+ import Foundation
2
+ import XCTest
3
+
4
+ final class ZMRShimUITestCase: XCTestCase {
5
+ func testRunZMRCommand() throws {
6
+ let environment = ProcessInfo.processInfo.environment
7
+ let app = makeApplication(bundleIdentifier: shimRuntimeValue("ZMR_APP_BUNDLE_ID", environment: environment))
8
+
9
+ if shimRuntimeValue("ZMR_SHIM_MODE", environment: environment) == "server" {
10
+ guard let serverDir = shimRuntimeValue("ZMR_SHIM_SERVER_DIR", environment: environment) else {
11
+ throw ZMRShimError.missingEnvironment
12
+ }
13
+ try runServer(serverDir: serverDir, app: app)
14
+ return
15
+ }
16
+
17
+ guard let requestFile = shimRuntimeValue("ZMR_SHIM_REQUEST_FILE", environment: environment),
18
+ let responseFile = shimRuntimeValue("ZMR_SHIM_RESPONSE_FILE", environment: environment) else {
19
+ throw ZMRShimError.missingEnvironment
20
+ }
21
+
22
+ try process(requestAt: requestFile, responseAt: responseFile, app: app)
23
+ }
24
+
25
+ private func runServer(serverDir: String, app: XCUIApplication) throws {
26
+ let fileManager = FileManager.default
27
+ try fileManager.createDirectory(atPath: serverDir, withIntermediateDirectories: true)
28
+
29
+ let readyFile = path(in: serverDir, named: "ready")
30
+ let stopFile = path(in: serverDir, named: "stop")
31
+ _ = fileManager.createFile(atPath: readyFile, contents: Data(), attributes: nil)
32
+
33
+ var idleDeadline = Date().addingTimeInterval(900)
34
+ while Date() < idleDeadline {
35
+ if fileManager.fileExists(atPath: stopFile) {
36
+ break
37
+ }
38
+
39
+ let requestNames = try fileManager.contentsOfDirectory(atPath: serverDir)
40
+ .filter { $0.hasPrefix("request-") && $0.hasSuffix(".json") }
41
+ .sorted()
42
+
43
+ if requestNames.isEmpty {
44
+ Thread.sleep(forTimeInterval: 0.05)
45
+ continue
46
+ }
47
+
48
+ for requestName in requestNames {
49
+ let requestID = requestName
50
+ .dropFirst("request-".count)
51
+ .dropLast(".json".count)
52
+ let requestFile = path(in: serverDir, named: requestName)
53
+ let responseFile = path(in: serverDir, named: "response-\(requestID).json")
54
+
55
+ try process(requestAt: requestFile, responseAt: responseFile, app: app)
56
+ try? fileManager.removeItem(atPath: requestFile)
57
+ idleDeadline = Date().addingTimeInterval(900)
58
+ }
59
+ }
60
+ }
61
+
62
+ private func process(requestAt requestFile: String, responseAt responseFile: String, app: XCUIApplication) throws {
63
+ let response = responseFor(requestAt: requestFile, app: app)
64
+ let responseData = try JSONSerialization.data(withJSONObject: response, options: [.sortedKeys])
65
+ let responseURL = URL(fileURLWithPath: responseFile)
66
+ let temporaryURL = URL(fileURLWithPath: "\(responseFile).tmp")
67
+ try responseData.write(to: temporaryURL, options: [.atomic])
68
+ if FileManager.default.fileExists(atPath: responseFile) {
69
+ try FileManager.default.removeItem(at: responseURL)
70
+ }
71
+ try FileManager.default.moveItem(at: temporaryURL, to: responseURL)
72
+ }
73
+
74
+ private func responseFor(requestAt requestFile: String, app: XCUIApplication) -> [String: Any] {
75
+ do {
76
+ let requestData = try Data(contentsOf: URL(fileURLWithPath: requestFile))
77
+ let command = try JSONDecoder().decode(ZMRShimCommand.self, from: requestData)
78
+ return run(command: command, app: app)
79
+ } catch {
80
+ return self.error("invalid.request", "\(error)")
81
+ }
82
+ }
83
+
84
+ private func path(in directory: String, named name: String) -> String {
85
+ (directory as NSString).appendingPathComponent(name)
86
+ }
87
+
88
+ private func shimRuntimeValue(_ key: String, environment: [String: String]) -> String? {
89
+ if let value = environment[key], !value.isEmpty, !value.hasPrefix("$(") {
90
+ return value
91
+ }
92
+ if let value = Bundle(for: Self.self).object(forInfoDictionaryKey: key) as? String,
93
+ !value.isEmpty,
94
+ !value.hasPrefix("$(") {
95
+ return value
96
+ }
97
+ return nil
98
+ }
99
+
100
+ private func makeApplication(bundleIdentifier: String?) -> XCUIApplication {
101
+ if let bundleIdentifier, !bundleIdentifier.isEmpty {
102
+ return XCUIApplication(bundleIdentifier: bundleIdentifier)
103
+ }
104
+ return XCUIApplication()
105
+ }
106
+
107
+ private func run(command: ZMRShimCommand, app: XCUIApplication) -> [String: Any] {
108
+ switch command.cmd {
109
+ case "snapshot":
110
+ return [
111
+ "status": "ok",
112
+ "nodes": ZMRShim.snapshot(app: app).map { $0.json }
113
+ ]
114
+ case "screenshot":
115
+ let screenshot = XCUIScreen.main.screenshot()
116
+ return [
117
+ "status": "ok",
118
+ "format": "png",
119
+ "base64": screenshot.pngRepresentation.base64EncodedString()
120
+ ]
121
+ case "query":
122
+ guard let selector = command.selector else {
123
+ return error("invalid.query", "query requires selector")
124
+ }
125
+ guard let parts = selectorParts(selector) else {
126
+ return error("selector.unsupported", "unsupported selector: \(selector)")
127
+ }
128
+ guard isFastQueryable(parts: parts) else {
129
+ return error("selector.unsupported", "unsupported query selector: \(selector)")
130
+ }
131
+ let element = resolveFastElement(selector: selector, app: app, preferredTypes: [])
132
+ return [
133
+ "status": "ok",
134
+ "exists": element?.exists ?? false,
135
+ "hittable": element?.isHittable ?? false
136
+ ]
137
+ case "tap":
138
+ if let selector = command.selector {
139
+ return tap(selector: selector, app: app)
140
+ }
141
+ guard let x = command.x, let y = command.y else {
142
+ return error("invalid.tap", "tap requires x and y")
143
+ }
144
+ app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
145
+ .withOffset(CGVector(dx: x, dy: y))
146
+ .tap()
147
+ return ok()
148
+ case "type":
149
+ if let selector = command.selector {
150
+ return typeText(selector: selector, text: command.text ?? "", app: app)
151
+ }
152
+ app.typeText(command.text ?? "")
153
+ return ok()
154
+ case "eraseText":
155
+ let count = Int(command.maxChars ?? 0)
156
+ if let selector = command.selector {
157
+ return eraseText(selector: selector, count: count, app: app)
158
+ }
159
+ if count > 0 {
160
+ app.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: count))
161
+ }
162
+ return ok()
163
+ case "hideKeyboard":
164
+ return hideKeyboard(app: app)
165
+ case "swipe":
166
+ guard let x1 = command.x1, let y1 = command.y1, let x2 = command.x2, let y2 = command.y2 else {
167
+ return error("invalid.swipe", "swipe requires x1, y1, x2, and y2")
168
+ }
169
+ let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
170
+ .withOffset(CGVector(dx: x1, dy: y1))
171
+ let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
172
+ .withOffset(CGVector(dx: x2, dy: y2))
173
+ start.press(forDuration: 0.01, thenDragTo: end)
174
+ return ok()
175
+ case "pressBack":
176
+ XCUIDevice.shared.press(.home)
177
+ return ok()
178
+ case "settle":
179
+ let timeout = TimeInterval(command.durationMs ?? 1000) / 1000.0
180
+ _ = app.wait(for: app.state, timeout: timeout)
181
+ return ok()
182
+ case "appState":
183
+ return ["status": "ok", "state": app.state.rawValue]
184
+ case "acceptSystemAlert":
185
+ return acceptSystemAlert(buttonText: command.text ?? "Open")
186
+ default:
187
+ return error("unknown.command", "unsupported command: \(command.cmd)")
188
+ }
189
+ }
190
+
191
+ private func ok() -> [String: Any] {
192
+ ["status": "ok"]
193
+ }
194
+
195
+ private func error(_ code: String, _ message: String) -> [String: Any] {
196
+ ["status": "error", "code": code, "message": message]
197
+ }
198
+
199
+ private func acceptSystemAlert(buttonText: String) -> [String: Any] {
200
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
201
+ var labels = [buttonText, "Open", "Allow", "OK", "Continue"]
202
+ labels = labels.reduce(into: [String]()) { unique, label in
203
+ if !label.isEmpty && !unique.contains(label) {
204
+ unique.append(label)
205
+ }
206
+ }
207
+
208
+ var acceptedCount = 0
209
+ var lastAcceptedLabel = ""
210
+ for _ in 0..<3 {
211
+ var tapped = false
212
+ for label in labels {
213
+ let button = springboard.buttons[label].firstMatch
214
+ if button.waitForExistence(timeout: 2), button.isHittable {
215
+ button.tap()
216
+ acceptedCount += 1
217
+ lastAcceptedLabel = label
218
+ tapped = true
219
+ Thread.sleep(forTimeInterval: 1.0)
220
+ break
221
+ }
222
+ }
223
+ if !tapped {
224
+ break
225
+ }
226
+ }
227
+
228
+ if acceptedCount > 0 {
229
+ return ["status": "ok", "accepted": true, "label": lastAcceptedLabel, "count": acceptedCount]
230
+ }
231
+ return ["status": "ok", "accepted": false, "count": 0]
232
+ }
233
+
234
+ private func hideKeyboard(app: XCUIApplication) -> [String: Any] {
235
+ guard app.keyboards.firstMatch.exists else {
236
+ return ok()
237
+ }
238
+
239
+ let keyboard = app.keyboards.firstMatch
240
+ let dismissKeyNames = [
241
+ "Done",
242
+ "done",
243
+ "Return",
244
+ "return",
245
+ "Go",
246
+ "go",
247
+ "Next",
248
+ "next",
249
+ "Search",
250
+ "search",
251
+ "Send",
252
+ "send"
253
+ ]
254
+
255
+ for keyName in dismissKeyNames {
256
+ if tapKeyboardElement(keyboard.buttons[keyName]) {
257
+ return ok()
258
+ }
259
+ if tapKeyboardElement(keyboard.keys[keyName]) {
260
+ return ok()
261
+ }
262
+ }
263
+
264
+ app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.05)).tap()
265
+ if app.keyboards.firstMatch.waitForNonExistence(timeout: 1) {
266
+ return ok()
267
+ }
268
+
269
+ return error("keyboard.dismiss_failed", "keyboard did not expose a known dismiss key")
270
+ }
271
+
272
+ private func tapKeyboardElement(_ element: XCUIElement) -> Bool {
273
+ guard element.exists, element.isHittable else {
274
+ return false
275
+ }
276
+ element.tap()
277
+ return true
278
+ }
279
+
280
+ private func tap(selector: String, app: XCUIApplication) -> [String: Any] {
281
+ guard selectorParts(selector) != nil else {
282
+ return error("selector.unsupported", "unsupported selector: \(selector)")
283
+ }
284
+ guard let element = resolveElement(selector: selector, app: app, preferredTypes: [.button]) else {
285
+ return error("selector.not_found", "selector did not match: \(selector)")
286
+ }
287
+ if !element.isHittable {
288
+ return error("selector.not_hittable", "selector matched a non-hittable element: \(selector)")
289
+ }
290
+ element.tap()
291
+ return ok()
292
+ }
293
+
294
+ private func typeText(selector: String, text: String, app: XCUIApplication) -> [String: Any] {
295
+ guard selectorParts(selector) != nil else {
296
+ return error("selector.unsupported", "unsupported selector: \(selector)")
297
+ }
298
+ guard let element = resolveElement(selector: selector, app: app, preferredTypes: [.textField, .secureTextField, .textView]) else {
299
+ return error("selector.not_found", "selector did not match: \(selector)")
300
+ }
301
+ if !element.isHittable {
302
+ return error("selector.not_hittable", "selector matched a non-hittable element: \(selector)")
303
+ }
304
+ element.tap()
305
+ app.typeText(text)
306
+ return ok()
307
+ }
308
+
309
+ private func eraseText(selector: String, count: Int, app: XCUIApplication) -> [String: Any] {
310
+ guard selectorParts(selector) != nil else {
311
+ return error("selector.unsupported", "unsupported selector: \(selector)")
312
+ }
313
+ guard let element = resolveElement(selector: selector, app: app, preferredTypes: [.textField, .secureTextField, .textView]) else {
314
+ return error("selector.not_found", "selector did not match: \(selector)")
315
+ }
316
+ if !element.isHittable {
317
+ return error("selector.not_hittable", "selector matched a non-hittable element: \(selector)")
318
+ }
319
+ element.tap()
320
+ if count > 0 {
321
+ app.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: count))
322
+ }
323
+ return ok()
324
+ }
325
+
326
+ private func resolveElement(selector: String, app: XCUIApplication, preferredTypes: [XCUIElement.ElementType] = []) -> XCUIElement? {
327
+ if let fast = resolveFastElement(selector: selector, app: app, preferredTypes: preferredTypes), fast.exists {
328
+ return fast
329
+ }
330
+
331
+ let matchedElements = app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
332
+ matches(selector: selector, element: element)
333
+ }
334
+ if let preferred = matchedElements.first(where: { preferredTypes.contains($0.elementType) && $0.isHittable }) {
335
+ return preferred
336
+ }
337
+ if let preferred = matchedElements.first(where: { preferredTypes.contains($0.elementType) }) {
338
+ return preferred
339
+ }
340
+ if let hittable = matchedElements.first(where: { $0.isHittable }) {
341
+ return hittable
342
+ }
343
+ return matchedElements.first
344
+ }
345
+
346
+ private func resolveFastElement(selector: String, app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> XCUIElement? {
347
+ guard let parts = selectorParts(selector) else {
348
+ return nil
349
+ }
350
+
351
+ switch parts.field {
352
+ case "text", "label":
353
+ let queries = fastTextQueries(app: app, preferredTypes: preferredTypes)
354
+ if parts.contains {
355
+ let predicate = NSPredicate(format: "label CONTAINS[c] %@", parts.value)
356
+ return firstExistingElement(queries: queries.map { $0.matching(predicate) })
357
+ }
358
+ let predicate = NSPredicate(format: "label == %@", parts.value)
359
+ return firstExistingElement(queries: queries.map { $0.matching(predicate) })
360
+ case "identifier", "resourceId":
361
+ let queries = fastIdentifierQueries(app: app, preferredTypes: preferredTypes, contains: parts.contains)
362
+ if parts.contains {
363
+ let predicate = NSPredicate(format: "identifier CONTAINS[c] %@", parts.value)
364
+ return firstExistingElement(queries: queries.map { $0.matching(predicate) })
365
+ }
366
+ return firstExistingElement(queries: queries.map { $0.matching(identifier: parts.value) })
367
+ case "value":
368
+ let queries = fastTextQueries(app: app, preferredTypes: preferredTypes)
369
+ let predicate = parts.contains
370
+ ? NSPredicate(format: "value CONTAINS[c] %@", parts.value)
371
+ : NSPredicate(format: "value == %@", parts.value)
372
+ return firstExistingElement(queries: queries.map { $0.matching(predicate) })
373
+ case "type", "id":
374
+ return nil
375
+ default:
376
+ return nil
377
+ }
378
+ }
379
+
380
+ private func fastTextQueries(app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> [XCUIElementQuery] {
381
+ var queries: [XCUIElementQuery] = []
382
+ if !preferredTypes.isEmpty {
383
+ queries.append(contentsOf: preferredTypes.map { app.descendants(matching: $0) })
384
+ }
385
+ queries.append(contentsOf: [
386
+ app.buttons,
387
+ app.staticTexts,
388
+ app.textFields,
389
+ app.secureTextFields,
390
+ app.textViews,
391
+ app.images
392
+ ])
393
+ return queries
394
+ }
395
+
396
+ private func fastIdentifierQueries(
397
+ app: XCUIApplication,
398
+ preferredTypes: [XCUIElement.ElementType],
399
+ contains: Bool
400
+ ) -> [XCUIElementQuery] {
401
+ var queries = fastTextQueries(app: app, preferredTypes: preferredTypes)
402
+ if !contains {
403
+ queries.append(app.otherElements)
404
+ }
405
+ return queries
406
+ }
407
+
408
+ private func firstExistingElement(queries: [XCUIElementQuery]) -> XCUIElement? {
409
+ for query in queries {
410
+ let element = query.firstMatch
411
+ if element.exists {
412
+ return element
413
+ }
414
+ }
415
+ return nil
416
+ }
417
+
418
+ private func isFastQueryable(parts: (field: String, value: String, contains: Bool)) -> Bool {
419
+ switch parts.field {
420
+ case "text", "label", "identifier", "resourceId", "value":
421
+ return true
422
+ default:
423
+ return false
424
+ }
425
+ }
426
+
427
+ private func matches(selector: String, element: XCUIElement) -> Bool {
428
+ guard element.exists, let parts = selectorParts(selector) else {
429
+ return false
430
+ }
431
+
432
+ let actual: String
433
+ switch parts.field {
434
+ case "text", "label":
435
+ actual = element.label
436
+ case "identifier", "resourceId":
437
+ actual = element.identifier
438
+ case "id":
439
+ actual = stableId(element: element)
440
+ case "value":
441
+ actual = element.value as? String ?? ""
442
+ case "type":
443
+ actual = String(describing: element.elementType)
444
+ default:
445
+ return false
446
+ }
447
+
448
+ if parts.contains {
449
+ return actual.localizedCaseInsensitiveContains(parts.value)
450
+ }
451
+ return actual == parts.value
452
+ }
453
+
454
+ private func selectorParts(_ selector: String) -> (field: String, value: String, contains: Bool)? {
455
+ let supportedPrefixes = [
456
+ ("textContains=", "text", true),
457
+ ("labelContains=", "label", true),
458
+ ("identifierContains=", "identifier", true),
459
+ ("resourceIdContains=", "resourceId", true),
460
+ ("valueContains=", "value", true),
461
+ ("type=", "type", false),
462
+ ("text=", "text", false),
463
+ ("label=", "label", false),
464
+ ("identifier=", "identifier", false),
465
+ ("resourceId=", "resourceId", false),
466
+ ("id=", "id", false),
467
+ ("value=", "value", false)
468
+ ]
469
+
470
+ for (prefix, field, contains) in supportedPrefixes {
471
+ if selector.hasPrefix(prefix) {
472
+ let value = String(selector.dropFirst(prefix.count))
473
+ return value.isEmpty ? nil : (field, value, contains)
474
+ }
475
+ }
476
+ return nil
477
+ }
478
+
479
+ private func stableId(element: XCUIElement) -> String {
480
+ if !element.identifier.isEmpty {
481
+ return "id:\(element.identifier)"
482
+ }
483
+ if !element.label.isEmpty {
484
+ return "label:\(element.label)"
485
+ }
486
+ return String(describing: element.elementType)
487
+ }
488
+ }
489
+
490
+ enum ZMRShimError: Error {
491
+ case missingEnvironment
492
+ }
493
+
494
+ private extension ZMRShimBounds {
495
+ var json: [String: Any] {
496
+ [
497
+ "x": x,
498
+ "y": y,
499
+ "width": width,
500
+ "height": height
501
+ ]
502
+ }
503
+ }
504
+
505
+ private extension ZMRShimNode {
506
+ var json: [String: Any] {
507
+ [
508
+ "id": id,
509
+ "type": type,
510
+ "label": label,
511
+ "identifier": identifier,
512
+ "bounds": bounds.json,
513
+ "enabled": enabled,
514
+ "visible": visible,
515
+ "selected": selected
516
+ ]
517
+ }
518
+ }