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/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
 
@@ -423,6 +423,38 @@ def benchmark_threshold_reason(row):
423
423
  return "requires " + ", ".join(reasons)
424
424
 
425
425
 
426
+ agent_workflow_fields = (
427
+ "agentWorkflow",
428
+ "mcp",
429
+ "jsonRpc",
430
+ "semanticSnapshot",
431
+ "typedActions",
432
+ "traceEvents",
433
+ "traceExplain",
434
+ "traceExplore",
435
+ "traceDiscover",
436
+ "scenarioValidation",
437
+ "redactedExport",
438
+ )
439
+
440
+
441
+ def agent_workflow_pass(row):
442
+ command = row.get("command")
443
+ if row.get("name") == "local release gate" and isinstance(command, str) and "release-gate.sh" in command:
444
+ return True
445
+ return all(row.get(field) is True for field in agent_workflow_fields)
446
+
447
+
448
+ def agent_workflow_reason(row):
449
+ command = row.get("command")
450
+ if row.get("name") == "local release gate" and (not isinstance(command, str) or "release-gate.sh" not in command):
451
+ return "requires release-gate.sh or structured agentWorkflow evidence"
452
+ missing = [field for field in agent_workflow_fields if row.get(field) is not True]
453
+ if missing:
454
+ return "requires release-gate.sh or structured agentWorkflow evidence: " + ", ".join(missing)
455
+ return "requires release-gate.sh or structured agentWorkflow evidence"
456
+
457
+
426
458
  def row_satisfies(label, row):
427
459
  if row.get("status") != "passed":
428
460
  return False
@@ -436,6 +468,8 @@ def row_satisfies(label, row):
436
468
  return physical_ios_device_value(row) is not None
437
469
  if label == "competitive benchmark comparison":
438
470
  return benchmark_thresholds_pass(row)
471
+ if label == "agent workflow smoke":
472
+ return agent_workflow_pass(row)
439
473
  return True
440
474
 
441
475
 
@@ -485,6 +519,8 @@ def requirement_status(label, names):
485
519
  reason = "requires concrete physical device identifier evidence"
486
520
  elif label == "competitive benchmark comparison":
487
521
  reason = benchmark_threshold_reason(row)
522
+ elif label == "agent workflow smoke":
523
+ reason = agent_workflow_reason(row)
488
524
  return {
489
525
  "name": label,
490
526
  "status": "insufficient",
@@ -508,6 +544,7 @@ requirements = [
508
544
 
509
545
  if target in ("production", "market-claim"):
510
546
  requirements.extend([
547
+ ("agent workflow smoke", ("agent workflow smoke", "local release gate")),
511
548
  ("physical iOS readiness", "physical iOS readiness"),
512
549
  ("Android hardware pilot", "Android hardware pilot"),
513
550
  ("iOS simulator hardware pilot", "iOS simulator hardware pilot"),
@@ -582,6 +619,7 @@ next_step_commands = {
582
619
  "local release gate": ["./scripts/release-candidate.sh --mode local"],
583
620
  "public Android demo": ["zmr-demo-android --runs 5"],
584
621
  "public iOS simulator demo": ["zmr-demo-ios --runs 5"],
622
+ "agent workflow smoke": ["./scripts/release-gate.sh"],
585
623
  "physical iOS readiness": [physical_ios_pilot_command(default_pilot_evidence)],
586
624
  "Android hardware pilot": [grouped_simulator_pilot_command(default_pilot_evidence)],
587
625
  "iOS simulator hardware pilot": [grouped_simulator_pilot_command(default_pilot_evidence)],
@@ -690,6 +728,11 @@ def append_grouped_next_steps(blocked_items):
690
728
  append_next_step(next_steps, label, commands, present)
691
729
  handled.update(present)
692
730
 
731
+ maybe_group(
732
+ ["local release gate", "agent workflow smoke"],
733
+ "local release gate + agent workflow smoke",
734
+ ["./scripts/release-candidate.sh --mode local"],
735
+ )
693
736
  maybe_group(
694
737
  ["Android hardware pilot", "iOS simulator hardware pilot"],
695
738
  "Android hardware pilot + iOS simulator hardware pilot",
@@ -171,6 +171,11 @@ run() {
171
171
  fi
172
172
  }
173
173
 
174
+ run_zmr_report() {
175
+ local trace_dir="$1"
176
+ run "$ZMR_BIN" report "$trace_dir" --out "$trace_dir/report.html" --junit "$trace_dir/junit.xml"
177
+ }
178
+
174
179
  capture() {
175
180
  if [[ "$DRY_RUN" -eq 1 ]]; then
176
181
  echo ""
@@ -505,7 +510,7 @@ if [[ "$RUNS" -eq 1 ]]; then
505
510
  SINGLE_TRACE="$TRACE_ROOT/scenario"
506
511
  run rm -rf "$SINGLE_TRACE"
507
512
  run_zmr_android_scenario "$SCENARIO" --device "$DEVICE" --app-id "$APP_ID" --trace-dir "$SINGLE_TRACE"
508
- run "$ZMR_BIN" report "$SINGLE_TRACE" --out "$SINGLE_TRACE/report.html"
513
+ run_zmr_report "$SINGLE_TRACE"
509
514
  run "$ZMR_BIN" export "$SINGLE_TRACE" --out "$TRACE_ROOT/scenario.zmrtrace"
510
515
  run "$ZMR_BIN" export "$SINGLE_TRACE" --out "$TRACE_ROOT/scenario-redacted.zmrtrace" --redact
511
516
  else
@@ -513,11 +518,11 @@ if [[ "$RUNS" -eq 1 ]]; then
513
518
  LOGIN_TRACE="$TRACE_ROOT/login-smoke"
514
519
  run rm -rf "$AUTH_TRACE" "$LOGIN_TRACE"
515
520
  run_zmr_android_scenario examples/android-app-auth-probe.json --device "$DEVICE" --app-id "$APP_ID" --trace-dir "$AUTH_TRACE"
516
- run "$ZMR_BIN" report "$AUTH_TRACE" --out "$AUTH_TRACE/report.html"
521
+ run_zmr_report "$AUTH_TRACE"
517
522
  run "$ZMR_BIN" export "$AUTH_TRACE" --out "$TRACE_ROOT/auth.zmrtrace"
518
523
  run "$ZMR_BIN" export "$AUTH_TRACE" --out "$TRACE_ROOT/auth-redacted.zmrtrace" --redact
519
524
  run_zmr_android_scenario examples/android-app-login-smoke.json --device "$DEVICE" --app-id "$APP_ID" --trace-dir "$LOGIN_TRACE"
520
- run "$ZMR_BIN" report "$LOGIN_TRACE" --out "$LOGIN_TRACE/report.html"
525
+ run_zmr_report "$LOGIN_TRACE"
521
526
  run "$ZMR_BIN" export "$LOGIN_TRACE" --out "$TRACE_ROOT/login-smoke.zmrtrace"
522
527
  run "$ZMR_BIN" export "$LOGIN_TRACE" --out "$TRACE_ROOT/login-smoke-redacted.zmrtrace" --redact
523
528
  fi
@@ -531,12 +536,12 @@ else
531
536
  fi
532
537
  if [[ -n "$SCENARIO" ]]; then
533
538
  run_android_benchmark --zmr "$SCENARIO" --device "$DEVICE" --app-id "$APP_ID" --runs "$RUNS" --trace-root "$TRACE_ROOT/bench-scenario" "${benchmark_gate_args[@]}"
534
- run "$ZMR_BIN" report "$TRACE_ROOT/bench-scenario" --out "$TRACE_ROOT/bench-scenario/report.html"
539
+ run_zmr_report "$TRACE_ROOT/bench-scenario"
535
540
  else
536
541
  run_android_benchmark --zmr examples/android-app-auth-probe.json --device "$DEVICE" --app-id "$APP_ID" --runs "$RUNS" --trace-root "$TRACE_ROOT/bench-auth" "${benchmark_gate_args[@]}"
537
- run "$ZMR_BIN" report "$TRACE_ROOT/bench-auth" --out "$TRACE_ROOT/bench-auth/report.html"
542
+ run_zmr_report "$TRACE_ROOT/bench-auth"
538
543
  run_android_benchmark --zmr examples/android-app-login-smoke.json --device "$DEVICE" --app-id "$APP_ID" --runs "$RUNS" --trace-root "$TRACE_ROOT/bench-login-smoke" "${benchmark_gate_args[@]}"
539
- run "$ZMR_BIN" report "$TRACE_ROOT/bench-login-smoke" --out "$TRACE_ROOT/bench-login-smoke/report.html"
544
+ run_zmr_report "$TRACE_ROOT/bench-login-smoke"
540
545
  fi
541
546
  fi
542
547
 
@@ -546,9 +551,30 @@ cat <<EOF
546
551
 
547
552
  Android pilot complete.
548
553
  Output directory: $TRACE_ROOT
549
- Shareable bundles:
550
- $TRACE_ROOT/auth-redacted.zmrtrace
551
- $TRACE_ROOT/login-smoke-redacted.zmrtrace
554
+ EOF
555
+
556
+ if [[ "$RUNS" -eq 1 ]]; then
557
+ echo "Shareable bundles:"
558
+ if [[ -n "$SCENARIO" ]]; then
559
+ echo " $TRACE_ROOT/scenario-redacted.zmrtrace"
560
+ else
561
+ echo " $TRACE_ROOT/auth-redacted.zmrtrace"
562
+ echo " $TRACE_ROOT/login-smoke-redacted.zmrtrace"
563
+ fi
564
+ else
565
+ echo "Benchmark reports:"
566
+ if [[ -n "$SCENARIO" ]]; then
567
+ echo " $TRACE_ROOT/bench-scenario/report.html"
568
+ echo " $TRACE_ROOT/bench-scenario/junit.xml"
569
+ else
570
+ echo " $TRACE_ROOT/bench-auth/report.html"
571
+ echo " $TRACE_ROOT/bench-auth/junit.xml"
572
+ echo " $TRACE_ROOT/bench-login-smoke/report.html"
573
+ echo " $TRACE_ROOT/bench-login-smoke/junit.xml"
574
+ fi
575
+ fi
576
+
577
+ cat <<EOF
552
578
  Viewer:
553
579
  $ROOT/viewer/index.html
554
580
  EOF
@@ -156,6 +156,11 @@ run() {
156
156
  fi
157
157
  }
158
158
 
159
+ run_zmr_report() {
160
+ local trace_dir="$1"
161
+ run "$ZMR_BIN" report "$trace_dir" --out "$trace_dir/report.html" --junit "$trace_dir/junit.xml"
162
+ }
163
+
159
164
  is_retryable_simctl_text() {
160
165
  local text="$1"
161
166
  [[ "$text" == *"CoreSimulatorService connection became invalid"* ]] ||
@@ -455,7 +460,7 @@ if [[ "$RUNS" -eq 1 ]]; then
455
460
  else
456
461
  run "$ZMR_BIN" run examples/ios-smoke.json --platform ios --ios-device-type "$IOS_DEVICE_TYPE" --device "$DEVICE" --app-id "$APP_ID" --xcrun "$XCRUN" --trace-dir "$TRACE_DIR"
457
462
  fi
458
- run "$ZMR_BIN" report "$TRACE_DIR" --out "$TRACE_DIR/report.html"
463
+ run_zmr_report "$TRACE_DIR"
459
464
  run "$ZMR_BIN" export "$TRACE_DIR" --out "$TRACE_ROOT/ios-smoke.zmrtrace"
460
465
  run "$ZMR_BIN" export "$TRACE_DIR" --out "$TRACE_ROOT/ios-smoke-redacted.zmrtrace" --redact
461
466
 
@@ -463,7 +468,7 @@ if [[ "$RUNS" -eq 1 ]]; then
463
468
  SHIM_TRACE_DIR="$TRACE_ROOT/ios-shim-smoke"
464
469
  run rm -rf "$SHIM_TRACE_DIR"
465
470
  run "$ZMR_BIN" run examples/ios-shim-smoke.json --platform ios --ios-device-type "$IOS_DEVICE_TYPE" --device "$DEVICE" --app-id "$APP_ID" --xcrun "$XCRUN" --ios-shim "$IOS_SHIM" --trace-dir "$SHIM_TRACE_DIR"
466
- run "$ZMR_BIN" report "$SHIM_TRACE_DIR" --out "$SHIM_TRACE_DIR/report.html"
471
+ run_zmr_report "$SHIM_TRACE_DIR"
467
472
  run "$ZMR_BIN" export "$SHIM_TRACE_DIR" --out "$TRACE_ROOT/ios-shim-smoke.zmrtrace"
468
473
  run "$ZMR_BIN" export "$SHIM_TRACE_DIR" --out "$TRACE_ROOT/ios-shim-smoke-redacted.zmrtrace" --redact
469
474
  fi
@@ -481,11 +486,11 @@ else
481
486
  else
482
487
  ZMR_BIN="$ZMR_BIN" run "$ROOT/scripts/benchmark.sh" --zmr examples/ios-smoke.json --device "$DEVICE" --platform ios --ios-device-type "$IOS_DEVICE_TYPE" --app-id "$APP_ID" --xcrun "$XCRUN" --runs "$RUNS" --trace-root "$TRACE_ROOT/ios-smoke-benchmark" "${benchmark_gate_args[@]}"
483
488
  fi
484
- run "$ZMR_BIN" report "$TRACE_ROOT/ios-smoke-benchmark" --out "$TRACE_ROOT/ios-smoke-benchmark/report.html"
489
+ run_zmr_report "$TRACE_ROOT/ios-smoke-benchmark"
485
490
 
486
491
  if [[ -n "$IOS_SHIM" ]]; then
487
492
  ZMR_BIN="$ZMR_BIN" run "$ROOT/scripts/benchmark.sh" --zmr examples/ios-shim-smoke.json --device "$DEVICE" --platform ios --ios-device-type "$IOS_DEVICE_TYPE" --app-id "$APP_ID" --xcrun "$XCRUN" --ios-shim "$IOS_SHIM" --runs "$RUNS" --trace-root "$TRACE_ROOT/ios-shim-smoke-benchmark" "${benchmark_gate_args[@]}"
488
- run "$ZMR_BIN" report "$TRACE_ROOT/ios-shim-smoke-benchmark" --out "$TRACE_ROOT/ios-shim-smoke-benchmark/report.html"
493
+ run_zmr_report "$TRACE_ROOT/ios-shim-smoke-benchmark"
489
494
  fi
490
495
  fi
491
496
 
@@ -501,8 +506,10 @@ if [[ "$RUNS" -eq 1 ]]; then
501
506
  else
502
507
  echo "Benchmark reports:"
503
508
  echo " $TRACE_ROOT/ios-smoke-benchmark/report.html"
509
+ echo " $TRACE_ROOT/ios-smoke-benchmark/junit.xml"
504
510
  if [[ -n "$IOS_SHIM" ]]; then
505
511
  echo " $TRACE_ROOT/ios-shim-smoke-benchmark/report.html"
512
+ echo " $TRACE_ROOT/ios-shim-smoke-benchmark/junit.xml"
506
513
  fi
507
514
  fi
508
515
  echo "Viewer:"
@@ -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",
@@ -57,9 +57,34 @@ zmr mcp --config .zmr/config.json --trace-dir traces/zmr-agent
57
57
  ```
58
58
 
59
59
  Use the `semantic_snapshot`, `tap`, `type`, `wait_visible`, `trace_events`, and
60
- `trace_export` tools. Prefer `semantic_snapshot` because it normalizes Android
61
- and iOS hierarchy classes into roles, selectors, bounds, and recommended
62
- actions.
60
+ `trace_explore`, `trace_discover`, and `trace_export` tools. Prefer
61
+ `semantic_snapshot` because it normalizes Android and iOS hierarchy classes
62
+ into roles, selectors, bounds, and recommended actions.
63
+
64
+ After a session has produced trace artifacts, prefer the review-first
65
+ exploration handoff when a goal should travel with the generated scenario
66
+ candidate:
67
+
68
+ ```json
69
+ {"method":"trace.explore","params":{"out":".zmr/discovered/login-smoke.json","goal":"find a stable login smoke","includeActions":true,"validate":true,"force":true}}
70
+ ```
71
+
72
+ For MCP agents, call `trace_explore` with the same `out`, `goal`,
73
+ `includeActions`, `validate`, and `force` arguments. The offline CLI equivalent
74
+ is:
75
+
76
+ ```bash
77
+ zmr explore --from-trace traces/zmr-agent \
78
+ --out .zmr/discovered/login-smoke.json \
79
+ --goal "find a stable login smoke" \
80
+ --include-actions \
81
+ --validate \
82
+ --json
83
+ ```
84
+
85
+ Treat the output as a starting point. Its JSON includes `autonomous:false`,
86
+ `reviewRequired:true`, `guardrails`, replay coverage, validation, and next
87
+ commands; it does not crawl, discover credentials, or commit tests.
63
88
 
64
89
  ## Scenario Pattern
65
90
 
@@ -0,0 +1,239 @@
1
+ const std = @import("std");
2
+
3
+ const cli_draft = @import("cli_draft.zig");
4
+ const cli_output = @import("cli_output.zig");
5
+ const trace = @import("trace.zig");
6
+ const validation = @import("validation.zig");
7
+ const version = @import("version.zig");
8
+
9
+ pub const ParsedArgs = struct {
10
+ from_trace: ?[]const u8 = null,
11
+ out_path: ?[]const u8 = null,
12
+ name: ?[]const u8 = null,
13
+ app_id: ?[]const u8 = null,
14
+ include_actions: bool = false,
15
+ validate: bool = false,
16
+ force: bool = false,
17
+ json: bool = false,
18
+ };
19
+
20
+ pub const DiscoverSummary = struct {
21
+ ok: bool,
22
+ draft: cli_draft.DraftSummary,
23
+ validated: bool,
24
+ };
25
+
26
+ pub const JsonOptions = struct {
27
+ mode: []const u8 = "discover",
28
+ goal: ?[]const u8 = null,
29
+ autonomous: ?bool = null,
30
+ review_required: ?bool = null,
31
+ guardrails: []const []const u8 = &.{},
32
+ };
33
+
34
+ pub const OwnedDiscover = struct {
35
+ draft: cli_draft.OwnedDraft,
36
+ validation: ?validation.Result = null,
37
+ summary: DiscoverSummary,
38
+
39
+ pub fn deinit(self: *OwnedDiscover, allocator: std.mem.Allocator) void {
40
+ if (self.validation) |result| result.deinit(allocator);
41
+ self.draft.deinit(allocator);
42
+ }
43
+ };
44
+
45
+ pub fn parseArgs(args: []const []const u8) !ParsedArgs {
46
+ var parsed = ParsedArgs{};
47
+ var index: usize = 0;
48
+ while (index < args.len) : (index += 1) {
49
+ const arg = args[index];
50
+ if (std.mem.eql(u8, arg, "--from-trace")) {
51
+ index += 1;
52
+ parsed.from_trace = if (index < args.len) args[index] else return error.MissingTraceDir;
53
+ } else if (std.mem.eql(u8, arg, "--out")) {
54
+ index += 1;
55
+ parsed.out_path = if (index < args.len) args[index] else return error.MissingDraftOut;
56
+ } else if (std.mem.eql(u8, arg, "--name")) {
57
+ index += 1;
58
+ parsed.name = if (index < args.len) args[index] else return error.MissingParam;
59
+ } else if (std.mem.eql(u8, arg, "--app-id")) {
60
+ index += 1;
61
+ parsed.app_id = if (index < args.len) args[index] else return error.MissingAppId;
62
+ } else if (std.mem.eql(u8, arg, "--include-actions")) {
63
+ parsed.include_actions = true;
64
+ } else if (std.mem.eql(u8, arg, "--validate")) {
65
+ parsed.validate = true;
66
+ } else if (std.mem.eql(u8, arg, "--force")) {
67
+ parsed.force = true;
68
+ } else if (std.mem.eql(u8, arg, "--json")) {
69
+ parsed.json = true;
70
+ } else {
71
+ return error.UnknownFlag;
72
+ }
73
+ }
74
+
75
+ if (parsed.from_trace == null) return error.MissingTraceDir;
76
+ if (parsed.out_path == null) return error.MissingDraftOut;
77
+ return parsed;
78
+ }
79
+
80
+ pub fn run(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !void {
81
+ var raw_args = std.ArrayList([]const u8).empty;
82
+ defer raw_args.deinit(allocator);
83
+ while (args.next()) |arg| try raw_args.append(allocator, arg);
84
+
85
+ const parsed = try parseArgs(raw_args.items);
86
+ var discovered = try discoverFromTrace(allocator, parsed);
87
+ defer discovered.deinit(allocator);
88
+
89
+ const stdout = std.fs.File.stdout().deprecatedWriter();
90
+ if (parsed.json) {
91
+ try writeJson(stdout, discovered.summary, discovered.validation);
92
+ } else {
93
+ try stdout.print("wrote {s}\n", .{discovered.summary.draft.out_path});
94
+ if (discovered.validation) |result| {
95
+ if (result.ok) {
96
+ try stdout.print("validated {s}\n", .{discovered.summary.draft.out_path});
97
+ } else {
98
+ try stdout.print("validation failed {s}\n", .{discovered.summary.draft.out_path});
99
+ }
100
+ }
101
+ try stdout.writeAll("next: zmr validate --json ");
102
+ try cli_output.writeShellArg(stdout, discovered.summary.draft.out_path);
103
+ try stdout.writeAll("\n");
104
+ }
105
+ if (!discovered.summary.ok) std.process.exit(1);
106
+ }
107
+
108
+ pub fn discoverFromTrace(allocator: std.mem.Allocator, parsed: ParsedArgs) !OwnedDiscover {
109
+ var draft = try cli_draft.draftFromTrace(allocator, .{
110
+ .from_trace = parsed.from_trace,
111
+ .out_path = parsed.out_path,
112
+ .name = parsed.name,
113
+ .app_id = parsed.app_id,
114
+ .include_actions = parsed.include_actions,
115
+ .force = parsed.force,
116
+ .json = parsed.json,
117
+ });
118
+ errdefer draft.deinit(allocator);
119
+
120
+ var validation_result: ?validation.Result = null;
121
+ errdefer if (validation_result) |result| result.deinit(allocator);
122
+ if (parsed.validate) {
123
+ validation_result = try validation.validateFile(allocator, draft.summary.out_path);
124
+ }
125
+
126
+ const ok = draft.summary.ok and (validation_result == null or validation_result.?.ok);
127
+ return .{
128
+ .draft = draft,
129
+ .validation = validation_result,
130
+ .summary = .{
131
+ .ok = ok,
132
+ .draft = draft.summary,
133
+ .validated = parsed.validate,
134
+ },
135
+ };
136
+ }
137
+
138
+ pub fn writeJson(writer: anytype, summary: DiscoverSummary, validation_result: ?validation.Result) !void {
139
+ try writeJsonWithOptions(writer, summary, validation_result, .{});
140
+ }
141
+
142
+ pub fn writeJsonWithOptions(writer: anytype, summary: DiscoverSummary, validation_result: ?validation.Result, options: JsonOptions) !void {
143
+ const draft = summary.draft;
144
+ try writer.writeAll("{\"ok\":");
145
+ try writer.writeAll(if (summary.ok) "true" else "false");
146
+ try writer.writeAll(",\"mode\":");
147
+ try trace.writeJsonString(writer, options.mode);
148
+ try writer.writeAll(",\"schemaVersion\":1");
149
+ try writer.writeAll(",\"runnerVersion\":");
150
+ try trace.writeJsonString(writer, version.runner_version);
151
+ try writer.writeAll(",\"protocolVersion\":");
152
+ try trace.writeJsonString(writer, version.protocol_version);
153
+ if (options.goal) |goal| {
154
+ try writer.writeAll(",\"goal\":");
155
+ try trace.writeJsonString(writer, goal);
156
+ }
157
+ if (options.autonomous) |autonomous| {
158
+ try writer.writeAll(",\"autonomous\":");
159
+ try writer.writeAll(if (autonomous) "true" else "false");
160
+ }
161
+ if (options.review_required) |review_required| {
162
+ try writer.writeAll(",\"reviewRequired\":");
163
+ try writer.writeAll(if (review_required) "true" else "false");
164
+ }
165
+ if (options.guardrails.len > 0) {
166
+ try writer.writeAll(",\"guardrails\":[");
167
+ for (options.guardrails, 0..) |guardrail, index| {
168
+ if (index > 0) try writer.writeAll(",");
169
+ try trace.writeJsonString(writer, guardrail);
170
+ }
171
+ try writer.writeAll("]");
172
+ }
173
+ try writer.writeAll(",\"out\":");
174
+ try trace.writeJsonString(writer, draft.out_path);
175
+ try writer.writeAll(",\"traceDir\":");
176
+ try trace.writeJsonString(writer, draft.trace_dir);
177
+ try writer.writeAll(",\"sourceSnapshot\":");
178
+ try trace.writeJsonString(writer, draft.source_snapshot);
179
+ try writer.writeAll(",\"name\":");
180
+ try trace.writeJsonString(writer, draft.name);
181
+ try writer.writeAll(",\"appId\":");
182
+ if (draft.app_id) |actual| {
183
+ try trace.writeJsonString(writer, actual);
184
+ } else {
185
+ try writer.writeAll("null");
186
+ }
187
+ try writer.print(",\"selectorCount\":{d},\"stepCount\":{d}", .{ draft.selector_count, draft.step_count });
188
+ try cli_draft.writeReplayJson(writer, draft.replay);
189
+ try writer.writeAll(",\"warnings\":[");
190
+ for (draft.warnings, 0..) |warning, index| {
191
+ if (index > 0) try writer.writeAll(",");
192
+ try trace.writeJsonString(writer, warning);
193
+ }
194
+ try writer.writeAll("],\"validated\":");
195
+ try writer.writeAll(if (summary.validated) "true" else "false");
196
+ try writer.writeAll(",\"validation\":");
197
+ if (validation_result) |result| {
198
+ try writeValidationObject(writer, draft.out_path, result);
199
+ } else {
200
+ try writer.writeAll("null");
201
+ }
202
+ try writer.writeAll(",\"nextCommands\":[\"zmr validate --json ");
203
+ try cli_output.writeShellArgJsonContent(writer, draft.out_path);
204
+ try writer.writeAll("\",\"zmr run ");
205
+ try cli_output.writeShellArgJsonContent(writer, draft.out_path);
206
+ try writer.writeAll(" --json --trace-dir ");
207
+ try cli_output.writeShellArgJsonContent(writer, draft.trace_dir);
208
+ try writer.writeAll("\"]}\n");
209
+ }
210
+
211
+ fn writeValidationObject(writer: anytype, path: []const u8, result: validation.Result) !void {
212
+ try writer.writeAll("{\"ok\":");
213
+ try writer.writeAll(if (result.ok) "true" else "false");
214
+ try writer.writeAll(",\"path\":");
215
+ try trace.writeJsonString(writer, path);
216
+ if (result.ok) {
217
+ try writer.writeAll(",\"name\":");
218
+ try trace.writeJsonString(writer, result.name.?);
219
+ try writer.writeAll(",\"appId\":");
220
+ if (result.app_id) |app_id| {
221
+ try trace.writeJsonString(writer, app_id);
222
+ } else {
223
+ try writer.writeAll("null");
224
+ }
225
+ try writer.print(",\"stepCount\":{d}", .{result.step_count});
226
+ } else {
227
+ try writer.writeAll(",\"errorCode\":");
228
+ try trace.writeJsonString(writer, result.error_code.?);
229
+ try writer.writeAll(",\"message\":");
230
+ try trace.writeJsonString(writer, result.message.?);
231
+ if (result.path) |field_path| {
232
+ try writer.writeAll(",\"fieldPath\":");
233
+ try trace.writeJsonString(writer, field_path);
234
+ }
235
+ if (result.line) |line| try writer.print(",\"line\":{d}", .{line});
236
+ if (result.column) |column| try writer.print(",\"column\":{d}", .{column});
237
+ }
238
+ try writer.writeAll("}");
239
+ }