zeno-mobile-runner 0.1.8 → 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 (62) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/FEATURES.md +1 -1
  3. package/README.md +167 -238
  4. package/clients/kotlin/README.md +1 -1
  5. package/clients/kotlin/build.gradle.kts +1 -1
  6. package/clients/python/pyproject.toml +1 -1
  7. package/clients/rust/Cargo.lock +1 -1
  8. package/clients/rust/Cargo.toml +1 -1
  9. package/clients/typescript/package.json +1 -1
  10. package/docs/agent-discovery.md +10 -0
  11. package/docs/ai-agents.md +18 -0
  12. package/docs/benchmarking.md +39 -0
  13. package/docs/benchmarks/2026-06-09-android-workflow.md +73 -0
  14. package/docs/benchmarks/2026-06-09-android-workflow.results.jsonl +20 -0
  15. package/docs/benchmarks/2026-06-09-framework-baseline-status.md +32 -0
  16. package/docs/benchmarks/2026-06-09-ios-appium-comparison.md +115 -0
  17. package/docs/benchmarks/2026-06-09-ios-appium-comparison.results.jsonl +40 -0
  18. package/docs/benchmarks/2026-06-09-ios-demo.md +90 -0
  19. package/docs/benchmarks/2026-06-09-ios-demo.results.jsonl +20 -0
  20. package/docs/benchmarks/2026-06-09-ios-maestro-comparison.md +128 -0
  21. package/docs/benchmarks/2026-06-09-ios-maestro-comparison.results.jsonl +40 -0
  22. package/docs/benchmarks/2026-06-09-ios-workflow-comparison.md +143 -0
  23. package/docs/benchmarks/2026-06-09-ios-workflow-comparison.results.jsonl +40 -0
  24. package/docs/benchmarks/2026-06-09-ios-xctest-floor.md +106 -0
  25. package/docs/benchmarks/2026-06-09-ios-xctest-floor.results.jsonl +40 -0
  26. package/docs/benchmarks/README.md +36 -0
  27. package/docs/benchmarks/benchmark-lab-v1.json +155 -0
  28. package/docs/benchmarks/benchmark-lab-v1.md +95 -0
  29. package/docs/clients.md +16 -0
  30. package/docs/demo.md +36 -1
  31. package/docs/frameworks.md +10 -0
  32. package/docs/npm.md +44 -2
  33. package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
  34. package/docs/protocol.md +10 -10
  35. package/docs/scenario-authoring.md +15 -0
  36. package/docs/trace-privacy.md +9 -0
  37. package/docs/troubleshooting.md +6 -0
  38. package/examples/android-workflow.json +79 -0
  39. package/examples/ios-shim-workflow.json +79 -0
  40. package/examples/react-native-expo-workflow.json +75 -0
  41. package/package.json +6 -1
  42. package/prebuilds/darwin-arm64/zmr +0 -0
  43. package/prebuilds/darwin-x64/zmr +0 -0
  44. package/prebuilds/linux-arm64/zmr +0 -0
  45. package/prebuilds/linux-x64/zmr +0 -0
  46. package/scripts/benchmark-lab.py +253 -0
  47. package/scripts/create-android-demo-app.sh +324 -29
  48. package/scripts/create-ios-demo-app.sh +174 -7
  49. package/scripts/create-react-native-expo-demo-app.sh +727 -0
  50. package/scripts/demo.sh +3 -0
  51. package/scripts/install-ios-shim.sh +2 -2
  52. package/shims/ios/ZMRShim.swift +10 -0
  53. package/shims/ios/ZMRShimUITestCase.swift +42 -0
  54. package/shims/ios/protocol.md +1 -0
  55. package/src/cli_import.zig +31 -15
  56. package/src/cli_trace.zig +38 -16
  57. package/src/cli_validate.zig +12 -6
  58. package/src/ios.zig +49 -12
  59. package/src/ios_shim.zig +36 -2
  60. package/src/main.zig +3 -0
  61. package/src/version.zig +1 -1
  62. package/viewer/app.js +23 -3
package/scripts/demo.sh CHANGED
@@ -37,9 +37,12 @@ echo "== Validate demo scenarios =="
37
37
  ./zig-out/bin/zmr validate examples/android-app-referral-deep-link.json
38
38
  ./zig-out/bin/zmr validate examples/android-app-error-state.json
39
39
  ./zig-out/bin/zmr validate examples/android-shim-smoke.json
40
+ ./zig-out/bin/zmr validate examples/android-workflow.json
41
+ ./zig-out/bin/zmr validate examples/react-native-expo-workflow.json
40
42
  ./zig-out/bin/zmr validate examples/ios-smoke.json
41
43
  ./zig-out/bin/zmr validate examples/ios-dev-client-open-link.json
42
44
  ./zig-out/bin/zmr validate examples/ios-shim-smoke.json
45
+ ./zig-out/bin/zmr validate examples/ios-shim-workflow.json
43
46
 
44
47
  echo
45
48
  echo "== Validate diagnostics: field and line location =="
@@ -342,14 +342,14 @@ is_server_running() {
342
342
  return 1
343
343
  fi
344
344
  command="\$(ps -p "\$pid" -o command= 2>/dev/null || true)"
345
- [[ "\$command" == *xcodebuild* && "\$command" == *ZMRShimUITests* ]]
345
+ [[ "\$command" == *xcodebuild* && "\$command" == *"$TEST_TARGET"* ]]
346
346
  }
347
347
 
348
348
  run_oneshot() {
349
349
  local request_file response_file oneshot_log destination_id
350
350
  request_file="\$(mktemp "\$STATE_DIR/request.XXXXXX")"
351
351
  response_file="\$(mktemp "\$STATE_DIR/response.XXXXXX")"
352
- oneshot_log="\$(mktemp "\$STATE_DIR/xcodebuild.oneshot.XXXXXX.log")"
352
+ oneshot_log="\$(mktemp "\$STATE_DIR/xcodebuild.oneshot.log.XXXXXX")"
353
353
  cp "\$STDIN_FILE" "\$request_file"
354
354
  destination_id="\$(destination_spec)"
355
355
 
@@ -22,6 +22,11 @@ struct ZMRShimBounds: Encodable {
22
22
  let height: Int
23
23
  }
24
24
 
25
+ struct ZMRShimViewport: Encodable {
26
+ let width: Int
27
+ let height: Int
28
+ }
29
+
25
30
  struct ZMRShimNode: Encodable {
26
31
  let id: String
27
32
  let type: String
@@ -35,6 +40,11 @@ struct ZMRShimNode: Encodable {
35
40
  }
36
41
 
37
42
  enum ZMRShim {
43
+ static func viewport(app: XCUIApplication) -> ZMRShimViewport {
44
+ let frame = app.frame
45
+ return ZMRShimViewport(width: Int(frame.size.width), height: Int(frame.size.height))
46
+ }
47
+
38
48
  static func snapshot(app: XCUIApplication) -> [ZMRShimNode] {
39
49
  let queries: [(XCUIElement.ElementType, XCUIElementQuery)] = [
40
50
  (.button, app.buttons),
@@ -105,10 +105,15 @@ final class ZMRShimUITestCase: XCTestCase {
105
105
  }
106
106
 
107
107
  private func run(command: ZMRShimCommand, app: XCUIApplication) -> [String: Any] {
108
+ if commandRequiresForeground(command), let foregroundError = ensureAppForeground(app: app) {
109
+ return foregroundError
110
+ }
111
+
108
112
  switch command.cmd {
109
113
  case "snapshot":
110
114
  return [
111
115
  "status": "ok",
116
+ "viewport": ZMRShim.viewport(app: app).json,
112
117
  "nodes": ZMRShim.snapshot(app: app).map { $0.json }
113
118
  ]
114
119
  case "screenshot":
@@ -188,6 +193,34 @@ final class ZMRShimUITestCase: XCTestCase {
188
193
  }
189
194
  }
190
195
 
196
+ private func commandRequiresForeground(_ command: ZMRShimCommand) -> Bool {
197
+ switch command.cmd {
198
+ case "snapshot", "query", "tap", "type", "eraseText", "hideKeyboard", "swipe", "settle":
199
+ return true
200
+ default:
201
+ return false
202
+ }
203
+ }
204
+
205
+ private func ensureAppForeground(app: XCUIApplication) -> [String: Any]? {
206
+ if app.state != .runningForeground {
207
+ app.activate()
208
+ }
209
+
210
+ let deadline = Date().addingTimeInterval(5)
211
+ while Date() < deadline {
212
+ if app.state == .runningForeground {
213
+ return nil
214
+ }
215
+ Thread.sleep(forTimeInterval: 0.1)
216
+ }
217
+
218
+ return error(
219
+ "app.not_foreground",
220
+ "target app did not become foreground; state=\(app.state.rawValue)"
221
+ )
222
+ }
223
+
191
224
  private func ok() -> [String: Any] {
192
225
  ["status": "ok"]
193
226
  }
@@ -532,6 +565,15 @@ private extension ZMRShimBounds {
532
565
  }
533
566
  }
534
567
 
568
+ private extension ZMRShimViewport {
569
+ var json: [String: Any] {
570
+ [
571
+ "width": width,
572
+ "height": height
573
+ ]
574
+ }
575
+ }
576
+
535
577
  private extension ZMRShimNode {
536
578
  var json: [String: Any] {
537
579
  [
@@ -51,6 +51,7 @@ turn every snapshot into a full hierarchy crawl:
51
51
  ```json
52
52
  {
53
53
  "status": "ok",
54
+ "viewport": { "width": 390, "height": 844 },
54
55
  "nodes": [
55
56
  {
56
57
  "id": "button-continue",
@@ -14,36 +14,52 @@ pub const ParsedArgs = struct {
14
14
  };
15
15
 
16
16
  pub fn parseArgs(args: []const []const u8) !ParsedArgs {
17
- if (args.len == 0) return error.MissingImportFormat;
18
- if (args.len == 1) return error.MissingImportPath;
17
+ var format: ?[]const u8 = null;
18
+ var source_path: ?[]const u8 = null;
19
+ var out_path: ?[]const u8 = null;
20
+ var name: ?[]const u8 = null;
21
+ var app_id: ?[]const u8 = null;
22
+ var force = false;
23
+ var json = false;
19
24
 
20
- var parsed = ParsedArgs{
21
- .format = args[0],
22
- .source_path = args[1],
23
- };
24
-
25
- var index: usize = 2;
25
+ var index: usize = 0;
26
26
  while (index < args.len) : (index += 1) {
27
27
  const arg = args[index];
28
28
  if (std.mem.eql(u8, arg, "--out")) {
29
29
  index += 1;
30
- parsed.out_path = if (index < args.len) args[index] else return error.MissingImportOut;
30
+ out_path = if (index < args.len) args[index] else return error.MissingImportOut;
31
31
  } else if (std.mem.eql(u8, arg, "--name")) {
32
32
  index += 1;
33
- parsed.name = if (index < args.len) args[index] else return error.MissingImportName;
33
+ name = if (index < args.len) args[index] else return error.MissingImportName;
34
34
  } else if (std.mem.eql(u8, arg, "--app-id")) {
35
35
  index += 1;
36
- parsed.app_id = if (index < args.len) args[index] else return error.MissingAppId;
36
+ app_id = if (index < args.len) args[index] else return error.MissingAppId;
37
37
  } else if (std.mem.eql(u8, arg, "--force")) {
38
- parsed.force = true;
38
+ force = true;
39
39
  } else if (std.mem.eql(u8, arg, "--json")) {
40
- parsed.json = true;
40
+ json = true;
41
+ } else if (std.mem.startsWith(u8, arg, "--")) {
42
+ return error.UnknownFlag;
43
+ } else if (format == null) {
44
+ format = arg;
45
+ } else if (source_path == null) {
46
+ source_path = arg;
41
47
  } else {
42
48
  return error.UnknownFlag;
43
49
  }
44
50
  }
45
- if (parsed.out_path == null) return error.MissingImportOut;
46
- return parsed;
51
+ if (format == null) return error.MissingImportFormat;
52
+ if (source_path == null) return error.MissingImportPath;
53
+ if (out_path == null) return error.MissingImportOut;
54
+ return ParsedArgs{
55
+ .format = format.?,
56
+ .source_path = source_path.?,
57
+ .out_path = out_path,
58
+ .name = name,
59
+ .app_id = app_id,
60
+ .force = force,
61
+ .json = json,
62
+ };
47
63
  }
48
64
 
49
65
  pub fn run(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !void {
package/src/cli_trace.zig CHANGED
@@ -22,24 +22,34 @@ pub const ExportArgs = struct {
22
22
  };
23
23
 
24
24
  pub fn parseReportArgs(args: []const []const u8) !ReportArgs {
25
- if (args.len == 0) return error.MissingReportInput;
26
- 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;
27
28
 
28
- var index: usize = 1;
29
+ var index: usize = 0;
29
30
  while (index < args.len) : (index += 1) {
30
31
  const arg = args[index];
31
32
  if (std.mem.eql(u8, arg, "--out")) {
32
33
  index += 1;
33
- 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;
34
35
  } else if (std.mem.eql(u8, arg, "--junit")) {
35
36
  index += 1;
36
- parsed.junit_path = if (index < args.len) args[index] else return error.MissingJUnitOutput;
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;
37
42
  } else {
38
43
  return error.UnknownFlag;
39
44
  }
40
45
  }
41
- if (parsed.out_path == null) return error.MissingReportOutput;
42
- 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
+ };
43
53
  }
44
54
 
45
55
  pub fn parseExplainArgs(args: []const []const u8) !ExplainArgs {
@@ -58,26 +68,38 @@ pub fn parseExplainArgs(args: []const []const u8) !ExplainArgs {
58
68
  }
59
69
 
60
70
  pub fn parseExportArgs(args: []const []const u8) !ExportArgs {
61
- if (args.len == 0) return error.MissingTraceDir;
62
- 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;
63
75
 
64
- var index: usize = 1;
76
+ var index: usize = 0;
65
77
  while (index < args.len) : (index += 1) {
66
78
  const arg = args[index];
67
79
  if (std.mem.eql(u8, arg, "--out")) {
68
80
  index += 1;
69
- 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;
70
82
  } else if (std.mem.eql(u8, arg, "--redact")) {
71
- parsed.redact = true;
83
+ redact = true;
72
84
  } else if (std.mem.eql(u8, arg, "--omit-screenshots")) {
73
- parsed.redact = true;
74
- 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;
75
91
  } else {
76
92
  return error.UnknownFlag;
77
93
  }
78
94
  }
79
- if (parsed.out_path == null) return error.MissingTraceBundleOutput;
80
- 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
+ };
81
103
  }
82
104
 
83
105
  pub fn runReport(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/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;
package/src/main.zig CHANGED
@@ -88,6 +88,9 @@ fn writeTopLevelError(err: anyerror) void {
88
88
  if (err == error.CommandFailed) {
89
89
  stderr.writeAll("hint: run `zmr doctor --json` for setup diagnostics.\n") catch {};
90
90
  }
91
+ if (err == error.UnknownFlag) {
92
+ stderr.writeAll("hint: run `zmr help` for each command's flags and arguments.\n") catch {};
93
+ }
91
94
  }
92
95
 
93
96
  fn exitCodeForError(err: anyerror) u8 {
package/src/version.zig CHANGED
@@ -1,4 +1,4 @@
1
- pub const runner_version = "0.1.8";
1
+ pub const runner_version = "0.2.0";
2
2
  pub const protocol_version = "2026-04-28";
3
3
  pub const protocol_min_compatible_version = "2026-04-28";
4
4
  pub const protocol_stability = "dev-preview";
package/viewer/app.js CHANGED
@@ -73,22 +73,42 @@ els.dropTarget.addEventListener("drop", async (event) => {
73
73
  });
74
74
 
75
75
  async function loadBundleFile(file) {
76
- setStatus(`Loading ${file.name}`);
76
+ await loadBundle(file.name, () => file.arrayBuffer());
77
+ }
78
+
79
+ async function loadBundleFromUrl(url) {
80
+ const name = url.split("/").pop() || url;
81
+ await loadBundle(name, async () => {
82
+ const response = await fetch(url);
83
+ if (!response.ok) {
84
+ throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
85
+ }
86
+ return await response.arrayBuffer();
87
+ });
88
+ }
89
+
90
+ async function loadBundle(name, getBuffer) {
91
+ setStatus(`Loading ${name}`);
77
92
  try {
78
93
  stopReplay();
79
94
  revokeObjectUrls();
80
- const entries = parseTarArchive(await file.arrayBuffer());
95
+ const entries = parseTarArchive(await getBuffer());
81
96
  const model = buildTraceModel(entries);
82
97
  state.model = model;
83
98
  state.selectedFrameIndex = 0;
84
99
  state.selectedEvent = model.replayFrames[0]?.event ?? model.events[0] ?? null;
85
- renderModel(file.name);
100
+ renderModel(name);
86
101
  } catch (error) {
87
102
  console.error(error);
88
103
  setStatus(error instanceof Error ? error.message : String(error), true);
89
104
  }
90
105
  }
91
106
 
107
+ const bundleParam = new URLSearchParams(window.location.search).get("bundle");
108
+ if (bundleParam) {
109
+ loadBundleFromUrl(bundleParam);
110
+ }
111
+
92
112
  function renderModel(fileName) {
93
113
  const { summary } = state.model;
94
114
  els.emptyState.classList.add("hidden");