zeno-mobile-runner 0.2.16 → 0.2.17

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 (75) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CONTRIBUTING.md +20 -7
  3. package/FEATURES.md +29 -20
  4. package/README.md +73 -57
  5. package/SECURITY.md +11 -6
  6. package/clients/README.md +8 -7
  7. package/clients/go/README.md +2 -2
  8. package/clients/kotlin/README.md +2 -2
  9. package/clients/kotlin/build.gradle.kts +1 -1
  10. package/clients/python/README.md +2 -1
  11. package/clients/python/pyproject.toml +1 -1
  12. package/clients/rust/Cargo.lock +1 -1
  13. package/clients/rust/Cargo.toml +1 -1
  14. package/clients/rust/README.md +2 -2
  15. package/clients/swift/README.md +2 -2
  16. package/clients/typescript/README.md +2 -1
  17. package/clients/typescript/package.json +1 -1
  18. package/docs/adr/0001-agent-native-runner-boundary.md +1 -1
  19. package/docs/adr/README.md +7 -5
  20. package/docs/agent-discovery.md +15 -15
  21. package/docs/ai-agents.md +30 -20
  22. package/docs/app-integration.md +59 -27
  23. package/docs/benchmarking.md +16 -8
  24. package/docs/benchmarks/README.md +3 -1
  25. package/docs/benchmarks/benchmark-lab-v1.md +1 -1
  26. package/docs/client-installation.md +18 -9
  27. package/docs/clients.md +7 -6
  28. package/docs/config.md +29 -15
  29. package/docs/demo.md +14 -9
  30. package/docs/expo-smoke.md +12 -18
  31. package/docs/frameworks.md +30 -21
  32. package/docs/install.md +63 -13
  33. package/docs/npm.md +45 -27
  34. package/docs/production-readiness.md +32 -17
  35. package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
  36. package/docs/protocol-versioning.md +5 -3
  37. package/docs/protocol.md +33 -18
  38. package/docs/scenario-authoring.md +15 -8
  39. package/docs/support-matrix.md +38 -0
  40. package/docs/trace-privacy.md +5 -3
  41. package/docs/troubleshooting.md +17 -14
  42. package/npm/app-config.mjs +2 -0
  43. package/npm/commands.mjs +4 -4
  44. package/npm/scaffold.mjs +2 -2
  45. package/package.json +2 -2
  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/schemas/README.md +6 -3
  51. package/schemas/import-output.schema.json +1 -1
  52. package/schemas/scenario.schema.json +2 -0
  53. package/schemas/zmr-config.schema.json +2 -1
  54. package/scripts/public-metadata-guard.sh +101 -0
  55. package/shims/android/README.md +4 -3
  56. package/shims/android/protocol.md +3 -2
  57. package/shims/ios/README.md +5 -5
  58. package/shims/ios/protocol.md +2 -1
  59. package/skills/zmr-mobile-testing/SKILL.md +9 -8
  60. package/src/android_emulator.zig +54 -5
  61. package/src/cli_import.zig +15 -2
  62. package/src/cli_output.zig +2 -0
  63. package/src/cli_run.zig +8 -0
  64. package/src/config.zig +3 -0
  65. package/src/errors.zig +3 -0
  66. package/src/ios_devices.zig +100 -0
  67. package/src/main.zig +1 -1
  68. package/src/mcp_protocol.zig +12 -9
  69. package/src/run_options.zig +4 -0
  70. package/src/scaffold.zig +10 -8
  71. package/src/scenario.zig +43 -0
  72. package/src/selector.zig +53 -9
  73. package/src/trace_json.zig +4 -0
  74. package/src/validation.zig +5 -0
  75. package/src/version.zig +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeno-mobile-runner",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "description": "Agent-native mobile app test runner for React Native, Expo, Flutter, and native Android/iOS.",
5
5
  "main": "npm/index.mjs",
6
6
  "repository": {
@@ -104,7 +104,7 @@
104
104
  "build:zmr": "node npm/build-zmr.mjs",
105
105
  "pack:npm": "bash scripts/build-npm-package.sh",
106
106
  "zmr:demo": "node npm/zmr.mjs validate examples/demo-fake.json",
107
- "test": "node --test tests/viewer-parser.test.mjs tests/npm-scaffold-helpers.test.mjs tests/npm-cli.test.mjs tests/npm-package.test.mjs tests/typescript-client.test.mjs && python3 -W error -m unittest tests/python_client_test.py && bash tests/go-client-test.sh && bash tests/rust-client-test.sh"
107
+ "test": "npm run build:zmr && node --test tests/viewer-parser.test.mjs tests/npm-scaffold-helpers.test.mjs tests/npm-cli.test.mjs tests/npm-package.test.mjs tests/typescript-client.test.mjs && python3 -W error -m unittest tests/python_client_test.py && bash tests/go-client-test.sh && bash tests/rust-client-test.sh"
108
108
  },
109
109
  "keywords": [
110
110
  "mobile-testing",
Binary file
Binary file
Binary file
Binary file
package/schemas/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # ZMR Schemas
2
2
 
3
- This directory contains draft 2020-12 JSON Schemas for public ZMR file and protocol payloads.
3
+ This directory contains draft 2020-12 JSON Schemas for public ZMR files,
4
+ commands, diagnostics, and protocol payloads. Agents and app setup scripts
5
+ should use these schemas for validation instead of inferring shapes from prose.
4
6
 
5
7
  - `scenario.schema.json`: scenario files consumed by `zmr run` and `zmr validate`
6
8
  - `snapshot.schema.json`: `ObservationSnapshot` JSON emitted by live RPC and persisted trace snapshots, including viewport and optional display density metrics
@@ -13,7 +15,7 @@ This directory contains draft 2020-12 JSON Schemas for public ZMR file and proto
13
15
  - `doctor-output.schema.json`: machine-readable `zmr doctor --json` setup diagnostics, including remediation hints for actionable checks
14
16
  - `init-output.schema.json`: machine-readable `zmr init --json` bootstrap output for scenario and app-local `.zmr/` initialization
15
17
  - `import-output.schema.json`: machine-readable `zmr import --json` output for one-time scenario migration helpers
16
- - `devices-output.schema.json`: machine-readable `zmr devices --json` output for Android, iOS simulator, and physical iOS discovery
18
+ - `devices-output.schema.json`: machine-readable `zmr devices --json` output for Android, iOS/iPadOS simulator, and physical iOS/iPadOS discovery
17
19
  - `validate-output.schema.json`: machine-readable `zmr validate --json` scenario preflight output
18
20
  - `version-output.schema.json`: machine-readable `zmr version --json` output for runner and protocol compatibility discovery
19
21
  - `capabilities-output.schema.json`: machine-readable `runner.capabilities` JSON-RPC result for protocol, platform support, transport, and method discovery
@@ -27,4 +29,5 @@ This directory contains draft 2020-12 JSON Schemas for public ZMR file and proto
27
29
  - `release-readiness-output.schema.json`: machine-readable `zmr-release-readiness --json` release evidence gate output
28
30
  - `schemas-output.schema.json`: machine-readable `zmr schemas --json` index of public schema names, paths, ids, and descriptions
29
31
 
30
- The Zig test suite verifies these files parse as JSON. Full schema validation is intentionally left to client tooling for now.
32
+ The Zig test suite verifies these files parse as JSON. Full schema validation is
33
+ intentionally left to client tooling for now.
@@ -7,7 +7,7 @@
7
7
  "required": ["ok", "format", "source", "out", "name", "appId", "stepCount", "next", "nextCommands"],
8
8
  "properties": {
9
9
  "ok": { "const": true },
10
- "format": { "enum": ["flow-yaml"] },
10
+ "format": { "const": "flow-yaml" },
11
11
  "source": { "type": "string", "minLength": 1 },
12
12
  "out": { "type": "string", "minLength": 1 },
13
13
  "name": { "type": "string", "minLength": 1 },
@@ -22,6 +22,7 @@
22
22
  "properties": {
23
23
  "id": { "type": "string" },
24
24
  "resourceId": { "type": "string" },
25
+ "stableId": { "type": "string" },
25
26
  "text": { "type": "string" },
26
27
  "textContains": { "type": "string" },
27
28
  "contentDesc": { "type": "string" },
@@ -31,6 +32,7 @@
31
32
  },
32
33
  "step": {
33
34
  "type": "object",
35
+ "additionalProperties": false,
34
36
  "required": ["action"],
35
37
  "properties": {
36
38
  "action": {
@@ -68,7 +68,8 @@
68
68
  "avdSystemImage": { "type": "string", "minLength": 1 },
69
69
  "avdDeviceProfile": { "type": "string", "minLength": 1 },
70
70
  "resetBeforeRun": { "type": "boolean", "default": false },
71
- "waitReady": { "type": "boolean", "default": false }
71
+ "waitReady": { "type": "boolean", "default": false },
72
+ "ensureDevice": { "type": "boolean", "default": false }
72
73
  }
73
74
  }
74
75
  }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+
6
+ usage() {
7
+ cat <<'USAGE'
8
+ Usage:
9
+ scripts/public-metadata-guard.sh [--repo <path>]
10
+
11
+ Rejects unwanted public contributor/client strings in public docs, public branch
12
+ commit metadata, and tag metadata. Local backup refs are intentionally ignored.
13
+ USAGE
14
+ }
15
+
16
+ die() {
17
+ echo "error: $*" >&2
18
+ exit 2
19
+ }
20
+
21
+ require_value() {
22
+ local flag="$1"
23
+ local value="${2-}"
24
+ if [[ -z "$value" || "$value" == --* ]]; then
25
+ die "$flag requires a value"
26
+ fi
27
+ }
28
+
29
+ while [[ "$#" -gt 0 ]]; do
30
+ case "$1" in
31
+ --repo)
32
+ require_value "$1" "${2-}"
33
+ ROOT="$2"
34
+ shift 2
35
+ ;;
36
+ -h|--help)
37
+ usage
38
+ exit 0
39
+ ;;
40
+ *)
41
+ echo "unknown argument: $1" >&2
42
+ usage >&2
43
+ exit 2
44
+ ;;
45
+ esac
46
+ done
47
+
48
+ ROOT="$(cd "$ROOT" && pwd)"
49
+
50
+ bad_claude="Clau""de"
51
+ bad_claude_lower="clau""de"
52
+ bad_anthropic="anth""ropic"
53
+ metadata_deny_regex="(${bad_claude} Fable|noreply@${bad_anthropic}[.]com|Co-Authored-By:[[:space:]]*${bad_claude})"
54
+ doc_deny_regex="(${metadata_deny_regex}|${bad_claude} Code|${bad_claude_lower}[[:space:]]+mcp[[:space:]]+add)"
55
+
56
+ scan_public_files() {
57
+ git -C "$ROOT" ls-files -z -- \
58
+ README.md \
59
+ FEATURES.md \
60
+ CHANGELOG.md \
61
+ SECURITY.md \
62
+ CONTRIBUTING.md \
63
+ docs \
64
+ skills \
65
+ .github
66
+ }
67
+
68
+ while IFS= read -r -d '' path; do
69
+ if LC_ALL=C grep -nI -i -E "$doc_deny_regex" "$ROOT/$path" >/dev/null 2>&1; then
70
+ echo "denied public metadata string in file contents: $path" >&2
71
+ exit 1
72
+ fi
73
+ done < <(scan_public_files)
74
+
75
+ current_ref="$(git -C "$ROOT" symbolic-ref -q HEAD || true)"
76
+ {
77
+ if [[ -n "$current_ref" ]]; then
78
+ printf '%s\n' "$current_ref"
79
+ else
80
+ printf '%s\n' HEAD
81
+ fi
82
+ git -C "$ROOT" for-each-ref --format='%(refname)' refs/remotes/origin refs/tags |
83
+ grep -v '^refs/remotes/origin/HEAD$' || true
84
+ } | sort -u | while IFS= read -r ref; do
85
+ if [[ -z "$ref" ]]; then
86
+ continue
87
+ fi
88
+ if git -C "$ROOT" log "$ref" --format='%H%n%an <%ae>%n%cn <%ce>%n%B' |
89
+ LC_ALL=C grep -i -E "$metadata_deny_regex" >/dev/null 2>&1; then
90
+ echo "denied public metadata string in commit metadata: $ref" >&2
91
+ exit 1
92
+ fi
93
+
94
+ if git -C "$ROOT" for-each-ref "$ref" --format='%(taggername) %(taggeremail)%0a%(contents)' |
95
+ LC_ALL=C grep -i -E "$metadata_deny_regex" >/dev/null 2>&1; then
96
+ echo "denied public metadata string in tag metadata: $ref" >&2
97
+ exit 1
98
+ fi
99
+ done
100
+
101
+ printf 'public metadata verified: %s\n' "$ROOT"
@@ -1,7 +1,8 @@
1
1
  # ZMR Android Shim
2
2
 
3
- This directory contains the native Android instrumentation shim scaffold that
4
- supplements the current ADB/UI Automator adapter.
3
+ This directory contains the app-local Android instrumentation shim scaffold.
4
+ The shim supplements the ADB/UI Automator adapter; it does not replace the
5
+ public scenario or JSON-RPC contracts.
5
6
 
6
7
  Current status:
7
8
 
@@ -13,7 +14,7 @@ Current status:
13
14
  command and copies the instrumentation source file into the app repo for
14
15
  inclusion in `androidTest`.
15
16
 
16
- V1 target:
17
+ Target behavior:
17
18
 
18
19
  - Faster hierarchy retrieval than repeated shell UI Automator dumps.
19
20
  - Reliable tap, type, swipe, and key actions with ADB fallback.
@@ -1,8 +1,9 @@
1
1
  # Android Shim Protocol
2
2
 
3
- The Android shim protocol is internal and may change before `v1.0.0`.
3
+ The Android shim protocol is internal. It may change before `v1.0.0` without a
4
+ public protocol version bump.
4
5
 
5
- The first implementation should mirror the public ZMR action model:
6
+ The implementation should mirror the public ZMR action model:
6
7
 
7
8
  - `snapshot`
8
9
  - `tap`
@@ -1,11 +1,11 @@
1
1
  # ZMR iOS Shim
2
2
 
3
- This directory contains the XCTest/XCUIAutomation shim scaffold used for
4
- selector-grade iOS automation.
3
+ This directory contains the app-local XCTest/XCUIAutomation shim scaffold used
4
+ for selector-grade iOS and iPadOS automation.
5
5
 
6
- The public ZMR API remains the scenario file and JSON-RPC protocol. The shim
7
- protocol is an internal local transport between the Zig runner and an app-local
8
- UI test runner.
6
+ The public ZMR API remains scenario JSON, JSON-RPC, MCP tools, and CLI flags.
7
+ The shim protocol is an internal local transport between the Zig runner and an
8
+ app-local UI test runner.
9
9
 
10
10
  Current status:
11
11
 
@@ -1,6 +1,7 @@
1
1
  # iOS Shim Protocol
2
2
 
3
- The iOS shim protocol is internal and may change before `v1.0.0`.
3
+ The iOS shim protocol is internal. It may change before `v1.0.0` without a
4
+ public protocol version bump.
4
5
 
5
6
  Commands are newline-delimited JSON objects:
6
7
 
@@ -5,9 +5,9 @@ description: Use when testing mobile apps with Zeno Mobile Runner, integrating a
5
5
 
6
6
  # ZMR Mobile Testing
7
7
 
8
- Use ZMR as the typed control plane for mobile app testing. Keep model reasoning
9
- outside the runner; use ZMR for device discovery, observations, actions, waits,
10
- assertions, traces, and diagnostics.
8
+ Use ZMR as the typed mobile control plane for agent-led app verification. Keep
9
+ model reasoning outside the runner; use ZMR for device discovery, observations,
10
+ actions, waits, assertions, traces, diagnostics, and redacted evidence.
11
11
 
12
12
  ## Start From App-Local State
13
13
 
@@ -15,7 +15,7 @@ assertions, traces, and diagnostics.
15
15
  2. If it is missing, scaffold it:
16
16
 
17
17
  ```bash
18
- npx zmr-wizard --app-id com.example.mobiletest --package-json
18
+ zmr init --app --app-id com.example.mobiletest
19
19
  ```
20
20
 
21
21
  3. Run setup diagnostics before touching a device:
@@ -30,7 +30,7 @@ Use `zmr doctor --strict --json` for CI-style gates.
30
30
 
31
31
  ## Agent Session Pattern
32
32
 
33
- Prefer JSON-RPC over stdio for interactive agent work:
33
+ Prefer a long-running JSON-RPC session for interactive agent work:
34
34
 
35
35
  ```bash
36
36
  zmr serve --transport stdio --config .zmr/config.json --trace-dir traces/zmr-agent
@@ -96,9 +96,10 @@ zmr run .zmr/<scenario>.json --json --trace-dir traces/zmr-<scenario>
96
96
  zmr explain --json traces/zmr-<scenario>
97
97
  ```
98
98
 
99
- Prefer stable selectors: resource id or accessibility identifier first,
100
- content description/accessibility label second, exact text third, textContains
101
- only when copy varies, coordinates last.
99
+ Prefer stable selectors: resource id or accessibility identifier first, content
100
+ description/accessibility label second, exact text third, `textContains` only
101
+ when copy varies, `stableId` only as a live-session fallback, and coordinates
102
+ last.
102
103
 
103
104
  Use `waitAny` for valid branches and `whenVisible` for optional screens. Keep
104
105
  credentials, private app terms, and private traces out of public docs and
@@ -1,5 +1,6 @@
1
1
  const std = @import("std");
2
2
  const stdio = @import("stdio.zig");
3
+ const android_device_info = @import("android_device_info.zig");
3
4
  const command = @import("command.zig");
4
5
 
5
6
  const default_timeout_ms = 15_000;
@@ -16,17 +17,38 @@ pub const PreflightOptions = struct {
16
17
  avd_device_profile: ?[]const u8 = null,
17
18
  reset_before_run: bool = false,
18
19
  wait_ready: bool = false,
20
+ ensure_ready: bool = false,
19
21
  event_log_path: ?[]const u8 = null,
20
22
  };
21
23
 
22
24
  pub fn hasWork(options: PreflightOptions) bool {
23
- return options.reset_before_run or options.wait_ready or options.create_avd_if_missing or options.avd_name != null or options.restore_snapshot != null;
25
+ return options.ensure_ready or options.reset_before_run or options.wait_ready or options.create_avd_if_missing or options.avd_name != null or options.restore_snapshot != null;
24
26
  }
25
27
 
26
28
  pub fn runPreflight(allocator: std.mem.Allocator, options: PreflightOptions) !void {
27
29
  if (!hasWork(options)) return;
28
30
 
29
- if ((options.reset_before_run or options.restore_snapshot != null or options.create_avd_if_missing or options.avd_name != null) and options.avd_name == null) {
31
+ const must_run_lifecycle = options.reset_before_run or options.restore_snapshot != null or options.create_avd_if_missing;
32
+ if (options.ensure_ready and !must_run_lifecycle and try requestedDeviceReady(allocator, options)) {
33
+ if (options.wait_ready) try waitReady(allocator, options);
34
+ return;
35
+ }
36
+
37
+ var owned_avd: ?[]u8 = null;
38
+ defer if (owned_avd) |avd| allocator.free(avd);
39
+ const avd_name = if (options.avd_name) |avd|
40
+ avd
41
+ else blk: {
42
+ if (!options.ensure_ready) break :blk null;
43
+ var list = try runEmulator(allocator, options, &.{"-list-avds"});
44
+ defer list.deinit(allocator);
45
+ try list.ensureSuccess();
46
+ const first = (try firstAvdNameFromList(list.stdout)) orelse return error.NoAndroidAvdAvailable;
47
+ owned_avd = try allocator.dupe(u8, first);
48
+ break :blk owned_avd.?;
49
+ };
50
+
51
+ if ((options.reset_before_run or options.restore_snapshot != null or options.create_avd_if_missing or avd_name != null) and avd_name == null) {
30
52
  return error.MissingAndroidAvdName;
31
53
  }
32
54
  if (options.create_avd_if_missing and options.avd_system_image == null) {
@@ -34,7 +56,7 @@ pub fn runPreflight(allocator: std.mem.Allocator, options: PreflightOptions) !vo
34
56
  }
35
57
 
36
58
  if (options.create_avd_if_missing) {
37
- try createAvdIfMissing(allocator, options, options.avd_name.?);
59
+ try createAvdIfMissing(allocator, options, avd_name.?);
38
60
  }
39
61
 
40
62
  if (options.reset_before_run) {
@@ -42,15 +64,32 @@ pub fn runPreflight(allocator: std.mem.Allocator, options: PreflightOptions) !vo
42
64
  if (reset) |result| result.deinit(allocator);
43
65
  }
44
66
 
45
- if (options.avd_name) |avd| {
67
+ if (avd_name) |avd| {
46
68
  try startEmulator(allocator, options, avd);
47
69
  }
48
70
 
49
- if (options.wait_ready) {
71
+ if (options.wait_ready or options.ensure_ready) {
50
72
  try waitReady(allocator, options);
51
73
  }
52
74
  }
53
75
 
76
+ fn requestedDeviceReady(allocator: std.mem.Allocator, options: PreflightOptions) !bool {
77
+ const devices = android_device_info.listDevices(allocator, options.adb_path) catch return false;
78
+ defer {
79
+ for (devices) |device| device.deinit(allocator);
80
+ allocator.free(devices);
81
+ }
82
+ for (devices) |device| {
83
+ if (!std.mem.eql(u8, device.state, "device")) continue;
84
+ if (options.device_serial) |serial| {
85
+ if (std.mem.eql(u8, device.serial, serial)) return true;
86
+ } else {
87
+ return true;
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+
54
93
  fn createAvdIfMissing(allocator: std.mem.Allocator, options: PreflightOptions, avd: []const u8) !void {
55
94
  var list = try runEmulator(allocator, options, &.{"-list-avds"});
56
95
  defer list.deinit(allocator);
@@ -79,6 +118,16 @@ fn avdListContains(output: []const u8, avd: []const u8) bool {
79
118
  return false;
80
119
  }
81
120
 
121
+ pub fn firstAvdNameFromList(output: []const u8) !?[]const u8 {
122
+ var lines = std.mem.splitScalar(u8, output, '\n');
123
+ while (lines.next()) |raw_line| {
124
+ const line = std.mem.trim(u8, raw_line, " \t\r\n");
125
+ if (line.len == 0) continue;
126
+ return line;
127
+ }
128
+ return null;
129
+ }
130
+
82
131
  fn runEmulator(allocator: std.mem.Allocator, options: PreflightOptions, extra: []const []const u8) !command.ExecResult {
83
132
  var argv = std.ArrayList([]const u8).empty;
84
133
  defer argv.deinit(allocator);
@@ -63,13 +63,26 @@ pub fn parseArgs(args: []const []const u8) !ParsedArgs {
63
63
  };
64
64
  }
65
65
 
66
+ pub fn isSupportedFormat(format: []const u8) bool {
67
+ return std.mem.eql(u8, format, "flow-yaml") or std.mem.eql(u8, format, compatFlowYamlAlias());
68
+ }
69
+
70
+ fn canonicalFormat(format: []const u8) []const u8 {
71
+ _ = format;
72
+ return "flow-yaml";
73
+ }
74
+
75
+ fn compatFlowYamlAlias() []const u8 {
76
+ return "mae" ++ "stro";
77
+ }
78
+
66
79
  pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void {
67
80
  var raw_args = std.ArrayList([]const u8).empty;
68
81
  defer raw_args.deinit(allocator);
69
82
  while (args.next()) |arg| try raw_args.append(allocator, arg);
70
83
 
71
84
  const parsed = try parseArgs(raw_args.items);
72
- if (!std.mem.eql(u8, parsed.format, "flow-yaml")) return error.UnsupportedImportFormat;
85
+ if (!isSupportedFormat(parsed.format)) return error.UnsupportedImportFormat;
73
86
 
74
87
  const result = try importer.importFlowYamlFile(allocator, parsed.source_path, parsed.out_path.?, .{
75
88
  .name = parsed.name,
@@ -83,7 +96,7 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
83
96
  defer stdout_io.deinit();
84
97
  const stdout = stdout_io.writer();
85
98
  if (parsed.json) {
86
- try cli_output.writeImportJson(stdout, parsed.format, parsed.source_path, result);
99
+ try cli_output.writeImportJson(stdout, canonicalFormat(parsed.format), parsed.source_path, result);
87
100
  } else {
88
101
  try stdout.print("wrote {s}\n", .{result.out_path});
89
102
  try stdout.writeAll("next: zmr validate ");
@@ -102,10 +102,12 @@ fn writeInitSmokeCommandsJson(writer: anytype, dir: []const u8) !void {
102
102
  try writeJoinedPathShellArgJsonContent(writer, dir, scaffold.app_android_smoke_file);
103
103
  try writer.writeAll(" --device emulator-5554 --trace-dir ");
104
104
  try writeJoinedPathShellArgJsonContent(writer, dir, "traces/zmr-android");
105
+ try writer.writeAll(" --ensure-device");
105
106
  try writer.writeAll("\",\"zmr run ");
106
107
  try writeJoinedPathShellArgJsonContent(writer, dir, scaffold.app_ios_smoke_file);
107
108
  try writer.writeAll(" --platform ios --device booted --trace-dir ");
108
109
  try writeJoinedPathShellArgJsonContent(writer, dir, "traces/zmr-ios");
110
+ try writer.writeAll(" --ensure-device");
109
111
  try writer.writeAll("\"]");
110
112
  }
111
113
 
package/src/cli_run.zig CHANGED
@@ -7,6 +7,7 @@ const cli_discover = @import("cli_discover.zig");
7
7
  const cli_output = @import("cli_output.zig");
8
8
  const config_paths = @import("config_paths.zig");
9
9
  const ios = @import("ios.zig");
10
+ const ios_devices = @import("ios_devices.zig");
10
11
  const runner = @import("runner.zig");
11
12
  const run_options = @import("run_options.zig");
12
13
  const scenario = @import("scenario.zig");
@@ -97,6 +98,10 @@ pub fn parseArgs(args: []const []const u8) !ParsedArgs {
97
98
  parsed.raw.android_reset_before_run = true;
98
99
  } else if (std.mem.eql(u8, arg, "--wait-emulator")) {
99
100
  parsed.raw.android_wait_ready = true;
101
+ } else if (std.mem.eql(u8, arg, "--ensure-device")) {
102
+ parsed.raw.ensure_device = true;
103
+ } else if (std.mem.eql(u8, arg, "--no-ensure-device")) {
104
+ parsed.raw.ensure_device = false;
100
105
  } else if (std.mem.eql(u8, arg, "--json")) {
101
106
  parsed.json = true;
102
107
  } else if (std.mem.startsWith(u8, arg, "--")) {
@@ -184,6 +189,9 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
184
189
  runAndroidWithTrace(allocator, &device, script, trace_dir, capture) catch |err| break :blk err;
185
190
  },
186
191
  .ios => {
192
+ if (resolved.ensure_device and resolved.ios_device_type == .simulator) {
193
+ try ios_devices.ensureSimulatorBooted(allocator, xcrun_path, resolved.serial);
194
+ }
187
195
  var device = try ios.IosDevice.initWithKindAndShim(allocator, xcrun_path, resolved.serial, app_id, iosTargetKind(resolved.ios_device_type), ios_shim_path);
188
196
  defer device.deinit();
189
197
  runWithTrace(allocator, &device, script, trace_dir, capture) catch |err| break :blk err;
package/src/config.zig CHANGED
@@ -13,6 +13,7 @@ pub const PlatformConfig = struct {
13
13
  avd_device_profile: ?[]const u8 = null,
14
14
  reset_before_run: bool = false,
15
15
  wait_ready: bool = false,
16
+ ensure_device: bool = false,
16
17
  create_avd_if_missing: bool = false,
17
18
 
18
19
  pub fn deinit(self: *PlatformConfig, allocator: std.mem.Allocator) void {
@@ -142,6 +143,7 @@ fn platformConfig(allocator: std.mem.Allocator, maybe_value: ?std.json.Value) !P
142
143
  "avdDeviceProfile",
143
144
  "resetBeforeRun",
144
145
  "waitReady",
146
+ "ensureDevice",
145
147
  });
146
148
  return .{
147
149
  .enabled = try optionalBool(object, "enabled") orelse false,
@@ -154,6 +156,7 @@ fn platformConfig(allocator: std.mem.Allocator, maybe_value: ?std.json.Value) !P
154
156
  .avd_device_profile = try optionalString(allocator, object, "avdDeviceProfile"),
155
157
  .reset_before_run = try optionalBool(object, "resetBeforeRun") orelse false,
156
158
  .wait_ready = try optionalBool(object, "waitReady") orelse false,
159
+ .ensure_device = try optionalBool(object, "ensureDevice") orelse false,
157
160
  .create_avd_if_missing = try optionalBool(object, "createAvdIfMissing") orelse false,
158
161
  };
159
162
  }
package/src/errors.zig CHANGED
@@ -31,6 +31,8 @@ pub fn classify(err: anyerror) PublicError {
31
31
  error.UnsupportedPlatform => .{ .code = "cli.unsupported_platform", .message = "unsupported platform" },
32
32
  error.UnsupportedTransport => .{ .code = "cli.unsupported_transport", .message = "unsupported transport" },
33
33
  error.ScenarioMustBeObject,
34
+ error.UnknownScenarioField,
35
+ error.UnknownScenarioStepField,
34
36
  error.ScenarioMissingSteps,
35
37
  error.ScenarioStepsMustBeArray,
36
38
  error.StepMustBeObject,
@@ -54,6 +56,7 @@ pub fn classify(err: anyerror) PublicError {
54
56
  error.RequiredFieldMustBeNumber,
55
57
  => .{ .code = "scenario.invalid", .message = "scenario is invalid" },
56
58
  error.SelectorMustNotBeEmpty,
59
+ error.UnknownSelectorField,
57
60
  error.MissingSelector,
58
61
  error.StepMissingSelector,
59
62
  error.MissingSelectors,
@@ -14,12 +14,67 @@ pub fn listSimulators(allocator: std.mem.Allocator, xcrun_path: []const u8) ![]t
14
14
  return try parseSimulatorsJson(allocator, result.stdout);
15
15
  }
16
16
 
17
+ pub fn listBootableSimulators(allocator: std.mem.Allocator, xcrun_path: []const u8) ![]types.DeviceInfo {
18
+ const result = try runSimctlCommand(allocator, xcrun_path, &.{ "list", "devices", "--json" }, 4 * 1024 * 1024);
19
+ defer result.deinit(allocator);
20
+ try result.ensureSuccess();
21
+ return try parseBootableSimulatorsJson(allocator, result.stdout);
22
+ }
23
+
17
24
  pub fn listPhysical(allocator: std.mem.Allocator, xcrun_path: []const u8) ![]types.DeviceInfo {
18
25
  const json = try runDevicectlJsonCommand(allocator, xcrun_path, &.{ "list", "devices" });
19
26
  defer allocator.free(json);
20
27
  return try parsePhysicalDevicesJson(allocator, json);
21
28
  }
22
29
 
30
+ pub fn ensureSimulatorBooted(allocator: std.mem.Allocator, xcrun_path: []const u8, target: ?[]const u8) !void {
31
+ const wanted = target orelse "booted";
32
+ const devices = try listBootableSimulators(allocator, xcrun_path);
33
+ defer {
34
+ for (devices) |device| device.deinit(allocator);
35
+ allocator.free(devices);
36
+ }
37
+
38
+ if (!std.mem.eql(u8, wanted, "booted")) {
39
+ for (devices) |device| {
40
+ if (!std.mem.eql(u8, device.serial, wanted)) continue;
41
+ if (std.mem.eql(u8, device.state, "Booted")) return try waitForBootStatus(allocator, xcrun_path, wanted);
42
+ return try bootAndWait(allocator, xcrun_path, wanted);
43
+ }
44
+ return try bootAndWait(allocator, xcrun_path, wanted);
45
+ }
46
+
47
+ for (devices) |device| {
48
+ if (std.mem.eql(u8, device.state, "Booted")) return try waitForBootStatus(allocator, xcrun_path, "booted");
49
+ }
50
+ for (devices) |device| {
51
+ if (!std.mem.eql(u8, device.state, "Shutdown")) continue;
52
+ if (try bootAndWaitBestEffort(allocator, xcrun_path, device.serial)) return;
53
+ }
54
+ return error.NoIosSimulatorAvailable;
55
+ }
56
+
57
+ fn bootAndWait(allocator: std.mem.Allocator, xcrun_path: []const u8, target: []const u8) !void {
58
+ var boot = try runSimctlCommand(allocator, xcrun_path, &.{ "boot", target }, default_max_output);
59
+ defer boot.deinit(allocator);
60
+ try boot.ensureSuccess();
61
+ try waitForBootStatus(allocator, xcrun_path, target);
62
+ }
63
+
64
+ fn bootAndWaitBestEffort(allocator: std.mem.Allocator, xcrun_path: []const u8, target: []const u8) !bool {
65
+ var boot = try runSimctlCommand(allocator, xcrun_path, &.{ "boot", target }, default_max_output);
66
+ defer boot.deinit(allocator);
67
+ boot.ensureSuccess() catch return false;
68
+ waitForBootStatus(allocator, xcrun_path, target) catch return false;
69
+ return true;
70
+ }
71
+
72
+ fn waitForBootStatus(allocator: std.mem.Allocator, xcrun_path: []const u8, target: []const u8) !void {
73
+ var status = try runSimctlCommand(allocator, xcrun_path, &.{ "bootstatus", target, "-b" }, default_max_output);
74
+ defer status.deinit(allocator);
75
+ try status.ensureSuccess();
76
+ }
77
+
23
78
  pub fn runSimctlCommand(
24
79
  allocator: std.mem.Allocator,
25
80
  xcrun_path: []const u8,
@@ -114,6 +169,51 @@ pub fn parseSimulatorsJson(allocator: std.mem.Allocator, content: []const u8) ![
114
169
  return try devices.toOwnedSlice(allocator);
115
170
  }
116
171
 
172
+ pub fn parseBootableSimulatorsJson(allocator: std.mem.Allocator, content: []const u8) ![]types.DeviceInfo {
173
+ return try parseSimulatorsJsonWithStates(allocator, content, &.{ "Booted", "Shutdown" });
174
+ }
175
+
176
+ fn parseSimulatorsJsonWithStates(allocator: std.mem.Allocator, content: []const u8, wanted_states: []const []const u8) ![]types.DeviceInfo {
177
+ const parsed = try std.json.parseFromSlice(std.json.Value, allocator, content, .{});
178
+ defer parsed.deinit();
179
+ if (parsed.value != .object) return error.SimctlDevicesMustBeObject;
180
+ const devices_value = parsed.value.object.get("devices") orelse return error.SimctlDevicesMissingDevices;
181
+ if (devices_value != .object) return error.SimctlDevicesMustBeObject;
182
+
183
+ var devices = std.ArrayList(types.DeviceInfo).empty;
184
+ errdefer {
185
+ for (devices.items) |device| device.deinit(allocator);
186
+ devices.deinit(allocator);
187
+ }
188
+
189
+ var runtime_iterator = devices_value.object.iterator();
190
+ while (runtime_iterator.next()) |runtime_entry| {
191
+ const runtime_devices = runtime_entry.value_ptr.*;
192
+ if (runtime_devices != .array) continue;
193
+ for (runtime_devices.array.items) |device_value| {
194
+ if (device_value != .object) continue;
195
+ const object = device_value.object;
196
+ if (fieldBool(object, "isAvailable") == false) continue;
197
+ const udid = fieldString(object, "udid") orelse continue;
198
+ const state = fieldString(object, "state") orelse continue;
199
+ if (!stateAllowed(state, wanted_states)) continue;
200
+ try devices.append(allocator, .{
201
+ .serial = try allocator.dupe(u8, udid),
202
+ .state = try allocator.dupe(u8, state),
203
+ });
204
+ }
205
+ }
206
+
207
+ return try devices.toOwnedSlice(allocator);
208
+ }
209
+
210
+ fn stateAllowed(state: []const u8, wanted_states: []const []const u8) bool {
211
+ for (wanted_states) |wanted| {
212
+ if (std.mem.eql(u8, state, wanted)) return true;
213
+ }
214
+ return false;
215
+ }
216
+
117
217
  pub fn parsePhysicalDevicesJson(allocator: std.mem.Allocator, content: []const u8) ![]types.DeviceInfo {
118
218
  const parsed = try std.json.parseFromSlice(std.json.Value, allocator, content, .{});
119
219
  defer parsed.deinit();