zig-mobile-runner 0.1.0

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 (225) hide show
  1. package/CHANGELOG.md +484 -0
  2. package/CONTRIBUTING.md +42 -0
  3. package/FEATURES.md +112 -0
  4. package/LICENSE +21 -0
  5. package/README.md +255 -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 +144 -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 +156 -0
  43. package/docs/app-integration.md +316 -0
  44. package/docs/benchmarking.md +275 -0
  45. package/docs/client-installation.md +141 -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/dsl.md +57 -0
  50. package/docs/install.md +233 -0
  51. package/docs/market-positioning.md +70 -0
  52. package/docs/npm.md +359 -0
  53. package/docs/protocol-fixtures/README.md +8 -0
  54. package/docs/protocol-fixtures/core-session.requests.jsonl +8 -0
  55. package/docs/protocol-fixtures/core-session.responses.jsonl +8 -0
  56. package/docs/protocol-versioning.md +65 -0
  57. package/docs/protocol.md +560 -0
  58. package/docs/publication.md +77 -0
  59. package/docs/release-audit.md +99 -0
  60. package/docs/release-candidate.md +111 -0
  61. package/docs/release-evidence.md +188 -0
  62. package/docs/release-notes-template.md +58 -0
  63. package/docs/roadmap.md +334 -0
  64. package/docs/scenario-authoring.md +88 -0
  65. package/docs/shipping.md +170 -0
  66. package/docs/trace-privacy.md +88 -0
  67. package/docs/troubleshooting.md +256 -0
  68. package/examples/android-app-auth-probe.json +89 -0
  69. package/examples/android-app-error-state.json +13 -0
  70. package/examples/android-app-login-smoke.json +192 -0
  71. package/examples/android-app-onboarding.json +12 -0
  72. package/examples/android-app-referral-deep-link.json +12 -0
  73. package/examples/android-shim-smoke.json +19 -0
  74. package/examples/demo-failure.json +12 -0
  75. package/examples/demo-fake.json +14 -0
  76. package/examples/ios-dev-client-open-link.json +26 -0
  77. package/examples/ios-dev-client-route-snapshot.json +24 -0
  78. package/examples/ios-shim-smoke.json +23 -0
  79. package/examples/ios-smoke.json +9 -0
  80. package/go.work +3 -0
  81. package/npm/agents.mjs +183 -0
  82. package/npm/app-config.mjs +95 -0
  83. package/npm/build-zmr.mjs +21 -0
  84. package/npm/commands.mjs +104 -0
  85. package/npm/generated-files.mjs +50 -0
  86. package/npm/index.mjs +75 -0
  87. package/npm/init-app.mjs +80 -0
  88. package/npm/package-scripts.mjs +72 -0
  89. package/npm/postinstall.mjs +21 -0
  90. package/npm/scaffold.mjs +179 -0
  91. package/npm/scenarios.mjs +93 -0
  92. package/npm/setup.mjs +69 -0
  93. package/npm/wizard.mjs +117 -0
  94. package/npm/zmr.mjs +23 -0
  95. package/package.json +114 -0
  96. package/prebuilds/darwin-arm64/zmr +0 -0
  97. package/prebuilds/darwin-x64/zmr +0 -0
  98. package/prebuilds/linux-arm64/zmr +0 -0
  99. package/prebuilds/linux-x64/zmr +0 -0
  100. package/schemas/README.md +26 -0
  101. package/schemas/action-result.schema.json +27 -0
  102. package/schemas/capabilities-output.schema.json +98 -0
  103. package/schemas/devices-output.schema.json +25 -0
  104. package/schemas/doctor-output.schema.json +51 -0
  105. package/schemas/explain-output.schema.json +51 -0
  106. package/schemas/import-output.schema.json +23 -0
  107. package/schemas/init-output.schema.json +71 -0
  108. package/schemas/json-rpc.schema.json +55 -0
  109. package/schemas/release-manifest.schema.json +43 -0
  110. package/schemas/release-readiness-output.schema.json +127 -0
  111. package/schemas/run-output.schema.json +43 -0
  112. package/schemas/scenario.schema.json +128 -0
  113. package/schemas/schemas-output.schema.json +26 -0
  114. package/schemas/semantic-snapshot.schema.json +116 -0
  115. package/schemas/snapshot.schema.json +60 -0
  116. package/schemas/trace-event.schema.json +14 -0
  117. package/schemas/trace-manifest.schema.json +59 -0
  118. package/schemas/validate-output.schema.json +42 -0
  119. package/schemas/version-output.schema.json +23 -0
  120. package/schemas/zmr-config.schema.json +75 -0
  121. package/scripts/android-emulator.sh +126 -0
  122. package/scripts/assert-ios-physical-ready.sh +213 -0
  123. package/scripts/benchmark-command.sh +307 -0
  124. package/scripts/benchmark.sh +359 -0
  125. package/scripts/benchmark_gate.py +117 -0
  126. package/scripts/benchmark_result_row.py +88 -0
  127. package/scripts/compare-benchmarks.py +288 -0
  128. package/scripts/create-android-demo-app.sh +342 -0
  129. package/scripts/create-ios-demo-app.sh +261 -0
  130. package/scripts/demo-android-real.sh +232 -0
  131. package/scripts/demo-ios-real.sh +270 -0
  132. package/scripts/demo.sh +464 -0
  133. package/scripts/device-matrix.sh +338 -0
  134. package/scripts/ensure-ios-shim-target.rb +237 -0
  135. package/scripts/install-android-shim.sh +281 -0
  136. package/scripts/install-ios-shim.sh +589 -0
  137. package/scripts/pilot-gate.sh +560 -0
  138. package/scripts/release-readiness.py +838 -0
  139. package/scripts/release-readiness.sh +91 -0
  140. package/scripts/run-android-pilot.sh +561 -0
  141. package/scripts/run-ios-pilot.sh +509 -0
  142. package/shims/android/README.md +21 -0
  143. package/shims/android/ZMRShimInstrumentedTest.java +152 -0
  144. package/shims/android/protocol.md +18 -0
  145. package/shims/ios/README.md +50 -0
  146. package/shims/ios/ZMRShim.swift +110 -0
  147. package/shims/ios/ZMRShimUITestCase.swift +475 -0
  148. package/shims/ios/protocol.md +74 -0
  149. package/skills/zmr-mobile-testing/SKILL.md +127 -0
  150. package/src/android.zig +344 -0
  151. package/src/android_device_info.zig +99 -0
  152. package/src/android_emulator.zig +154 -0
  153. package/src/android_screen_recording.zig +112 -0
  154. package/src/android_shell.zig +112 -0
  155. package/src/bundle.zig +124 -0
  156. package/src/bundle_redaction.zig +272 -0
  157. package/src/bundle_tar.zig +123 -0
  158. package/src/cli_devices.zig +97 -0
  159. package/src/cli_doctor.zig +114 -0
  160. package/src/cli_import.zig +70 -0
  161. package/src/cli_info.zig +39 -0
  162. package/src/cli_init.zig +72 -0
  163. package/src/cli_output.zig +467 -0
  164. package/src/cli_run.zig +259 -0
  165. package/src/cli_serve.zig +287 -0
  166. package/src/cli_trace.zig +111 -0
  167. package/src/cli_validate.zig +41 -0
  168. package/src/command.zig +211 -0
  169. package/src/config.zig +305 -0
  170. package/src/config_diagnostics.zig +212 -0
  171. package/src/config_paths.zig +49 -0
  172. package/src/device_registry.zig +37 -0
  173. package/src/doctor.zig +412 -0
  174. package/src/doctor_hints.zig +52 -0
  175. package/src/errors.zig +55 -0
  176. package/src/fake_device.zig +163 -0
  177. package/src/health.zig +28 -0
  178. package/src/importer.zig +343 -0
  179. package/src/importer_json.zig +100 -0
  180. package/src/importer_model.zig +103 -0
  181. package/src/ios.zig +399 -0
  182. package/src/ios_devices.zig +219 -0
  183. package/src/ios_lifecycle.zig +72 -0
  184. package/src/ios_shim.zig +242 -0
  185. package/src/ios_snapshot.zig +20 -0
  186. package/src/json_fields.zig +80 -0
  187. package/src/json_rpc.zig +150 -0
  188. package/src/json_rpc_methods.zig +318 -0
  189. package/src/json_rpc_observation.zig +31 -0
  190. package/src/json_rpc_params.zig +52 -0
  191. package/src/json_rpc_protocol.zig +110 -0
  192. package/src/json_rpc_trace.zig +73 -0
  193. package/src/main.zig +135 -0
  194. package/src/mcp.zig +234 -0
  195. package/src/mcp_protocol.zig +64 -0
  196. package/src/mcp_trace.zig +83 -0
  197. package/src/report.zig +346 -0
  198. package/src/report_html.zig +63 -0
  199. package/src/report_values.zig +27 -0
  200. package/src/run_options.zig +152 -0
  201. package/src/runner.zig +280 -0
  202. package/src/runner_actions.zig +109 -0
  203. package/src/runner_config.zig +6 -0
  204. package/src/runner_diagnostics.zig +268 -0
  205. package/src/runner_events.zig +170 -0
  206. package/src/runner_native.zig +88 -0
  207. package/src/runner_waits.zig +300 -0
  208. package/src/scaffold.zig +472 -0
  209. package/src/scenario.zig +346 -0
  210. package/src/scenario_fields.zig +50 -0
  211. package/src/schema_registry.zig +53 -0
  212. package/src/selector.zig +84 -0
  213. package/src/semantic.zig +171 -0
  214. package/src/trace.zig +315 -0
  215. package/src/trace_json.zig +340 -0
  216. package/src/trace_summary.zig +218 -0
  217. package/src/trace_summary_diagnostic.zig +202 -0
  218. package/src/types.zig +120 -0
  219. package/src/uiautomator.zig +164 -0
  220. package/src/validation.zig +187 -0
  221. package/src/version.zig +22 -0
  222. package/viewer/app.js +373 -0
  223. package/viewer/index.html +126 -0
  224. package/viewer/parser.js +233 -0
  225. 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,475 @@
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
+ if app.keyboards.firstMatch.exists {
165
+ app.keyboards.buttons["Return"].tap()
166
+ }
167
+ return ok()
168
+ case "swipe":
169
+ guard let x1 = command.x1, let y1 = command.y1, let x2 = command.x2, let y2 = command.y2 else {
170
+ return error("invalid.swipe", "swipe requires x1, y1, x2, and y2")
171
+ }
172
+ let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
173
+ .withOffset(CGVector(dx: x1, dy: y1))
174
+ let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
175
+ .withOffset(CGVector(dx: x2, dy: y2))
176
+ start.press(forDuration: 0.01, thenDragTo: end)
177
+ return ok()
178
+ case "pressBack":
179
+ XCUIDevice.shared.press(.home)
180
+ return ok()
181
+ case "settle":
182
+ let timeout = TimeInterval(command.durationMs ?? 1000) / 1000.0
183
+ _ = app.wait(for: app.state, timeout: timeout)
184
+ return ok()
185
+ case "appState":
186
+ return ["status": "ok", "state": app.state.rawValue]
187
+ case "acceptSystemAlert":
188
+ return acceptSystemAlert(buttonText: command.text ?? "Open")
189
+ default:
190
+ return error("unknown.command", "unsupported command: \(command.cmd)")
191
+ }
192
+ }
193
+
194
+ private func ok() -> [String: Any] {
195
+ ["status": "ok"]
196
+ }
197
+
198
+ private func error(_ code: String, _ message: String) -> [String: Any] {
199
+ ["status": "error", "code": code, "message": message]
200
+ }
201
+
202
+ private func acceptSystemAlert(buttonText: String) -> [String: Any] {
203
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
204
+ var labels = [buttonText, "Open", "Allow", "OK", "Continue"]
205
+ labels = labels.reduce(into: [String]()) { unique, label in
206
+ if !label.isEmpty && !unique.contains(label) {
207
+ unique.append(label)
208
+ }
209
+ }
210
+
211
+ var acceptedCount = 0
212
+ var lastAcceptedLabel = ""
213
+ for _ in 0..<3 {
214
+ var tapped = false
215
+ for label in labels {
216
+ let button = springboard.buttons[label].firstMatch
217
+ if button.waitForExistence(timeout: 2), button.isHittable {
218
+ button.tap()
219
+ acceptedCount += 1
220
+ lastAcceptedLabel = label
221
+ tapped = true
222
+ Thread.sleep(forTimeInterval: 1.0)
223
+ break
224
+ }
225
+ }
226
+ if !tapped {
227
+ break
228
+ }
229
+ }
230
+
231
+ if acceptedCount > 0 {
232
+ return ["status": "ok", "accepted": true, "label": lastAcceptedLabel, "count": acceptedCount]
233
+ }
234
+ return ["status": "ok", "accepted": false, "count": 0]
235
+ }
236
+
237
+ private func tap(selector: String, app: XCUIApplication) -> [String: Any] {
238
+ guard selectorParts(selector) != nil else {
239
+ return error("selector.unsupported", "unsupported selector: \(selector)")
240
+ }
241
+ guard let element = resolveElement(selector: selector, app: app, preferredTypes: [.button]) else {
242
+ return error("selector.not_found", "selector did not match: \(selector)")
243
+ }
244
+ if !element.isHittable {
245
+ return error("selector.not_hittable", "selector matched a non-hittable element: \(selector)")
246
+ }
247
+ element.tap()
248
+ return ok()
249
+ }
250
+
251
+ private func typeText(selector: String, text: String, app: XCUIApplication) -> [String: Any] {
252
+ guard selectorParts(selector) != nil else {
253
+ return error("selector.unsupported", "unsupported selector: \(selector)")
254
+ }
255
+ guard let element = resolveElement(selector: selector, app: app, preferredTypes: [.textField, .secureTextField, .textView]) else {
256
+ return error("selector.not_found", "selector did not match: \(selector)")
257
+ }
258
+ if !element.isHittable {
259
+ return error("selector.not_hittable", "selector matched a non-hittable element: \(selector)")
260
+ }
261
+ element.tap()
262
+ app.typeText(text)
263
+ return ok()
264
+ }
265
+
266
+ private func eraseText(selector: String, count: Int, app: XCUIApplication) -> [String: Any] {
267
+ guard selectorParts(selector) != nil else {
268
+ return error("selector.unsupported", "unsupported selector: \(selector)")
269
+ }
270
+ guard let element = resolveElement(selector: selector, app: app, preferredTypes: [.textField, .secureTextField, .textView]) else {
271
+ return error("selector.not_found", "selector did not match: \(selector)")
272
+ }
273
+ if !element.isHittable {
274
+ return error("selector.not_hittable", "selector matched a non-hittable element: \(selector)")
275
+ }
276
+ element.tap()
277
+ if count > 0 {
278
+ app.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: count))
279
+ }
280
+ return ok()
281
+ }
282
+
283
+ private func resolveElement(selector: String, app: XCUIApplication, preferredTypes: [XCUIElement.ElementType] = []) -> XCUIElement? {
284
+ if let fast = resolveFastElement(selector: selector, app: app, preferredTypes: preferredTypes), fast.exists {
285
+ return fast
286
+ }
287
+
288
+ let matchedElements = app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
289
+ matches(selector: selector, element: element)
290
+ }
291
+ if let preferred = matchedElements.first(where: { preferredTypes.contains($0.elementType) && $0.isHittable }) {
292
+ return preferred
293
+ }
294
+ if let preferred = matchedElements.first(where: { preferredTypes.contains($0.elementType) }) {
295
+ return preferred
296
+ }
297
+ if let hittable = matchedElements.first(where: { $0.isHittable }) {
298
+ return hittable
299
+ }
300
+ return matchedElements.first
301
+ }
302
+
303
+ private func resolveFastElement(selector: String, app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> XCUIElement? {
304
+ guard let parts = selectorParts(selector) else {
305
+ return nil
306
+ }
307
+
308
+ switch parts.field {
309
+ case "text", "label":
310
+ let queries = fastTextQueries(app: app, preferredTypes: preferredTypes)
311
+ if parts.contains {
312
+ let predicate = NSPredicate(format: "label CONTAINS[c] %@", parts.value)
313
+ return firstExistingElement(queries: queries.map { $0.matching(predicate) })
314
+ }
315
+ let predicate = NSPredicate(format: "label == %@", parts.value)
316
+ return firstExistingElement(queries: queries.map { $0.matching(predicate) })
317
+ case "identifier", "resourceId":
318
+ let queries = fastIdentifierQueries(app: app, preferredTypes: preferredTypes, contains: parts.contains)
319
+ if parts.contains {
320
+ let predicate = NSPredicate(format: "identifier CONTAINS[c] %@", parts.value)
321
+ return firstExistingElement(queries: queries.map { $0.matching(predicate) })
322
+ }
323
+ return firstExistingElement(queries: queries.map { $0.matching(identifier: parts.value) })
324
+ case "value":
325
+ let queries = fastTextQueries(app: app, preferredTypes: preferredTypes)
326
+ let predicate = parts.contains
327
+ ? NSPredicate(format: "value CONTAINS[c] %@", parts.value)
328
+ : NSPredicate(format: "value == %@", parts.value)
329
+ return firstExistingElement(queries: queries.map { $0.matching(predicate) })
330
+ case "type", "id":
331
+ return nil
332
+ default:
333
+ return nil
334
+ }
335
+ }
336
+
337
+ private func fastTextQueries(app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> [XCUIElementQuery] {
338
+ var queries: [XCUIElementQuery] = []
339
+ if !preferredTypes.isEmpty {
340
+ queries.append(contentsOf: preferredTypes.map { app.descendants(matching: $0) })
341
+ }
342
+ queries.append(contentsOf: [
343
+ app.buttons,
344
+ app.staticTexts,
345
+ app.textFields,
346
+ app.secureTextFields,
347
+ app.textViews,
348
+ app.images
349
+ ])
350
+ return queries
351
+ }
352
+
353
+ private func fastIdentifierQueries(
354
+ app: XCUIApplication,
355
+ preferredTypes: [XCUIElement.ElementType],
356
+ contains: Bool
357
+ ) -> [XCUIElementQuery] {
358
+ var queries = fastTextQueries(app: app, preferredTypes: preferredTypes)
359
+ if !contains {
360
+ queries.append(app.otherElements)
361
+ }
362
+ return queries
363
+ }
364
+
365
+ private func firstExistingElement(queries: [XCUIElementQuery]) -> XCUIElement? {
366
+ for query in queries {
367
+ let element = query.firstMatch
368
+ if element.exists {
369
+ return element
370
+ }
371
+ }
372
+ return nil
373
+ }
374
+
375
+ private func isFastQueryable(parts: (field: String, value: String, contains: Bool)) -> Bool {
376
+ switch parts.field {
377
+ case "text", "label", "identifier", "resourceId", "value":
378
+ return true
379
+ default:
380
+ return false
381
+ }
382
+ }
383
+
384
+ private func matches(selector: String, element: XCUIElement) -> Bool {
385
+ guard element.exists, let parts = selectorParts(selector) else {
386
+ return false
387
+ }
388
+
389
+ let actual: String
390
+ switch parts.field {
391
+ case "text", "label":
392
+ actual = element.label
393
+ case "identifier", "resourceId":
394
+ actual = element.identifier
395
+ case "id":
396
+ actual = stableId(element: element)
397
+ case "value":
398
+ actual = element.value as? String ?? ""
399
+ case "type":
400
+ actual = String(describing: element.elementType)
401
+ default:
402
+ return false
403
+ }
404
+
405
+ if parts.contains {
406
+ return actual.localizedCaseInsensitiveContains(parts.value)
407
+ }
408
+ return actual == parts.value
409
+ }
410
+
411
+ private func selectorParts(_ selector: String) -> (field: String, value: String, contains: Bool)? {
412
+ let supportedPrefixes = [
413
+ ("textContains=", "text", true),
414
+ ("labelContains=", "label", true),
415
+ ("identifierContains=", "identifier", true),
416
+ ("resourceIdContains=", "resourceId", true),
417
+ ("valueContains=", "value", true),
418
+ ("type=", "type", false),
419
+ ("text=", "text", false),
420
+ ("label=", "label", false),
421
+ ("identifier=", "identifier", false),
422
+ ("resourceId=", "resourceId", false),
423
+ ("id=", "id", false),
424
+ ("value=", "value", false)
425
+ ]
426
+
427
+ for (prefix, field, contains) in supportedPrefixes {
428
+ if selector.hasPrefix(prefix) {
429
+ let value = String(selector.dropFirst(prefix.count))
430
+ return value.isEmpty ? nil : (field, value, contains)
431
+ }
432
+ }
433
+ return nil
434
+ }
435
+
436
+ private func stableId(element: XCUIElement) -> String {
437
+ if !element.identifier.isEmpty {
438
+ return "id:\(element.identifier)"
439
+ }
440
+ if !element.label.isEmpty {
441
+ return "label:\(element.label)"
442
+ }
443
+ return String(describing: element.elementType)
444
+ }
445
+ }
446
+
447
+ enum ZMRShimError: Error {
448
+ case missingEnvironment
449
+ }
450
+
451
+ private extension ZMRShimBounds {
452
+ var json: [String: Any] {
453
+ [
454
+ "x": x,
455
+ "y": y,
456
+ "width": width,
457
+ "height": height
458
+ ]
459
+ }
460
+ }
461
+
462
+ private extension ZMRShimNode {
463
+ var json: [String: Any] {
464
+ [
465
+ "id": id,
466
+ "type": type,
467
+ "label": label,
468
+ "identifier": identifier,
469
+ "bounds": bounds.json,
470
+ "enabled": enabled,
471
+ "visible": visible,
472
+ "selected": selected
473
+ ]
474
+ }
475
+ }