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
package/src/health.zig ADDED
@@ -0,0 +1,28 @@
1
+ const std = @import("std");
2
+ const selector = @import("selector.zig");
3
+ const types = @import("types.zig");
4
+
5
+ const default_selectors = [_]selector.Selector{
6
+ .{ .text_contains = "Uncaught Error" },
7
+ .{ .text_contains = "Application has crashed" },
8
+ .{ .text_contains = "This development build encountered the following error" },
9
+ .{ .text_contains = "There was a problem loading the project" },
10
+ .{ .text_contains = "Failed to connect" },
11
+ .{ .text_contains = "Could not connect to development server" },
12
+ .{ .text_contains = "Unable to load script" },
13
+ .{ .text_contains = "Invariant Violation" },
14
+ .{ .text_contains = "ReferenceError" },
15
+ .{ .text_contains = "TypeError" },
16
+ .{ .text_contains = "SyntaxError" },
17
+ };
18
+
19
+ pub fn defaultSelectors() []const selector.Selector {
20
+ return default_selectors[0..];
21
+ }
22
+
23
+ pub fn hasUnhealthyOverlay(nodes: []const types.UiNode) bool {
24
+ for (defaultSelectors()) |wanted| {
25
+ if (selector.find(nodes, wanted) != null) return true;
26
+ }
27
+ return false;
28
+ }
@@ -0,0 +1,343 @@
1
+ const std = @import("std");
2
+ const importer_json = @import("importer_json.zig");
3
+ const model = @import("importer_model.zig");
4
+
5
+ pub const ImportOptions = model.ImportOptions;
6
+ pub const ImportResult = model.ImportResult;
7
+
8
+ pub fn importFlowYamlFile(
9
+ allocator: std.mem.Allocator,
10
+ source_path: []const u8,
11
+ out_path: []const u8,
12
+ options: ImportOptions,
13
+ ) !ImportResult {
14
+ if (!options.force and fileExists(out_path)) return error.ImportOutputExists;
15
+
16
+ const content = try std.fs.cwd().readFileAlloc(allocator, source_path, 4 * 1024 * 1024);
17
+ defer allocator.free(content);
18
+
19
+ var imported = try parseFlowYamlSlice(allocator, content, options);
20
+ defer imported.deinit(allocator);
21
+
22
+ if (std.fs.path.dirname(out_path)) |dir| {
23
+ if (dir.len > 0) try std.fs.cwd().makePath(dir);
24
+ }
25
+
26
+ var file = try std.fs.cwd().createFile(out_path, .{ .truncate = true });
27
+ defer file.close();
28
+ var write_buffer: [8192]u8 = undefined;
29
+ var file_writer = file.writer(&write_buffer);
30
+ try importer_json.writeScenarioJson(&file_writer.interface, imported);
31
+ try file_writer.interface.flush();
32
+
33
+ return .{
34
+ .out_path = try allocator.dupe(u8, out_path),
35
+ .name = try allocator.dupe(u8, imported.name),
36
+ .app_id = try dupeOptional(allocator, imported.app_id),
37
+ .step_count = imported.steps.len,
38
+ };
39
+ }
40
+
41
+ fn fileExists(path: []const u8) bool {
42
+ std.fs.cwd().access(path, .{}) catch return false;
43
+ return true;
44
+ }
45
+
46
+ fn parseFlowYamlSlice(allocator: std.mem.Allocator, content: []const u8, options: ImportOptions) !model.ImportedScenario {
47
+ var header_app_id: ?[]const u8 = null;
48
+ defer if (header_app_id) |value| allocator.free(value);
49
+ var header_name: ?[]const u8 = null;
50
+ defer if (header_name) |value| allocator.free(value);
51
+
52
+ var steps = std.ArrayList(model.ImportedStep).empty;
53
+ errdefer {
54
+ for (steps.items) |step| step.deinit(allocator);
55
+ steps.deinit(allocator);
56
+ }
57
+
58
+ var lines = std.ArrayList([]const u8).empty;
59
+ defer lines.deinit(allocator);
60
+ var split = std.mem.splitScalar(u8, content, '\n');
61
+ while (split.next()) |line| {
62
+ try lines.append(allocator, std.mem.trimRight(u8, line, "\r"));
63
+ }
64
+
65
+ var in_commands = false;
66
+ var index: usize = 0;
67
+ while (index < lines.items.len) {
68
+ const raw = lines.items[index];
69
+ const trimmed = trim(raw);
70
+ if (trimmed.len == 0 or std.mem.startsWith(u8, trimmed, "#")) {
71
+ index += 1;
72
+ continue;
73
+ }
74
+ if (std.mem.eql(u8, trimmed, "---")) {
75
+ in_commands = true;
76
+ index += 1;
77
+ continue;
78
+ }
79
+ if (!in_commands and !std.mem.startsWith(u8, trimmed, "- ")) {
80
+ if (splitColon(trimmed)) |pair| {
81
+ if (std.mem.eql(u8, pair.key, "appId")) {
82
+ if (header_app_id) |value| allocator.free(value);
83
+ header_app_id = try parseScalarString(allocator, pair.value);
84
+ } else if (std.mem.eql(u8, pair.key, "name")) {
85
+ if (header_name) |value| allocator.free(value);
86
+ header_name = try parseScalarString(allocator, pair.value);
87
+ }
88
+ }
89
+ index += 1;
90
+ continue;
91
+ }
92
+
93
+ in_commands = true;
94
+ if (!std.mem.startsWith(u8, trimmed, "- ")) return error.ImportExpectedCommand;
95
+ const item = trim(trimmed[2..]);
96
+ index += 1;
97
+ const block_start = index;
98
+ while (index < lines.items.len) : (index += 1) {
99
+ const next = trim(lines.items[index]);
100
+ if (std.mem.startsWith(u8, next, "- ") or std.mem.eql(u8, next, "---")) break;
101
+ }
102
+ try steps.append(allocator, try parseFlowYamlCommand(allocator, item, lines.items[block_start..index]));
103
+ }
104
+
105
+ const name = if (options.name) |value|
106
+ try allocator.dupe(u8, value)
107
+ else if (header_name) |value|
108
+ try allocator.dupe(u8, value)
109
+ else
110
+ try allocator.dupe(u8, "Imported mobile flow");
111
+ errdefer allocator.free(name);
112
+
113
+ const app_id = if (options.app_id) |value|
114
+ try allocator.dupe(u8, value)
115
+ else
116
+ try dupeOptional(allocator, header_app_id);
117
+ errdefer if (app_id) |value| allocator.free(value);
118
+
119
+ return .{
120
+ .name = name,
121
+ .app_id = app_id,
122
+ .steps = try steps.toOwnedSlice(allocator),
123
+ };
124
+ }
125
+
126
+ fn parseFlowYamlCommand(allocator: std.mem.Allocator, item: []const u8, block: []const []const u8) !model.ImportedStep {
127
+ if (item.len == 0) return error.ImportExpectedCommand;
128
+ if (splitColon(item)) |pair| {
129
+ return try parseFlowYamlCommandWithValue(allocator, pair.key, pair.value, block);
130
+ }
131
+ if (std.mem.eql(u8, item, "launchApp")) return .launch;
132
+ if (std.mem.eql(u8, item, "stopApp")) return .stop;
133
+ if (std.mem.eql(u8, item, "clearState") or std.mem.eql(u8, item, "clearAppState")) return .clear_state;
134
+ if (std.mem.eql(u8, item, "hideKeyboard")) return .hide_keyboard;
135
+ if (std.mem.eql(u8, item, "back") or std.mem.eql(u8, item, "pressBack")) return .press_back;
136
+ if (std.mem.eql(u8, item, "takeScreenshot")) return .snapshot;
137
+ if (std.mem.eql(u8, item, "eraseText")) return .{ .erase_text = 80 };
138
+ if (std.mem.eql(u8, item, "waitForAnimationToEnd")) return .{ .sleep_ms = 1000 };
139
+ return error.UnsupportedImportCommand;
140
+ }
141
+
142
+ fn parseFlowYamlCommandWithValue(
143
+ allocator: std.mem.Allocator,
144
+ key: []const u8,
145
+ value: []const u8,
146
+ block: []const []const u8,
147
+ ) !model.ImportedStep {
148
+ if (std.mem.eql(u8, key, "tapOn")) {
149
+ return .{ .tap = try parseSelectorValueOrBlock(allocator, value, block) };
150
+ }
151
+ if (std.mem.eql(u8, key, "inputText")) {
152
+ return .{ .type_text = try parseRequiredScalarOrBlockValue(allocator, value, block, "text") };
153
+ }
154
+ if (std.mem.eql(u8, key, "eraseText")) {
155
+ const parsed = if (value.len == 0) try parseOptionalU32FromBlock(block, "characters") else try parseU32(value);
156
+ return .{ .erase_text = parsed orelse 80 };
157
+ }
158
+ if (std.mem.eql(u8, key, "hideKeyboard")) return .hide_keyboard;
159
+ if (std.mem.eql(u8, key, "back") or std.mem.eql(u8, key, "pressBack")) return .press_back;
160
+ if (std.mem.eql(u8, key, "launchApp")) return .launch;
161
+ if (std.mem.eql(u8, key, "stopApp")) return .stop;
162
+ if (std.mem.eql(u8, key, "clearState") or std.mem.eql(u8, key, "clearAppState")) return .clear_state;
163
+ if (std.mem.eql(u8, key, "takeScreenshot")) return .snapshot;
164
+ if (std.mem.eql(u8, key, "openLink")) {
165
+ return .{ .open_link = try parseRequiredScalarOrBlockValue(allocator, value, block, "link") };
166
+ }
167
+ if (std.mem.eql(u8, key, "assertVisible")) {
168
+ return .{ .assert_visible = try parseSelectorValueOrBlock(allocator, value, block) };
169
+ }
170
+ if (std.mem.eql(u8, key, "assertNotVisible")) {
171
+ return .{ .assert_not_visible = try parseSelectorValueOrBlock(allocator, value, block) };
172
+ }
173
+ if (std.mem.eql(u8, key, "waitUntilVisible")) {
174
+ return .{ .wait_visible = .{
175
+ .selector = try parseSelectorValueOrBlock(allocator, value, block),
176
+ .timeout_ms = (try parseOptionalU64FromBlock(block, "timeout")) orelse 5000,
177
+ } };
178
+ }
179
+ if (std.mem.eql(u8, key, "waitUntilNotVisible")) {
180
+ return .{ .wait_not_visible = .{
181
+ .selector = try parseSelectorValueOrBlock(allocator, value, block),
182
+ .timeout_ms = (try parseOptionalU64FromBlock(block, "timeout")) orelse 5000,
183
+ } };
184
+ }
185
+ if (std.mem.eql(u8, key, "scrollUntilVisible")) {
186
+ var scroll = model.ScrollStep{
187
+ .selector = try parseSelectorValueOrBlock(allocator, value, block),
188
+ .direction = (try parseDirectionFromBlock(block)) orelse "down",
189
+ .timeout_ms = (try parseOptionalU64FromBlock(block, "timeout")) orelse 5000,
190
+ };
191
+ errdefer scroll.deinit(allocator);
192
+ if (try parseOptionalU64FromBlock(block, "timeoutMs")) |timeout| scroll.timeout_ms = timeout;
193
+ return .{ .scroll_until_visible = scroll };
194
+ }
195
+ if (std.mem.eql(u8, key, "waitForAnimationToEnd")) {
196
+ return .{ .sleep_ms = if (value.len == 0) ((try parseOptionalU64FromBlock(block, "timeout")) orelse 1000) else try parseU64(value) };
197
+ }
198
+ return error.UnsupportedImportCommand;
199
+ }
200
+
201
+ fn parseSelectorValueOrBlock(allocator: std.mem.Allocator, value: []const u8, block: []const []const u8) !model.SelectorSpec {
202
+ if (value.len > 0) return .{ .text = try parseScalarString(allocator, value) };
203
+ const parsed = try parseSelectorBlock(allocator, block);
204
+ if (!parsed.hasAny()) return error.ImportMissingSelector;
205
+ return parsed;
206
+ }
207
+
208
+ fn parseSelectorBlock(allocator: std.mem.Allocator, block: []const []const u8) !model.SelectorSpec {
209
+ var out = model.SelectorSpec{};
210
+ errdefer out.deinit(allocator);
211
+ for (block) |line| {
212
+ const trimmed = trim(line);
213
+ if (trimmed.len == 0 or std.mem.startsWith(u8, trimmed, "#")) continue;
214
+ const pair = splitColon(trimmed) orelse continue;
215
+ if (std.mem.eql(u8, pair.key, "id")) {
216
+ replaceString(allocator, &out.id, try parseScalarString(allocator, pair.value));
217
+ } else if (std.mem.eql(u8, pair.key, "text")) {
218
+ replaceString(allocator, &out.text, try parseScalarString(allocator, pair.value));
219
+ } else if (std.mem.eql(u8, pair.key, "textContains") or std.mem.eql(u8, pair.key, "contains")) {
220
+ replaceString(allocator, &out.text_contains, try parseScalarString(allocator, pair.value));
221
+ } else if (std.mem.eql(u8, pair.key, "contentDescription") or std.mem.eql(u8, pair.key, "contentDesc")) {
222
+ replaceString(allocator, &out.content_desc, try parseScalarString(allocator, pair.value));
223
+ } else if (std.mem.eql(u8, pair.key, "element") and pair.value.len > 0) {
224
+ if (!out.hasAny()) out.text = try parseScalarString(allocator, pair.value);
225
+ }
226
+ }
227
+ return out;
228
+ }
229
+
230
+ fn parseRequiredScalarOrBlockValue(allocator: std.mem.Allocator, value: []const u8, block: []const []const u8, block_key: []const u8) ![]const u8 {
231
+ if (value.len > 0) return try parseScalarString(allocator, value);
232
+ for (block) |line| {
233
+ const pair = splitColon(trim(line)) orelse continue;
234
+ if (std.mem.eql(u8, pair.key, block_key) or std.mem.eql(u8, pair.key, "value")) {
235
+ return try parseScalarString(allocator, pair.value);
236
+ }
237
+ }
238
+ return error.ImportMissingValue;
239
+ }
240
+
241
+ fn parseDirectionFromBlock(block: []const []const u8) !?[]const u8 {
242
+ for (block) |line| {
243
+ const pair = splitColon(trim(line)) orelse continue;
244
+ if (!std.mem.eql(u8, pair.key, "direction")) continue;
245
+ const value = normalizeScalar(pair.value);
246
+ if (equalsIgnoreCase(value, "DOWN")) return "down";
247
+ if (equalsIgnoreCase(value, "UP")) return "up";
248
+ return error.UnsupportedImportDirection;
249
+ }
250
+ return null;
251
+ }
252
+
253
+ fn parseOptionalU64FromBlock(block: []const []const u8, key: []const u8) !?u64 {
254
+ for (block) |line| {
255
+ const pair = splitColon(trim(line)) orelse continue;
256
+ if (std.mem.eql(u8, pair.key, key)) return try parseU64(pair.value);
257
+ }
258
+ return null;
259
+ }
260
+
261
+ fn parseOptionalU32FromBlock(block: []const []const u8, key: []const u8) !?u32 {
262
+ const value = (try parseOptionalU64FromBlock(block, key)) orelse return null;
263
+ if (value > std.math.maxInt(u32)) return error.ImportNumberOutOfRange;
264
+ return @intCast(value);
265
+ }
266
+
267
+ fn replaceString(allocator: std.mem.Allocator, target: *?[]const u8, value: []const u8) void {
268
+ if (target.*) |old| allocator.free(old);
269
+ target.* = value;
270
+ }
271
+
272
+ const Pair = struct {
273
+ key: []const u8,
274
+ value: []const u8,
275
+ };
276
+
277
+ fn splitColon(line: []const u8) ?Pair {
278
+ const index = std.mem.indexOfScalar(u8, line, ':') orelse return null;
279
+ return .{
280
+ .key = trim(line[0..index]),
281
+ .value = trim(line[index + 1 ..]),
282
+ };
283
+ }
284
+
285
+ fn parseScalarString(allocator: std.mem.Allocator, value: []const u8) ![]const u8 {
286
+ const normalized = normalizeScalar(value);
287
+ if (normalized.len >= 2 and normalized[0] == '"' and normalized[normalized.len - 1] == '"') {
288
+ return try unescapeDoubleQuoted(allocator, normalized[1 .. normalized.len - 1]);
289
+ }
290
+ if (normalized.len >= 2 and normalized[0] == '\'' and normalized[normalized.len - 1] == '\'') {
291
+ return try allocator.dupe(u8, normalized[1 .. normalized.len - 1]);
292
+ }
293
+ return try allocator.dupe(u8, normalized);
294
+ }
295
+
296
+ fn normalizeScalar(value: []const u8) []const u8 {
297
+ return trim(value);
298
+ }
299
+
300
+ fn unescapeDoubleQuoted(allocator: std.mem.Allocator, value: []const u8) ![]const u8 {
301
+ var out = std.ArrayList(u8).empty;
302
+ errdefer out.deinit(allocator);
303
+ var index: usize = 0;
304
+ while (index < value.len) : (index += 1) {
305
+ if (value[index] != '\\' or index + 1 >= value.len) {
306
+ try out.append(allocator, value[index]);
307
+ continue;
308
+ }
309
+ index += 1;
310
+ switch (value[index]) {
311
+ '"' => try out.append(allocator, '"'),
312
+ '\\' => try out.append(allocator, '\\'),
313
+ 'n' => try out.append(allocator, '\n'),
314
+ 'r' => try out.append(allocator, '\r'),
315
+ 't' => try out.append(allocator, '\t'),
316
+ else => try out.append(allocator, value[index]),
317
+ }
318
+ }
319
+ return try out.toOwnedSlice(allocator);
320
+ }
321
+
322
+ fn parseU64(value: []const u8) !u64 {
323
+ return try std.fmt.parseInt(u64, normalizeScalar(value), 10);
324
+ }
325
+
326
+ fn parseU32(value: []const u8) !?u32 {
327
+ const parsed = try parseU64(value);
328
+ if (parsed > std.math.maxInt(u32)) return error.ImportNumberOutOfRange;
329
+ return @intCast(parsed);
330
+ }
331
+
332
+ fn trim(value: []const u8) []const u8 {
333
+ return std.mem.trim(u8, value, " \t\r\n");
334
+ }
335
+
336
+ fn equalsIgnoreCase(left: []const u8, right: []const u8) bool {
337
+ return std.ascii.eqlIgnoreCase(left, right);
338
+ }
339
+
340
+ fn dupeOptional(allocator: std.mem.Allocator, value: ?[]const u8) !?[]const u8 {
341
+ if (value) |actual| return try allocator.dupe(u8, actual);
342
+ return null;
343
+ }
@@ -0,0 +1,100 @@
1
+ const trace = @import("trace.zig");
2
+ const model = @import("importer_model.zig");
3
+
4
+ pub fn writeScenarioJson(writer: anytype, imported: model.ImportedScenario) !void {
5
+ try writer.writeAll("{\n \"name\": ");
6
+ try trace.writeJsonString(writer, imported.name);
7
+ if (imported.app_id) |app_id| {
8
+ try writer.writeAll(",\n \"appId\": ");
9
+ try trace.writeJsonString(writer, app_id);
10
+ }
11
+ try writer.writeAll(",\n \"steps\": [\n");
12
+ for (imported.steps, 0..) |step, index| {
13
+ if (index > 0) try writer.writeAll(",\n");
14
+ try writer.writeAll(" ");
15
+ try writeStepJson(writer, step);
16
+ }
17
+ try writer.writeAll("\n ]\n}\n");
18
+ }
19
+
20
+ fn writeStepJson(writer: anytype, step: model.ImportedStep) !void {
21
+ switch (step) {
22
+ .launch => try writer.writeAll("{\"action\":\"launch\"}"),
23
+ .stop => try writer.writeAll("{\"action\":\"stop\"}"),
24
+ .clear_state => try writer.writeAll("{\"action\":\"clearState\"}"),
25
+ .snapshot => try writer.writeAll("{\"action\":\"snapshot\"}"),
26
+ .hide_keyboard => try writer.writeAll("{\"action\":\"hideKeyboard\"}"),
27
+ .press_back => try writer.writeAll("{\"action\":\"pressBack\"}"),
28
+ .open_link => |value| {
29
+ try writer.writeAll("{\"action\":\"openLink\",\"url\":");
30
+ try trace.writeJsonString(writer, value);
31
+ try writer.writeAll("}");
32
+ },
33
+ .tap => |wanted| {
34
+ try writer.writeAll("{\"action\":\"tap\",\"selector\":");
35
+ try writeSelectorJson(writer, wanted);
36
+ try writer.writeAll("}");
37
+ },
38
+ .type_text => |value| {
39
+ try writer.writeAll("{\"action\":\"typeText\",\"text\":");
40
+ try trace.writeJsonString(writer, value);
41
+ try writer.writeAll("}");
42
+ },
43
+ .erase_text => |value| try writer.print("{{\"action\":\"eraseText\",\"maxChars\":{d}}}", .{value}),
44
+ .assert_visible => |wanted| {
45
+ try writer.writeAll("{\"action\":\"assertVisible\",\"selector\":");
46
+ try writeSelectorJson(writer, wanted);
47
+ try writer.writeAll("}");
48
+ },
49
+ .assert_not_visible => |wanted| {
50
+ try writer.writeAll("{\"action\":\"assertNotVisible\",\"selector\":");
51
+ try writeSelectorJson(writer, wanted);
52
+ try writer.writeAll("}");
53
+ },
54
+ .wait_visible => |wait| {
55
+ try writer.writeAll("{\"action\":\"waitVisible\",\"selector\":");
56
+ try writeSelectorJson(writer, wait.selector);
57
+ try writer.print(",\"timeoutMs\":{d}}}", .{wait.timeout_ms});
58
+ },
59
+ .wait_not_visible => |wait| {
60
+ try writer.writeAll("{\"action\":\"waitNotVisible\",\"selector\":");
61
+ try writeSelectorJson(writer, wait.selector);
62
+ try writer.print(",\"timeoutMs\":{d}}}", .{wait.timeout_ms});
63
+ },
64
+ .scroll_until_visible => |scroll| {
65
+ try writer.writeAll("{\"action\":\"scrollUntilVisible\",\"selector\":");
66
+ try writeSelectorJson(writer, scroll.selector);
67
+ try writer.writeAll(",\"direction\":");
68
+ try trace.writeJsonString(writer, scroll.direction);
69
+ try writer.print(",\"timeoutMs\":{d}}}", .{scroll.timeout_ms});
70
+ },
71
+ .sleep_ms => |value| try writer.print("{{\"action\":\"sleep\",\"ms\":{d}}}", .{value}),
72
+ }
73
+ }
74
+
75
+ fn writeSelectorJson(writer: anytype, wanted: model.SelectorSpec) !void {
76
+ try writer.writeAll("{");
77
+ var first = true;
78
+ if (wanted.id) |value| {
79
+ try writeSelectorField(writer, "id", value, &first);
80
+ }
81
+ if (wanted.text) |value| {
82
+ try writeSelectorField(writer, "text", value, &first);
83
+ }
84
+ if (wanted.text_contains) |value| {
85
+ try writeSelectorField(writer, "textContains", value, &first);
86
+ }
87
+ if (wanted.content_desc) |value| {
88
+ try writeSelectorField(writer, "contentDesc", value, &first);
89
+ }
90
+ try writer.writeAll("}");
91
+ }
92
+
93
+ fn writeSelectorField(writer: anytype, key: []const u8, value: []const u8, first: *bool) !void {
94
+ if (!first.*) try writer.writeAll(",");
95
+ first.* = false;
96
+ try writer.writeAll("\"");
97
+ try writer.writeAll(key);
98
+ try writer.writeAll("\":");
99
+ try trace.writeJsonString(writer, value);
100
+ }
@@ -0,0 +1,103 @@
1
+ const std = @import("std");
2
+
3
+ pub const ImportOptions = struct {
4
+ name: ?[]const u8 = null,
5
+ app_id: ?[]const u8 = null,
6
+ force: bool = false,
7
+ };
8
+
9
+ pub const ImportResult = struct {
10
+ out_path: []const u8,
11
+ name: []const u8,
12
+ app_id: ?[]const u8,
13
+ step_count: usize,
14
+
15
+ pub fn deinit(self: ImportResult, allocator: std.mem.Allocator) void {
16
+ allocator.free(self.out_path);
17
+ allocator.free(self.name);
18
+ if (self.app_id) |value| allocator.free(value);
19
+ }
20
+ };
21
+
22
+ pub const ImportedScenario = struct {
23
+ name: []const u8,
24
+ app_id: ?[]const u8 = null,
25
+ steps: []ImportedStep,
26
+
27
+ pub fn deinit(self: ImportedScenario, allocator: std.mem.Allocator) void {
28
+ allocator.free(self.name);
29
+ if (self.app_id) |value| allocator.free(value);
30
+ for (self.steps) |step| step.deinit(allocator);
31
+ allocator.free(self.steps);
32
+ }
33
+ };
34
+
35
+ pub const SelectorSpec = struct {
36
+ id: ?[]const u8 = null,
37
+ text: ?[]const u8 = null,
38
+ text_contains: ?[]const u8 = null,
39
+ content_desc: ?[]const u8 = null,
40
+
41
+ pub fn deinit(self: SelectorSpec, allocator: std.mem.Allocator) void {
42
+ if (self.id) |value| allocator.free(value);
43
+ if (self.text) |value| allocator.free(value);
44
+ if (self.text_contains) |value| allocator.free(value);
45
+ if (self.content_desc) |value| allocator.free(value);
46
+ }
47
+
48
+ pub fn hasAny(self: SelectorSpec) bool {
49
+ return self.id != null or self.text != null or self.text_contains != null or self.content_desc != null;
50
+ }
51
+ };
52
+
53
+ pub const WaitSelector = struct {
54
+ selector: SelectorSpec,
55
+ timeout_ms: u64 = 5000,
56
+
57
+ pub fn deinit(self: WaitSelector, allocator: std.mem.Allocator) void {
58
+ self.selector.deinit(allocator);
59
+ }
60
+ };
61
+
62
+ pub const ScrollStep = struct {
63
+ selector: SelectorSpec,
64
+ direction: []const u8 = "down",
65
+ timeout_ms: u64 = 5000,
66
+
67
+ pub fn deinit(self: ScrollStep, allocator: std.mem.Allocator) void {
68
+ self.selector.deinit(allocator);
69
+ }
70
+ };
71
+
72
+ pub const ImportedStep = union(enum) {
73
+ launch,
74
+ stop,
75
+ clear_state,
76
+ snapshot,
77
+ hide_keyboard,
78
+ press_back,
79
+ open_link: []const u8,
80
+ tap: SelectorSpec,
81
+ type_text: []const u8,
82
+ erase_text: u32,
83
+ assert_visible: SelectorSpec,
84
+ assert_not_visible: SelectorSpec,
85
+ wait_visible: WaitSelector,
86
+ wait_not_visible: WaitSelector,
87
+ scroll_until_visible: ScrollStep,
88
+ sleep_ms: u64,
89
+
90
+ pub fn deinit(self: ImportedStep, allocator: std.mem.Allocator) void {
91
+ switch (self) {
92
+ .open_link => |value| allocator.free(value),
93
+ .tap => |value| value.deinit(allocator),
94
+ .type_text => |value| allocator.free(value),
95
+ .assert_visible => |value| value.deinit(allocator),
96
+ .assert_not_visible => |value| value.deinit(allocator),
97
+ .wait_visible => |value| value.deinit(allocator),
98
+ .wait_not_visible => |value| value.deinit(allocator),
99
+ .scroll_until_visible => |value| value.deinit(allocator),
100
+ else => {},
101
+ }
102
+ }
103
+ };