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/src/main.zig CHANGED
@@ -154,7 +154,7 @@ fn usage() !void {
154
154
  \\ zmr init --app [--dir <app-root>] [--app-id <id>] [--force] [--json]
155
155
  \\ zmr import flow-yaml <flow.yaml> --out <scenario.json> [--name <name>] [--app-id <id>] [--force] [--json]
156
156
  \\ zmr inspect [--json] [--dir <app-root>] [--config <path>]
157
- \\ zmr run [scenario.json] [--json] [--config <path>] [--platform android|ios] [--ios-device-type simulator|physical] [--device <serial>] [--app-id <id>] [--trace-dir <path>] [--discover-out <scenario.json>] [--android-avd <name>] [--create-avd-if-missing] [--avd-system-image <pkg>] [--avd-device <profile>] [--restore-snapshot <name>] [--reset-emulator] [--wait-emulator] [--screen-record] [--no-screen-record] [--adb <path>] [--emulator <path>] [--avdmanager <path>] [--android-shim <path>] [--xcrun <path>] [--ios-shim <path>]
157
+ \\ zmr run [scenario.json] [--json] [--config <path>] [--platform android|ios] [--ios-device-type simulator|physical] [--device <serial>] [--app-id <id>] [--trace-dir <path>] [--discover-out <scenario.json>] [--android-avd <name>] [--create-avd-if-missing] [--avd-system-image <pkg>] [--avd-device <profile>] [--restore-snapshot <name>] [--reset-emulator] [--wait-emulator] [--ensure-device] [--no-ensure-device] [--screen-record] [--no-screen-record] [--adb <path>] [--emulator <path>] [--avdmanager <path>] [--android-shim <path>] [--xcrun <path>] [--ios-shim <path>]
158
158
  \\ zmr report <trace-or-benchmark-dir> --out <report.html> [--junit <report.xml>]
159
159
  \\ zmr explain <trace-dir> [--json]
160
160
  \\ zmr export <trace-dir> --out <bundle.zmrtrace> [--redact] [--omit-screenshots]
@@ -61,6 +61,9 @@ pub fn writeId(writer: anytype, id: ?std.json.Value) !void {
61
61
  }
62
62
  }
63
63
 
64
+ const selector_schema_json =
65
+ "{\"type\":\"object\",\"additionalProperties\":false,\"minProperties\":1,\"properties\":{\"id\":{\"type\":\"string\"},\"resourceId\":{\"type\":\"string\"},\"stableId\":{\"type\":\"string\"},\"text\":{\"type\":\"string\"},\"textContains\":{\"type\":\"string\"},\"contentDesc\":{\"type\":\"string\"},\"contentDescContains\":{\"type\":\"string\"},\"className\":{\"type\":\"string\"}}}";
66
+
64
67
  const tool_list_json =
65
68
  "{\"tools\":[" ++
66
69
  "{\"name\":\"snapshot\",\"description\":\"Capture the current mobile observation snapshot as JSON.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\":{}}}," ++
@@ -69,19 +72,19 @@ const tool_list_json =
69
72
  "{\"name\":\"launch_app\",\"description\":\"Launch the configured app on the selected device.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\":{}}}," ++
70
73
  "{\"name\":\"stop_app\",\"description\":\"Stop the configured app on the selected device.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\":{}}}," ++
71
74
  "{\"name\":\"clear_state\",\"description\":\"Clear the configured app state where the platform supports it.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\":{}}}," ++
72
- "{\"name\":\"tap\",\"description\":\"Tap a visible element by selector.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":{\"type\":\"object\"}}}}," ++
73
- "{\"name\":\"type\",\"description\":\"Type text, optionally after focusing an element by selector.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"text\"],\"properties\":{\"selector\":{\"type\":\"object\"},\"text\":{\"type\":\"string\"}}}}," ++
75
+ "{\"name\":\"tap\",\"description\":\"Tap a visible element by selector.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":" ++ selector_schema_json ++ "}}}," ++
76
+ "{\"name\":\"type\",\"description\":\"Type text, optionally after focusing an element by selector.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"text\"],\"properties\":{\"selector\":" ++ selector_schema_json ++ ",\"text\":{\"type\":\"string\"}}}}," ++
74
77
  "{\"name\":\"hide_keyboard\",\"description\":\"Dismiss the software keyboard when the platform can do so.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\":{}}}," ++
75
- "{\"name\":\"erase_text\",\"description\":\"Erase text, optionally after focusing an element by selector.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"selector\":{\"type\":\"object\"},\"maxChars\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
78
+ "{\"name\":\"erase_text\",\"description\":\"Erase text, optionally after focusing an element by selector.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"selector\":" ++ selector_schema_json ++ ",\"maxChars\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
76
79
  "{\"name\":\"swipe\",\"description\":\"Swipe between absolute screen coordinates.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"x1\",\"y1\",\"x2\",\"y2\"],\"properties\":{\"x1\":{\"type\":\"integer\"},\"y1\":{\"type\":\"integer\"},\"x2\":{\"type\":\"integer\"},\"y2\":{\"type\":\"integer\"},\"durationMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
77
80
  "{\"name\":\"press_back\",\"description\":\"Press Android back or the platform-equivalent navigation action.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\":{}}}," ++
78
81
  "{\"name\":\"open_link\",\"description\":\"Open a deep link URL in the target app.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"url\"],\"properties\":{\"url\":{\"type\":\"string\"}}}}," ++
79
- "{\"name\":\"wait_visible\",\"description\":\"Wait for an element selector to become visible.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":{\"type\":\"object\"},\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
80
- "{\"name\":\"wait_not_visible\",\"description\":\"Wait for an element selector to no longer be visible.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":{\"type\":\"object\"},\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
81
- "{\"name\":\"wait_any\",\"description\":\"Wait for the first visible selector from an ordered selector list.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selectors\"],\"properties\":{\"selectors\":{\"type\":\"array\",\"minItems\":1,\"items\":{\"type\":\"object\"}},\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
82
- "{\"name\":\"scroll_until_visible\",\"description\":\"Scroll until an element selector is visible.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":{\"type\":\"object\"},\"direction\":{\"type\":\"string\",\"enum\":[\"down\",\"up\"]},\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
83
- "{\"name\":\"assert_visible\",\"description\":\"Assert that an element selector is visible within the timeout.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":{\"type\":\"object\"},\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
84
- "{\"name\":\"assert_not_visible\",\"description\":\"Assert that an element selector is not visible within the timeout.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":{\"type\":\"object\"},\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
82
+ "{\"name\":\"wait_visible\",\"description\":\"Wait for an element selector to become visible.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":" ++ selector_schema_json ++ ",\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
83
+ "{\"name\":\"wait_not_visible\",\"description\":\"Wait for an element selector to no longer be visible.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":" ++ selector_schema_json ++ ",\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
84
+ "{\"name\":\"wait_any\",\"description\":\"Wait for the first visible selector from an ordered selector list.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selectors\"],\"properties\":{\"selectors\":{\"type\":\"array\",\"minItems\":1,\"items\":" ++ selector_schema_json ++ "},\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
85
+ "{\"name\":\"scroll_until_visible\",\"description\":\"Scroll until an element selector is visible.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":" ++ selector_schema_json ++ ",\"direction\":{\"type\":\"string\",\"enum\":[\"down\",\"up\"]},\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
86
+ "{\"name\":\"assert_visible\",\"description\":\"Assert that an element selector is visible within the timeout.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":" ++ selector_schema_json ++ ",\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
87
+ "{\"name\":\"assert_not_visible\",\"description\":\"Assert that an element selector is not visible within the timeout.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":" ++ selector_schema_json ++ ",\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
85
88
  "{\"name\":\"assert_healthy\",\"description\":\"Assert that the app is free of common crash and error overlays.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"timeoutMs\":{\"type\":\"integer\",\"minimum\":0}}}}," ++
86
89
  "{\"name\":\"scenario_validate\",\"description\":\"Validate a ZMR scenario file and return structured diagnostics.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\"}}}}," ++
87
90
  "{\"name\":\"trace_events\",\"description\":\"Read live trace events from a traced MCP session.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"properties\":{\"afterSeq\":{\"type\":\"integer\",\"minimum\":0},\"limit\":{\"type\":\"integer\",\"minimum\":1}}}}," ++
@@ -28,6 +28,7 @@ pub const RawRunOptions = struct {
28
28
  android_avd_device_profile: ?[]const u8 = null,
29
29
  android_reset_before_run: ?bool = null,
30
30
  android_wait_ready: ?bool = null,
31
+ ensure_device: ?bool = null,
31
32
  platform: Platform = .android,
32
33
  ios_device_type: IosDeviceType = .simulator,
33
34
  };
@@ -46,6 +47,7 @@ pub const ResolvedRunOptions = struct {
46
47
  android_avd_device_profile: ?[]const u8,
47
48
  android_reset_before_run: bool,
48
49
  android_wait_ready: bool,
50
+ ensure_device: bool,
49
51
  platform: Platform,
50
52
  ios_device_type: IosDeviceType,
51
53
  };
@@ -86,6 +88,7 @@ pub fn resolveRun(raw: RawRunOptions, cfg: ?config.Config) ResolvedRunOptions {
86
88
  .android_avd_device_profile = raw.android_avd_device_profile orelse if (platform_cfg) |pc| pc.avd_device_profile else null,
87
89
  .android_reset_before_run = raw.android_reset_before_run orelse if (platform_cfg) |pc| pc.reset_before_run else false,
88
90
  .android_wait_ready = raw.android_wait_ready orelse if (platform_cfg) |pc| pc.wait_ready else false,
91
+ .ensure_device = raw.ensure_device orelse if (platform_cfg) |pc| pc.ensure_device else false,
89
92
  .platform = raw.platform,
90
93
  .ios_device_type = raw.ios_device_type,
91
94
  };
@@ -122,6 +125,7 @@ pub fn androidPreflight(
122
125
  .avd_device_profile = resolved.android_avd_device_profile,
123
126
  .reset_before_run = resolved.android_reset_before_run,
124
127
  .wait_ready = resolved.android_wait_ready,
128
+ .ensure_ready = resolved.ensure_device,
125
129
  };
126
130
  return if (android_emulator.hasWork(options)) options else null;
127
131
  }
package/src/scaffold.zig CHANGED
@@ -131,13 +131,15 @@ fn writeAppConfig(path: []const u8, app_id: []const u8, force: bool) !void {
131
131
  \\ "enabled": true,
132
132
  \\ "defaultDevice": "emulator-5554",
133
133
  \\ "smokeScenario": ".zmr/android-smoke.json",
134
- \\ "traceDir": "traces/zmr-android"
134
+ \\ "traceDir": "traces/zmr-android",
135
+ \\ "ensureDevice": true
135
136
  \\ },
136
137
  \\ "ios": {
137
138
  \\ "enabled": true,
138
139
  \\ "defaultDevice": "booted",
139
140
  \\ "smokeScenario": ".zmr/ios-smoke.json",
140
- \\ "traceDir": "traces/zmr-ios"
141
+ \\ "traceDir": "traces/zmr-ios",
142
+ \\ "ensureDevice": true
141
143
  \\ },
142
144
  \\ "artifacts": {
143
145
  \\ "screenshots": true,
@@ -149,7 +151,7 @@ fn writeAppConfig(path: []const u8, app_id: []const u8, force: bool) !void {
149
151
  \\ "doctor": "zmr doctor --strict --json --config .zmr/config.json",
150
152
  \\ "schemas": "zmr schemas --json",
151
153
  \\ "validate": "zmr validate --json .zmr/android-smoke.json && zmr validate --json .zmr/ios-smoke.json",
152
- \\ "android": "zmr run .zmr/android-smoke.json --device emulator-5554 --trace-dir traces/zmr-android",
154
+ \\ "android": "zmr run .zmr/android-smoke.json --device emulator-5554 --trace-dir traces/zmr-android --ensure-device",
153
155
  \\ "androidReport": "zmr report traces/zmr-android --out traces/zmr-android/report.html --junit traces/zmr-android/junit.xml",
154
156
  \\ "androidReliability": "export ZMR_BIN=\"${ZMR_BIN:-zmr}\"; zmr-benchmark --zmr .zmr/android-smoke.json --device emulator-5554 --app-id
155
157
  );
@@ -157,7 +159,7 @@ fn writeAppConfig(path: []const u8, app_id: []const u8, force: bool) !void {
157
159
  try writeJsonShellArg(writer, app_id);
158
160
  try writer.writeAll(
159
161
  \\ --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",
160
- \\ "ios": "zmr run .zmr/ios-smoke.json --platform ios --device booted --trace-dir traces/zmr-ios",
162
+ \\ "ios": "zmr run .zmr/ios-smoke.json --platform ios --device booted --trace-dir traces/zmr-ios --ensure-device",
161
163
  \\ "iosReport": "zmr report traces/zmr-ios --out traces/zmr-ios/report.html --junit traces/zmr-ios/junit.xml",
162
164
  \\ "iosReliability": "export ZMR_BIN=\"${ZMR_BIN:-zmr}\"; zmr-benchmark --zmr .zmr/ios-smoke.json --platform ios --device booted --app-id
163
165
  );
@@ -366,7 +368,7 @@ fn writeAgentInstructions(path: []const u8, app_id: []const u8, force: bool) !vo
366
368
  \\## Direct Smoke Runs
367
369
  \\
368
370
  \\```bash
369
- \\zmr run .zmr/android-smoke.json --device emulator-5554 --trace-dir traces/zmr-android
371
+ \\zmr run .zmr/android-smoke.json --device emulator-5554 --trace-dir traces/zmr-android --ensure-device
370
372
  \\zmr report traces/zmr-android --out traces/zmr-android/report.html --junit traces/zmr-android/junit.xml
371
373
  \\export ZMR_BIN="${ZMR_BIN:-zmr}"; zmr-benchmark --zmr .zmr/android-smoke.json --device emulator-5554 --app-id
372
374
  );
@@ -374,7 +376,7 @@ fn writeAgentInstructions(path: []const u8, app_id: []const u8, force: bool) !vo
374
376
  try writeShellArg(writer, app_id);
375
377
  try writer.writeAll(
376
378
  \\ --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
377
- \\zmr run .zmr/ios-smoke.json --platform ios --device booted --trace-dir traces/zmr-ios
379
+ \\zmr run .zmr/ios-smoke.json --platform ios --device booted --trace-dir traces/zmr-ios --ensure-device
378
380
  \\zmr report traces/zmr-ios --out traces/zmr-ios/report.html --junit traces/zmr-ios/junit.xml
379
381
  \\export ZMR_BIN="${ZMR_BIN:-zmr}"; zmr-benchmark --zmr .zmr/ios-smoke.json --platform ios --device booted --app-id
380
382
  );
@@ -399,7 +401,7 @@ fn writeAgentInstructions(path: []const u8, app_id: []const u8, force: bool) !vo
399
401
  \\zmr doctor --strict --json --config .zmr/config.json
400
402
  \\zmr schemas --json
401
403
  \\zmr validate --json .zmr/android-smoke.json && zmr validate --json .zmr/ios-smoke.json
402
- \\zmr run .zmr/android-smoke.json --device emulator-5554 --trace-dir traces/zmr-android
404
+ \\zmr run .zmr/android-smoke.json --device emulator-5554 --trace-dir traces/zmr-android --ensure-device
403
405
  \\zmr report traces/zmr-android --out traces/zmr-android/report.html --junit traces/zmr-android/junit.xml
404
406
  \\export ZMR_BIN="${ZMR_BIN:-zmr}"; zmr-benchmark --zmr .zmr/android-smoke.json --device emulator-5554 --app-id
405
407
  );
@@ -407,7 +409,7 @@ fn writeAgentInstructions(path: []const u8, app_id: []const u8, force: bool) !vo
407
409
  try writeShellArg(writer, app_id);
408
410
  try writer.writeAll(
409
411
  \\ --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
410
- \\zmr run .zmr/ios-smoke.json --platform ios --device booted --trace-dir traces/zmr-ios
412
+ \\zmr run .zmr/ios-smoke.json --platform ios --device booted --trace-dir traces/zmr-ios --ensure-device
411
413
  \\zmr report traces/zmr-ios --out traces/zmr-ios/report.html --junit traces/zmr-ios/junit.xml
412
414
  \\export ZMR_BIN="${ZMR_BIN:-zmr}"; zmr-benchmark --zmr .zmr/ios-smoke.json --platform ios --device booted --app-id
413
415
  );
package/src/scenario.zig CHANGED
@@ -179,6 +179,7 @@ pub fn parseSlice(allocator: std.mem.Allocator, content: []const u8) !Scenario {
179
179
  defer parsed.deinit();
180
180
  if (parsed.value != .object) return error.ScenarioMustBeObject;
181
181
  const root = parsed.value.object;
182
+ try rejectUnknownRootFields(root);
182
183
 
183
184
  const name = try fields.requiredString(allocator, root, "name");
184
185
  errdefer allocator.free(name);
@@ -204,6 +205,7 @@ pub fn parseSlice(allocator: std.mem.Allocator, content: []const u8) !Scenario {
204
205
  fn parseStep(allocator: std.mem.Allocator, value: std.json.Value) anyerror!Step {
205
206
  if (value != .object) return error.StepMustBeObject;
206
207
  const object = value.object;
208
+ try rejectUnknownStepFields(object);
207
209
  var parsed = try parseRawStep(allocator, object);
208
210
  errdefer parsed.deinit(allocator);
209
211
 
@@ -352,6 +354,47 @@ fn parseRawStep(allocator: std.mem.Allocator, object: std.json.ObjectMap) anyerr
352
354
  return error.unknownScenarioAction;
353
355
  }
354
356
 
357
+ fn rejectUnknownRootFields(object: std.json.ObjectMap) !void {
358
+ var iterator = object.iterator();
359
+ while (iterator.next()) |entry| {
360
+ const key = entry.key_ptr.*;
361
+ if (std.mem.eql(u8, key, "name") or
362
+ std.mem.eql(u8, key, "appId") or
363
+ std.mem.eql(u8, key, "steps")) continue;
364
+ return error.UnknownScenarioField;
365
+ }
366
+ }
367
+
368
+ fn rejectUnknownStepFields(object: std.json.ObjectMap) !void {
369
+ var iterator = object.iterator();
370
+ while (iterator.next()) |entry| {
371
+ if (!isKnownStepField(entry.key_ptr.*)) return error.UnknownScenarioStepField;
372
+ }
373
+ }
374
+
375
+ fn isKnownStepField(key: []const u8) bool {
376
+ return std.mem.eql(u8, key, "action") or
377
+ std.mem.eql(u8, key, "optional") or
378
+ std.mem.eql(u8, key, "url") or
379
+ std.mem.eql(u8, key, "latitude") or
380
+ std.mem.eql(u8, key, "longitude") or
381
+ std.mem.eql(u8, key, "selector") or
382
+ std.mem.eql(u8, key, "selectors") or
383
+ std.mem.eql(u8, key, "text") or
384
+ std.mem.eql(u8, key, "maxChars") or
385
+ std.mem.eql(u8, key, "x1") or
386
+ std.mem.eql(u8, key, "y1") or
387
+ std.mem.eql(u8, key, "x2") or
388
+ std.mem.eql(u8, key, "y2") or
389
+ std.mem.eql(u8, key, "durationMs") or
390
+ std.mem.eql(u8, key, "timeoutMs") or
391
+ std.mem.eql(u8, key, "direction") or
392
+ std.mem.eql(u8, key, "times") or
393
+ std.mem.eql(u8, key, "steps") or
394
+ std.mem.eql(u8, key, "step") or
395
+ std.mem.eql(u8, key, "ms");
396
+ }
397
+
355
398
  fn parseLatitude(object: std.json.ObjectMap) !f64 {
356
399
  const latitude = try fields.requiredF64OrError(object, "latitude", error.StepMissingLatitude, error.StepLatitudeMustBeNumber);
357
400
  if (latitude < -90.0 or latitude > 90.0) return error.StepLatitudeOutOfRange;
package/src/selector.zig CHANGED
@@ -3,6 +3,7 @@ const types = @import("types.zig");
3
3
 
4
4
  pub const Selector = struct {
5
5
  id: ?[]const u8 = null,
6
+ stable_id: ?[]const u8 = null,
6
7
  text: ?[]const u8 = null,
7
8
  text_contains: ?[]const u8 = null,
8
9
  content_desc: ?[]const u8 = null,
@@ -11,6 +12,7 @@ pub const Selector = struct {
11
12
 
12
13
  pub fn deinit(self: Selector, allocator: std.mem.Allocator) void {
13
14
  if (self.id) |value| allocator.free(value);
15
+ if (self.stable_id) |value| allocator.free(value);
14
16
  if (self.text) |value| allocator.free(value);
15
17
  if (self.text_contains) |value| allocator.free(value);
16
18
  if (self.content_desc) |value| allocator.free(value);
@@ -21,6 +23,7 @@ pub const Selector = struct {
21
23
  pub fn clone(self: Selector, allocator: std.mem.Allocator) !Selector {
22
24
  return .{
23
25
  .id = try types.dupeOptional(allocator, self.id),
26
+ .stable_id = try types.dupeOptional(allocator, self.stable_id),
24
27
  .text = try types.dupeOptional(allocator, self.text),
25
28
  .text_contains = try types.dupeOptional(allocator, self.text_contains),
26
29
  .content_desc = try types.dupeOptional(allocator, self.content_desc),
@@ -28,12 +31,26 @@ pub const Selector = struct {
28
31
  .class_name = try types.dupeOptional(allocator, self.class_name),
29
32
  };
30
33
  }
34
+
35
+ pub fn hasAny(self: Selector) bool {
36
+ return self.id != null or
37
+ self.stable_id != null or
38
+ self.text != null or
39
+ self.text_contains != null or
40
+ self.content_desc != null or
41
+ self.content_desc_contains != null or
42
+ self.class_name != null;
43
+ }
31
44
  };
32
45
 
33
46
  pub fn matches(node: types.UiNode, wanted: Selector) bool {
47
+ if (!wanted.hasAny()) return false;
34
48
  if (wanted.id) |id| {
35
49
  if (node.resource_id == null or !std.mem.eql(u8, node.resource_id.?, id)) return false;
36
50
  }
51
+ if (wanted.stable_id) |stable_id| {
52
+ if (!std.mem.eql(u8, node.stable_id, stable_id)) return false;
53
+ }
37
54
  if (wanted.text) |text| {
38
55
  if (node.text == null or !std.mem.eql(u8, node.text.?, text)) return false;
39
56
  }
@@ -62,15 +79,42 @@ pub fn find(nodes: []const types.UiNode, wanted: Selector) ?types.UiNode {
62
79
  pub fn parseFromJson(allocator: std.mem.Allocator, value: std.json.Value) !Selector {
63
80
  if (value != .object) return error.SelectorMustBeObject;
64
81
  const object = value.object;
65
- const id = try stringField(allocator, object, "id") orelse try stringField(allocator, object, "resourceId");
66
- return .{
67
- .id = id,
68
- .text = try stringField(allocator, object, "text"),
69
- .text_contains = try stringField(allocator, object, "textContains"),
70
- .content_desc = try stringField(allocator, object, "contentDesc"),
71
- .content_desc_contains = try stringField(allocator, object, "contentDescContains"),
72
- .class_name = try stringField(allocator, object, "className"),
73
- };
82
+ try rejectUnknownFields(object);
83
+ var parsed: Selector = .{};
84
+ errdefer parsed.deinit(allocator);
85
+ parsed.id = try stringField(allocator, object, "id");
86
+ const resource_id = try stringField(allocator, object, "resourceId");
87
+ if (parsed.id == null) {
88
+ parsed.id = resource_id;
89
+ } else if (resource_id) |resource_id_value| {
90
+ allocator.free(resource_id_value);
91
+ }
92
+ parsed.stable_id = try stringField(allocator, object, "stableId");
93
+ parsed.text = try stringField(allocator, object, "text");
94
+ parsed.text_contains = try stringField(allocator, object, "textContains");
95
+ parsed.content_desc = try stringField(allocator, object, "contentDesc");
96
+ parsed.content_desc_contains = try stringField(allocator, object, "contentDescContains");
97
+ parsed.class_name = try stringField(allocator, object, "className");
98
+ if (!parsed.hasAny()) return error.SelectorMustNotBeEmpty;
99
+ return parsed;
100
+ }
101
+
102
+ fn rejectUnknownFields(object: std.json.ObjectMap) !void {
103
+ var iterator = object.iterator();
104
+ while (iterator.next()) |entry| {
105
+ if (!isKnownField(entry.key_ptr.*)) return error.UnknownSelectorField;
106
+ }
107
+ }
108
+
109
+ fn isKnownField(key: []const u8) bool {
110
+ return std.mem.eql(u8, key, "id") or
111
+ std.mem.eql(u8, key, "resourceId") or
112
+ std.mem.eql(u8, key, "stableId") or
113
+ std.mem.eql(u8, key, "text") or
114
+ std.mem.eql(u8, key, "textContains") or
115
+ std.mem.eql(u8, key, "contentDesc") or
116
+ std.mem.eql(u8, key, "contentDescContains") or
117
+ std.mem.eql(u8, key, "className");
74
118
  }
75
119
 
76
120
  fn stringField(
@@ -109,6 +109,10 @@ pub fn writeSelectorJson(writer: anytype, wanted: selector.Selector) !void {
109
109
  try jsonField(writer, "id", value, first);
110
110
  first = false;
111
111
  }
112
+ if (wanted.stable_id) |value| {
113
+ try jsonField(writer, "stableId", value, first);
114
+ first = false;
115
+ }
112
116
  if (wanted.text) |value| {
113
117
  try jsonField(writer, "text", value, first);
114
118
  first = false;
@@ -87,9 +87,13 @@ fn diagnoseFailure(allocator: std.mem.Allocator, content: []const u8, err: anyer
87
87
  if (syntaxLocation(allocator, content)) |location| return location;
88
88
  return switch (err) {
89
89
  error.ScenarioMustBeObject => try pathDiagnostic(allocator, content, "$", null),
90
+ error.UnknownScenarioField,
91
+ => try pathDiagnostic(allocator, content, "$", null),
90
92
  error.ScenarioMissingSteps,
91
93
  error.ScenarioStepsMustBeArray,
92
94
  => try pathDiagnostic(allocator, content, "$.steps", "steps"),
95
+ error.UnknownScenarioStepField,
96
+ => try pathDiagnostic(allocator, content, "$.steps[]", null),
93
97
  error.StepMissingAction,
94
98
  error.StepActionMustBeString,
95
99
  error.unknownAction,
@@ -120,6 +124,7 @@ fn diagnoseFailure(allocator: std.mem.Allocator, content: []const u8, err: anyer
120
124
  error.MissingSelector,
121
125
  error.StepMissingSelector,
122
126
  error.SelectorMustNotBeEmpty,
127
+ error.UnknownSelectorField,
123
128
  => try pathDiagnostic(allocator, content, "$.steps[].selector", "selector"),
124
129
  error.MissingSelectors,
125
130
  error.StepMissingSelectors,
package/src/version.zig CHANGED
@@ -1,4 +1,4 @@
1
- pub const runner_version = "0.2.16";
1
+ pub const runner_version = "0.2.17";
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";