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,202 @@
1
+ const std = @import("std");
2
+ const trace = @import("trace.zig");
3
+
4
+ pub const DiagnosticEvent = struct {
5
+ kind: ?[]u8 = null,
6
+ status: ?[]u8 = null,
7
+ snapshot_id: ?[]u8 = null,
8
+ artifact_status: ?[]u8 = null,
9
+ semantic_status: ?[]u8 = null,
10
+ error_name: ?[]u8 = null,
11
+ screenshot_artifact: ?[]u8 = null,
12
+ source: ?[]u8 = null,
13
+ active_package: ?[]u8 = null,
14
+ active_activity: ?[]u8 = null,
15
+ visible_texts: ?[]u8 = null,
16
+ nearest_matches: ?[]u8 = null,
17
+
18
+ pub fn deinit(self: *DiagnosticEvent, allocator: std.mem.Allocator) void {
19
+ if (self.kind) |value| allocator.free(value);
20
+ if (self.status) |value| allocator.free(value);
21
+ if (self.snapshot_id) |value| allocator.free(value);
22
+ if (self.artifact_status) |value| allocator.free(value);
23
+ if (self.semantic_status) |value| allocator.free(value);
24
+ if (self.error_name) |value| allocator.free(value);
25
+ if (self.screenshot_artifact) |value| allocator.free(value);
26
+ if (self.source) |value| allocator.free(value);
27
+ if (self.active_package) |value| allocator.free(value);
28
+ if (self.active_activity) |value| allocator.free(value);
29
+ if (self.visible_texts) |value| allocator.free(value);
30
+ if (self.nearest_matches) |value| allocator.free(value);
31
+ }
32
+
33
+ pub fn fromPayload(allocator: std.mem.Allocator, kind_value: []const u8, payload: std.json.ObjectMap) !DiagnosticEvent {
34
+ return .{
35
+ .kind = try allocator.dupe(u8, kind_value),
36
+ .status = try dupeOptionalString(allocator, stringField(payload, "status")),
37
+ .snapshot_id = try dupeOptionalString(allocator, stringField(payload, "snapshotId")),
38
+ .artifact_status = try dupeOptionalString(allocator, stringField(payload, "artifactStatus")),
39
+ .semantic_status = try dupeOptionalString(allocator, stringField(payload, "semanticStatus")),
40
+ .error_name = try dupeOptionalString(allocator, stringField(payload, "error")),
41
+ .screenshot_artifact = try dupeOptionalString(allocator, stringField(payload, "screenshotArtifact")),
42
+ .source = try dupeOptionalString(allocator, stringField(payload, "source")),
43
+ .active_package = try dupeOptionalString(allocator, stringField(payload, "activePackage")),
44
+ .active_activity = try dupeOptionalString(allocator, stringField(payload, "activeActivity")),
45
+ .visible_texts = if (payload.get("visibleTexts")) |value| try joinStringArray(allocator, value, 8) else null,
46
+ .nearest_matches = if (payload.get("nearestTextMatches")) |value| try joinNearestMatches(allocator, value, 5) else null,
47
+ };
48
+ }
49
+ };
50
+
51
+ pub fn writeJson(writer: anytype, diagnostic: DiagnosticEvent) !void {
52
+ try writer.writeAll("{\"kind\":");
53
+ try trace.writeJsonString(writer, diagnostic.kind.?);
54
+ if (diagnostic.status) |value| {
55
+ try writer.writeAll(",\"status\":");
56
+ try trace.writeJsonString(writer, value);
57
+ }
58
+ if (diagnostic.snapshot_id) |value| {
59
+ try writer.writeAll(",\"snapshotId\":");
60
+ try trace.writeJsonString(writer, value);
61
+ }
62
+ if (diagnostic.artifact_status) |value| {
63
+ try writer.writeAll(",\"artifactStatus\":");
64
+ try trace.writeJsonString(writer, value);
65
+ }
66
+ if (diagnostic.semantic_status) |value| {
67
+ try writer.writeAll(",\"semanticStatus\":");
68
+ try trace.writeJsonString(writer, value);
69
+ }
70
+ if (diagnostic.error_name) |value| {
71
+ try writer.writeAll(",\"error\":");
72
+ try trace.writeJsonString(writer, value);
73
+ }
74
+ if (diagnostic.screenshot_artifact) |value| {
75
+ try writer.writeAll(",\"screenshotArtifact\":");
76
+ try trace.writeJsonString(writer, value);
77
+ }
78
+ if (diagnostic.source) |value| {
79
+ try writer.writeAll(",\"source\":");
80
+ try trace.writeJsonString(writer, value);
81
+ }
82
+ if (diagnostic.active_package) |value| {
83
+ try writer.writeAll(",\"activePackage\":");
84
+ try trace.writeJsonString(writer, value);
85
+ }
86
+ if (diagnostic.active_activity) |value| {
87
+ try writer.writeAll(",\"activeActivity\":");
88
+ try trace.writeJsonString(writer, value);
89
+ }
90
+ if (diagnostic.visible_texts) |value| {
91
+ try writer.writeAll(",\"visibleTexts\":");
92
+ try writeJoinedStringArrayJson(writer, value);
93
+ }
94
+ if (diagnostic.nearest_matches) |value| {
95
+ try writer.writeAll(",\"nearestTextMatches\":");
96
+ try writeJoinedStringArrayJson(writer, value);
97
+ }
98
+ try writer.writeAll("}");
99
+ }
100
+
101
+ pub fn writePartialJson(writer: anytype, partial: DiagnosticEvent) !void {
102
+ try writer.writeAll("{\"kind\":");
103
+ try trace.writeJsonString(writer, partial.kind.?);
104
+ if (partial.status) |value| {
105
+ try writer.writeAll(",\"status\":");
106
+ try trace.writeJsonString(writer, value);
107
+ }
108
+ if (partial.artifact_status) |value| {
109
+ try writer.writeAll(",\"artifactStatus\":");
110
+ try trace.writeJsonString(writer, value);
111
+ }
112
+ if (partial.semantic_status) |value| {
113
+ try writer.writeAll(",\"semanticStatus\":");
114
+ try trace.writeJsonString(writer, value);
115
+ }
116
+ if (partial.error_name) |value| {
117
+ try writer.writeAll(",\"error\":");
118
+ try trace.writeJsonString(writer, value);
119
+ }
120
+ if (partial.screenshot_artifact) |value| {
121
+ try writer.writeAll(",\"screenshotArtifact\":");
122
+ try trace.writeJsonString(writer, value);
123
+ }
124
+ if (partial.source) |value| {
125
+ try writer.writeAll(",\"source\":");
126
+ try trace.writeJsonString(writer, value);
127
+ }
128
+ try writer.writeAll("}");
129
+ }
130
+
131
+ fn writeJoinedStringArrayJson(writer: anytype, value: []const u8) !void {
132
+ try writer.writeAll("[");
133
+ var first = true;
134
+ var parts = std.mem.splitSequence(u8, value, " | ");
135
+ while (parts.next()) |part| {
136
+ if (!first) try writer.writeAll(",");
137
+ first = false;
138
+ try trace.writeJsonString(writer, part);
139
+ }
140
+ try writer.writeAll("]");
141
+ }
142
+
143
+ fn joinStringArray(allocator: std.mem.Allocator, value: std.json.Value, limit: usize) !?[]u8 {
144
+ if (value != .array or value.array.items.len == 0) return null;
145
+ var out = std.ArrayList(u8).empty;
146
+ errdefer out.deinit(allocator);
147
+ var written: usize = 0;
148
+ for (value.array.items) |item| {
149
+ if (item != .string) continue;
150
+ if (written > 0) try out.writer(allocator).writeAll(" | ");
151
+ try out.writer(allocator).writeAll(item.string);
152
+ written += 1;
153
+ if (written >= limit) break;
154
+ }
155
+ if (written == 0) {
156
+ out.deinit(allocator);
157
+ return null;
158
+ }
159
+ return try out.toOwnedSlice(allocator);
160
+ }
161
+
162
+ fn joinNearestMatches(allocator: std.mem.Allocator, value: std.json.Value, limit: usize) !?[]u8 {
163
+ if (value != .array or value.array.items.len == 0) return null;
164
+ var out = std.ArrayList(u8).empty;
165
+ errdefer out.deinit(allocator);
166
+ var written: usize = 0;
167
+ for (value.array.items) |item| {
168
+ if (item != .object) continue;
169
+ const text = stringField(item.object, "text") orelse continue;
170
+ if (written > 0) try out.writer(allocator).writeAll(" | ");
171
+ try out.writer(allocator).writeAll(text);
172
+ if (intField(item.object, "score")) |score| {
173
+ try out.writer(allocator).print(" (score {d})", .{score});
174
+ }
175
+ written += 1;
176
+ if (written >= limit) break;
177
+ }
178
+ if (written == 0) {
179
+ out.deinit(allocator);
180
+ return null;
181
+ }
182
+ return try out.toOwnedSlice(allocator);
183
+ }
184
+
185
+ fn dupeOptionalString(allocator: std.mem.Allocator, value: ?[]const u8) !?[]u8 {
186
+ if (value) |actual| return try allocator.dupe(u8, actual);
187
+ return null;
188
+ }
189
+
190
+ fn stringField(object: std.json.ObjectMap, key: []const u8) ?[]const u8 {
191
+ const value = object.get(key) orelse return null;
192
+ if (value != .string) return null;
193
+ return value.string;
194
+ }
195
+
196
+ fn intField(object: std.json.ObjectMap, key: []const u8) ?i64 {
197
+ const value = object.get(key) orelse return null;
198
+ return switch (value) {
199
+ .integer => |actual| actual,
200
+ else => null,
201
+ };
202
+ }
package/src/types.zig ADDED
@@ -0,0 +1,120 @@
1
+ const std = @import("std");
2
+
3
+ pub const Allocator = std.mem.Allocator;
4
+
5
+ pub const Bounds = struct {
6
+ x: i32 = 0,
7
+ y: i32 = 0,
8
+ width: i32 = 0,
9
+ height: i32 = 0,
10
+
11
+ pub fn centerX(self: Bounds) i32 {
12
+ return self.x + @divTrunc(self.width, 2);
13
+ }
14
+
15
+ pub fn centerY(self: Bounds) i32 {
16
+ return self.y + @divTrunc(self.height, 2);
17
+ }
18
+ };
19
+
20
+ pub const Viewport = struct {
21
+ width: u32 = 0,
22
+ height: u32 = 0,
23
+ };
24
+
25
+ pub const UiNode = struct {
26
+ stable_id: []const u8,
27
+ class_name: []const u8,
28
+ resource_id: ?[]const u8 = null,
29
+ text: ?[]const u8 = null,
30
+ content_desc: ?[]const u8 = null,
31
+ bounds: Bounds = .{},
32
+ enabled: bool = true,
33
+ visible: bool = true,
34
+ selected: bool = false,
35
+
36
+ pub fn deinit(self: UiNode, allocator: Allocator) void {
37
+ allocator.free(self.stable_id);
38
+ allocator.free(self.class_name);
39
+ if (self.resource_id) |value| allocator.free(value);
40
+ if (self.text) |value| allocator.free(value);
41
+ if (self.content_desc) |value| allocator.free(value);
42
+ }
43
+ };
44
+
45
+ pub const ObservationSnapshot = struct {
46
+ id: []const u8,
47
+ timestamp_ms: i64,
48
+ viewport: Viewport = .{},
49
+ display_density_dpi: ?u32 = null,
50
+ active_package: ?[]const u8 = null,
51
+ active_activity: ?[]const u8 = null,
52
+ screenshot_artifact: ?[]const u8 = null,
53
+ tree_artifact: ?[]const u8 = null,
54
+ focused_node_id: ?[]const u8 = null,
55
+ log_delta: ?[]const u8 = null,
56
+ nodes: []UiNode = &.{},
57
+
58
+ pub fn deinit(self: ObservationSnapshot, allocator: Allocator) void {
59
+ allocator.free(self.id);
60
+ if (self.active_package) |value| allocator.free(value);
61
+ if (self.active_activity) |value| allocator.free(value);
62
+ if (self.screenshot_artifact) |value| allocator.free(value);
63
+ if (self.tree_artifact) |value| allocator.free(value);
64
+ if (self.focused_node_id) |value| allocator.free(value);
65
+ if (self.log_delta) |value| allocator.free(value);
66
+ for (self.nodes) |node| node.deinit(allocator);
67
+ allocator.free(self.nodes);
68
+ }
69
+ };
70
+
71
+ pub const StructuredError = struct {
72
+ code: []const u8,
73
+ message: []const u8,
74
+
75
+ pub fn deinit(self: StructuredError, allocator: Allocator) void {
76
+ allocator.free(self.code);
77
+ allocator.free(self.message);
78
+ }
79
+ };
80
+
81
+ pub const ActionStatus = enum {
82
+ ok,
83
+ not_found,
84
+ timeout,
85
+ device_error,
86
+ protocol_error,
87
+ };
88
+
89
+ pub const ActionResult = struct {
90
+ status: ActionStatus,
91
+ elapsed_ms: u64,
92
+ action: []const u8,
93
+ target: ?[]const u8 = null,
94
+ before_snapshot_id: ?[]const u8 = null,
95
+ after_snapshot_id: ?[]const u8 = null,
96
+ err: ?StructuredError = null,
97
+
98
+ pub fn deinit(self: ActionResult, allocator: Allocator) void {
99
+ allocator.free(self.action);
100
+ if (self.target) |value| allocator.free(value);
101
+ if (self.before_snapshot_id) |value| allocator.free(value);
102
+ if (self.after_snapshot_id) |value| allocator.free(value);
103
+ if (self.err) |value| value.deinit(allocator);
104
+ }
105
+ };
106
+
107
+ pub const DeviceInfo = struct {
108
+ serial: []const u8,
109
+ state: []const u8,
110
+
111
+ pub fn deinit(self: DeviceInfo, allocator: Allocator) void {
112
+ allocator.free(self.serial);
113
+ allocator.free(self.state);
114
+ }
115
+ };
116
+
117
+ pub fn dupeOptional(allocator: Allocator, value: ?[]const u8) !?[]const u8 {
118
+ if (value) |actual| return try allocator.dupe(u8, actual);
119
+ return null;
120
+ }
@@ -0,0 +1,164 @@
1
+ const std = @import("std");
2
+ const types = @import("types.zig");
3
+
4
+ pub fn parseHierarchy(allocator: std.mem.Allocator, xml: []const u8) ![]types.UiNode {
5
+ var nodes = std.ArrayList(types.UiNode).empty;
6
+ errdefer {
7
+ for (nodes.items) |node| node.deinit(allocator);
8
+ nodes.deinit(allocator);
9
+ }
10
+
11
+ var cursor: usize = 0;
12
+ var index: usize = 0;
13
+ while (std.mem.indexOfPos(u8, xml, cursor, "<node")) |start| {
14
+ const end = std.mem.indexOfScalarPos(u8, xml, start, '>') orelse break;
15
+ const tag = xml[start..end];
16
+ const class_name = try attrOwned(allocator, tag, "class") orelse try allocator.dupe(u8, "");
17
+ errdefer allocator.free(class_name);
18
+ const resource_id = try attrOwned(allocator, tag, "resource-id");
19
+ errdefer if (resource_id) |value| allocator.free(value);
20
+ var text = try attrOwned(allocator, tag, "text");
21
+ errdefer if (text) |value| allocator.free(value);
22
+ var content_desc = try attrOwned(allocator, tag, "content-desc");
23
+ errdefer if (content_desc) |value| allocator.free(value);
24
+ const bounds_text = try attrOwned(allocator, tag, "bounds");
25
+ defer if (bounds_text) |value| allocator.free(value);
26
+
27
+ const bounds = if (bounds_text) |value| parseBounds(value) catch types.Bounds{} else types.Bounds{};
28
+ const enabled = parseBoolAttr(tag, "enabled", true);
29
+ const selected = parseBoolAttr(tag, "selected", false);
30
+ const visible = bounds.width > 0 and bounds.height > 0;
31
+ text = emptyToNullOwned(allocator, text);
32
+ content_desc = emptyToNullOwned(allocator, content_desc);
33
+ const stable_id = try stableId(allocator, index, class_name, resource_id, text, content_desc, bounds);
34
+
35
+ try nodes.append(allocator, .{
36
+ .stable_id = stable_id,
37
+ .class_name = class_name,
38
+ .resource_id = resource_id,
39
+ .text = text,
40
+ .content_desc = content_desc,
41
+ .bounds = bounds,
42
+ .enabled = enabled,
43
+ .visible = visible,
44
+ .selected = selected,
45
+ });
46
+
47
+ index += 1;
48
+ cursor = end + 1;
49
+ }
50
+
51
+ return try nodes.toOwnedSlice(allocator);
52
+ }
53
+
54
+ fn emptyToNullOwned(allocator: std.mem.Allocator, value: ?[]const u8) ?[]const u8 {
55
+ if (value) |actual| {
56
+ if (actual.len == 0) {
57
+ allocator.free(actual);
58
+ return null;
59
+ }
60
+ }
61
+ return value;
62
+ }
63
+
64
+ fn attrOwned(allocator: std.mem.Allocator, tag: []const u8, name: []const u8) !?[]const u8 {
65
+ var cursor: usize = 0;
66
+ while (std.mem.indexOfPos(u8, tag, cursor, name)) |pos| {
67
+ const eq = pos + name.len;
68
+ if (eq + 1 < tag.len and tag[eq] == '=' and tag[eq + 1] == '"') {
69
+ const value_start = eq + 2;
70
+ const value_end = std.mem.indexOfScalarPos(u8, tag, value_start, '"') orelse return error.MalformedAttribute;
71
+ return try xmlUnescape(allocator, tag[value_start..value_end]);
72
+ }
73
+ cursor = pos + name.len;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ fn xmlUnescape(allocator: std.mem.Allocator, input: []const u8) ![]const u8 {
79
+ var out = std.ArrayList(u8).empty;
80
+ errdefer out.deinit(allocator);
81
+ var i: usize = 0;
82
+ while (i < input.len) {
83
+ if (std.mem.startsWith(u8, input[i..], "&amp;")) {
84
+ try out.append(allocator, '&');
85
+ i += 5;
86
+ } else if (std.mem.startsWith(u8, input[i..], "&lt;")) {
87
+ try out.append(allocator, '<');
88
+ i += 4;
89
+ } else if (std.mem.startsWith(u8, input[i..], "&gt;")) {
90
+ try out.append(allocator, '>');
91
+ i += 4;
92
+ } else if (std.mem.startsWith(u8, input[i..], "&quot;")) {
93
+ try out.append(allocator, '"');
94
+ i += 6;
95
+ } else if (std.mem.startsWith(u8, input[i..], "&apos;")) {
96
+ try out.append(allocator, '\'');
97
+ i += 6;
98
+ } else {
99
+ try out.append(allocator, input[i]);
100
+ i += 1;
101
+ }
102
+ }
103
+ return try out.toOwnedSlice(allocator);
104
+ }
105
+
106
+ fn parseBoolAttr(tag: []const u8, name: []const u8, default_value: bool) bool {
107
+ var cursor: usize = 0;
108
+ while (std.mem.indexOfPos(u8, tag, cursor, name)) |pos| {
109
+ const eq = pos + name.len;
110
+ if (eq + 1 < tag.len and tag[eq] == '=' and tag[eq + 1] == '"') {
111
+ const value_start = eq + 2;
112
+ const value_end = std.mem.indexOfScalarPos(u8, tag, value_start, '"') orelse return default_value;
113
+ return std.mem.eql(u8, tag[value_start..value_end], "true");
114
+ }
115
+ cursor = pos + name.len;
116
+ }
117
+ return default_value;
118
+ }
119
+
120
+ pub fn parseBounds(input: []const u8) !types.Bounds {
121
+ if (input.len < 10 or input[0] != '[') return error.MalformedBounds;
122
+ const comma_1 = std.mem.indexOfScalar(u8, input, ',') orelse return error.MalformedBounds;
123
+ const close_1 = std.mem.indexOfScalarPos(u8, input, comma_1, ']') orelse return error.MalformedBounds;
124
+ const open_2 = std.mem.indexOfScalarPos(u8, input, close_1, '[') orelse return error.MalformedBounds;
125
+ const comma_2 = std.mem.indexOfScalarPos(u8, input, open_2, ',') orelse return error.MalformedBounds;
126
+ const close_2 = std.mem.indexOfScalarPos(u8, input, comma_2, ']') orelse return error.MalformedBounds;
127
+
128
+ const x1 = try std.fmt.parseInt(i32, input[1..comma_1], 10);
129
+ const y1 = try std.fmt.parseInt(i32, input[comma_1 + 1 .. close_1], 10);
130
+ const x2 = try std.fmt.parseInt(i32, input[open_2 + 1 .. comma_2], 10);
131
+ const y2 = try std.fmt.parseInt(i32, input[comma_2 + 1 .. close_2], 10);
132
+
133
+ return .{
134
+ .x = x1,
135
+ .y = y1,
136
+ .width = @max(0, x2 - x1),
137
+ .height = @max(0, y2 - y1),
138
+ };
139
+ }
140
+
141
+ fn stableId(
142
+ allocator: std.mem.Allocator,
143
+ index: usize,
144
+ class_name: []const u8,
145
+ resource_id: ?[]const u8,
146
+ text: ?[]const u8,
147
+ content_desc: ?[]const u8,
148
+ bounds: types.Bounds,
149
+ ) ![]const u8 {
150
+ if (resource_id) |value| {
151
+ if (value.len > 0) return try std.fmt.allocPrint(allocator, "rid:{s}:{d}", .{ value, index });
152
+ }
153
+ if (content_desc) |value| {
154
+ if (value.len > 0) return try std.fmt.allocPrint(allocator, "desc:{s}:{d}", .{ value, index });
155
+ }
156
+ if (text) |value| {
157
+ if (value.len > 0) return try std.fmt.allocPrint(allocator, "text:{s}:{d}", .{ value, index });
158
+ }
159
+ return try std.fmt.allocPrint(
160
+ allocator,
161
+ "node:{s}:{d}:{d}:{d}:{d}:{d}",
162
+ .{ class_name, bounds.x, bounds.y, bounds.width, bounds.height, index },
163
+ );
164
+ }
@@ -0,0 +1,187 @@
1
+ const std = @import("std");
2
+ const errors = @import("errors.zig");
3
+ const scenario = @import("scenario.zig");
4
+
5
+ pub const Result = struct {
6
+ ok: bool,
7
+ name: ?[]const u8 = null,
8
+ app_id: ?[]const u8 = null,
9
+ step_count: usize = 0,
10
+ error_code: ?[]const u8 = null,
11
+ message: ?[]const u8 = null,
12
+ path: ?[]const u8 = null,
13
+ line: ?usize = null,
14
+ column: ?usize = null,
15
+
16
+ pub fn deinit(self: Result, allocator: std.mem.Allocator) void {
17
+ if (self.name) |value| allocator.free(value);
18
+ if (self.app_id) |value| allocator.free(value);
19
+ if (self.error_code) |value| allocator.free(value);
20
+ if (self.message) |value| allocator.free(value);
21
+ if (self.path) |value| allocator.free(value);
22
+ }
23
+ };
24
+
25
+ pub fn validateFile(allocator: std.mem.Allocator, path: []const u8) !Result {
26
+ const content = std.fs.cwd().readFileAlloc(allocator, path, 16 * 1024 * 1024) catch |err| return failure(allocator, null, err);
27
+ defer allocator.free(content);
28
+ const script = scenario.parseSlice(allocator, content) catch |err| return failure(allocator, content, err);
29
+ defer script.deinit(allocator);
30
+ return success(allocator, script);
31
+ }
32
+
33
+ pub fn validateSlice(allocator: std.mem.Allocator, content: []const u8) !Result {
34
+ const script = scenario.parseSlice(allocator, content) catch |err| return failure(allocator, content, err);
35
+ defer script.deinit(allocator);
36
+ return success(allocator, script);
37
+ }
38
+
39
+ fn success(allocator: std.mem.Allocator, script: scenario.Scenario) !Result {
40
+ return .{
41
+ .ok = true,
42
+ .name = try allocator.dupe(u8, script.name),
43
+ .app_id = try dupeOptionalString(allocator, script.app_id),
44
+ .step_count = script.steps.len,
45
+ };
46
+ }
47
+
48
+ fn dupeOptionalString(allocator: std.mem.Allocator, value: ?[]const u8) !?[]u8 {
49
+ if (value) |actual| return try allocator.dupe(u8, actual);
50
+ return null;
51
+ }
52
+
53
+ fn failure(allocator: std.mem.Allocator, content: ?[]const u8, err: anyerror) !Result {
54
+ const classified = errors.classify(err);
55
+ const diagnostic = if (content) |actual| try diagnoseFailure(allocator, actual, err) else Diagnostic{};
56
+ errdefer diagnostic.deinit(allocator);
57
+ const code = if (diagnostic.line != null and diagnostic.path == null and std.mem.eql(u8, classified.code, "internal.error"))
58
+ "scenario.invalid"
59
+ else
60
+ classified.code;
61
+ const message = if (diagnostic.line != null and diagnostic.path == null and std.mem.eql(u8, classified.code, "internal.error"))
62
+ "malformed scenario json"
63
+ else
64
+ classified.message;
65
+ return .{
66
+ .ok = false,
67
+ .error_code = try allocator.dupe(u8, code),
68
+ .message = try allocator.dupe(u8, message),
69
+ .path = diagnostic.path,
70
+ .line = diagnostic.line,
71
+ .column = diagnostic.column,
72
+ };
73
+ }
74
+
75
+ const Diagnostic = struct {
76
+ path: ?[]const u8 = null,
77
+ line: ?usize = null,
78
+ column: ?usize = null,
79
+
80
+ fn deinit(self: Diagnostic, allocator: std.mem.Allocator) void {
81
+ if (self.path) |value| allocator.free(value);
82
+ }
83
+ };
84
+
85
+ fn diagnoseFailure(allocator: std.mem.Allocator, content: []const u8, err: anyerror) !Diagnostic {
86
+ if (syntaxLocation(allocator, content)) |location| return location;
87
+ return switch (err) {
88
+ error.ScenarioMustBeObject => try pathDiagnostic(allocator, content, "$", null),
89
+ error.ScenarioMissingSteps,
90
+ error.ScenarioStepsMustBeArray,
91
+ => try pathDiagnostic(allocator, content, "$.steps", "steps"),
92
+ error.StepMissingAction,
93
+ error.StepActionMustBeString,
94
+ error.UnknownAction,
95
+ error.UnknownScenarioAction,
96
+ => try pathDiagnostic(allocator, content, "$.steps[].action", "action"),
97
+ error.UnknownScrollDirection,
98
+ => try pathDiagnostic(allocator, content, "$.steps[].direction", "direction"),
99
+ error.StepMissingUrl,
100
+ => try pathDiagnostic(allocator, content, "$.steps[].url", "url"),
101
+ error.StepMissingText,
102
+ => try pathDiagnostic(allocator, content, "$.steps[].text", "text"),
103
+ error.StepMissingX1,
104
+ => try pathDiagnostic(allocator, content, "$.steps[].x1", "x1"),
105
+ error.StepMissingY1,
106
+ => try pathDiagnostic(allocator, content, "$.steps[].y1", "y1"),
107
+ error.StepMissingX2,
108
+ => try pathDiagnostic(allocator, content, "$.steps[].x2", "x2"),
109
+ error.StepMissingY2,
110
+ => try pathDiagnostic(allocator, content, "$.steps[].y2", "y2"),
111
+ error.MissingSelector,
112
+ error.StepMissingSelector,
113
+ error.SelectorMustNotBeEmpty,
114
+ => try pathDiagnostic(allocator, content, "$.steps[].selector", "selector"),
115
+ error.MissingSelectors,
116
+ error.StepMissingSelectors,
117
+ error.SelectorsMustBeArray,
118
+ error.SelectorsMustNotBeEmpty,
119
+ => try pathDiagnostic(allocator, content, "$.steps[].selectors", "selectors"),
120
+ else => .{},
121
+ };
122
+ }
123
+
124
+ fn syntaxLocation(allocator: std.mem.Allocator, content: []const u8) ?Diagnostic {
125
+ var scanner = std.json.Scanner.initCompleteInput(allocator, content);
126
+ defer scanner.deinit();
127
+ var diagnostics = std.json.Diagnostics{};
128
+ scanner.enableDiagnostics(&diagnostics);
129
+ const parsed = std.json.parseFromTokenSource(std.json.Value, allocator, &scanner, .{}) catch {
130
+ return .{
131
+ .line = @as(usize, @intCast(diagnostics.getLine())),
132
+ .column = @as(usize, @intCast(diagnostics.getColumn())),
133
+ };
134
+ };
135
+ parsed.deinit();
136
+ return null;
137
+ }
138
+
139
+ fn pathDiagnostic(allocator: std.mem.Allocator, content: []const u8, path: []const u8, key: ?[]const u8) !Diagnostic {
140
+ var diagnostic = Diagnostic{ .path = try allocator.dupe(u8, path) };
141
+ if (key) |actual| {
142
+ if (findJsonKeyOffset(allocator, content, actual)) |offset| {
143
+ const location = lineColumnForOffset(content, offset);
144
+ diagnostic.line = location.line;
145
+ diagnostic.column = location.column;
146
+ }
147
+ } else {
148
+ const offset = firstNonWhitespaceOffset(content) orelse 0;
149
+ const location = lineColumnForOffset(content, offset);
150
+ diagnostic.line = location.line;
151
+ diagnostic.column = location.column;
152
+ }
153
+ return diagnostic;
154
+ }
155
+
156
+ fn findJsonKeyOffset(allocator: std.mem.Allocator, content: []const u8, key: []const u8) ?usize {
157
+ const needle = std.fmt.allocPrint(allocator, "\"{s}\"", .{key}) catch return null;
158
+ defer allocator.free(needle);
159
+ return std.mem.indexOf(u8, content, needle);
160
+ }
161
+
162
+ fn firstNonWhitespaceOffset(content: []const u8) ?usize {
163
+ for (content, 0..) |byte, index| switch (byte) {
164
+ ' ', '\t', '\r', '\n' => {},
165
+ else => return index,
166
+ };
167
+ return null;
168
+ }
169
+
170
+ const SourceLocation = struct {
171
+ line: usize,
172
+ column: usize,
173
+ };
174
+
175
+ fn lineColumnForOffset(content: []const u8, offset: usize) SourceLocation {
176
+ var line: usize = 1;
177
+ var column: usize = 1;
178
+ for (content[0..@min(offset, content.len)]) |byte| {
179
+ if (byte == '\n') {
180
+ line += 1;
181
+ column = 1;
182
+ } else {
183
+ column += 1;
184
+ }
185
+ }
186
+ return .{ .line = line, .column = column };
187
+ }
@@ -0,0 +1,22 @@
1
+ pub const runner_version = "0.1.0";
2
+ pub const protocol_version = "2026-04-28";
3
+ pub const protocol_min_compatible_version = "2026-04-28";
4
+ pub const protocol_stability = "dev-preview";
5
+ pub const protocol_breaking_change_policy = "version-and-changelog";
6
+
7
+ pub fn writePlain(writer: anytype) !void {
8
+ try writer.print("zmr {s} protocol {s}\n", .{ runner_version, protocol_version });
9
+ }
10
+
11
+ pub fn writeJson(writer: anytype) !void {
12
+ try writer.print(
13
+ "{{\"name\":\"zmr\",\"version\":\"{s}\",\"protocolVersion\":\"{s}\",\"minimumCompatibleProtocolVersion\":\"{s}\",\"stability\":\"{s}\",\"breakingChangePolicy\":\"{s}\"}}\n",
14
+ .{
15
+ runner_version,
16
+ protocol_version,
17
+ protocol_min_compatible_version,
18
+ protocol_stability,
19
+ protocol_breaking_change_policy,
20
+ },
21
+ );
22
+ }