zeno-mobile-runner 0.1.8 → 0.2.1

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 (66) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/FEATURES.md +1 -1
  3. package/README.md +175 -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-dev-client-open-link.json +24 -13
  40. package/examples/ios-dev-client-route-snapshot.json +33 -8
  41. package/examples/ios-shim-workflow.json +79 -0
  42. package/examples/react-native-expo-workflow.json +75 -0
  43. package/npm/scenarios.mjs +15 -8
  44. package/npm/wizard.mjs +1 -1
  45. package/package.json +6 -1
  46. package/prebuilds/darwin-arm64/zmr +0 -0
  47. package/prebuilds/darwin-x64/zmr +0 -0
  48. package/prebuilds/linux-arm64/zmr +0 -0
  49. package/prebuilds/linux-x64/zmr +0 -0
  50. package/scripts/benchmark-lab.py +253 -0
  51. package/scripts/create-android-demo-app.sh +324 -29
  52. package/scripts/create-ios-demo-app.sh +174 -7
  53. package/scripts/create-react-native-expo-demo-app.sh +727 -0
  54. package/scripts/demo.sh +3 -0
  55. package/scripts/install-ios-shim.sh +2 -2
  56. package/shims/ios/ZMRShim.swift +10 -0
  57. package/shims/ios/ZMRShimUITestCase.swift +49 -1
  58. package/shims/ios/protocol.md +1 -0
  59. package/src/cli_import.zig +31 -15
  60. package/src/cli_trace.zig +38 -16
  61. package/src/cli_validate.zig +12 -6
  62. package/src/ios.zig +44 -11
  63. package/src/ios_shim.zig +36 -2
  64. package/src/main.zig +6 -0
  65. package/src/version.zig +1 -1
  66. 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
  }
@@ -208,10 +241,16 @@ final class ZMRShimUITestCase: XCTestCase {
208
241
  var acceptedCount = 0
209
242
  var lastAcceptedLabel = ""
210
243
  for _ in 0..<3 {
244
+ // One existence probe on the alert container keeps the no-dialog
245
+ // path to a single short wait instead of a per-label wait, so the
246
+ // best-effort accept after every openLink stays cheap.
247
+ guard springboard.alerts.firstMatch.waitForExistence(timeout: 2) else {
248
+ break
249
+ }
211
250
  var tapped = false
212
251
  for label in labels {
213
252
  let button = springboard.buttons[label].firstMatch
214
- if button.waitForExistence(timeout: 2), button.isHittable {
253
+ if button.exists, button.isHittable {
215
254
  button.tap()
216
255
  acceptedCount += 1
217
256
  lastAcceptedLabel = label
@@ -532,6 +571,15 @@ private extension ZMRShimBounds {
532
571
  }
533
572
  }
534
573
 
574
+ private extension ZMRShimViewport {
575
+ var json: [String: Any] {
576
+ [
577
+ "width": width,
578
+ "height": height
579
+ ]
580
+ }
581
+ }
582
+
535
583
  private extension ZMRShimNode {
536
584
  var json: [String: Any] {
537
585
  [
@@ -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,6 +128,12 @@ 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();
131
+ // Opening a URL on the simulator can raise a SpringBoard "Open in <App>?"
132
+ // confirmation for universal links (http/https) and, just as often, for
133
+ // custom schemes — the common Expo dev-client case
134
+ // (exp+scheme://expo-development-client/...). Attempt a best-effort accept
135
+ // whenever a shim is configured; the shim probes briefly and returns fast
136
+ // when no dialog is present, so this stays cheap on the no-prompt path.
129
137
  self.acceptOpenURLConfirmationBestEffort();
130
138
  }
131
139
 
@@ -212,15 +220,22 @@ pub const IosDevice = struct {
212
220
 
213
221
  const active_package = try self.allocator.dupe(u8, self.app_id);
214
222
  errdefer self.allocator.free(active_package);
215
- const nodes = if (self.shim_path != null)
216
- self.snapshotNodesFromShim() catch |err| blk: {
223
+ var shim_viewport: ?types.Viewport = null;
224
+ const nodes = if (self.shim_path != null) blk: {
225
+ const shim_snapshot = self.snapshotFromShim() catch |err| {
217
226
  if (screenshot_artifact == null) return err;
218
227
  if (writer) |tw| try self.recordSnapshotSemanticFailure(tw, screenshot_artifact.?, err);
219
228
  break :blk try self.allocator.alloc(types.UiNode, 0);
220
- }
221
- else
222
- try self.allocator.alloc(types.UiNode, 0);
229
+ };
230
+ shim_viewport = shim_snapshot.viewport;
231
+ break :blk shim_snapshot.nodes;
232
+ } else try self.allocator.alloc(types.UiNode, 0);
223
233
  errdefer self.allocator.free(nodes);
234
+ if (shim_viewport) |value| {
235
+ if (value.width > 0 and value.height > 0) {
236
+ viewport = value;
237
+ }
238
+ }
224
239
 
225
240
  return .{
226
241
  .id = id,
@@ -260,10 +275,10 @@ pub const IosDevice = struct {
260
275
  return try self.allocator.dupe(u8, result.stdout);
261
276
  }
262
277
 
263
- fn snapshotNodesFromShim(self: *IosDevice) ![]types.UiNode {
278
+ fn snapshotFromShim(self: *IosDevice) !ios_shim.SnapshotResponse {
264
279
  const response = try self.runShim(.{ .kind = .snapshot });
265
280
  defer self.allocator.free(response);
266
- return try ios_shim.parseSnapshotNodes(self.allocator, response);
281
+ return try ios_shim.parseSnapshotResponse(self.allocator, response);
267
282
  }
268
283
 
269
284
  fn recordSnapshotSemanticFailure(self: *IosDevice, writer: *trace.TraceWriter, screenshot_artifact: []const u8, err: anyerror) !void {
@@ -319,7 +334,7 @@ pub const IosDevice = struct {
319
334
  }
320
335
 
321
336
  fn runShim(self: *IosDevice, shim_command: ios_shim.Command) ![]u8 {
322
- return self.runShimWithTimeout(shim_command, shim_timeout_ms);
337
+ return self.runShimWithTimeout(shim_command, shimTimeoutMs());
323
338
  }
324
339
 
325
340
  fn runShimWithTimeout(self: *IosDevice, shim_command: ios_shim.Command, timeout_ms: u64) ![]u8 {
@@ -384,6 +399,17 @@ fn isTransientShimBootstrapFailure(result: command.ExecResult) bool {
384
399
  std.mem.indexOf(u8, result.stderr, "operation never finished bootstrapping") != null;
385
400
  }
386
401
 
402
+ fn shimTimeoutMs() u64 {
403
+ return parseShimTimeoutMs(std.posix.getenv(shim_timeout_env));
404
+ }
405
+
406
+ fn parseShimTimeoutMs(raw: ?[]const u8) u64 {
407
+ const value = raw orelse return default_shim_timeout_ms;
408
+ const parsed = std.fmt.parseInt(u64, value, 10) catch return default_shim_timeout_ms;
409
+ if (parsed == 0) return default_shim_timeout_ms;
410
+ return parsed;
411
+ }
412
+
387
413
  pub fn listDevices(allocator: std.mem.Allocator, xcrun_path: []const u8) ![]types.DeviceInfo {
388
414
  return try ios_devices.listSimulators(allocator, xcrun_path);
389
415
  }
@@ -401,6 +427,13 @@ pub fn parsePhysicalDevicesJson(allocator: std.mem.Allocator, content: []const u
401
427
  }
402
428
 
403
429
  test "ios xctest shim timeout allows cold xcodebuild startup" {
404
- try std.testing.expect(shim_timeout_ms >= 5_400_000);
430
+ try std.testing.expect(default_shim_timeout_ms >= 5_400_000);
405
431
  try std.testing.expect(shim_best_effort_timeout_ms <= 15_000);
406
432
  }
433
+
434
+ test "ios xctest shim timeout env override" {
435
+ try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs(null));
436
+ try std.testing.expectEqual(@as(u64, 600_000), parseShimTimeoutMs("600000"));
437
+ try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs("not-a-number"));
438
+ try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs("0"));
439
+ }
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
@@ -16,6 +16,9 @@ const errors = @import("errors.zig");
16
16
 
17
17
  pub fn main() void {
18
18
  mainInner() catch |err| {
19
+ // stdout's consumer went away (e.g. `zmr ... | head`); exit quietly
20
+ // with the conventional SIGPIPE status instead of reporting an error.
21
+ if (err == error.BrokenPipe) std.process.exit(141);
19
22
  writeTopLevelError(err);
20
23
  std.process.exit(exitCodeForError(err));
21
24
  };
@@ -88,6 +91,9 @@ fn writeTopLevelError(err: anyerror) void {
88
91
  if (err == error.CommandFailed) {
89
92
  stderr.writeAll("hint: run `zmr doctor --json` for setup diagnostics.\n") catch {};
90
93
  }
94
+ if (err == error.UnknownFlag) {
95
+ stderr.writeAll("hint: run `zmr help` for each command's flags and arguments.\n") catch {};
96
+ }
91
97
  }
92
98
 
93
99
  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.1";
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");