zeno-mobile-runner 0.2.15 → 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 (77) hide show
  1. package/CHANGELOG.md +45 -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/install-ios-shim.sh +39 -4
  55. package/scripts/public-metadata-guard.sh +101 -0
  56. package/shims/android/README.md +4 -3
  57. package/shims/android/protocol.md +3 -2
  58. package/shims/ios/README.md +8 -7
  59. package/shims/ios/ZMRShimUITestCase.swift +58 -17
  60. package/shims/ios/protocol.md +2 -1
  61. package/skills/zmr-mobile-testing/SKILL.md +9 -8
  62. package/src/android_emulator.zig +54 -5
  63. package/src/cli_import.zig +15 -2
  64. package/src/cli_output.zig +2 -0
  65. package/src/cli_run.zig +8 -0
  66. package/src/config.zig +3 -0
  67. package/src/errors.zig +3 -0
  68. package/src/ios_devices.zig +100 -0
  69. package/src/main.zig +1 -1
  70. package/src/mcp_protocol.zig +12 -9
  71. package/src/run_options.zig +4 -0
  72. package/src/scaffold.zig +10 -8
  73. package/src/scenario.zig +43 -0
  74. package/src/selector.zig +53 -9
  75. package/src/trace_json.zig +4 -0
  76. package/src/validation.zig +5 -0
  77. package/src/version.zig +1 -1
@@ -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();
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.15";
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";