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.
- package/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +20 -7
- package/FEATURES.md +29 -20
- package/README.md +73 -57
- package/SECURITY.md +11 -6
- package/clients/README.md +8 -7
- package/clients/go/README.md +2 -2
- package/clients/kotlin/README.md +2 -2
- package/clients/kotlin/build.gradle.kts +1 -1
- package/clients/python/README.md +2 -1
- package/clients/python/pyproject.toml +1 -1
- package/clients/rust/Cargo.lock +1 -1
- package/clients/rust/Cargo.toml +1 -1
- package/clients/rust/README.md +2 -2
- package/clients/swift/README.md +2 -2
- package/clients/typescript/README.md +2 -1
- package/clients/typescript/package.json +1 -1
- package/docs/adr/0001-agent-native-runner-boundary.md +1 -1
- package/docs/adr/README.md +7 -5
- package/docs/agent-discovery.md +15 -15
- package/docs/ai-agents.md +30 -20
- package/docs/app-integration.md +59 -27
- package/docs/benchmarking.md +16 -8
- package/docs/benchmarks/README.md +3 -1
- package/docs/benchmarks/benchmark-lab-v1.md +1 -1
- package/docs/client-installation.md +18 -9
- package/docs/clients.md +7 -6
- package/docs/config.md +29 -15
- package/docs/demo.md +14 -9
- package/docs/expo-smoke.md +12 -18
- package/docs/frameworks.md +30 -21
- package/docs/install.md +63 -13
- package/docs/npm.md +45 -27
- package/docs/production-readiness.md +32 -17
- package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
- package/docs/protocol-versioning.md +5 -3
- package/docs/protocol.md +33 -18
- package/docs/scenario-authoring.md +15 -8
- package/docs/support-matrix.md +38 -0
- package/docs/trace-privacy.md +5 -3
- package/docs/troubleshooting.md +17 -14
- package/npm/app-config.mjs +2 -0
- package/npm/commands.mjs +4 -4
- package/npm/scaffold.mjs +2 -2
- package/package.json +2 -2
- package/prebuilds/darwin-arm64/zmr +0 -0
- package/prebuilds/darwin-x64/zmr +0 -0
- package/prebuilds/linux-arm64/zmr +0 -0
- package/prebuilds/linux-x64/zmr +0 -0
- package/schemas/README.md +6 -3
- package/schemas/import-output.schema.json +1 -1
- package/schemas/scenario.schema.json +2 -0
- package/schemas/zmr-config.schema.json +2 -1
- package/scripts/install-ios-shim.sh +39 -4
- package/scripts/public-metadata-guard.sh +101 -0
- package/shims/android/README.md +4 -3
- package/shims/android/protocol.md +3 -2
- package/shims/ios/README.md +8 -7
- package/shims/ios/ZMRShimUITestCase.swift +58 -17
- package/shims/ios/protocol.md +2 -1
- package/skills/zmr-mobile-testing/SKILL.md +9 -8
- package/src/android_emulator.zig +54 -5
- package/src/cli_import.zig +15 -2
- package/src/cli_output.zig +2 -0
- package/src/cli_run.zig +8 -0
- package/src/config.zig +3 -0
- package/src/errors.zig +3 -0
- package/src/ios_devices.zig +100 -0
- package/src/main.zig +1 -1
- package/src/mcp_protocol.zig +12 -9
- package/src/run_options.zig +4 -0
- package/src/scaffold.zig +10 -8
- package/src/scenario.zig +43 -0
- package/src/selector.zig +53 -9
- package/src/trace_json.zig +4 -0
- package/src/validation.zig +5 -0
- package/src/version.zig +1 -1
package/src/cli_import.zig
CHANGED
|
@@ -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 (!
|
|
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 ");
|
package/src/cli_output.zig
CHANGED
|
@@ -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,
|
package/src/ios_devices.zig
CHANGED
|
@@ -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]
|
package/src/mcp_protocol.zig
CHANGED
|
@@ -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\":
|
|
73
|
-
"{\"name\":\"type\",\"description\":\"Type text, optionally after focusing an element by selector.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"text\"],\"properties\":{\"selector\":
|
|
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\":
|
|
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\":
|
|
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\":
|
|
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\":
|
|
82
|
-
"{\"name\":\"scroll_until_visible\",\"description\":\"Scroll until an element selector is visible.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":
|
|
83
|
-
"{\"name\":\"assert_visible\",\"description\":\"Assert that an element selector is visible within the timeout.\",\"inputSchema\":{\"type\":\"object\",\"additionalProperties\":false,\"required\":[\"selector\"],\"properties\":{\"selector\":
|
|
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\":
|
|
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}}}}," ++
|
package/src/run_options.zig
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
.
|
|
72
|
-
|
|
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(
|
package/src/trace_json.zig
CHANGED
|
@@ -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;
|
package/src/validation.zig
CHANGED
|
@@ -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