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,74 @@
1
+ # iOS Shim Protocol
2
+
3
+ The iOS shim protocol is internal and may change before `v1.0.0`.
4
+
5
+ Commands are newline-delimited JSON objects:
6
+
7
+ ```json
8
+ {"cmd":"snapshot"}
9
+ {"cmd":"tap","selector":"text=Continue","x":20,"y":40}
10
+ {"cmd":"type","selector":"identifier=email","text":"hello"}
11
+ {"cmd":"eraseText","selector":"identifier=email","maxChars":20}
12
+ {"cmd":"hideKeyboard"}
13
+ {"cmd":"swipe","x1":300,"y1":900,"x2":300,"y2":300,"durationMs":250}
14
+ {"cmd":"pressBack"}
15
+ {"cmd":"settle","durationMs":1000}
16
+ {"cmd":"appState"}
17
+ {"cmd":"acceptSystemAlert","text":"Open"}
18
+ {"cmd":"query","selector":"textContains=Continue"}
19
+ {"cmd":"screenshot"}
20
+ ```
21
+
22
+ Selector-addressed `tap`, `type`, and `eraseText` support these internal
23
+ selector strings:
24
+
25
+ - `text=<exact>` and `textContains=<substring>`
26
+ - `label=<exact>` and `labelContains=<substring>`
27
+ - `identifier=<exact>` and `identifierContains=<substring>`
28
+ - `resourceId=<exact>` and `resourceIdContains=<substring>`
29
+ - `id=<stable snapshot id>`
30
+ - `value=<exact>` and `valueContains=<substring>`
31
+ - `type=<XCUIElementType...>`
32
+
33
+ `query` is the fast path used by Zig waits/assertions for single-field selectors
34
+ that XCTest can evaluate natively. It returns:
35
+
36
+ ```json
37
+ {"status":"ok","exists":true,"hittable":true}
38
+ ```
39
+
40
+ `screenshot` returns a PNG payload encoded for the local shim transport:
41
+
42
+ ```json
43
+ {"status":"ok","format":"png","base64":"..."}
44
+ ```
45
+
46
+ Snapshot responses return bounded XCTest element data in a shape Zig can map
47
+ into `UiNode`. The shim captures common interactive and readable element
48
+ families and caps the response at 256 nodes so large application trees do not
49
+ turn every snapshot into a full hierarchy crawl:
50
+
51
+ ```json
52
+ {
53
+ "status": "ok",
54
+ "nodes": [
55
+ {
56
+ "id": "button-continue",
57
+ "type": "XCUIElementTypeButton",
58
+ "label": "Continue",
59
+ "value": "Continue",
60
+ "identifier": "continue_button",
61
+ "bounds": { "x": 10, "y": 20, "width": 100, "height": 44 },
62
+ "enabled": true,
63
+ "visible": true,
64
+ "selected": false
65
+ }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ Errors use a stable envelope:
71
+
72
+ ```json
73
+ {"status":"error","code":"selector.timeout","message":"selector did not match"}
74
+ ```
@@ -0,0 +1,127 @@
1
+ ---
2
+ name: zmr-mobile-testing
3
+ description: Use when testing mobile apps with Zig Mobile Runner, integrating app-local .zmr setup, driving Android or iOS simulator scenarios, using JSON-RPC or MCP agent sessions, exporting traces, or comparing mobile runner benchmarks.
4
+ ---
5
+
6
+ # ZMR Mobile Testing
7
+
8
+ Use ZMR as the typed control plane for mobile app testing. Keep model reasoning
9
+ outside the runner; use ZMR for device discovery, observations, actions, waits,
10
+ assertions, traces, and diagnostics.
11
+
12
+ ## Start From App-Local State
13
+
14
+ 1. Look for `.zmr/config.json` in the app checkout.
15
+ 2. If it is missing, scaffold it:
16
+
17
+ ```bash
18
+ npx zmr-wizard --app-id com.example.mobiletest --package-json
19
+ ```
20
+
21
+ 3. Run setup diagnostics before touching a device:
22
+
23
+ ```bash
24
+ zmr doctor --json --config .zmr/config.json
25
+ zmr validate --json .zmr/android-smoke.json
26
+ zmr validate --json .zmr/ios-smoke.json
27
+ ```
28
+
29
+ Use `zmr doctor --strict --json` for CI-style gates.
30
+
31
+ ## Agent Session Pattern
32
+
33
+ Prefer JSON-RPC over stdio for interactive agent work:
34
+
35
+ ```bash
36
+ zmr serve --transport stdio --config .zmr/config.json --trace-dir traces/zmr-agent
37
+ ```
38
+
39
+ Call methods in this order:
40
+
41
+ 1. `runner.capabilities`
42
+ 2. `session.create`
43
+ 3. `observe.semanticSnapshot` for planning, or `observe.snapshot` for raw adapter data
44
+ 4. one typed action, wait, or assertion
45
+ 5. `observe.semanticSnapshot`
46
+ 6. `trace.events` while the session is active
47
+ 7. `trace.export` with redaction enabled
48
+ 8. `session.close`
49
+
50
+ Do not scrape terminal output when CLI JSON, snapshots, action results, or trace
51
+ events contain the same information.
52
+
53
+ For MCP-capable agents, start:
54
+
55
+ ```bash
56
+ zmr mcp --config .zmr/config.json --trace-dir traces/zmr-agent
57
+ ```
58
+
59
+ Use the `semantic_snapshot`, `tap`, `type`, `wait_visible`, `trace_events`, and
60
+ `trace_export` tools. Prefer `semantic_snapshot` because it normalizes Android
61
+ and iOS hierarchy classes into roles, selectors, bounds, and recommended
62
+ actions.
63
+
64
+ ## Scenario Pattern
65
+
66
+ For repeatable tests, edit `.zmr/*.json` scenarios and run:
67
+
68
+ ```bash
69
+ zmr validate --json .zmr/<scenario>.json
70
+ zmr run .zmr/<scenario>.json --json --trace-dir traces/zmr-<scenario>
71
+ zmr explain --json traces/zmr-<scenario>
72
+ ```
73
+
74
+ Prefer stable selectors: resource id or accessibility identifier first,
75
+ content description/accessibility label second, exact text third, textContains
76
+ only when copy varies, coordinates last.
77
+
78
+ Use `waitAny` for valid branches and `whenVisible` for optional screens. Keep
79
+ credentials, private app terms, and private traces out of public docs and
80
+ examples.
81
+
82
+ ## Trace Handling
83
+
84
+ When a run fails, inspect `zmr explain --json`, `events.jsonl`, the final
85
+ snapshot, and the trace viewer report from `zmr report`.
86
+
87
+ Before sharing:
88
+
89
+ ```bash
90
+ zmr export traces/zmr-<scenario> --out traces/zmr-<scenario>-redacted.zmrtrace --redact
91
+ ```
92
+
93
+ Add `--omit-screenshots` if visual artifacts may contain sensitive data.
94
+
95
+ ## Release And Claim Guard
96
+
97
+ Before reporting that ZMR is ready for a release, production use, or a market
98
+ comparison, ask the runner to evaluate evidence instead of inferring from test
99
+ passes:
100
+
101
+ ```bash
102
+ zmr-release-readiness --json \
103
+ --evidence traces/release-candidate/<run>/evidence.jsonl \
104
+ --target dev-preview
105
+ ```
106
+
107
+ For production or market claims, include app-local pilot and benchmark evidence
108
+ with additional `--evidence` arguments. Read `satisfied` for proven requirements
109
+ and `blocked`, `missing`, `insufficient`, `failed`, and `planned` for remaining
110
+ work. Use `recommendedWording` as the release summary and respect
111
+ `claimLimitations`; do not infer stronger claims from `passed` alone or upgrade
112
+ a dev-preview result into a production-stable or competitive claim. When
113
+ blocked, execute `nextSteps[].commands` in order and use `nextSteps[].covers`
114
+ to understand which blocked requirements each step resolves.
115
+
116
+ ## Benchmarks
117
+
118
+ Use ZMR repeated runs:
119
+
120
+ ```bash
121
+ zmr-benchmark --zmr .zmr/android-smoke.json --device emulator-5554 --runs 20 --trace-root traces/zmr-android-reliability --min-pass-rate 100 --max-failures 0
122
+ ```
123
+
124
+ Use `zmr-benchmark-command` for any app-local baseline command and
125
+ `zmr-compare-benchmarks` for reports. Only claim performance wins from
126
+ equivalent app paths, same device state, repeated runs, and trace-backed
127
+ failure diagnostics.
@@ -0,0 +1,344 @@
1
+ const std = @import("std");
2
+ const command = @import("command.zig");
3
+ const android_device_info = @import("android_device_info.zig");
4
+ const android_shell = @import("android_shell.zig");
5
+ const android_screen_recording = @import("android_screen_recording.zig");
6
+ const ios_shim = @import("ios_shim.zig");
7
+ const trace = @import("trace.zig");
8
+ const types = @import("types.zig");
9
+ const uiautomator = @import("uiautomator.zig");
10
+
11
+ const default_max_output = 32 * 1024 * 1024;
12
+ const default_adb_timeout_ms = 15_000;
13
+ const install_adb_timeout_ms = 120_000;
14
+ const shim_timeout_ms = 5_000;
15
+ const open_link_attempts = 3;
16
+ const open_link_retry_delay_ms = 500;
17
+
18
+ pub const AndroidDevice = struct {
19
+ allocator: std.mem.Allocator,
20
+ adb_path: []const u8 = "adb",
21
+ serial: ?[]const u8 = null,
22
+ app_id: []const u8,
23
+ shim_path: ?[]const u8 = null,
24
+
25
+ pub fn init(
26
+ allocator: std.mem.Allocator,
27
+ adb_path: []const u8,
28
+ serial: ?[]const u8,
29
+ app_id: []const u8,
30
+ ) !AndroidDevice {
31
+ return try initWithShim(allocator, adb_path, serial, app_id, null);
32
+ }
33
+
34
+ pub fn initWithShim(
35
+ allocator: std.mem.Allocator,
36
+ adb_path: []const u8,
37
+ serial: ?[]const u8,
38
+ app_id: []const u8,
39
+ shim_path: ?[]const u8,
40
+ ) !AndroidDevice {
41
+ return .{
42
+ .allocator = allocator,
43
+ .adb_path = try allocator.dupe(u8, adb_path),
44
+ .serial = try types.dupeOptional(allocator, serial),
45
+ .app_id = try allocator.dupe(u8, app_id),
46
+ .shim_path = try types.dupeOptional(allocator, shim_path),
47
+ };
48
+ }
49
+
50
+ pub fn deinit(self: *AndroidDevice) void {
51
+ self.allocator.free(self.adb_path);
52
+ if (self.serial) |value| self.allocator.free(value);
53
+ self.allocator.free(self.app_id);
54
+ if (self.shim_path) |value| self.allocator.free(value);
55
+ }
56
+
57
+ pub fn listDevices(self: *AndroidDevice) ![]types.DeviceInfo {
58
+ return try android_device_info.listDevices(self.allocator, self.adb_path);
59
+ }
60
+
61
+ pub fn install(self: *AndroidDevice, apk_path: []const u8) !void {
62
+ const result = try self.runAdbWithTimeout(&.{ "install", "-r", apk_path }, default_max_output, install_adb_timeout_ms);
63
+ defer result.deinit(self.allocator);
64
+ try result.ensureSuccess();
65
+ }
66
+
67
+ pub fn launch(self: *AndroidDevice) !void {
68
+ const result = try self.runAdb(&.{ "shell", "monkey", "-p", self.app_id, "-c", "android.intent.category.LAUNCHER", "1" }, default_max_output);
69
+ defer result.deinit(self.allocator);
70
+ try result.ensureSuccess();
71
+ }
72
+
73
+ pub fn stop(self: *AndroidDevice) !void {
74
+ const result = try self.runAdb(&.{ "shell", "am", "force-stop", self.app_id }, default_max_output);
75
+ defer result.deinit(self.allocator);
76
+ try result.ensureSuccess();
77
+ }
78
+
79
+ pub fn clearState(self: *AndroidDevice) !void {
80
+ const result = try self.runAdb(&.{ "shell", "pm", "clear", self.app_id }, default_max_output);
81
+ defer result.deinit(self.allocator);
82
+ try result.ensureSuccess();
83
+ }
84
+
85
+ pub fn openLink(self: *AndroidDevice, url: []const u8) !void {
86
+ var args = try android_shell.openLinkIntent(self.allocator, url, self.app_id);
87
+ defer args.deinit();
88
+ var attempt: usize = 0;
89
+ while (attempt < open_link_attempts) : (attempt += 1) {
90
+ const result = try self.runAdb(args.items(), default_max_output);
91
+ defer result.deinit(self.allocator);
92
+ try result.ensureSuccess();
93
+
94
+ if (self.isAppForeground() catch false) return;
95
+ if (attempt + 1 < open_link_attempts) {
96
+ std.Thread.sleep(open_link_retry_delay_ms * std.time.ns_per_ms);
97
+ }
98
+ }
99
+ return error.AppDidNotOpen;
100
+ }
101
+
102
+ pub fn tap(self: *AndroidDevice, x: i32, y: i32) !void {
103
+ if (self.shim_path != null) return try self.runShimAction(.{ .kind = .tap, .x = x, .y = y });
104
+ var args = try android_shell.tap(self.allocator, x, y);
105
+ defer args.deinit();
106
+ const result = try self.runAdb(args.items(), default_max_output);
107
+ defer result.deinit(self.allocator);
108
+ try result.ensureSuccess();
109
+ }
110
+
111
+ pub fn typeText(self: *AndroidDevice, text: []const u8) !void {
112
+ if (self.shim_path != null) return try self.runShimAction(.{ .kind = .type_text, .text = text });
113
+ var args = try android_shell.typeText(self.allocator, text);
114
+ defer args.deinit();
115
+ const result = try self.runAdb(args.items(), default_max_output);
116
+ defer result.deinit(self.allocator);
117
+ try result.ensureSuccess();
118
+ }
119
+
120
+ pub fn eraseText(self: *AndroidDevice, max_chars: u32) !void {
121
+ if (self.shim_path != null) return try self.runShimAction(.{ .kind = .erase_text, .max_chars = max_chars });
122
+ var args = try android_shell.eraseText(self.allocator, max_chars);
123
+ defer args.deinit();
124
+ const result = try self.runAdb(args.items(), default_max_output);
125
+ defer result.deinit(self.allocator);
126
+ try result.ensureSuccess();
127
+ }
128
+
129
+ pub fn hideKeyboard(self: *AndroidDevice) !void {
130
+ if (self.shim_path != null) return try self.runShimAction(.{ .kind = .hide_keyboard });
131
+ var args = try android_shell.pressBack(self.allocator);
132
+ defer args.deinit();
133
+ const result = try self.runAdb(args.items(), default_max_output);
134
+ defer result.deinit(self.allocator);
135
+ try result.ensureSuccess();
136
+ }
137
+
138
+ pub fn swipe(self: *AndroidDevice, x1: i32, y1: i32, x2: i32, y2: i32, duration_ms: u32) !void {
139
+ if (self.shim_path != null) return try self.runShimAction(.{ .kind = .swipe, .x1 = x1, .y1 = y1, .x2 = x2, .y2 = y2, .duration_ms = duration_ms });
140
+ var args = try android_shell.swipe(self.allocator, x1, y1, x2, y2, duration_ms);
141
+ defer args.deinit();
142
+ const result = try self.runAdb(args.items(), default_max_output);
143
+ defer result.deinit(self.allocator);
144
+ try result.ensureSuccess();
145
+ }
146
+
147
+ pub fn pressBack(self: *AndroidDevice) !void {
148
+ if (self.shim_path != null) return try self.runShimAction(.{ .kind = .press_back });
149
+ var args = try android_shell.pressBack(self.allocator);
150
+ defer args.deinit();
151
+ const result = try self.runAdb(args.items(), default_max_output);
152
+ defer result.deinit(self.allocator);
153
+ try result.ensureSuccess();
154
+ }
155
+
156
+ pub fn startScreenRecording(self: *AndroidDevice, remote_path: []const u8) !AndroidScreenRecording {
157
+ return try android_screen_recording.start(self.allocator, self.adb_path, self.serial, remote_path);
158
+ }
159
+
160
+ pub fn settle(self: *AndroidDevice, timeout_ms: u64) !void {
161
+ if (self.shim_path != null) {
162
+ return try self.runShimAction(.{
163
+ .kind = .settle,
164
+ .duration_ms = @as(u32, @intCast(@min(timeout_ms, std.math.maxInt(u32)))),
165
+ });
166
+ }
167
+ std.Thread.sleep(timeout_ms * std.time.ns_per_ms);
168
+ }
169
+
170
+ pub fn snapshot(self: *AndroidDevice, writer: ?*trace.TraceWriter) !types.ObservationSnapshot {
171
+ const id = if (writer) |tw| try tw.nextSnapshotId() else try std.fmt.allocPrint(self.allocator, "snapshot-{d}", .{std.time.milliTimestamp()});
172
+ errdefer self.allocator.free(id);
173
+
174
+ const xml = if (self.shim_path == null) try self.dumpHierarchy() else null;
175
+ defer if (xml) |value| self.allocator.free(value);
176
+ const nodes = if (self.shim_path) |_|
177
+ try self.snapshotNodesFromShim()
178
+ else
179
+ try uiautomator.parseHierarchy(self.allocator, xml.?);
180
+ errdefer {
181
+ for (nodes) |node| node.deinit(self.allocator);
182
+ self.allocator.free(nodes);
183
+ }
184
+
185
+ var screenshot_artifact: ?[]const u8 = null;
186
+ errdefer if (screenshot_artifact) |path| self.allocator.free(path);
187
+ var tree_artifact: ?[]const u8 = null;
188
+ errdefer if (tree_artifact) |path| self.allocator.free(path);
189
+
190
+ if (writer) |tw| {
191
+ if (tw.capture.capture_screenshots) {
192
+ const screenshot = self.captureScreenshot() catch null;
193
+ if (screenshot) |bytes| {
194
+ defer self.allocator.free(bytes);
195
+ const file_name = try std.fmt.allocPrint(self.allocator, "{s}.png", .{id});
196
+ defer self.allocator.free(file_name);
197
+ screenshot_artifact = try tw.writeArtifact(file_name, bytes);
198
+ }
199
+ }
200
+ if (tw.capture.capture_hierarchy and xml != null) {
201
+ const tree_name = try std.fmt.allocPrint(self.allocator, "{s}.xml", .{id});
202
+ defer self.allocator.free(tree_name);
203
+ tree_artifact = try tw.writeArtifact(tree_name, xml.?);
204
+ }
205
+ }
206
+
207
+ const active = try self.activeWindow();
208
+ errdefer active.deinit(self.allocator);
209
+
210
+ const screen = self.viewport() catch types.Viewport{};
211
+ const display_density_dpi = self.displayDensityDpi() catch null;
212
+ const capture_logs = if (writer) |tw| tw.capture.capture_logs else true;
213
+ const logs = if (capture_logs) self.logDelta() catch null else null;
214
+ errdefer if (logs) |value| self.allocator.free(value);
215
+
216
+ return .{
217
+ .id = id,
218
+ .timestamp_ms = std.time.milliTimestamp(),
219
+ .viewport = screen,
220
+ .display_density_dpi = display_density_dpi,
221
+ .active_package = active.package,
222
+ .active_activity = active.activity,
223
+ .screenshot_artifact = screenshot_artifact,
224
+ .tree_artifact = tree_artifact,
225
+ .focused_node_id = null,
226
+ .log_delta = logs,
227
+ .nodes = nodes,
228
+ };
229
+ }
230
+
231
+ fn dumpHierarchy(self: *AndroidDevice) ![]u8 {
232
+ const result = try self.runAdb(&.{ "exec-out", "uiautomator", "dump", "/dev/tty" }, default_max_output);
233
+ defer result.deinit(self.allocator);
234
+ try result.ensureSuccess();
235
+ return try self.allocator.dupe(u8, result.stdout);
236
+ }
237
+
238
+ fn captureScreenshot(self: *AndroidDevice) ![]u8 {
239
+ const result = try self.runAdb(&.{ "exec-out", "screencap", "-p" }, default_max_output);
240
+ defer result.deinit(self.allocator);
241
+ try result.ensureSuccess();
242
+ return try self.allocator.dupe(u8, result.stdout);
243
+ }
244
+
245
+ fn activeWindow(self: *AndroidDevice) !ActiveWindow {
246
+ const result = try self.runAdb(&.{ "shell", "dumpsys", "window" }, default_max_output);
247
+ defer result.deinit(self.allocator);
248
+ try result.ensureSuccess();
249
+ return try android_device_info.parseActiveWindow(self.allocator, result.stdout);
250
+ }
251
+
252
+ fn isAppForeground(self: *AndroidDevice) !bool {
253
+ const active = try self.activeWindow();
254
+ defer active.deinit(self.allocator);
255
+ const package = active.package orelse return false;
256
+ return std.mem.eql(u8, package, self.app_id);
257
+ }
258
+
259
+ fn viewport(self: *AndroidDevice) !types.Viewport {
260
+ const result = try self.runAdb(&.{ "shell", "wm", "size" }, 4096);
261
+ defer result.deinit(self.allocator);
262
+ try result.ensureSuccess();
263
+ return android_device_info.parseViewport(result.stdout) catch types.Viewport{};
264
+ }
265
+
266
+ fn displayDensityDpi(self: *AndroidDevice) !?u32 {
267
+ const result = try self.runAdb(&.{ "shell", "wm", "density" }, 4096);
268
+ defer result.deinit(self.allocator);
269
+ try result.ensureSuccess();
270
+ return android_device_info.parseDisplayDensityDpi(result.stdout);
271
+ }
272
+
273
+ fn logDelta(self: *AndroidDevice) !?[]const u8 {
274
+ const result = try self.runAdb(&.{ "logcat", "-d", "-t", "80" }, 1024 * 1024);
275
+ defer result.deinit(self.allocator);
276
+ if (result.term != .Exited or result.term.Exited != 0) return null;
277
+ return try self.allocator.dupe(u8, result.stdout);
278
+ }
279
+
280
+ fn snapshotNodesFromShim(self: *AndroidDevice) ![]types.UiNode {
281
+ const response = try self.runShim(.{ .kind = .snapshot });
282
+ defer self.allocator.free(response);
283
+ return try ios_shim.parseSnapshotNodes(self.allocator, response);
284
+ }
285
+
286
+ fn runShimAction(self: *AndroidDevice, shim_command: ios_shim.Command) !void {
287
+ const response = try self.runShim(shim_command);
288
+ defer self.allocator.free(response);
289
+ try ios_shim.parseOkResponse(response);
290
+ }
291
+
292
+ fn runShim(self: *AndroidDevice, shim_command: ios_shim.Command) ![]u8 {
293
+ const path = self.shim_path orelse return error.AndroidShimRequired;
294
+
295
+ var input = std.ArrayList(u8).empty;
296
+ defer input.deinit(self.allocator);
297
+ try ios_shim.writeCommandJson(input.writer(self.allocator), shim_command);
298
+
299
+ const result = try command.runWithInputTimeout(self.allocator, &.{path}, input.items, 4 * 1024 * 1024, shim_timeout_ms);
300
+ defer result.deinit(self.allocator);
301
+ try result.ensureSuccess();
302
+ return try self.allocator.dupe(u8, result.stdout);
303
+ }
304
+
305
+ fn runAdb(self: *AndroidDevice, extra: []const []const u8, max_output_bytes: usize) !command.ExecResult {
306
+ return try self.runAdbWithTimeout(extra, max_output_bytes, default_adb_timeout_ms);
307
+ }
308
+
309
+ fn runAdbWithTimeout(self: *AndroidDevice, extra: []const []const u8, max_output_bytes: usize, timeout_ms: u64) !command.ExecResult {
310
+ var argv = std.ArrayList([]const u8).empty;
311
+ defer argv.deinit(self.allocator);
312
+ try self.appendAdbBase(&argv);
313
+ try argv.appendSlice(self.allocator, extra);
314
+ return try command.runWithTimeout(self.allocator, argv.items, max_output_bytes, timeout_ms);
315
+ }
316
+
317
+ fn appendAdbBase(self: *AndroidDevice, argv: *std.ArrayList([]const u8)) !void {
318
+ try argv.append(self.allocator, self.adb_path);
319
+ if (self.serial) |serial| {
320
+ try argv.append(self.allocator, "-s");
321
+ try argv.append(self.allocator, serial);
322
+ }
323
+ }
324
+ };
325
+
326
+ pub const AndroidScreenRecording = android_screen_recording.AndroidScreenRecording;
327
+
328
+ pub fn listDevices(allocator: std.mem.Allocator, adb_path: []const u8) ![]types.DeviceInfo {
329
+ return try android_device_info.listDevices(allocator, adb_path);
330
+ }
331
+
332
+ pub const ActiveWindow = android_device_info.ActiveWindow;
333
+
334
+ pub fn parseActiveWindow(allocator: std.mem.Allocator, dumpsys: []const u8) !ActiveWindow {
335
+ return try android_device_info.parseActiveWindow(allocator, dumpsys);
336
+ }
337
+
338
+ pub fn parseViewport(output: []const u8) !types.Viewport {
339
+ return try android_device_info.parseViewport(output);
340
+ }
341
+
342
+ pub fn parseDisplayDensityDpi(output: []const u8) ?u32 {
343
+ return android_device_info.parseDisplayDensityDpi(output);
344
+ }
@@ -0,0 +1,99 @@
1
+ const std = @import("std");
2
+ const command = @import("command.zig");
3
+ const types = @import("types.zig");
4
+
5
+ const default_adb_timeout_ms = 15_000;
6
+
7
+ pub fn listDevices(allocator: std.mem.Allocator, adb_path: []const u8) ![]types.DeviceInfo {
8
+ const result = try command.runWithTimeout(allocator, &.{ adb_path, "devices" }, 1024 * 1024, default_adb_timeout_ms);
9
+ defer result.deinit(allocator);
10
+ try result.ensureSuccess();
11
+
12
+ var devices = std.ArrayList(types.DeviceInfo).empty;
13
+ errdefer {
14
+ for (devices.items) |device| device.deinit(allocator);
15
+ devices.deinit(allocator);
16
+ }
17
+
18
+ var lines = std.mem.splitScalar(u8, result.stdout, '\n');
19
+ _ = lines.next();
20
+ while (lines.next()) |raw_line| {
21
+ const line = std.mem.trim(u8, raw_line, " \t\r\n");
22
+ if (line.len == 0) continue;
23
+ var parts = std.mem.tokenizeAny(u8, line, " \t");
24
+ const serial = parts.next() orelse continue;
25
+ const state = parts.next() orelse continue;
26
+ try devices.append(allocator, .{
27
+ .serial = try allocator.dupe(u8, serial),
28
+ .state = try allocator.dupe(u8, state),
29
+ });
30
+ }
31
+
32
+ return try devices.toOwnedSlice(allocator);
33
+ }
34
+
35
+ pub const ActiveWindow = struct {
36
+ package: ?[]const u8 = null,
37
+ activity: ?[]const u8 = null,
38
+
39
+ pub fn deinit(self: ActiveWindow, allocator: std.mem.Allocator) void {
40
+ if (self.package) |value| allocator.free(value);
41
+ if (self.activity) |value| allocator.free(value);
42
+ }
43
+ };
44
+
45
+ pub fn parseActiveWindow(allocator: std.mem.Allocator, dumpsys: []const u8) !ActiveWindow {
46
+ const markers = [_][]const u8{ "mCurrentFocus=", "mFocusedApp=", "topResumedActivity=" };
47
+ for (markers) |marker| {
48
+ if (std.mem.indexOf(u8, dumpsys, marker)) |pos| {
49
+ const line_end = std.mem.indexOfScalarPos(u8, dumpsys, pos, '\n') orelse dumpsys.len;
50
+ const line = dumpsys[pos..line_end];
51
+ if (parsePackageActivity(allocator, line)) |active| return active else |_| continue;
52
+ }
53
+ }
54
+ return .{};
55
+ }
56
+
57
+ fn parsePackageActivity(allocator: std.mem.Allocator, line: []const u8) !ActiveWindow {
58
+ const slash = std.mem.indexOfScalar(u8, line, '/') orelse return error.NoActivity;
59
+ var pkg_start = slash;
60
+ while (pkg_start > 0) : (pkg_start -= 1) {
61
+ const ch = line[pkg_start - 1];
62
+ if (!(std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '.')) break;
63
+ }
64
+ var activity_end = slash + 1;
65
+ while (activity_end < line.len) : (activity_end += 1) {
66
+ const ch = line[activity_end];
67
+ if (!(std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '.' or ch == '$')) break;
68
+ }
69
+ if (pkg_start >= slash or activity_end <= slash + 1) return error.NoActivity;
70
+ return .{
71
+ .package = try allocator.dupe(u8, line[pkg_start..slash]),
72
+ .activity = try allocator.dupe(u8, line[slash + 1 .. activity_end]),
73
+ };
74
+ }
75
+
76
+ pub fn parseViewport(output: []const u8) !types.Viewport {
77
+ const marker = "Physical size:";
78
+ const start = std.mem.indexOf(u8, output, marker) orelse return error.NoViewport;
79
+ const after = std.mem.trim(u8, output[start + marker.len ..], " \t\r\n");
80
+ const x = std.mem.indexOfScalar(u8, after, 'x') orelse return error.NoViewport;
81
+ var end: usize = x + 1;
82
+ while (end < after.len and std.ascii.isDigit(after[end])) : (end += 1) {}
83
+ return .{
84
+ .width = try std.fmt.parseInt(u32, std.mem.trim(u8, after[0..x], " \t"), 10),
85
+ .height = try std.fmt.parseInt(u32, after[x + 1 .. end], 10),
86
+ };
87
+ }
88
+
89
+ pub fn parseDisplayDensityDpi(output: []const u8) ?u32 {
90
+ var lines = std.mem.splitScalar(u8, output, '\n');
91
+ while (lines.next()) |raw_line| {
92
+ const line = std.mem.trim(u8, raw_line, " \t\r\n");
93
+ const prefix = "Physical density:";
94
+ if (!std.mem.startsWith(u8, line, prefix)) continue;
95
+ const value = std.mem.trim(u8, line[prefix.len..], " \t\r\n");
96
+ return std.fmt.parseInt(u32, value, 10) catch null;
97
+ }
98
+ return null;
99
+ }