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,432 @@
1
+ package zmr
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "os/exec"
11
+ "sync"
12
+ )
13
+
14
+ type Client struct {
15
+ cmd *exec.Cmd
16
+ stdin io.WriteCloser
17
+ lines *bufio.Scanner
18
+ nextID int64
19
+ mu sync.Mutex
20
+ closed bool
21
+ }
22
+
23
+ type RPCError struct {
24
+ Code int `json:"code"`
25
+ Message string `json:"message"`
26
+ PublicCode string `json:"publicCode,omitempty"`
27
+ Data json.RawMessage `json:"data,omitempty"`
28
+ }
29
+
30
+ func (e *RPCError) Error() string {
31
+ if e.Message == "" {
32
+ return "ZMR JSON-RPC error"
33
+ }
34
+ return e.Message
35
+ }
36
+
37
+ type rpcRequest struct {
38
+ JSONRPC string `json:"jsonrpc"`
39
+ ID int64 `json:"id"`
40
+ Method string `json:"method"`
41
+ Params interface{} `json:"params"`
42
+ }
43
+
44
+ type rpcResponse struct {
45
+ JSONRPC string `json:"jsonrpc"`
46
+ ID int64 `json:"id"`
47
+ Result json.RawMessage `json:"result,omitempty"`
48
+ Error *RPCError `json:"error,omitempty"`
49
+ }
50
+
51
+ type Capabilities struct {
52
+ Name string `json:"name"`
53
+ Version string `json:"version"`
54
+ ProtocolVersion string `json:"protocolVersion"`
55
+ Platforms []string `json:"platforms"`
56
+ PlatformSupport map[string]PlatformSupport `json:"platformSupport"`
57
+ IosPreview bool `json:"iosPreview"`
58
+ Transports []string `json:"transports"`
59
+ Methods []string `json:"methods"`
60
+ }
61
+
62
+ type PlatformSupport struct {
63
+ Status string `json:"status"`
64
+ DeviceTypes []string `json:"deviceTypes"`
65
+ Automation []string `json:"automation"`
66
+ PhysicalDevices bool `json:"physicalDevices,omitempty"`
67
+ }
68
+
69
+ type Session struct {
70
+ SessionID string `json:"sessionId"`
71
+ }
72
+
73
+ type DeviceInfo struct {
74
+ Serial string `json:"serial"`
75
+ State string `json:"state"`
76
+ Ready bool `json:"ready"`
77
+ }
78
+
79
+ type Snapshot struct {
80
+ ID string `json:"id"`
81
+ TimestampMS int64 `json:"timestampMs"`
82
+ Viewport map[string]interface{} `json:"viewport"`
83
+ ActivePackage string `json:"activePackage"`
84
+ ActiveActivity string `json:"activeActivity"`
85
+ Nodes []Node `json:"nodes"`
86
+ }
87
+
88
+ type Node struct {
89
+ StableID string `json:"stableId"`
90
+ ClassName string `json:"className"`
91
+ ResourceID string `json:"resourceId,omitempty"`
92
+ Text *string `json:"text"`
93
+ ContentDesc *string `json:"contentDesc"`
94
+ Bounds map[string]interface{} `json:"bounds"`
95
+ Enabled bool `json:"enabled"`
96
+ Visible bool `json:"visible"`
97
+ Selected bool `json:"selected"`
98
+ }
99
+
100
+ type SemanticSnapshot struct {
101
+ ID string `json:"id"`
102
+ TimestampMS int64 `json:"timestampMs"`
103
+ Viewport map[string]any `json:"viewport"`
104
+ ActivePackage string `json:"activePackage"`
105
+ ActiveActivity string `json:"activeActivity"`
106
+ FocusedNodeID *string `json:"focusedNodeId"`
107
+ Nodes []SemanticNode `json:"nodes"`
108
+ Summary struct {
109
+ NodeCount int `json:"nodeCount"`
110
+ InteractiveCount int `json:"interactiveCount"`
111
+ VisibleText []string `json:"visibleText"`
112
+ } `json:"summary"`
113
+ }
114
+
115
+ type SemanticNode struct {
116
+ ID string `json:"id"`
117
+ Role string `json:"role"`
118
+ Name string `json:"name"`
119
+ Selector map[string]string `json:"selector"`
120
+ Source map[string]interface{} `json:"source"`
121
+ Bounds map[string]interface{} `json:"bounds"`
122
+ Enabled bool `json:"enabled"`
123
+ Visible bool `json:"visible"`
124
+ Selected bool `json:"selected"`
125
+ Interactive bool `json:"interactive"`
126
+ RecommendedAction *string `json:"recommendedAction"`
127
+ }
128
+
129
+ type TraceEvents struct {
130
+ TraceDir string `json:"traceDir"`
131
+ AfterSeq int64 `json:"afterSeq"`
132
+ NextSeq int64 `json:"nextSeq"`
133
+ LatestSeq int64 `json:"latestSeq"`
134
+ Events []map[string]interface{} `json:"events"`
135
+ }
136
+
137
+ type TraceExport struct {
138
+ TraceDir string `json:"traceDir"`
139
+ Out string `json:"out"`
140
+ Redacted bool `json:"redacted"`
141
+ OmitScreenshots bool `json:"omitScreenshots"`
142
+ }
143
+
144
+ func Start(ctx context.Context, command string, args ...string) (*Client, error) {
145
+ cmd := exec.CommandContext(ctx, command, args...)
146
+ stdin, err := cmd.StdinPipe()
147
+ if err != nil {
148
+ return nil, err
149
+ }
150
+ stdout, err := cmd.StdoutPipe()
151
+ if err != nil {
152
+ return nil, err
153
+ }
154
+ if err := cmd.Start(); err != nil {
155
+ return nil, err
156
+ }
157
+ scanner := bufio.NewScanner(stdout)
158
+ scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
159
+ return &Client{cmd: cmd, stdin: stdin, lines: scanner, nextID: 1}, nil
160
+ }
161
+
162
+ func (c *Client) Close() error {
163
+ c.mu.Lock()
164
+ if c.closed {
165
+ c.mu.Unlock()
166
+ return nil
167
+ }
168
+ c.closed = true
169
+ stdin := c.stdin
170
+ cmd := c.cmd
171
+ c.mu.Unlock()
172
+
173
+ _ = stdin.Close()
174
+ if cmd.Process != nil {
175
+ _ = cmd.Process.Kill()
176
+ }
177
+ return cmd.Wait()
178
+ }
179
+
180
+ func (c *Client) Request(ctx context.Context, method string, params interface{}, out interface{}) error {
181
+ c.mu.Lock()
182
+ defer c.mu.Unlock()
183
+ if c.closed {
184
+ return errors.New("zmr client is closed")
185
+ }
186
+ id := c.nextID
187
+ c.nextID++
188
+ request := rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
189
+ line, err := json.Marshal(request)
190
+ if err != nil {
191
+ return err
192
+ }
193
+ if _, err := c.stdin.Write(append(line, '\n')); err != nil {
194
+ return err
195
+ }
196
+
197
+ type responseResult struct {
198
+ line string
199
+ ok bool
200
+ }
201
+ responseCh := make(chan responseResult, 1)
202
+ go func() {
203
+ ok := c.lines.Scan()
204
+ responseCh <- responseResult{line: c.lines.Text(), ok: ok}
205
+ }()
206
+
207
+ select {
208
+ case <-ctx.Done():
209
+ return ctx.Err()
210
+ case response := <-responseCh:
211
+ if !response.ok {
212
+ if err := c.lines.Err(); err != nil {
213
+ return err
214
+ }
215
+ return io.EOF
216
+ }
217
+ var decoded rpcResponse
218
+ if err := json.Unmarshal([]byte(response.line), &decoded); err != nil {
219
+ return err
220
+ }
221
+ if decoded.ID != id {
222
+ return fmt.Errorf("unexpected JSON-RPC response id %d", decoded.ID)
223
+ }
224
+ if decoded.Error != nil {
225
+ return decoded.Error
226
+ }
227
+ if out == nil {
228
+ return nil
229
+ }
230
+ return json.Unmarshal(decoded.Result, out)
231
+ }
232
+ }
233
+
234
+ func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) {
235
+ var out Capabilities
236
+ err := c.Request(ctx, "runner.capabilities", map[string]interface{}{}, &out)
237
+ return out, err
238
+ }
239
+
240
+ func (c *Client) CreateSession(ctx context.Context) (Session, error) {
241
+ var out Session
242
+ err := c.Request(ctx, "session.create", map[string]interface{}{}, &out)
243
+ return out, err
244
+ }
245
+
246
+ func (c *Client) CloseSession(ctx context.Context) (bool, error) {
247
+ var out bool
248
+ err := c.Request(ctx, "session.close", map[string]interface{}{}, &out)
249
+ return out, err
250
+ }
251
+
252
+ func (c *Client) Devices(ctx context.Context) ([]DeviceInfo, error) {
253
+ var out []DeviceInfo
254
+ err := c.Request(ctx, "device.list", map[string]interface{}{}, &out)
255
+ return out, err
256
+ }
257
+
258
+ func (c *Client) Launch(ctx context.Context) (bool, error) {
259
+ var out bool
260
+ err := c.Request(ctx, "app.launch", map[string]interface{}{}, &out)
261
+ return out, err
262
+ }
263
+
264
+ func (c *Client) Stop(ctx context.Context) (bool, error) {
265
+ var out bool
266
+ err := c.Request(ctx, "app.stop", map[string]interface{}{}, &out)
267
+ return out, err
268
+ }
269
+
270
+ func (c *Client) ClearState(ctx context.Context) (bool, error) {
271
+ var out bool
272
+ err := c.Request(ctx, "app.clearState", map[string]interface{}{}, &out)
273
+ return out, err
274
+ }
275
+
276
+ func (c *Client) OpenLink(ctx context.Context, url string) (bool, error) {
277
+ var out bool
278
+ err := c.Request(ctx, "app.openLink", map[string]interface{}{"url": url}, &out)
279
+ return out, err
280
+ }
281
+
282
+ func (c *Client) Snapshot(ctx context.Context) (Snapshot, error) {
283
+ var out Snapshot
284
+ err := c.Request(ctx, "observe.snapshot", map[string]interface{}{}, &out)
285
+ return out, err
286
+ }
287
+
288
+ func (c *Client) SemanticSnapshot(ctx context.Context) (SemanticSnapshot, error) {
289
+ var out SemanticSnapshot
290
+ err := c.Request(ctx, "observe.semanticSnapshot", map[string]interface{}{}, &out)
291
+ return out, err
292
+ }
293
+
294
+ func (c *Client) Tap(ctx context.Context, selector map[string]interface{}) (bool, error) {
295
+ var out bool
296
+ err := c.Request(ctx, "ui.tap", map[string]interface{}{"selector": selector}, &out)
297
+ return out, err
298
+ }
299
+
300
+ func (c *Client) TypeText(ctx context.Context, text string, selector map[string]interface{}) (bool, error) {
301
+ var out bool
302
+ params := map[string]interface{}{"text": text}
303
+ if selector != nil {
304
+ params["selector"] = selector
305
+ }
306
+ err := c.Request(ctx, "ui.type", params, &out)
307
+ return out, err
308
+ }
309
+
310
+ func (c *Client) EraseText(ctx context.Context, selector map[string]interface{}, maxChars int64) (bool, error) {
311
+ var out bool
312
+ params := map[string]interface{}{}
313
+ if selector != nil {
314
+ params["selector"] = selector
315
+ }
316
+ if maxChars > 0 {
317
+ params["maxChars"] = maxChars
318
+ }
319
+ err := c.Request(ctx, "ui.eraseText", params, &out)
320
+ return out, err
321
+ }
322
+
323
+ func (c *Client) HideKeyboard(ctx context.Context) (bool, error) {
324
+ var out bool
325
+ err := c.Request(ctx, "ui.hideKeyboard", map[string]interface{}{}, &out)
326
+ return out, err
327
+ }
328
+
329
+ func (c *Client) Swipe(ctx context.Context, x1 int64, y1 int64, x2 int64, y2 int64, durationMS int64) (bool, error) {
330
+ var out bool
331
+ params := map[string]interface{}{"x1": x1, "y1": y1, "x2": x2, "y2": y2}
332
+ if durationMS > 0 {
333
+ params["durationMs"] = durationMS
334
+ }
335
+ err := c.Request(ctx, "ui.swipe", params, &out)
336
+ return out, err
337
+ }
338
+
339
+ func (c *Client) PressBack(ctx context.Context) (bool, error) {
340
+ var out bool
341
+ err := c.Request(ctx, "ui.pressBack", map[string]interface{}{}, &out)
342
+ return out, err
343
+ }
344
+
345
+ func (c *Client) ScrollUntilVisible(ctx context.Context, selector map[string]interface{}, direction string, timeoutMS int64) (bool, error) {
346
+ var out bool
347
+ params := map[string]interface{}{"selector": selector}
348
+ if direction != "" {
349
+ params["direction"] = direction
350
+ }
351
+ if timeoutMS > 0 {
352
+ params["timeoutMs"] = timeoutMS
353
+ }
354
+ err := c.Request(ctx, "ui.scrollUntilVisible", params, &out)
355
+ return out, err
356
+ }
357
+
358
+ func (c *Client) WaitUntil(ctx context.Context, selector map[string]interface{}, timeoutMS int64) (bool, error) {
359
+ var out bool
360
+ params := map[string]interface{}{"visible": selector}
361
+ if timeoutMS > 0 {
362
+ params["timeoutMs"] = timeoutMS
363
+ }
364
+ err := c.Request(ctx, "wait.until", params, &out)
365
+ return out, err
366
+ }
367
+
368
+ func (c *Client) WaitAny(ctx context.Context, selectors []map[string]interface{}, timeoutMS int64) (bool, error) {
369
+ var out bool
370
+ params := map[string]interface{}{"selectors": selectors}
371
+ if timeoutMS > 0 {
372
+ params["timeoutMs"] = timeoutMS
373
+ }
374
+ err := c.Request(ctx, "wait.any", params, &out)
375
+ return out, err
376
+ }
377
+
378
+ func (c *Client) WaitGone(ctx context.Context, selector map[string]interface{}, timeoutMS int64) (bool, error) {
379
+ var out bool
380
+ params := map[string]interface{}{"selector": selector}
381
+ if timeoutMS > 0 {
382
+ params["timeoutMs"] = timeoutMS
383
+ }
384
+ err := c.Request(ctx, "wait.gone", params, &out)
385
+ return out, err
386
+ }
387
+
388
+ func (c *Client) AssertVisible(ctx context.Context, selector map[string]interface{}, timeoutMS int64) (bool, error) {
389
+ var out bool
390
+ params := map[string]interface{}{"selector": selector}
391
+ if timeoutMS > 0 {
392
+ params["timeoutMs"] = timeoutMS
393
+ }
394
+ err := c.Request(ctx, "assert.visible", params, &out)
395
+ return out, err
396
+ }
397
+
398
+ func (c *Client) AssertNotVisible(ctx context.Context, selector map[string]interface{}, timeoutMS int64) (bool, error) {
399
+ var out bool
400
+ params := map[string]interface{}{"selector": selector}
401
+ if timeoutMS > 0 {
402
+ params["timeoutMs"] = timeoutMS
403
+ }
404
+ err := c.Request(ctx, "assert.notVisible", params, &out)
405
+ return out, err
406
+ }
407
+
408
+ func (c *Client) AssertHealthy(ctx context.Context, timeoutMS int64) (bool, error) {
409
+ var out bool
410
+ params := map[string]interface{}{}
411
+ if timeoutMS > 0 {
412
+ params["timeoutMs"] = timeoutMS
413
+ }
414
+ err := c.Request(ctx, "assert.healthy", params, &out)
415
+ return out, err
416
+ }
417
+
418
+ func (c *Client) ExportTrace(ctx context.Context, outPath string, redact bool, omitScreenshots bool) (TraceExport, error) {
419
+ var out TraceExport
420
+ err := c.Request(ctx, "trace.export", map[string]interface{}{"out": outPath, "redact": redact, "omitScreenshots": omitScreenshots}, &out)
421
+ return out, err
422
+ }
423
+
424
+ func (c *Client) TraceEvents(ctx context.Context, afterSeq int64, limit int64) (TraceEvents, error) {
425
+ var out TraceEvents
426
+ params := map[string]interface{}{"afterSeq": afterSeq}
427
+ if limit > 0 {
428
+ params["limit"] = limit
429
+ }
430
+ err := c.Request(ctx, "trace.events", params, &out)
431
+ return out, err
432
+ }
@@ -0,0 +1,35 @@
1
+ # ZMR Kotlin Client
2
+
3
+ Small JVM client for Kotlin agents and test harnesses that drive
4
+ `zmr serve --transport stdio`.
5
+
6
+ For now, build it from a local checkout and consume the generated jar:
7
+
8
+ ```bash
9
+ git submodule add https://github.com/johnmikel/zig-mobile-runner.git vendor/zig-mobile-runner
10
+ gradle -p vendor/zig-mobile-runner/clients/kotlin build
11
+ ```
12
+
13
+ Run the package test from the repository root:
14
+
15
+ ```bash
16
+ gradle -p clients/kotlin test
17
+ ```
18
+
19
+ Run the fake-session example from the repository root:
20
+
21
+ ```bash
22
+ gradle -p clients/kotlin runFakeSession \
23
+ -Pzmr="$PWD/zig-out/bin/zmr" \
24
+ -Padb="$PWD/tests/fake-adb.sh" \
25
+ -PtraceDir="$PWD/traces/demo-kotlin-client" \
26
+ -PtraceOut="$PWD/traces/demo-kotlin-client-redacted.zmrtrace"
27
+ ```
28
+
29
+ ```kotlin
30
+ implementation(files("path/to/zig-mobile-runner/clients/kotlin/build/libs/zmr-client-0.1.0.jar"))
31
+ ```
32
+
33
+ The Kotlin client is host-side. It is useful for Android teams that want test
34
+ or agent tooling in Kotlin, but it still controls the app through the local
35
+ `zmr` binary rather than running inside the app process.
@@ -0,0 +1,35 @@
1
+ plugins {
2
+ kotlin("jvm") version "2.0.21"
3
+ `java-library`
4
+ }
5
+
6
+ group = "dev.zmr"
7
+ version = "0.1.0"
8
+
9
+ kotlin {
10
+ jvmToolchain(17)
11
+ }
12
+
13
+ dependencies {
14
+ testImplementation(kotlin("test"))
15
+ }
16
+
17
+ tasks.test {
18
+ useJUnitPlatform()
19
+ }
20
+
21
+ tasks.register<JavaExec>("runFakeSession") {
22
+ group = "application"
23
+ description = "Run the fake ZMR JSON-RPC session example."
24
+ dependsOn(tasks.named("classes"))
25
+ classpath = sourceSets["main"].runtimeClasspath
26
+ mainClass.set("dev.zmr.FakeSessionKt")
27
+ args = listOf(
28
+ "--zmr", providers.gradleProperty("zmr").orElse("zig-out/bin/zmr").get(),
29
+ "--adb", providers.gradleProperty("adb").orElse("tests/fake-adb.sh").get(),
30
+ "--device", providers.gradleProperty("device").orElse("fake-android-1").get(),
31
+ "--app-id", providers.gradleProperty("appId").orElse("com.example.mobiletest").get(),
32
+ "--trace-dir", providers.gradleProperty("traceDir").orElse("traces/demo-kotlin-client").get(),
33
+ "--trace-out", providers.gradleProperty("traceOut").orElse("traces/demo-kotlin-client-redacted.zmrtrace").get()
34
+ )
35
+ }
@@ -0,0 +1,15 @@
1
+ pluginManagement {
2
+ repositories {
3
+ mavenCentral()
4
+ gradlePluginPortal()
5
+ }
6
+ }
7
+
8
+ dependencyResolutionManagement {
9
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10
+ repositories {
11
+ mavenCentral()
12
+ }
13
+ }
14
+
15
+ rootProject.name = "zmr-client"
@@ -0,0 +1,86 @@
1
+ package dev.zmr
2
+
3
+ private data class Options(
4
+ var zmr: String = "zig-out/bin/zmr",
5
+ var adb: String = "tests/fake-adb.sh",
6
+ var device: String = "fake-android-1",
7
+ var appId: String = "com.example.mobiletest",
8
+ var traceDir: String = "traces/demo-kotlin-client",
9
+ var traceOut: String = "traces/demo-kotlin-client-redacted.zmrtrace"
10
+ )
11
+
12
+ fun main(args: Array<String>) {
13
+ val options = parseOptions(args)
14
+ ZmrClient(
15
+ listOf(
16
+ options.zmr,
17
+ "serve",
18
+ "--transport", "stdio",
19
+ "--device", options.device,
20
+ "--app-id", options.appId,
21
+ "--adb", options.adb,
22
+ "--trace-dir", options.traceDir
23
+ )
24
+ ).use { client ->
25
+ val capabilities = client.call("runner.capabilities")
26
+ client.createSession()
27
+ client.call("app.openLink", """{"url":"exampleapp://kotlin-client"}""")
28
+ client.call("wait.until", """{"visible":{"text":"Dashboard"},"timeoutMs":1000}""")
29
+ client.call("ui.tap", """{"selector":{"text":"Sign in"}}""")
30
+ client.call("ui.type", """{"text":"agent@example.com","selector":{"resourceId":"email-login-email-input"}}""")
31
+ client.call("assert.notVisible", """{"selector":{"text":"Application has crashed"},"timeoutMs":100}""")
32
+ client.assertHealthy(timeoutMs = 100)
33
+ val snapshot = client.snapshot()
34
+ val exported = client.call(
35
+ "trace.export",
36
+ """{"out":"${escapeJson(options.traceOut)}","redact":true,"includeScreenshots":true}"""
37
+ )
38
+ val events = client.call("trace.events", """{"afterSeq":0,"limit":10}""")
39
+
40
+ println(
41
+ "{" +
42
+ "\"protocolVersion\":\"${extractString(capabilities, "protocolVersion")}\"," +
43
+ "\"activePackage\":\"${extractString(snapshot, "activePackage")}\"," +
44
+ "\"nodes\":${countOccurrences(snapshot, "\"stableId\"")}," +
45
+ "\"events\":${extractNumber(events, "nextSeq")}," +
46
+ "\"traceDir\":\"${escapeJson(options.traceDir)}\"," +
47
+ "\"traceOut\":\"${escapeJson(extractString(exported, "out"))}\"" +
48
+ "}"
49
+ )
50
+ }
51
+ }
52
+
53
+ private fun parseOptions(args: Array<String>): Options {
54
+ val options = Options()
55
+ var index = 0
56
+ while (index < args.size) {
57
+ val value = args.getOrNull(index + 1) ?: error("${args[index]} requires a value")
58
+ when (args[index]) {
59
+ "--zmr" -> options.zmr = value
60
+ "--adb" -> options.adb = value
61
+ "--device" -> options.device = value
62
+ "--app-id" -> options.appId = value
63
+ "--trace-dir" -> options.traceDir = value
64
+ "--trace-out" -> options.traceOut = value
65
+ else -> error("unknown argument: ${args[index]}")
66
+ }
67
+ index += 2
68
+ }
69
+ return options
70
+ }
71
+
72
+ private fun extractString(json: String, key: String): String {
73
+ val match = Regex(""""$key"\s*:\s*"([^"]*)"""").find(json)
74
+ return match?.groupValues?.get(1) ?: ""
75
+ }
76
+
77
+ private fun extractNumber(json: String, key: String): String {
78
+ val match = Regex(""""$key"\s*:\s*([0-9]+)""").find(json)
79
+ return match?.groupValues?.get(1) ?: "0"
80
+ }
81
+
82
+ private fun countOccurrences(value: String, needle: String): Int =
83
+ Regex.escape(needle).toRegex().findAll(value).count()
84
+
85
+ private fun escapeJson(value: String): String =
86
+ value.replace("\\", "\\\\").replace("\"", "\\\"")
@@ -0,0 +1,67 @@
1
+ package dev.zmr
2
+
3
+ import java.io.BufferedReader
4
+ import java.io.BufferedWriter
5
+ import java.io.Closeable
6
+ import java.io.InputStreamReader
7
+ import java.io.OutputStreamWriter
8
+
9
+ class ZmrRpcException(
10
+ val code: Int,
11
+ message: String,
12
+ val publicCode: String? = null
13
+ ) : RuntimeException(message)
14
+
15
+ class ZmrClient(
16
+ private val command: List<String> = listOf("zmr", "serve", "--transport", "stdio")
17
+ ) : Closeable {
18
+ private var nextId = 1
19
+ private val process = ProcessBuilder(command).redirectError(ProcessBuilder.Redirect.INHERIT).start()
20
+ private val input = BufferedWriter(OutputStreamWriter(process.outputStream))
21
+ private val output = BufferedReader(InputStreamReader(process.inputStream))
22
+
23
+ fun createSession(): String = call("session.create")
24
+
25
+ fun snapshot(): String = call("observe.snapshot")
26
+
27
+ fun semanticSnapshot(): String = call("observe.semanticSnapshot")
28
+
29
+ fun assertHealthy(timeoutMs: Long? = null): String {
30
+ val params = timeoutMs?.let { "{\"timeoutMs\":$it}" } ?: "{}"
31
+ return call("assert.healthy", params)
32
+ }
33
+
34
+ @Synchronized
35
+ fun call(method: String, paramsJson: String? = null): String {
36
+ val id = nextId++
37
+ val params = paramsJson?.let { "," + "\"params\":" + it } ?: ""
38
+ input.write("{\"jsonrpc\":\"2.0\",\"id\":$id,\"method\":\"$method\"$params}")
39
+ input.newLine()
40
+ input.flush()
41
+ val response = output.readLine() ?: error("zmr closed stdout")
42
+ if (response.contains(""""error"""")) {
43
+ throw ZmrRpcException(
44
+ code = extractNumber(response, "code") ?: -32000,
45
+ message = extractString(response, "message").ifEmpty { "ZMR JSON-RPC error" },
46
+ publicCode = extractString(response, "publicCode").ifEmpty { null }
47
+ )
48
+ }
49
+ return response
50
+ }
51
+
52
+ override fun close() {
53
+ runCatching { call("session.close") }
54
+ runCatching { input.close() }
55
+ process.destroy()
56
+ }
57
+ }
58
+
59
+ private fun extractString(json: String, key: String): String {
60
+ val pattern = """"$key"\s*:\s*"([^"]*)"""".toRegex()
61
+ return pattern.find(json)?.groupValues?.get(1) ?: ""
62
+ }
63
+
64
+ private fun extractNumber(json: String, key: String): Int? {
65
+ val pattern = """"$key"\s*:\s*(-?[0-9]+)""".toRegex()
66
+ return pattern.find(json)?.groupValues?.get(1)?.toIntOrNull()
67
+ }