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
@@ -15,34 +15,62 @@ pub fn waitUntilVisible(
15
15
  timeout_ms: u64,
16
16
  writer: ?*trace.TraceWriter,
17
17
  options: RunOptions,
18
+ ) !bool {
19
+ return try untilVisibleKind(device, wanted, timeout_ms, writer, options, "wait.visible");
20
+ }
21
+
22
+ pub fn assertVisible(
23
+ device: anytype,
24
+ wanted: selector.Selector,
25
+ timeout_ms: u64,
26
+ writer: ?*trace.TraceWriter,
27
+ options: RunOptions,
28
+ ) !bool {
29
+ return try untilVisibleKind(device, wanted, timeout_ms, writer, options, "assert.visible");
30
+ }
31
+
32
+ fn untilVisibleKind(
33
+ device: anytype,
34
+ wanted: selector.Selector,
35
+ timeout_ms: u64,
36
+ writer: ?*trace.TraceWriter,
37
+ options: RunOptions,
38
+ kind: []const u8,
18
39
  ) !bool {
19
40
  const deadline = std.time.milliTimestamp() + @as(i64, @intCast(timeout_ms));
20
41
  while (true) {
21
42
  if (try nativeVisibleBySelector(device, wanted)) |visible| {
22
43
  if (visible) {
23
- if (writer) |tw| try runner_events.recordNativeWait(tw, "wait.visible", wanted, null);
44
+ if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
24
45
  return true;
25
46
  }
26
47
  if (std.time.milliTimestamp() >= deadline) {
27
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "wait.visible", &[_]selector.Selector{wanted});
48
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
28
49
  return false;
29
50
  }
30
51
  try sleepMs(options.poll_ms);
31
52
  continue;
32
53
  }
33
54
  var snap = device.snapshot(writer) catch |err| {
34
- if (try retryTransientObservation(err, "wait.visible", writer, deadline, options)) continue;
55
+ if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
35
56
  return err;
36
57
  };
37
58
  defer snap.deinit(device.allocator);
38
- if (selector.find(snap.nodes, wanted) != null) {
39
- if (writer) |tw| try tw.recordEvent("wait.visible", "{\"status\":\"ok\"}");
59
+ if (selector.find(snap.nodes, wanted)) |node| {
60
+ if (writer) |tw| {
61
+ var payload = std.ArrayList(u8).empty;
62
+ defer payload.deinit(tw.allocator);
63
+ try payload.writer(tw.allocator).print("{{\"status\":\"ok\",\"target\":\"{s}\",\"selector\":", .{node.stable_id});
64
+ try trace.writeSelectorJson(payload.writer(tw.allocator), wanted);
65
+ try payload.writer(tw.allocator).print(",\"timeoutMs\":{d}}}", .{timeout_ms});
66
+ try tw.recordEvent(kind, payload.items);
67
+ }
40
68
  return true;
41
69
  }
42
70
  if (std.time.milliTimestamp() >= deadline) {
43
71
  if (writer) |tw| {
44
72
  const selectors = [_]selector.Selector{wanted};
45
- try runner_events.recordWaitTimeout(tw, "wait.visible", selectors[0..], snap);
73
+ try runner_events.recordDiagnosticWithStrategyAndTimeout(tw, kind, "timeout", null, selectors[0..], snap, timeout_ms);
46
74
  }
47
75
  return false;
48
76
  }
@@ -56,34 +84,62 @@ pub fn waitUntilNotVisible(
56
84
  timeout_ms: u64,
57
85
  writer: ?*trace.TraceWriter,
58
86
  options: RunOptions,
87
+ ) !bool {
88
+ return try untilNotVisibleKind(device, wanted, timeout_ms, writer, options, "wait.notVisible");
89
+ }
90
+
91
+ pub fn assertNotVisible(
92
+ device: anytype,
93
+ wanted: selector.Selector,
94
+ timeout_ms: u64,
95
+ writer: ?*trace.TraceWriter,
96
+ options: RunOptions,
97
+ ) !bool {
98
+ return try untilNotVisibleKind(device, wanted, timeout_ms, writer, options, "assert.notVisible");
99
+ }
100
+
101
+ fn untilNotVisibleKind(
102
+ device: anytype,
103
+ wanted: selector.Selector,
104
+ timeout_ms: u64,
105
+ writer: ?*trace.TraceWriter,
106
+ options: RunOptions,
107
+ kind: []const u8,
59
108
  ) !bool {
60
109
  const deadline = std.time.milliTimestamp() + @as(i64, @intCast(timeout_ms));
61
110
  while (true) {
62
111
  if (try nativeVisibleBySelector(device, wanted)) |visible| {
63
112
  if (!visible) {
64
- if (writer) |tw| try runner_events.recordNativeWait(tw, "wait.notVisible", wanted, null);
113
+ if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
65
114
  return true;
66
115
  }
67
116
  if (std.time.milliTimestamp() >= deadline) {
68
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "wait.notVisible", &[_]selector.Selector{wanted});
117
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
69
118
  return false;
70
119
  }
71
120
  try sleepMs(options.poll_ms);
72
121
  continue;
73
122
  }
74
123
  var snap = device.snapshot(writer) catch |err| {
75
- if (try retryTransientObservation(err, "wait.notVisible", writer, deadline, options)) continue;
124
+ if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
76
125
  return err;
77
126
  };
78
127
  defer snap.deinit(device.allocator);
79
128
  if (selector.find(snap.nodes, wanted) == null) {
80
- if (writer) |tw| try tw.recordEvent("wait.notVisible", "{\"status\":\"ok\"}");
129
+ if (writer) |tw| {
130
+ var payload = std.ArrayList(u8).empty;
131
+ defer payload.deinit(tw.allocator);
132
+ try payload.writer(tw.allocator).writeAll("{\"status\":\"ok\",\"selector\":");
133
+ try trace.writeSelectorJson(payload.writer(tw.allocator), wanted);
134
+ try payload.writer(tw.allocator).print(",\"timeoutMs\":{d}}}", .{timeout_ms});
135
+ try tw.recordEvent(kind, payload.items);
136
+ }
81
137
  return true;
82
138
  }
83
139
  if (std.time.milliTimestamp() >= deadline) {
84
140
  if (writer) |tw| {
85
141
  const selectors = [_]selector.Selector{wanted};
86
- try runner_events.recordWaitTimeout(tw, "wait.notVisible", selectors[0..], snap);
142
+ try runner_events.recordDiagnosticWithStrategyAndTimeout(tw, kind, "timeout", null, selectors[0..], snap, timeout_ms);
87
143
  }
88
144
  return false;
89
145
  }
@@ -104,7 +160,7 @@ pub fn waitUntilAnyVisible(
104
160
  for (selectors, 0..) |wanted, index| {
105
161
  if (try nativeVisibleBySelector(device, wanted)) |visible| {
106
162
  if (visible) {
107
- if (writer) |tw| try runner_events.recordNativeWait(tw, "wait.any", wanted, index);
163
+ if (writer) |tw| try runner_events.recordNativeWait(tw, "wait.any", wanted, index, timeout_ms);
108
164
  return index;
109
165
  }
110
166
  } else {
@@ -114,7 +170,7 @@ pub fn waitUntilAnyVisible(
114
170
  }
115
171
  if (all_native) {
116
172
  if (std.time.milliTimestamp() >= deadline) {
117
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "wait.any", selectors);
173
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "wait.any", selectors, timeout_ms);
118
174
  return null;
119
175
  }
120
176
  try sleepMs(options.poll_ms);
@@ -132,7 +188,7 @@ pub fn waitUntilAnyVisible(
132
188
  defer payload.deinit(tw.allocator);
133
189
  try payload.writer(tw.allocator).print("{{\"status\":\"ok\",\"matchedIndex\":{d},\"target\":\"{s}\",\"selector\":", .{ index, node.stable_id });
134
190
  try trace.writeSelectorJson(payload.writer(tw.allocator), wanted);
135
- try payload.writer(tw.allocator).writeAll("}");
191
+ try payload.writer(tw.allocator).print(",\"timeoutMs\":{d}}}", .{timeout_ms});
136
192
  try tw.recordEvent("wait.any", payload.items);
137
193
  }
138
194
  return index;
@@ -170,12 +226,12 @@ pub fn assertNoneVisible(
170
226
  }
171
227
 
172
228
  if (!matched) {
173
- if (writer) |tw| try tw.recordEvent("assert.noneVisible", "{\"status\":\"ok\"}");
229
+ if (writer) |tw| try runner_events.recordSelectorArrayStatus(tw, "assert.noneVisible", "ok", selectors, timeout_ms);
174
230
  return true;
175
231
  }
176
232
 
177
233
  if (std.time.milliTimestamp() >= deadline) {
178
- if (writer) |tw| try runner_events.recordDiagnostic(tw, "assert.noneVisible", "visible", selectors, snap);
234
+ if (writer) |tw| try runner_events.recordDiagnosticWithStrategyAndTimeout(tw, "assert.noneVisible", "visible", null, selectors, snap, timeout_ms);
179
235
  return false;
180
236
  }
181
237
 
@@ -199,12 +255,16 @@ pub fn assertHealthy(
199
255
  defer snap.deinit(device.allocator);
200
256
 
201
257
  if (!health.hasUnhealthyOverlay(snap.nodes)) {
202
- if (writer) |tw| try tw.recordEvent("assert.healthy", "{\"status\":\"ok\"}");
258
+ if (writer) |tw| {
259
+ const payload = try std.fmt.allocPrint(tw.allocator, "{{\"status\":\"ok\",\"timeoutMs\":{d}}}", .{timeout_ms});
260
+ defer tw.allocator.free(payload);
261
+ try tw.recordEvent("assert.healthy", payload);
262
+ }
203
263
  return true;
204
264
  }
205
265
 
206
266
  if (std.time.milliTimestamp() >= deadline) {
207
- if (writer) |tw| try runner_events.recordDiagnostic(tw, "assert.healthy", "unhealthy", health_selectors, snap);
267
+ if (writer) |tw| try runner_events.recordDiagnosticWithStrategyAndTimeout(tw, "assert.healthy", "unhealthy", null, health_selectors, snap, timeout_ms);
208
268
  return false;
209
269
  }
210
270
 
@@ -233,7 +293,10 @@ pub fn scrollUntilVisible(
233
293
  defer payload.deinit(tw.allocator);
234
294
  try payload.writer(tw.allocator).print("{{\"status\":\"ok\",\"target\":\"{s}\",\"selector\":", .{node.stable_id});
235
295
  try trace.writeSelectorJson(payload.writer(tw.allocator), wanted);
236
- try payload.writer(tw.allocator).writeAll("}");
296
+ try payload.writer(tw.allocator).print(",\"direction\":\"{s}\",\"timeoutMs\":{d}}}", .{
297
+ if (direction == .down) "down" else "up",
298
+ timeout_ms,
299
+ });
237
300
  try tw.recordEvent("ui.scrollUntilVisible", payload.items);
238
301
  }
239
302
  return true;
package/src/scaffold.zig CHANGED
@@ -149,21 +149,21 @@ fn writeAppConfig(path: []const u8, app_id: []const u8, force: bool) !void {
149
149
  \\ "schemas": "zmr schemas --json",
150
150
  \\ "validate": "zmr validate --json .zmr/android-smoke.json && zmr validate --json .zmr/ios-smoke.json",
151
151
  \\ "android": "zmr run .zmr/android-smoke.json --device emulator-5554 --trace-dir traces/zmr-android",
152
- \\ "androidReport": "zmr report traces/zmr-android --out traces/zmr-android/report.html",
152
+ \\ "androidReport": "zmr report traces/zmr-android --out traces/zmr-android/report.html --junit traces/zmr-android/junit.xml",
153
153
  \\ "androidReliability": "export ZMR_BIN=\"${ZMR_BIN:-zmr}\"; zmr-benchmark --zmr .zmr/android-smoke.json --device emulator-5554 --app-id
154
154
  );
155
155
  try writer.writeAll(" ");
156
156
  try writeJsonShellArg(writer, app_id);
157
157
  try writer.writeAll(
158
- \\ --runs 20 --trace-root traces/zmr-android-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 30000 && \"$ZMR_BIN\" report traces/zmr-android-reliability --out traces/zmr-android-reliability/report.html",
158
+ \\ --runs 20 --trace-root traces/zmr-android-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 30000 && \"$ZMR_BIN\" report traces/zmr-android-reliability --out traces/zmr-android-reliability/report.html --junit traces/zmr-android-reliability/junit.xml",
159
159
  \\ "ios": "zmr run .zmr/ios-smoke.json --platform ios --device booted --trace-dir traces/zmr-ios",
160
- \\ "iosReport": "zmr report traces/zmr-ios --out traces/zmr-ios/report.html",
160
+ \\ "iosReport": "zmr report traces/zmr-ios --out traces/zmr-ios/report.html --junit traces/zmr-ios/junit.xml",
161
161
  \\ "iosReliability": "export ZMR_BIN=\"${ZMR_BIN:-zmr}\"; zmr-benchmark --zmr .zmr/ios-smoke.json --platform ios --device booted --app-id
162
162
  );
163
163
  try writer.writeAll(" ");
164
164
  try writeJsonShellArg(writer, app_id);
165
165
  try writer.writeAll(
166
- \\ --xcrun xcrun --runs 20 --trace-root traces/zmr-ios-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 45000 && \"$ZMR_BIN\" report traces/zmr-ios-reliability --out traces/zmr-ios-reliability/report.html",
166
+ \\ --xcrun xcrun --runs 20 --trace-root traces/zmr-ios-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 45000 && \"$ZMR_BIN\" report traces/zmr-ios-reliability --out traces/zmr-ios-reliability/report.html --junit traces/zmr-ios-reliability/junit.xml",
167
167
  \\ "matrix": "ZMR_BIN=${ZMR_BIN:-zmr} zmr-device-matrix --matrix .zmr/device-matrix.json --trace-root traces/zmr-matrix --min-pass-rate 100 --max-failures 0",
168
168
  \\ "pilotGate": "zmr-pilot-gate --android --ios --android-app-root . --android-app-id
169
169
  );
@@ -318,6 +318,7 @@ fn writeAgentInstructions(path: []const u8, app_id: []const u8, force: bool) !vo
318
318
  \\## Setup Checks
319
319
  \\
320
320
  \\```bash
321
+ \\zmr inspect --json --dir .
321
322
  \\zmr doctor --strict --json --config .zmr/config.json
322
323
  \\zmr schemas --json
323
324
  \\zmr validate --json .zmr/android-smoke.json && zmr validate --json .zmr/ios-smoke.json
@@ -332,6 +333,19 @@ fn writeAgentInstructions(path: []const u8, app_id: []const u8, force: bool) !vo
332
333
  \\
333
334
  \\Use `semantic_snapshot` before choosing tap or type actions. Prefer selectors from accessibility identifiers, resource ids, labels, or exact text before coordinates. Export redacted traces before sharing artifacts.
334
335
  \\
336
+ \\## Discover From Trace
337
+ \\
338
+ \\```bash
339
+ \\zmr explore --from-trace traces/zmr-agent --out .zmr/discovered/login-smoke.json --goal "find a stable login smoke" --include-actions --validate --json
340
+ \\zmr discover --from-trace traces/zmr-agent --out .zmr/discovered/replay-smoke.json --include-actions --validate --json
341
+ \\zmr draft --from-trace traces/zmr-agent --out .zmr/discovered/surface-smoke.json --json
342
+ \\zmr validate --json .zmr/discovered/surface-smoke.json
343
+ \\zmr draft --from-trace traces/zmr-agent --out .zmr/discovered/replay-smoke.json --include-actions --json
344
+ \\zmr validate --json .zmr/discovered/replay-smoke.json
345
+ \\```
346
+ \\
347
+ \\Prefer `zmr explore` for CLI agent loops when the goal should travel with the generated candidate. Its JSON includes `autonomous:false`, `reviewRequired:true`, `guardrails`, replay coverage, validation, and deterministic next commands. Treat discover output as a lower-level reviewable starting point. It writes from trace evidence and validates the generated file when `--validate` is present, but it does not crawl, invent missing actions, discover credentials, or commit tests. Treat draft output as a reviewable starting point when using the lower-level split workflow. The default draft contains only `launch`, `snapshot`, and conservative `assertVisible` checks. Use `--include-actions` only when the trace came from a reviewed agent session; unsupported events are skipped with warnings instead of guessed. Do not commit a discovered or drafted scenario until a human has reviewed and rerun it.
348
+ \\
335
349
  \\## Failure Triage
336
350
  \\
337
351
  \\```bash
@@ -352,21 +366,21 @@ fn writeAgentInstructions(path: []const u8, app_id: []const u8, force: bool) !vo
352
366
  \\
353
367
  \\```bash
354
368
  \\zmr run .zmr/android-smoke.json --device emulator-5554 --trace-dir traces/zmr-android
355
- \\zmr report traces/zmr-android --out traces/zmr-android/report.html
369
+ \\zmr report traces/zmr-android --out traces/zmr-android/report.html --junit traces/zmr-android/junit.xml
356
370
  \\export ZMR_BIN="${ZMR_BIN:-zmr}"; zmr-benchmark --zmr .zmr/android-smoke.json --device emulator-5554 --app-id
357
371
  );
358
372
  try writer.writeAll(" ");
359
373
  try writeShellArg(writer, app_id);
360
374
  try writer.writeAll(
361
- \\ --runs 20 --trace-root traces/zmr-android-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 30000 && "$ZMR_BIN" report traces/zmr-android-reliability --out traces/zmr-android-reliability/report.html
375
+ \\ --runs 20 --trace-root traces/zmr-android-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 30000 && "$ZMR_BIN" report traces/zmr-android-reliability --out traces/zmr-android-reliability/report.html --junit traces/zmr-android-reliability/junit.xml
362
376
  \\zmr run .zmr/ios-smoke.json --platform ios --device booted --trace-dir traces/zmr-ios
363
- \\zmr report traces/zmr-ios --out traces/zmr-ios/report.html
377
+ \\zmr report traces/zmr-ios --out traces/zmr-ios/report.html --junit traces/zmr-ios/junit.xml
364
378
  \\export ZMR_BIN="${ZMR_BIN:-zmr}"; zmr-benchmark --zmr .zmr/ios-smoke.json --platform ios --device booted --app-id
365
379
  );
366
380
  try writer.writeAll(" ");
367
381
  try writeShellArg(writer, app_id);
368
382
  try writer.writeAll(
369
- \\ --xcrun xcrun --runs 20 --trace-root traces/zmr-ios-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 45000 && "$ZMR_BIN" report traces/zmr-ios-reliability --out traces/zmr-ios-reliability/report.html
383
+ \\ --xcrun xcrun --runs 20 --trace-root traces/zmr-ios-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 45000 && "$ZMR_BIN" report traces/zmr-ios-reliability --out traces/zmr-ios-reliability/report.html --junit traces/zmr-ios-reliability/junit.xml
370
384
  \\```
371
385
  \\
372
386
  \\## Release Claims
@@ -380,25 +394,26 @@ fn writeAgentInstructions(path: []const u8, app_id: []const u8, force: bool) !vo
380
394
  \\## App Commands
381
395
  \\
382
396
  \\```bash
397
+ \\zmr inspect --json --dir .
383
398
  \\zmr doctor --strict --json --config .zmr/config.json
384
399
  \\zmr schemas --json
385
400
  \\zmr validate --json .zmr/android-smoke.json && zmr validate --json .zmr/ios-smoke.json
386
401
  \\zmr run .zmr/android-smoke.json --device emulator-5554 --trace-dir traces/zmr-android
387
- \\zmr report traces/zmr-android --out traces/zmr-android/report.html
402
+ \\zmr report traces/zmr-android --out traces/zmr-android/report.html --junit traces/zmr-android/junit.xml
388
403
  \\export ZMR_BIN="${ZMR_BIN:-zmr}"; zmr-benchmark --zmr .zmr/android-smoke.json --device emulator-5554 --app-id
389
404
  );
390
405
  try writer.writeAll(" ");
391
406
  try writeShellArg(writer, app_id);
392
407
  try writer.writeAll(
393
- \\ --runs 20 --trace-root traces/zmr-android-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 30000 && "$ZMR_BIN" report traces/zmr-android-reliability --out traces/zmr-android-reliability/report.html
408
+ \\ --runs 20 --trace-root traces/zmr-android-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 30000 && "$ZMR_BIN" report traces/zmr-android-reliability --out traces/zmr-android-reliability/report.html --junit traces/zmr-android-reliability/junit.xml
394
409
  \\zmr run .zmr/ios-smoke.json --platform ios --device booted --trace-dir traces/zmr-ios
395
- \\zmr report traces/zmr-ios --out traces/zmr-ios/report.html
410
+ \\zmr report traces/zmr-ios --out traces/zmr-ios/report.html --junit traces/zmr-ios/junit.xml
396
411
  \\export ZMR_BIN="${ZMR_BIN:-zmr}"; zmr-benchmark --zmr .zmr/ios-smoke.json --platform ios --device booted --app-id
397
412
  );
398
413
  try writer.writeAll(" ");
399
414
  try writeShellArg(writer, app_id);
400
415
  try writer.writeAll(
401
- \\ --xcrun xcrun --runs 20 --trace-root traces/zmr-ios-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 45000 && "$ZMR_BIN" report traces/zmr-ios-reliability --out traces/zmr-ios-reliability/report.html
416
+ \\ --xcrun xcrun --runs 20 --trace-root traces/zmr-ios-reliability --min-pass-rate 100 --max-failures 0 --max-p95-ms 45000 && "$ZMR_BIN" report traces/zmr-ios-reliability --out traces/zmr-ios-reliability/report.html --junit traces/zmr-ios-reliability/junit.xml
402
417
  \\ZMR_BIN=${ZMR_BIN:-zmr} zmr-device-matrix --matrix .zmr/device-matrix.json --trace-root traces/zmr-matrix --min-pass-rate 100 --max-failures 0
403
418
  );
404
419
  try writer.writeAll("zmr-pilot-gate --android --ios --android-app-root . --android-app-id ");
@@ -410,6 +425,7 @@ fn writeAgentInstructions(path: []const u8, app_id: []const u8, force: bool) !vo
410
425
  \\zmr-release-readiness --evidence traces/zmr-pilots/evidence.jsonl --target production --json
411
426
  \\zmr serve --transport stdio --config .zmr/config.json --trace-dir traces/zmr-agent
412
427
  \\zmr mcp --config .zmr/config.json --trace-dir traces/zmr-agent
428
+ \\zmr discover --from-trace traces/zmr-agent --out .zmr/discovered/replay-smoke.json --include-actions --validate --json
413
429
  \\zmr explain traces/zmr-agent --json
414
430
  \\zmr export traces/zmr-agent --out traces/zmr-agent-redacted.zmrtrace --redact
415
431
  \\```
package/src/scenario.zig CHANGED
@@ -25,6 +25,15 @@ pub const WaitAny = struct {
25
25
  }
26
26
  };
27
27
 
28
+ pub const VisibilityAssertion = struct {
29
+ selector: selector.Selector,
30
+ timeout_ms: ?u64 = null,
31
+
32
+ pub fn deinit(self: VisibilityAssertion, allocator: std.mem.Allocator) void {
33
+ self.selector.deinit(allocator);
34
+ }
35
+ };
36
+
28
37
  pub const TypeText = struct {
29
38
  selector: ?selector.Selector = null,
30
39
  text: []const u8,
@@ -105,8 +114,8 @@ pub const Step = union(enum) {
105
114
  wait_visible: WaitVisible,
106
115
  wait_not_visible: WaitVisible,
107
116
  wait_any: WaitAny,
108
- assert_visible: selector.Selector,
109
- assert_not_visible: selector.Selector,
117
+ assert_visible: VisibilityAssertion,
118
+ assert_not_visible: VisibilityAssertion,
110
119
  assert_none_visible: WaitAny,
111
120
  assert_healthy_timeout_ms: u64,
112
121
  optional: *Step,
@@ -265,8 +274,22 @@ fn parseRawStep(allocator: std.mem.Allocator, object: std.json.ObjectMap) anyerr
265
274
  .timeout_ms = try fields.optionalU64(object, "timeoutMs", 5000),
266
275
  } };
267
276
  }
268
- if (std.mem.eql(u8, action, "assertVisible")) return .{ .assert_visible = try fields.parseSelectorField(allocator, object) };
269
- if (std.mem.eql(u8, action, "assertNotVisible")) return .{ .assert_not_visible = try fields.parseSelectorField(allocator, object) };
277
+ if (std.mem.eql(u8, action, "assertVisible")) {
278
+ const wanted = try fields.parseSelectorField(allocator, object);
279
+ errdefer wanted.deinit(allocator);
280
+ return .{ .assert_visible = .{
281
+ .selector = wanted,
282
+ .timeout_ms = try optionalTimeoutMs(object),
283
+ } };
284
+ }
285
+ if (std.mem.eql(u8, action, "assertNotVisible")) {
286
+ const wanted = try fields.parseSelectorField(allocator, object);
287
+ errdefer wanted.deinit(allocator);
288
+ return .{ .assert_not_visible = .{
289
+ .selector = wanted,
290
+ .timeout_ms = try optionalTimeoutMs(object),
291
+ } };
292
+ }
270
293
  if (std.mem.eql(u8, action, "assertHealthy")) return .{ .assert_healthy_timeout_ms = try fields.optionalU64(object, "timeoutMs", 0) };
271
294
  if (std.mem.eql(u8, action, "assertNoneVisible")) {
272
295
  const selectors = try fields.parseSelectorArrayField(allocator, object);
@@ -344,3 +367,8 @@ fn optionalDirection(object: std.json.ObjectMap, key: []const u8, default_value:
344
367
  if (std.mem.eql(u8, value.string, "up")) return .up;
345
368
  return error.UnknownScrollDirection;
346
369
  }
370
+
371
+ fn optionalTimeoutMs(object: std.json.ObjectMap) !?u64 {
372
+ if (object.get("timeoutMs") == null) return null;
373
+ return try fields.optionalU64(object, "timeoutMs", 0);
374
+ }
@@ -26,6 +26,10 @@ const public_schemas = [_]PublicSchema{
26
26
  .{ .name = "capabilities-output", .path = "schemas/capabilities-output.schema.json", .id = "https://zmr.dev/schemas/capabilities-output.schema.json", .description = "Machine-readable runner.capabilities JSON-RPC result" },
27
27
  .{ .name = "explain-output", .path = "schemas/explain-output.schema.json", .id = "https://zmr.dev/schemas/explain-output.schema.json", .description = "Machine-readable zmr explain --json failure triage output" },
28
28
  .{ .name = "run-output", .path = "schemas/run-output.schema.json", .id = "https://zmr.dev/schemas/run-output.schema.json", .description = "Machine-readable zmr run --json terminal summary output" },
29
+ .{ .name = "inspect-output", .path = "schemas/inspect-output.schema.json", .id = "https://zmr.dev/schemas/inspect-output.schema.json", .description = "Machine-readable zmr inspect --json app and agent handoff output" },
30
+ .{ .name = "discover-output", .path = "schemas/discover-output.schema.json", .id = "https://zmr.dev/schemas/discover-output.schema.json", .description = "Machine-readable zmr discover --json trace-backed scenario discovery output" },
31
+ .{ .name = "explore-output", .path = "schemas/explore-output.schema.json", .id = "https://zmr.dev/schemas/explore-output.schema.json", .description = "Machine-readable zmr explore --json review-first trace exploration output" },
32
+ .{ .name = "draft-output", .path = "schemas/draft-output.schema.json", .id = "https://zmr.dev/schemas/draft-output.schema.json", .description = "Machine-readable zmr draft --json scenario draft output" },
29
33
  .{ .name = "release-manifest", .path = "schemas/release-manifest.schema.json", .id = "https://zmr.dev/schemas/release-manifest.schema.json", .description = "Machine-readable RELEASE_MANIFEST.json emitted with release archives" },
30
34
  .{ .name = "release-readiness-output", .path = "schemas/release-readiness-output.schema.json", .id = "https://zmr.dev/schemas/release-readiness-output.schema.json", .description = "Machine-readable zmr-release-readiness --json release evidence gate output" },
31
35
  .{ .name = "schemas-output", .path = "schemas/schemas-output.schema.json", .id = "https://zmr.dev/schemas/schemas-output.schema.json", .description = "Machine-readable zmr schemas --json public schema index" },
package/src/version.zig CHANGED
@@ -1,4 +1,4 @@
1
- pub const runner_version = "0.1.3";
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");