zeno-mobile-runner 0.1.3 → 0.2.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 (115) hide show
  1. package/CHANGELOG.md +192 -2
  2. package/FEATURES.md +50 -7
  3. package/README.md +168 -120
  4. package/build.zig.zon +3 -3
  5. package/clients/README.md +60 -3
  6. package/clients/go/README.md +12 -0
  7. package/clients/go/zmr/client.go +142 -0
  8. package/clients/kotlin/README.md +18 -1
  9. package/clients/kotlin/build.gradle.kts +1 -1
  10. package/clients/kotlin/src/main/kotlin/dev/zmr/ZmrClient.kt +76 -1
  11. package/clients/python/README.md +19 -0
  12. package/clients/python/pyproject.toml +1 -1
  13. package/clients/python/zmr_client.py +33 -0
  14. package/clients/rust/Cargo.lock +1 -1
  15. package/clients/rust/Cargo.toml +1 -1
  16. package/clients/rust/README.md +25 -1
  17. package/clients/rust/src/lib.rs +201 -0
  18. package/clients/swift/README.md +18 -0
  19. package/clients/swift/Sources/ZMRClient/ZMRClient.swift +82 -0
  20. package/clients/typescript/README.md +16 -0
  21. package/clients/typescript/index.d.ts +12 -0
  22. package/clients/typescript/index.mjs +16 -0
  23. package/clients/typescript/package.json +1 -1
  24. package/docs/agent-discovery.md +151 -22
  25. package/docs/ai-agents.md +99 -11
  26. package/docs/benchmarking.md +49 -3
  27. package/docs/benchmarks/2026-06-09-android-workflow.md +73 -0
  28. package/docs/benchmarks/2026-06-09-android-workflow.results.jsonl +20 -0
  29. package/docs/benchmarks/2026-06-09-framework-baseline-status.md +32 -0
  30. package/docs/benchmarks/2026-06-09-ios-appium-comparison.md +115 -0
  31. package/docs/benchmarks/2026-06-09-ios-appium-comparison.results.jsonl +40 -0
  32. package/docs/benchmarks/2026-06-09-ios-demo.md +90 -0
  33. package/docs/benchmarks/2026-06-09-ios-demo.results.jsonl +20 -0
  34. package/docs/benchmarks/2026-06-09-ios-maestro-comparison.md +128 -0
  35. package/docs/benchmarks/2026-06-09-ios-maestro-comparison.results.jsonl +40 -0
  36. package/docs/benchmarks/2026-06-09-ios-workflow-comparison.md +143 -0
  37. package/docs/benchmarks/2026-06-09-ios-workflow-comparison.results.jsonl +40 -0
  38. package/docs/benchmarks/2026-06-09-ios-xctest-floor.md +106 -0
  39. package/docs/benchmarks/2026-06-09-ios-xctest-floor.results.jsonl +40 -0
  40. package/docs/benchmarks/README.md +36 -0
  41. package/docs/benchmarks/benchmark-lab-v1.json +155 -0
  42. package/docs/benchmarks/benchmark-lab-v1.md +95 -0
  43. package/docs/clients.md +26 -6
  44. package/docs/demo.md +40 -1
  45. package/docs/expo-smoke.md +8 -8
  46. package/docs/frameworks.md +10 -0
  47. package/docs/install.md +3 -2
  48. package/docs/npm.md +100 -4
  49. package/docs/production-readiness.md +123 -0
  50. package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
  51. package/docs/protocol.md +215 -16
  52. package/docs/scenario-authoring.md +18 -0
  53. package/docs/trace-privacy.md +9 -0
  54. package/docs/troubleshooting.md +7 -1
  55. package/examples/android-workflow.json +79 -0
  56. package/examples/ios-shim-workflow.json +79 -0
  57. package/examples/react-native-expo-workflow.json +75 -0
  58. package/npm/agents.mjs +16 -0
  59. package/npm/commands.mjs +9 -5
  60. package/package.json +6 -1
  61. package/prebuilds/darwin-arm64/zmr +0 -0
  62. package/prebuilds/darwin-x64/zmr +0 -0
  63. package/prebuilds/linux-arm64/zmr +0 -0
  64. package/prebuilds/linux-x64/zmr +0 -0
  65. package/schemas/README.md +4 -0
  66. package/schemas/discover-output.schema.json +83 -0
  67. package/schemas/draft-output.schema.json +58 -0
  68. package/schemas/explore-output.schema.json +94 -0
  69. package/schemas/inspect-output.schema.json +88 -0
  70. package/schemas/run-output.schema.json +2 -0
  71. package/scripts/benchmark-lab.py +253 -0
  72. package/scripts/create-android-demo-app.sh +324 -29
  73. package/scripts/create-ios-demo-app.sh +174 -7
  74. package/scripts/create-react-native-expo-demo-app.sh +727 -0
  75. package/scripts/demo.sh +3 -0
  76. package/scripts/install-ios-shim.sh +2 -2
  77. package/scripts/release-readiness.py +43 -0
  78. package/scripts/run-android-pilot.sh +35 -9
  79. package/scripts/run-ios-pilot.sh +11 -4
  80. package/shims/ios/ZMRShim.swift +10 -0
  81. package/shims/ios/ZMRShimUITestCase.swift +42 -0
  82. package/shims/ios/protocol.md +1 -0
  83. package/skills/zmr-mobile-testing/SKILL.md +28 -3
  84. package/src/cli_discover.zig +239 -0
  85. package/src/cli_draft.zig +924 -0
  86. package/src/cli_explore.zig +136 -0
  87. package/src/cli_import.zig +31 -15
  88. package/src/cli_inspect.zig +310 -0
  89. package/src/cli_output.zig +26 -2
  90. package/src/cli_run.zig +28 -0
  91. package/src/cli_trace.zig +45 -15
  92. package/src/cli_validate.zig +12 -6
  93. package/src/errors.zig +9 -0
  94. package/src/ios.zig +49 -12
  95. package/src/ios_shim.zig +36 -2
  96. package/src/json_rpc_methods.zig +85 -11
  97. package/src/json_rpc_params.zig +8 -0
  98. package/src/json_rpc_protocol.zig +1 -1
  99. package/src/json_rpc_trace.zig +112 -0
  100. package/src/main.zig +27 -2
  101. package/src/mcp.zig +209 -6
  102. package/src/mcp_protocol.zig +29 -1
  103. package/src/mcp_trace.zig +126 -4
  104. package/src/report.zig +186 -0
  105. package/src/runner.zig +26 -4
  106. package/src/runner_actions.zig +10 -0
  107. package/src/runner_diagnostics.zig +31 -1
  108. package/src/runner_events.zig +70 -7
  109. package/src/runner_native.zig +17 -1
  110. package/src/runner_waits.zig +82 -19
  111. package/src/scaffold.zig +28 -12
  112. package/src/scenario.zig +32 -4
  113. package/src/schema_registry.zig +4 -0
  114. package/src/version.zig +1 -1
  115. package/viewer/app.js +23 -3
package/src/cli_trace.zig CHANGED
@@ -6,6 +6,7 @@ const report = @import("report.zig");
6
6
  pub const ReportArgs = struct {
7
7
  input_path: []const u8,
8
8
  out_path: ?[]const u8 = null,
9
+ junit_path: ?[]const u8 = null,
9
10
  };
10
11
 
11
12
  pub const ExplainArgs = struct {
@@ -21,21 +22,34 @@ pub const ExportArgs = struct {
21
22
  };
22
23
 
23
24
  pub fn parseReportArgs(args: []const []const u8) !ReportArgs {
24
- if (args.len == 0) return error.MissingReportInput;
25
- var parsed = ReportArgs{ .input_path = args[0] };
25
+ var input_path: ?[]const u8 = null;
26
+ var out_path: ?[]const u8 = null;
27
+ var junit_path: ?[]const u8 = null;
26
28
 
27
- var index: usize = 1;
29
+ var index: usize = 0;
28
30
  while (index < args.len) : (index += 1) {
29
31
  const arg = args[index];
30
32
  if (std.mem.eql(u8, arg, "--out")) {
31
33
  index += 1;
32
- parsed.out_path = if (index < args.len) args[index] else return error.MissingReportOutput;
34
+ out_path = if (index < args.len) args[index] else return error.MissingReportOutput;
35
+ } else if (std.mem.eql(u8, arg, "--junit")) {
36
+ index += 1;
37
+ junit_path = if (index < args.len) args[index] else return error.MissingJUnitOutput;
38
+ } else if (std.mem.startsWith(u8, arg, "--")) {
39
+ return error.UnknownFlag;
40
+ } else if (input_path == null) {
41
+ input_path = arg;
33
42
  } else {
34
43
  return error.UnknownFlag;
35
44
  }
36
45
  }
37
- if (parsed.out_path == null) return error.MissingReportOutput;
38
- return parsed;
46
+ if (input_path == null) return error.MissingReportInput;
47
+ if (out_path == null) return error.MissingReportOutput;
48
+ return ReportArgs{
49
+ .input_path = input_path.?,
50
+ .out_path = out_path,
51
+ .junit_path = junit_path,
52
+ };
39
53
  }
40
54
 
41
55
  pub fn parseExplainArgs(args: []const []const u8) !ExplainArgs {
@@ -54,26 +68,38 @@ pub fn parseExplainArgs(args: []const []const u8) !ExplainArgs {
54
68
  }
55
69
 
56
70
  pub fn parseExportArgs(args: []const []const u8) !ExportArgs {
57
- if (args.len == 0) return error.MissingTraceDir;
58
- var parsed = ExportArgs{ .trace_dir = args[0] };
71
+ var trace_dir: ?[]const u8 = null;
72
+ var out_path: ?[]const u8 = null;
73
+ var redact = false;
74
+ var omit_screenshots = false;
59
75
 
60
- var index: usize = 1;
76
+ var index: usize = 0;
61
77
  while (index < args.len) : (index += 1) {
62
78
  const arg = args[index];
63
79
  if (std.mem.eql(u8, arg, "--out")) {
64
80
  index += 1;
65
- parsed.out_path = if (index < args.len) args[index] else return error.MissingTraceBundleOutput;
81
+ out_path = if (index < args.len) args[index] else return error.MissingTraceBundleOutput;
66
82
  } else if (std.mem.eql(u8, arg, "--redact")) {
67
- parsed.redact = true;
83
+ redact = true;
68
84
  } else if (std.mem.eql(u8, arg, "--omit-screenshots")) {
69
- parsed.redact = true;
70
- parsed.omit_screenshots = true;
85
+ redact = true;
86
+ omit_screenshots = true;
87
+ } else if (std.mem.startsWith(u8, arg, "--")) {
88
+ return error.UnknownFlag;
89
+ } else if (trace_dir == null) {
90
+ trace_dir = arg;
71
91
  } else {
72
92
  return error.UnknownFlag;
73
93
  }
74
94
  }
75
- if (parsed.out_path == null) return error.MissingTraceBundleOutput;
76
- return parsed;
95
+ if (trace_dir == null) return error.MissingTraceDir;
96
+ if (out_path == null) return error.MissingTraceBundleOutput;
97
+ return ExportArgs{
98
+ .trace_dir = trace_dir.?,
99
+ .out_path = out_path,
100
+ .redact = redact,
101
+ .omit_screenshots = omit_screenshots,
102
+ };
77
103
  }
78
104
 
79
105
  pub fn runReport(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !void {
@@ -84,6 +110,10 @@ pub fn runReport(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !
84
110
  const parsed = try parseReportArgs(raw_args.items);
85
111
  try report.writeHtmlReport(allocator, parsed.input_path, parsed.out_path.?);
86
112
  try std.fs.File.stdout().deprecatedWriter().print("wrote {s}\n", .{parsed.out_path.?});
113
+ if (parsed.junit_path) |junit_path| {
114
+ try report.writeJUnitReport(allocator, parsed.input_path, junit_path);
115
+ try std.fs.File.stdout().deprecatedWriter().print("wrote {s}\n", .{junit_path});
116
+ }
87
117
  }
88
118
 
89
119
  pub fn runExplain(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !void {
@@ -9,17 +9,23 @@ pub const ParsedArgs = struct {
9
9
  };
10
10
 
11
11
  pub fn parseArgs(args: []const []const u8) !ParsedArgs {
12
- if (args.len == 0) return error.MissingScenarioPath;
13
-
14
- var parsed = ParsedArgs{ .path = args[0] };
15
- for (args[1..]) |arg| {
12
+ var path: ?[]const u8 = null;
13
+ var json = false;
14
+ for (args) |arg| {
16
15
  if (std.mem.eql(u8, arg, "--json")) {
17
- parsed.json = true;
16
+ json = true;
17
+ } else if (std.mem.startsWith(u8, arg, "--")) {
18
+ return error.UnknownFlag;
19
+ } else if (path == null) {
20
+ path = arg;
18
21
  } else {
19
22
  return error.UnknownFlag;
20
23
  }
21
24
  }
22
- return parsed;
25
+ return ParsedArgs{
26
+ .path = path orelse return error.MissingScenarioPath,
27
+ .json = json,
28
+ };
23
29
  }
24
30
 
25
31
  pub fn run(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !void {
package/src/errors.zig CHANGED
@@ -10,6 +10,12 @@ pub fn classify(err: anyerror) PublicError {
10
10
  error.MissingScenarioPath => .{ .code = "cli.missing_scenario", .message = "missing scenario path" },
11
11
  error.MissingDeviceSerial => .{ .code = "cli.missing_device", .message = "missing device serial" },
12
12
  error.MissingTraceDir => .{ .code = "cli.missing_trace_dir", .message = "missing trace directory" },
13
+ error.MissingReportInput => .{ .code = "cli.missing_report_input", .message = "missing report input path" },
14
+ error.MissingReportOutput => .{ .code = "cli.missing_report_output", .message = "missing report output path" },
15
+ error.MissingDraftOut => .{ .code = "cli.missing_draft_out", .message = "missing draft output path" },
16
+ error.MissingDiscoverOut => .{ .code = "cli.missing_discover_out", .message = "missing discover output path" },
17
+ error.MissingJUnitOutput => .{ .code = "cli.missing_junit_output", .message = "missing JUnit output path" },
18
+ error.MissingTraceBundleOutput => .{ .code = "cli.missing_trace_bundle_output", .message = "missing trace bundle output path" },
13
19
  error.MissingAppId => .{ .code = "cli.missing_app_id", .message = "missing app id" },
14
20
  error.MissingAdbPath => .{ .code = "cli.missing_adb_path", .message = "missing adb path" },
15
21
  error.MissingXcrunPath => .{ .code = "cli.missing_xcrun_path", .message = "missing xcrun path" },
@@ -19,6 +25,9 @@ pub fn classify(err: anyerror) PublicError {
19
25
  error.UnknownFlag => .{ .code = "cli.unknown_flag", .message = "unknown flag" },
20
26
  error.MissingParam => .{ .code = "cli.missing_param", .message = "missing parameter" },
21
27
  error.FileNotFound => .{ .code = "scenario.file_not_found", .message = "scenario file was not found" },
28
+ error.SemanticSnapshotMissing => .{ .code = "draft.no_snapshot", .message = "no semantic snapshot was found in the trace" },
29
+ error.InvalidTraceManifest => .{ .code = "trace.invalid_manifest", .message = "trace manifest is invalid" },
30
+ error.InvalidSemanticSnapshot => .{ .code = "trace.invalid_semantic_snapshot", .message = "semantic snapshot is invalid" },
22
31
  error.UnsupportedPlatform => .{ .code = "cli.unsupported_platform", .message = "unsupported platform" },
23
32
  error.UnsupportedTransport => .{ .code = "cli.unsupported_transport", .message = "unsupported transport" },
24
33
  error.ScenarioMustBeObject,
package/src/ios.zig CHANGED
@@ -10,8 +10,10 @@ const types = @import("types.zig");
10
10
 
11
11
  const default_max_output = 32 * 1024 * 1024;
12
12
  // Clean iOS prebuilds can force the XCTest shim script through a full native
13
- // dependency build before it can answer the first selector query.
14
- const shim_timeout_ms = 5_400_000;
13
+ // dependency build before it can answer the first selector query. Slower CI
14
+ // hardware can override the default with ZMR_IOS_SHIM_TIMEOUT_MS.
15
+ const default_shim_timeout_ms = 5_400_000;
16
+ const shim_timeout_env = "ZMR_IOS_SHIM_TIMEOUT_MS";
15
17
  const shim_best_effort_timeout_ms = 10_000;
16
18
  const shim_command_attempts = 2;
17
19
  const shim_bootstrap_retry_delay_ms = 500;
@@ -126,7 +128,9 @@ pub const IosDevice = struct {
126
128
  const result = try self.runSimctl(&.{ "openurl", self.target(), url }, default_max_output);
127
129
  defer result.deinit(self.allocator);
128
130
  try result.ensureSuccess();
129
- self.acceptOpenURLConfirmationBestEffort();
131
+ if (urlMayNeedOpenConfirmation(url)) {
132
+ self.acceptOpenURLConfirmationBestEffort();
133
+ }
130
134
  }
131
135
 
132
136
  pub fn tap(self: *IosDevice, x: i32, y: i32) !void {
@@ -212,15 +216,22 @@ pub const IosDevice = struct {
212
216
 
213
217
  const active_package = try self.allocator.dupe(u8, self.app_id);
214
218
  errdefer self.allocator.free(active_package);
215
- const nodes = if (self.shim_path != null)
216
- self.snapshotNodesFromShim() catch |err| blk: {
219
+ var shim_viewport: ?types.Viewport = null;
220
+ const nodes = if (self.shim_path != null) blk: {
221
+ const shim_snapshot = self.snapshotFromShim() catch |err| {
217
222
  if (screenshot_artifact == null) return err;
218
223
  if (writer) |tw| try self.recordSnapshotSemanticFailure(tw, screenshot_artifact.?, err);
219
224
  break :blk try self.allocator.alloc(types.UiNode, 0);
220
- }
221
- else
222
- try self.allocator.alloc(types.UiNode, 0);
225
+ };
226
+ shim_viewport = shim_snapshot.viewport;
227
+ break :blk shim_snapshot.nodes;
228
+ } else try self.allocator.alloc(types.UiNode, 0);
223
229
  errdefer self.allocator.free(nodes);
230
+ if (shim_viewport) |value| {
231
+ if (value.width > 0 and value.height > 0) {
232
+ viewport = value;
233
+ }
234
+ }
224
235
 
225
236
  return .{
226
237
  .id = id,
@@ -260,10 +271,10 @@ pub const IosDevice = struct {
260
271
  return try self.allocator.dupe(u8, result.stdout);
261
272
  }
262
273
 
263
- fn snapshotNodesFromShim(self: *IosDevice) ![]types.UiNode {
274
+ fn snapshotFromShim(self: *IosDevice) !ios_shim.SnapshotResponse {
264
275
  const response = try self.runShim(.{ .kind = .snapshot });
265
276
  defer self.allocator.free(response);
266
- return try ios_shim.parseSnapshotNodes(self.allocator, response);
277
+ return try ios_shim.parseSnapshotResponse(self.allocator, response);
267
278
  }
268
279
 
269
280
  fn recordSnapshotSemanticFailure(self: *IosDevice, writer: *trace.TraceWriter, screenshot_artifact: []const u8, err: anyerror) !void {
@@ -319,7 +330,7 @@ pub const IosDevice = struct {
319
330
  }
320
331
 
321
332
  fn runShim(self: *IosDevice, shim_command: ios_shim.Command) ![]u8 {
322
- return self.runShimWithTimeout(shim_command, shim_timeout_ms);
333
+ return self.runShimWithTimeout(shim_command, shimTimeoutMs());
323
334
  }
324
335
 
325
336
  fn runShimWithTimeout(self: *IosDevice, shim_command: ios_shim.Command, timeout_ms: u64) ![]u8 {
@@ -384,6 +395,25 @@ fn isTransientShimBootstrapFailure(result: command.ExecResult) bool {
384
395
  std.mem.indexOf(u8, result.stderr, "operation never finished bootstrapping") != null;
385
396
  }
386
397
 
398
+ fn shimTimeoutMs() u64 {
399
+ return parseShimTimeoutMs(std.posix.getenv(shim_timeout_env));
400
+ }
401
+
402
+ fn parseShimTimeoutMs(raw: ?[]const u8) u64 {
403
+ const value = raw orelse return default_shim_timeout_ms;
404
+ const parsed = std.fmt.parseInt(u64, value, 10) catch return default_shim_timeout_ms;
405
+ if (parsed == 0) return default_shim_timeout_ms;
406
+ return parsed;
407
+ }
408
+
409
+ fn urlMayNeedOpenConfirmation(url: []const u8) bool {
410
+ return startsWithIgnoreCase(url, "http://") or startsWithIgnoreCase(url, "https://");
411
+ }
412
+
413
+ fn startsWithIgnoreCase(value: []const u8, prefix: []const u8) bool {
414
+ return value.len >= prefix.len and std.ascii.eqlIgnoreCase(value[0..prefix.len], prefix);
415
+ }
416
+
387
417
  pub fn listDevices(allocator: std.mem.Allocator, xcrun_path: []const u8) ![]types.DeviceInfo {
388
418
  return try ios_devices.listSimulators(allocator, xcrun_path);
389
419
  }
@@ -401,6 +431,13 @@ pub fn parsePhysicalDevicesJson(allocator: std.mem.Allocator, content: []const u
401
431
  }
402
432
 
403
433
  test "ios xctest shim timeout allows cold xcodebuild startup" {
404
- try std.testing.expect(shim_timeout_ms >= 5_400_000);
434
+ try std.testing.expect(default_shim_timeout_ms >= 5_400_000);
405
435
  try std.testing.expect(shim_best_effort_timeout_ms <= 15_000);
406
436
  }
437
+
438
+ test "ios xctest shim timeout env override" {
439
+ try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs(null));
440
+ try std.testing.expectEqual(@as(u64, 600_000), parseShimTimeoutMs("600000"));
441
+ try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs("not-a-number"));
442
+ try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs("0"));
443
+ }
package/src/ios_shim.zig CHANGED
@@ -32,6 +32,16 @@ pub const Command = struct {
32
32
  max_chars: ?u32 = null,
33
33
  };
34
34
 
35
+ pub const SnapshotResponse = struct {
36
+ nodes: []types.UiNode,
37
+ viewport: types.Viewport = .{},
38
+
39
+ pub fn deinit(self: SnapshotResponse, allocator: std.mem.Allocator) void {
40
+ for (self.nodes) |node| node.deinit(allocator);
41
+ allocator.free(self.nodes);
42
+ }
43
+ };
44
+
35
45
  pub fn writeCommandJson(writer: anytype, command: Command) !void {
36
46
  try writer.writeAll("{\"cmd\":");
37
47
  try trace.writeJsonString(writer, commandName(command.kind));
@@ -54,12 +64,13 @@ pub fn writeCommandJson(writer: anytype, command: Command) !void {
54
64
  try writer.writeAll("}\n");
55
65
  }
56
66
 
57
- pub fn parseSnapshotNodes(allocator: std.mem.Allocator, content: []const u8) ![]types.UiNode {
67
+ pub fn parseSnapshotResponse(allocator: std.mem.Allocator, content: []const u8) !SnapshotResponse {
58
68
  const parsed = try std.json.parseFromSlice(std.json.Value, allocator, content, .{});
59
69
  defer parsed.deinit();
60
70
  if (parsed.value != .object) return error.IosShimResponseMustBeObject;
61
71
  const status = fieldString(parsed.value.object, "status") orelse return error.IosShimMissingStatus;
62
72
  if (!std.mem.eql(u8, status, "ok")) return error.IosShimResponseNotOk;
73
+ const viewport = parseViewport(parsed.value.object.get("viewport"));
63
74
  const nodes_value = parsed.value.object.get("nodes") orelse return error.IosShimMissingNodes;
64
75
  if (nodes_value != .array) return error.IosShimNodesMustBeArray;
65
76
 
@@ -97,7 +108,15 @@ pub fn parseSnapshotNodes(allocator: std.mem.Allocator, content: []const u8) ![]
97
108
  });
98
109
  }
99
110
 
100
- return try nodes.toOwnedSlice(allocator);
111
+ return .{
112
+ .nodes = try nodes.toOwnedSlice(allocator),
113
+ .viewport = viewport,
114
+ };
115
+ }
116
+
117
+ pub fn parseSnapshotNodes(allocator: std.mem.Allocator, content: []const u8) ![]types.UiNode {
118
+ const snapshot = try parseSnapshotResponse(allocator, content);
119
+ return snapshot.nodes;
101
120
  }
102
121
 
103
122
  pub fn parseOkResponse(content: []const u8) !void {
@@ -278,6 +297,21 @@ fn intField(object: std.json.ObjectMap, key: []const u8) !i32 {
278
297
  return @intCast(value.integer);
279
298
  }
280
299
 
300
+ fn parseViewport(value: ?std.json.Value) types.Viewport {
301
+ const actual = value orelse return .{};
302
+ if (actual != .object) return .{};
303
+ const width = optionalU32Field(actual.object, "width") orelse return .{};
304
+ const height = optionalU32Field(actual.object, "height") orelse return .{};
305
+ return .{ .width = width, .height = height };
306
+ }
307
+
308
+ fn optionalU32Field(object: std.json.ObjectMap, key: []const u8) ?u32 {
309
+ const value = object.get(key) orelse return null;
310
+ if (value != .integer) return null;
311
+ if (value.integer < 0 or value.integer > std.math.maxInt(u32)) return null;
312
+ return @intCast(value.integer);
313
+ }
314
+
281
315
  fn dupeOptional(allocator: std.mem.Allocator, value: ?[]const u8) !?[]const u8 {
282
316
  if (value) |actual| return try allocator.dupe(u8, actual);
283
317
  return null;
@@ -1,12 +1,15 @@
1
1
  const std = @import("std");
2
2
  const bundle = @import("bundle.zig");
3
+ const cli_output = @import("cli_output.zig");
3
4
  const observation = @import("json_rpc_observation.zig");
4
5
  const params_parser = @import("json_rpc_params.zig");
5
6
  const protocol = @import("json_rpc_protocol.zig");
6
7
  const rpc_trace = @import("json_rpc_trace.zig");
7
8
  const runner = @import("runner.zig");
9
+ const runner_events = @import("runner_events.zig");
8
10
  const selector = @import("selector.zig");
9
11
  const trace = @import("trace.zig");
12
+ const validation = @import("validation.zig");
10
13
 
11
14
  pub fn dispatchMethod(
12
15
  allocator: std.mem.Allocator,
@@ -18,11 +21,12 @@ pub fn dispatchMethod(
18
21
  live_trace: ?*trace.TraceWriter,
19
22
  ) !void {
20
23
  if (try dispatchCoreMethod(allocator, device, method, id, writer)) return;
21
- if (try dispatchAppMethod(device, method, params, id, writer)) return;
24
+ if (try dispatchAppMethod(device, method, params, id, writer, live_trace)) return;
22
25
  if (try dispatchObserveMethod(device, method, id, writer, live_trace)) return;
23
26
  if (try dispatchUiMethod(allocator, device, method, params, id, writer, live_trace)) return;
24
27
  if (try dispatchWaitMethod(allocator, device, method, params, id, writer, live_trace)) return;
25
28
  if (try dispatchAssertMethod(allocator, device, method, params, id, writer, live_trace)) return;
29
+ if (try dispatchScenarioMethod(allocator, method, params, id, writer)) return;
26
30
  if (try dispatchTraceMethod(allocator, method, params, id, writer, live_trace)) return;
27
31
 
28
32
  try protocol.writeError(writer, id, -32601, "method not found");
@@ -65,37 +69,53 @@ fn dispatchAppMethod(
65
69
  params: ?std.json.Value,
66
70
  id: ?std.json.Value,
67
71
  writer: anytype,
72
+ live_trace: ?*trace.TraceWriter,
68
73
  ) !bool {
69
74
  if (std.mem.eql(u8, method, "app.install")) {
70
75
  const path = try params_parser.requiredString(params, "path");
71
76
  try device.install(path);
77
+ if (live_trace) |tw| try rpc_trace.recordSimplePayload(tw, "app.install", "path", path);
72
78
  try protocol.writeResultRaw(writer, id, "true");
73
79
  return true;
74
80
  }
75
81
  if (std.mem.eql(u8, method, "app.launch")) {
76
82
  try device.launch();
83
+ if (live_trace) |tw| try tw.recordEvent("app.launch", "{\"status\":\"ok\"}");
77
84
  try protocol.writeResultRaw(writer, id, "true");
78
85
  return true;
79
86
  }
80
87
  if (std.mem.eql(u8, method, "app.stop")) {
81
88
  try device.stop();
89
+ if (live_trace) |tw| try tw.recordEvent("app.stop", "{\"status\":\"ok\"}");
82
90
  try protocol.writeResultRaw(writer, id, "true");
83
91
  return true;
84
92
  }
85
93
  if (std.mem.eql(u8, method, "app.clearState")) {
86
94
  try device.clearState();
95
+ if (live_trace) |tw| try tw.recordEvent("app.clearState", "{\"status\":\"ok\"}");
87
96
  try protocol.writeResultRaw(writer, id, "true");
88
97
  return true;
89
98
  }
90
99
  if (std.mem.eql(u8, method, "app.openLink")) {
91
100
  const url = try params_parser.requiredString(params, "url");
92
101
  try device.openLink(url);
102
+ if (live_trace) |tw| try recordAppOpenLink(tw, url);
93
103
  try protocol.writeResultRaw(writer, id, "true");
94
104
  return true;
95
105
  }
96
106
  return false;
97
107
  }
98
108
 
109
+ fn recordAppOpenLink(tw: *trace.TraceWriter, url: []const u8) !void {
110
+ var payload = std.ArrayList(u8).empty;
111
+ defer payload.deinit(tw.allocator);
112
+ const writer = payload.writer(tw.allocator);
113
+ try writer.writeAll("{\"status\":\"ok\",\"url\":");
114
+ try trace.writeJsonString(writer, url);
115
+ try writer.writeAll("}");
116
+ try tw.recordEvent("app.openLink", payload.items);
117
+ }
118
+
99
119
  fn dispatchObserveMethod(
100
120
  device: anytype,
101
121
  method: []const u8,
@@ -175,14 +195,13 @@ fn dispatchUiMethod(
175
195
  return true;
176
196
  }
177
197
  if (std.mem.eql(u8, method, "ui.swipe")) {
178
- try device.swipe(
179
- try params_parser.requiredI32(params, "x1"),
180
- try params_parser.requiredI32(params, "y1"),
181
- try params_parser.requiredI32(params, "x2"),
182
- try params_parser.requiredI32(params, "y2"),
183
- @as(u32, @intCast(try params_parser.optionalU64(params, "durationMs", 300))),
184
- );
185
- if (live_trace) |tw| try tw.recordEvent("ui.swipe", "{\"status\":\"ok\"}");
198
+ const x1 = try params_parser.requiredI32(params, "x1");
199
+ const y1 = try params_parser.requiredI32(params, "y1");
200
+ const x2 = try params_parser.requiredI32(params, "x2");
201
+ const y2 = try params_parser.requiredI32(params, "y2");
202
+ const duration_ms = @as(u32, @intCast(try params_parser.optionalU64(params, "durationMs", 300)));
203
+ try device.swipe(x1, y1, x2, y2, duration_ms);
204
+ if (live_trace) |tw| try runner_events.recordSwipe(tw, x1, y1, x2, y2, duration_ms);
186
205
  try protocol.writeResultRaw(writer, id, "true");
187
206
  return true;
188
207
  }
@@ -263,14 +282,14 @@ fn dispatchAssertMethod(
263
282
  if (std.mem.eql(u8, method, "assert.visible")) {
264
283
  const wanted = try params_parser.selectorParam(allocator, params);
265
284
  defer wanted.deinit(allocator);
266
- if (!try runner.waitUntilVisible(device, wanted, try params_parser.optionalU64(params, "timeoutMs", 5000), live_trace, .{})) return error.AssertionFailed;
285
+ if (!try runner.assertVisible(device, wanted, try params_parser.optionalU64(params, "timeoutMs", 5000), live_trace, .{})) return error.AssertionFailed;
267
286
  try protocol.writeResultRaw(writer, id, "true");
268
287
  return true;
269
288
  }
270
289
  if (std.mem.eql(u8, method, "assert.notVisible")) {
271
290
  const wanted = try params_parser.selectorParam(allocator, params);
272
291
  defer wanted.deinit(allocator);
273
- if (!try runner.waitUntilNotVisible(device, wanted, try params_parser.optionalU64(params, "timeoutMs", 5000), live_trace, .{})) return error.AssertionFailed;
292
+ if (!try runner.assertNotVisible(device, wanted, try params_parser.optionalU64(params, "timeoutMs", 5000), live_trace, .{})) return error.AssertionFailed;
274
293
  try protocol.writeResultRaw(writer, id, "true");
275
294
  return true;
276
295
  }
@@ -282,6 +301,26 @@ fn dispatchAssertMethod(
282
301
  return false;
283
302
  }
284
303
 
304
+ fn dispatchScenarioMethod(
305
+ allocator: std.mem.Allocator,
306
+ method: []const u8,
307
+ params: ?std.json.Value,
308
+ id: ?std.json.Value,
309
+ writer: anytype,
310
+ ) !bool {
311
+ if (std.mem.eql(u8, method, "scenario.validate")) {
312
+ const path = try params_parser.requiredString(params, "path");
313
+ var result = try validation.validateFile(allocator, path);
314
+ defer result.deinit(allocator);
315
+ var payload = std.ArrayList(u8).empty;
316
+ defer payload.deinit(allocator);
317
+ try cli_output.writeValidationJson(payload.writer(allocator), path, result);
318
+ try protocol.writeResultRaw(writer, id, std.mem.trimRight(u8, payload.items, " \t\r\n"));
319
+ return true;
320
+ }
321
+ return false;
322
+ }
323
+
285
324
  fn dispatchTraceMethod(
286
325
  allocator: std.mem.Allocator,
287
326
  method: []const u8,
@@ -296,6 +335,41 @@ fn dispatchTraceMethod(
296
335
  try rpc_trace.writeEventsResult(allocator, writer, id, live_trace, after_seq, limit);
297
336
  return true;
298
337
  }
338
+ if (std.mem.eql(u8, method, "trace.discover")) {
339
+ try rpc_trace.writeDiscoverResult(
340
+ allocator,
341
+ writer,
342
+ id,
343
+ live_trace,
344
+ try params_parser.requiredString(params, "out"),
345
+ try params_parser.optionalBool(params, "includeActions", false),
346
+ try params_parser.optionalBool(params, "validate", false),
347
+ try params_parser.optionalBool(params, "force", false),
348
+ try params_parser.optionalString(params, "name"),
349
+ try params_parser.optionalString(params, "appId"),
350
+ );
351
+ return true;
352
+ }
353
+ if (std.mem.eql(u8, method, "trace.explore")) {
354
+ try rpc_trace.writeExploreResult(
355
+ allocator,
356
+ writer,
357
+ id,
358
+ live_trace,
359
+ try params_parser.requiredString(params, "out"),
360
+ try params_parser.requiredString(params, "goal"),
361
+ try params_parser.optionalBool(params, "includeActions", false),
362
+ try params_parser.optionalBool(params, "validate", false),
363
+ try params_parser.optionalBool(params, "force", false),
364
+ try params_parser.optionalString(params, "name"),
365
+ try params_parser.optionalString(params, "appId"),
366
+ );
367
+ return true;
368
+ }
369
+ if (std.mem.eql(u8, method, "trace.explain")) {
370
+ try rpc_trace.writeExplainResult(allocator, writer, id, live_trace);
371
+ return true;
372
+ }
299
373
  if (std.mem.eql(u8, method, "trace.export")) {
300
374
  const tw = live_trace orelse {
301
375
  try protocol.writeTraceDisabledResult(writer, id);
@@ -31,6 +31,14 @@ pub fn requiredString(params: ?std.json.Value, key: []const u8) ![]const u8 {
31
31
  return try json_fields.requiredString(params, key, error.MissingParam, error.ParamMustBeString);
32
32
  }
33
33
 
34
+ pub fn optionalString(params: ?std.json.Value, key: []const u8) !?[]const u8 {
35
+ const value = field(params, key) orelse return null;
36
+ return switch (value) {
37
+ .string => |actual| actual,
38
+ else => error.ParamMustBeString,
39
+ };
40
+ }
41
+
34
42
  pub fn requiredI32(params: ?std.json.Value, key: []const u8) !i32 {
35
43
  return try json_fields.requiredI32(params, key, error.MissingParam, error.ParamMustBeInteger);
36
44
  }
@@ -11,7 +11,7 @@ pub const capabilities_json =
11
11
  "\",\"minimumCompatibleVersion\":\"" ++ version.protocol_min_compatible_version ++
12
12
  "\",\"stability\":\"" ++ version.protocol_stability ++
13
13
  "\",\"breakingChangePolicy\":\"" ++ version.protocol_breaking_change_policy ++
14
- "\"},\"platforms\":[\"android\",\"ios\"],\"platformSupport\":{\"android\":{\"status\":\"supported\",\"deviceTypes\":[\"emulator\",\"physical\"],\"automation\":[\"adb\",\"uiautomator\",\"android-shim\"]},\"ios\":{\"status\":\"supported\",\"deviceTypes\":[\"simulator\",\"physical\"],\"automation\":[\"simctl\",\"devicectl\",\"xctest-shim\"],\"physicalDevices\":true}},\"iosPreview\":false,\"transports\":[\"stdio\",\"tcp\"],\"methods\":[\"runner.capabilities\",\"device.list\",\"session.create\",\"session.close\",\"app.install\",\"app.launch\",\"app.stop\",\"app.openLink\",\"app.clearState\",\"observe.snapshot\",\"observe.semanticSnapshot\",\"ui.tap\",\"ui.type\",\"ui.eraseText\",\"ui.hideKeyboard\",\"ui.swipe\",\"ui.pressBack\",\"ui.scrollUntilVisible\",\"wait.until\",\"wait.any\",\"wait.gone\",\"assert.visible\",\"assert.notVisible\",\"assert.healthy\",\"trace.events\",\"trace.export\"]}";
14
+ "\"},\"platforms\":[\"android\",\"ios\"],\"platformSupport\":{\"android\":{\"status\":\"supported\",\"deviceTypes\":[\"emulator\",\"physical\"],\"automation\":[\"adb\",\"uiautomator\",\"android-shim\"]},\"ios\":{\"status\":\"supported\",\"deviceTypes\":[\"simulator\",\"physical\"],\"automation\":[\"simctl\",\"devicectl\",\"xctest-shim\"],\"physicalDevices\":true}},\"iosPreview\":false,\"transports\":[\"stdio\",\"tcp\"],\"methods\":[\"runner.capabilities\",\"device.list\",\"session.create\",\"session.close\",\"app.install\",\"app.launch\",\"app.stop\",\"app.openLink\",\"app.clearState\",\"observe.snapshot\",\"observe.semanticSnapshot\",\"ui.tap\",\"ui.type\",\"ui.eraseText\",\"ui.hideKeyboard\",\"ui.swipe\",\"ui.pressBack\",\"ui.scrollUntilVisible\",\"wait.until\",\"wait.any\",\"wait.gone\",\"assert.visible\",\"assert.notVisible\",\"assert.healthy\",\"scenario.validate\",\"trace.events\",\"trace.explore\",\"trace.discover\",\"trace.explain\",\"trace.export\"]}";
15
15
 
16
16
  pub fn writeCapabilitiesResult(writer: anytype, id: ?std.json.Value) !void {
17
17
  try writeResultRaw(writer, id, capabilities_json);