zeno-mobile-runner 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/FEATURES.md +1 -1
- package/README.md +167 -238
- package/clients/kotlin/README.md +1 -1
- package/clients/kotlin/build.gradle.kts +1 -1
- package/clients/python/pyproject.toml +1 -1
- package/clients/rust/Cargo.lock +1 -1
- package/clients/rust/Cargo.toml +1 -1
- package/clients/typescript/package.json +1 -1
- package/docs/agent-discovery.md +10 -0
- package/docs/ai-agents.md +18 -0
- package/docs/benchmarking.md +39 -0
- package/docs/benchmarks/2026-06-09-android-workflow.md +73 -0
- package/docs/benchmarks/2026-06-09-android-workflow.results.jsonl +20 -0
- package/docs/benchmarks/2026-06-09-framework-baseline-status.md +32 -0
- package/docs/benchmarks/2026-06-09-ios-appium-comparison.md +115 -0
- package/docs/benchmarks/2026-06-09-ios-appium-comparison.results.jsonl +40 -0
- package/docs/benchmarks/2026-06-09-ios-demo.md +90 -0
- package/docs/benchmarks/2026-06-09-ios-demo.results.jsonl +20 -0
- package/docs/benchmarks/2026-06-09-ios-maestro-comparison.md +128 -0
- package/docs/benchmarks/2026-06-09-ios-maestro-comparison.results.jsonl +40 -0
- package/docs/benchmarks/2026-06-09-ios-workflow-comparison.md +143 -0
- package/docs/benchmarks/2026-06-09-ios-workflow-comparison.results.jsonl +40 -0
- package/docs/benchmarks/2026-06-09-ios-xctest-floor.md +106 -0
- package/docs/benchmarks/2026-06-09-ios-xctest-floor.results.jsonl +40 -0
- package/docs/benchmarks/README.md +36 -0
- package/docs/benchmarks/benchmark-lab-v1.json +155 -0
- package/docs/benchmarks/benchmark-lab-v1.md +95 -0
- package/docs/clients.md +16 -0
- package/docs/demo.md +36 -1
- package/docs/frameworks.md +10 -0
- package/docs/npm.md +44 -2
- package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
- package/docs/protocol.md +10 -10
- package/docs/scenario-authoring.md +15 -0
- package/docs/trace-privacy.md +9 -0
- package/docs/troubleshooting.md +6 -0
- package/examples/android-workflow.json +79 -0
- package/examples/ios-shim-workflow.json +79 -0
- package/examples/react-native-expo-workflow.json +75 -0
- package/package.json +6 -1
- 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/scripts/benchmark-lab.py +253 -0
- package/scripts/create-android-demo-app.sh +324 -29
- package/scripts/create-ios-demo-app.sh +174 -7
- package/scripts/create-react-native-expo-demo-app.sh +727 -0
- package/scripts/demo.sh +3 -0
- package/scripts/install-ios-shim.sh +2 -2
- package/shims/ios/ZMRShim.swift +10 -0
- package/shims/ios/ZMRShimUITestCase.swift +42 -0
- package/shims/ios/protocol.md +1 -0
- package/src/cli_import.zig +31 -15
- package/src/cli_trace.zig +38 -16
- package/src/cli_validate.zig +12 -6
- package/src/ios.zig +49 -12
- package/src/ios_shim.zig +36 -2
- package/src/main.zig +3 -0
- package/src/version.zig +1 -1
- package/viewer/app.js +23 -3
package/scripts/demo.sh
CHANGED
|
@@ -37,9 +37,12 @@ echo "== Validate demo scenarios =="
|
|
|
37
37
|
./zig-out/bin/zmr validate examples/android-app-referral-deep-link.json
|
|
38
38
|
./zig-out/bin/zmr validate examples/android-app-error-state.json
|
|
39
39
|
./zig-out/bin/zmr validate examples/android-shim-smoke.json
|
|
40
|
+
./zig-out/bin/zmr validate examples/android-workflow.json
|
|
41
|
+
./zig-out/bin/zmr validate examples/react-native-expo-workflow.json
|
|
40
42
|
./zig-out/bin/zmr validate examples/ios-smoke.json
|
|
41
43
|
./zig-out/bin/zmr validate examples/ios-dev-client-open-link.json
|
|
42
44
|
./zig-out/bin/zmr validate examples/ios-shim-smoke.json
|
|
45
|
+
./zig-out/bin/zmr validate examples/ios-shim-workflow.json
|
|
43
46
|
|
|
44
47
|
echo
|
|
45
48
|
echo "== Validate diagnostics: field and line location =="
|
|
@@ -342,14 +342,14 @@ is_server_running() {
|
|
|
342
342
|
return 1
|
|
343
343
|
fi
|
|
344
344
|
command="\$(ps -p "\$pid" -o command= 2>/dev/null || true)"
|
|
345
|
-
[[ "\$command" == *xcodebuild* && "\$command" == *
|
|
345
|
+
[[ "\$command" == *xcodebuild* && "\$command" == *"$TEST_TARGET"* ]]
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
run_oneshot() {
|
|
349
349
|
local request_file response_file oneshot_log destination_id
|
|
350
350
|
request_file="\$(mktemp "\$STATE_DIR/request.XXXXXX")"
|
|
351
351
|
response_file="\$(mktemp "\$STATE_DIR/response.XXXXXX")"
|
|
352
|
-
oneshot_log="\$(mktemp "\$STATE_DIR/xcodebuild.oneshot.XXXXXX
|
|
352
|
+
oneshot_log="\$(mktemp "\$STATE_DIR/xcodebuild.oneshot.log.XXXXXX")"
|
|
353
353
|
cp "\$STDIN_FILE" "\$request_file"
|
|
354
354
|
destination_id="\$(destination_spec)"
|
|
355
355
|
|
package/shims/ios/ZMRShim.swift
CHANGED
|
@@ -22,6 +22,11 @@ struct ZMRShimBounds: Encodable {
|
|
|
22
22
|
let height: Int
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
struct ZMRShimViewport: Encodable {
|
|
26
|
+
let width: Int
|
|
27
|
+
let height: Int
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
struct ZMRShimNode: Encodable {
|
|
26
31
|
let id: String
|
|
27
32
|
let type: String
|
|
@@ -35,6 +40,11 @@ struct ZMRShimNode: Encodable {
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
enum ZMRShim {
|
|
43
|
+
static func viewport(app: XCUIApplication) -> ZMRShimViewport {
|
|
44
|
+
let frame = app.frame
|
|
45
|
+
return ZMRShimViewport(width: Int(frame.size.width), height: Int(frame.size.height))
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
static func snapshot(app: XCUIApplication) -> [ZMRShimNode] {
|
|
39
49
|
let queries: [(XCUIElement.ElementType, XCUIElementQuery)] = [
|
|
40
50
|
(.button, app.buttons),
|
|
@@ -105,10 +105,15 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
private func run(command: ZMRShimCommand, app: XCUIApplication) -> [String: Any] {
|
|
108
|
+
if commandRequiresForeground(command), let foregroundError = ensureAppForeground(app: app) {
|
|
109
|
+
return foregroundError
|
|
110
|
+
}
|
|
111
|
+
|
|
108
112
|
switch command.cmd {
|
|
109
113
|
case "snapshot":
|
|
110
114
|
return [
|
|
111
115
|
"status": "ok",
|
|
116
|
+
"viewport": ZMRShim.viewport(app: app).json,
|
|
112
117
|
"nodes": ZMRShim.snapshot(app: app).map { $0.json }
|
|
113
118
|
]
|
|
114
119
|
case "screenshot":
|
|
@@ -188,6 +193,34 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
188
193
|
}
|
|
189
194
|
}
|
|
190
195
|
|
|
196
|
+
private func commandRequiresForeground(_ command: ZMRShimCommand) -> Bool {
|
|
197
|
+
switch command.cmd {
|
|
198
|
+
case "snapshot", "query", "tap", "type", "eraseText", "hideKeyboard", "swipe", "settle":
|
|
199
|
+
return true
|
|
200
|
+
default:
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private func ensureAppForeground(app: XCUIApplication) -> [String: Any]? {
|
|
206
|
+
if app.state != .runningForeground {
|
|
207
|
+
app.activate()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let deadline = Date().addingTimeInterval(5)
|
|
211
|
+
while Date() < deadline {
|
|
212
|
+
if app.state == .runningForeground {
|
|
213
|
+
return nil
|
|
214
|
+
}
|
|
215
|
+
Thread.sleep(forTimeInterval: 0.1)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return error(
|
|
219
|
+
"app.not_foreground",
|
|
220
|
+
"target app did not become foreground; state=\(app.state.rawValue)"
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
191
224
|
private func ok() -> [String: Any] {
|
|
192
225
|
["status": "ok"]
|
|
193
226
|
}
|
|
@@ -532,6 +565,15 @@ private extension ZMRShimBounds {
|
|
|
532
565
|
}
|
|
533
566
|
}
|
|
534
567
|
|
|
568
|
+
private extension ZMRShimViewport {
|
|
569
|
+
var json: [String: Any] {
|
|
570
|
+
[
|
|
571
|
+
"width": width,
|
|
572
|
+
"height": height
|
|
573
|
+
]
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
535
577
|
private extension ZMRShimNode {
|
|
536
578
|
var json: [String: Any] {
|
|
537
579
|
[
|
package/shims/ios/protocol.md
CHANGED
package/src/cli_import.zig
CHANGED
|
@@ -14,36 +14,52 @@ pub const ParsedArgs = struct {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
pub fn parseArgs(args: []const []const u8) !ParsedArgs {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
var format: ?[]const u8 = null;
|
|
18
|
+
var source_path: ?[]const u8 = null;
|
|
19
|
+
var out_path: ?[]const u8 = null;
|
|
20
|
+
var name: ?[]const u8 = null;
|
|
21
|
+
var app_id: ?[]const u8 = null;
|
|
22
|
+
var force = false;
|
|
23
|
+
var json = false;
|
|
19
24
|
|
|
20
|
-
var
|
|
21
|
-
.format = args[0],
|
|
22
|
-
.source_path = args[1],
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
var index: usize = 2;
|
|
25
|
+
var index: usize = 0;
|
|
26
26
|
while (index < args.len) : (index += 1) {
|
|
27
27
|
const arg = args[index];
|
|
28
28
|
if (std.mem.eql(u8, arg, "--out")) {
|
|
29
29
|
index += 1;
|
|
30
|
-
|
|
30
|
+
out_path = if (index < args.len) args[index] else return error.MissingImportOut;
|
|
31
31
|
} else if (std.mem.eql(u8, arg, "--name")) {
|
|
32
32
|
index += 1;
|
|
33
|
-
|
|
33
|
+
name = if (index < args.len) args[index] else return error.MissingImportName;
|
|
34
34
|
} else if (std.mem.eql(u8, arg, "--app-id")) {
|
|
35
35
|
index += 1;
|
|
36
|
-
|
|
36
|
+
app_id = if (index < args.len) args[index] else return error.MissingAppId;
|
|
37
37
|
} else if (std.mem.eql(u8, arg, "--force")) {
|
|
38
|
-
|
|
38
|
+
force = true;
|
|
39
39
|
} else if (std.mem.eql(u8, arg, "--json")) {
|
|
40
|
-
|
|
40
|
+
json = true;
|
|
41
|
+
} else if (std.mem.startsWith(u8, arg, "--")) {
|
|
42
|
+
return error.UnknownFlag;
|
|
43
|
+
} else if (format == null) {
|
|
44
|
+
format = arg;
|
|
45
|
+
} else if (source_path == null) {
|
|
46
|
+
source_path = arg;
|
|
41
47
|
} else {
|
|
42
48
|
return error.UnknownFlag;
|
|
43
49
|
}
|
|
44
50
|
}
|
|
45
|
-
if (
|
|
46
|
-
return
|
|
51
|
+
if (format == null) return error.MissingImportFormat;
|
|
52
|
+
if (source_path == null) return error.MissingImportPath;
|
|
53
|
+
if (out_path == null) return error.MissingImportOut;
|
|
54
|
+
return ParsedArgs{
|
|
55
|
+
.format = format.?,
|
|
56
|
+
.source_path = source_path.?,
|
|
57
|
+
.out_path = out_path,
|
|
58
|
+
.name = name,
|
|
59
|
+
.app_id = app_id,
|
|
60
|
+
.force = force,
|
|
61
|
+
.json = json,
|
|
62
|
+
};
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
pub fn run(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !void {
|
package/src/cli_trace.zig
CHANGED
|
@@ -22,24 +22,34 @@ pub const ExportArgs = struct {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
pub fn parseReportArgs(args: []const []const u8) !ReportArgs {
|
|
25
|
-
|
|
26
|
-
var
|
|
25
|
+
var input_path: ?[]const u8 = null;
|
|
26
|
+
var out_path: ?[]const u8 = null;
|
|
27
|
+
var junit_path: ?[]const u8 = null;
|
|
27
28
|
|
|
28
|
-
var index: usize =
|
|
29
|
+
var index: usize = 0;
|
|
29
30
|
while (index < args.len) : (index += 1) {
|
|
30
31
|
const arg = args[index];
|
|
31
32
|
if (std.mem.eql(u8, arg, "--out")) {
|
|
32
33
|
index += 1;
|
|
33
|
-
|
|
34
|
+
out_path = if (index < args.len) args[index] else return error.MissingReportOutput;
|
|
34
35
|
} else if (std.mem.eql(u8, arg, "--junit")) {
|
|
35
36
|
index += 1;
|
|
36
|
-
|
|
37
|
+
junit_path = if (index < args.len) args[index] else return error.MissingJUnitOutput;
|
|
38
|
+
} else if (std.mem.startsWith(u8, arg, "--")) {
|
|
39
|
+
return error.UnknownFlag;
|
|
40
|
+
} else if (input_path == null) {
|
|
41
|
+
input_path = arg;
|
|
37
42
|
} else {
|
|
38
43
|
return error.UnknownFlag;
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
|
-
if (
|
|
42
|
-
return
|
|
46
|
+
if (input_path == null) return error.MissingReportInput;
|
|
47
|
+
if (out_path == null) return error.MissingReportOutput;
|
|
48
|
+
return ReportArgs{
|
|
49
|
+
.input_path = input_path.?,
|
|
50
|
+
.out_path = out_path,
|
|
51
|
+
.junit_path = junit_path,
|
|
52
|
+
};
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
pub fn parseExplainArgs(args: []const []const u8) !ExplainArgs {
|
|
@@ -58,26 +68,38 @@ pub fn parseExplainArgs(args: []const []const u8) !ExplainArgs {
|
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
pub fn parseExportArgs(args: []const []const u8) !ExportArgs {
|
|
61
|
-
|
|
62
|
-
var
|
|
71
|
+
var trace_dir: ?[]const u8 = null;
|
|
72
|
+
var out_path: ?[]const u8 = null;
|
|
73
|
+
var redact = false;
|
|
74
|
+
var omit_screenshots = false;
|
|
63
75
|
|
|
64
|
-
var index: usize =
|
|
76
|
+
var index: usize = 0;
|
|
65
77
|
while (index < args.len) : (index += 1) {
|
|
66
78
|
const arg = args[index];
|
|
67
79
|
if (std.mem.eql(u8, arg, "--out")) {
|
|
68
80
|
index += 1;
|
|
69
|
-
|
|
81
|
+
out_path = if (index < args.len) args[index] else return error.MissingTraceBundleOutput;
|
|
70
82
|
} else if (std.mem.eql(u8, arg, "--redact")) {
|
|
71
|
-
|
|
83
|
+
redact = true;
|
|
72
84
|
} else if (std.mem.eql(u8, arg, "--omit-screenshots")) {
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
redact = true;
|
|
86
|
+
omit_screenshots = true;
|
|
87
|
+
} else if (std.mem.startsWith(u8, arg, "--")) {
|
|
88
|
+
return error.UnknownFlag;
|
|
89
|
+
} else if (trace_dir == null) {
|
|
90
|
+
trace_dir = arg;
|
|
75
91
|
} else {
|
|
76
92
|
return error.UnknownFlag;
|
|
77
93
|
}
|
|
78
94
|
}
|
|
79
|
-
if (
|
|
80
|
-
return
|
|
95
|
+
if (trace_dir == null) return error.MissingTraceDir;
|
|
96
|
+
if (out_path == null) return error.MissingTraceBundleOutput;
|
|
97
|
+
return ExportArgs{
|
|
98
|
+
.trace_dir = trace_dir.?,
|
|
99
|
+
.out_path = out_path,
|
|
100
|
+
.redact = redact,
|
|
101
|
+
.omit_screenshots = omit_screenshots,
|
|
102
|
+
};
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
pub fn runReport(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !void {
|
package/src/cli_validate.zig
CHANGED
|
@@ -9,17 +9,23 @@ pub const ParsedArgs = struct {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
pub fn parseArgs(args: []const []const u8) !ParsedArgs {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
for (args[1..]) |arg| {
|
|
12
|
+
var path: ?[]const u8 = null;
|
|
13
|
+
var json = false;
|
|
14
|
+
for (args) |arg| {
|
|
16
15
|
if (std.mem.eql(u8, arg, "--json")) {
|
|
17
|
-
|
|
16
|
+
json = true;
|
|
17
|
+
} else if (std.mem.startsWith(u8, arg, "--")) {
|
|
18
|
+
return error.UnknownFlag;
|
|
19
|
+
} else if (path == null) {
|
|
20
|
+
path = arg;
|
|
18
21
|
} else {
|
|
19
22
|
return error.UnknownFlag;
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
|
-
return
|
|
25
|
+
return ParsedArgs{
|
|
26
|
+
.path = path orelse return error.MissingScenarioPath,
|
|
27
|
+
.json = json,
|
|
28
|
+
};
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
pub fn run(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !void {
|
package/src/ios.zig
CHANGED
|
@@ -10,8 +10,10 @@ const types = @import("types.zig");
|
|
|
10
10
|
|
|
11
11
|
const default_max_output = 32 * 1024 * 1024;
|
|
12
12
|
// Clean iOS prebuilds can force the XCTest shim script through a full native
|
|
13
|
-
// dependency build before it can answer the first selector query.
|
|
14
|
-
|
|
13
|
+
// dependency build before it can answer the first selector query. Slower CI
|
|
14
|
+
// hardware can override the default with ZMR_IOS_SHIM_TIMEOUT_MS.
|
|
15
|
+
const default_shim_timeout_ms = 5_400_000;
|
|
16
|
+
const shim_timeout_env = "ZMR_IOS_SHIM_TIMEOUT_MS";
|
|
15
17
|
const shim_best_effort_timeout_ms = 10_000;
|
|
16
18
|
const shim_command_attempts = 2;
|
|
17
19
|
const shim_bootstrap_retry_delay_ms = 500;
|
|
@@ -126,7 +128,9 @@ pub const IosDevice = struct {
|
|
|
126
128
|
const result = try self.runSimctl(&.{ "openurl", self.target(), url }, default_max_output);
|
|
127
129
|
defer result.deinit(self.allocator);
|
|
128
130
|
try result.ensureSuccess();
|
|
129
|
-
|
|
131
|
+
if (urlMayNeedOpenConfirmation(url)) {
|
|
132
|
+
self.acceptOpenURLConfirmationBestEffort();
|
|
133
|
+
}
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
pub fn tap(self: *IosDevice, x: i32, y: i32) !void {
|
|
@@ -212,15 +216,22 @@ pub const IosDevice = struct {
|
|
|
212
216
|
|
|
213
217
|
const active_package = try self.allocator.dupe(u8, self.app_id);
|
|
214
218
|
errdefer self.allocator.free(active_package);
|
|
215
|
-
|
|
216
|
-
|
|
219
|
+
var shim_viewport: ?types.Viewport = null;
|
|
220
|
+
const nodes = if (self.shim_path != null) blk: {
|
|
221
|
+
const shim_snapshot = self.snapshotFromShim() catch |err| {
|
|
217
222
|
if (screenshot_artifact == null) return err;
|
|
218
223
|
if (writer) |tw| try self.recordSnapshotSemanticFailure(tw, screenshot_artifact.?, err);
|
|
219
224
|
break :blk try self.allocator.alloc(types.UiNode, 0);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
225
|
+
};
|
|
226
|
+
shim_viewport = shim_snapshot.viewport;
|
|
227
|
+
break :blk shim_snapshot.nodes;
|
|
228
|
+
} else try self.allocator.alloc(types.UiNode, 0);
|
|
223
229
|
errdefer self.allocator.free(nodes);
|
|
230
|
+
if (shim_viewport) |value| {
|
|
231
|
+
if (value.width > 0 and value.height > 0) {
|
|
232
|
+
viewport = value;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
224
235
|
|
|
225
236
|
return .{
|
|
226
237
|
.id = id,
|
|
@@ -260,10 +271,10 @@ pub const IosDevice = struct {
|
|
|
260
271
|
return try self.allocator.dupe(u8, result.stdout);
|
|
261
272
|
}
|
|
262
273
|
|
|
263
|
-
fn
|
|
274
|
+
fn snapshotFromShim(self: *IosDevice) !ios_shim.SnapshotResponse {
|
|
264
275
|
const response = try self.runShim(.{ .kind = .snapshot });
|
|
265
276
|
defer self.allocator.free(response);
|
|
266
|
-
return try ios_shim.
|
|
277
|
+
return try ios_shim.parseSnapshotResponse(self.allocator, response);
|
|
267
278
|
}
|
|
268
279
|
|
|
269
280
|
fn recordSnapshotSemanticFailure(self: *IosDevice, writer: *trace.TraceWriter, screenshot_artifact: []const u8, err: anyerror) !void {
|
|
@@ -319,7 +330,7 @@ pub const IosDevice = struct {
|
|
|
319
330
|
}
|
|
320
331
|
|
|
321
332
|
fn runShim(self: *IosDevice, shim_command: ios_shim.Command) ![]u8 {
|
|
322
|
-
return self.runShimWithTimeout(shim_command,
|
|
333
|
+
return self.runShimWithTimeout(shim_command, shimTimeoutMs());
|
|
323
334
|
}
|
|
324
335
|
|
|
325
336
|
fn runShimWithTimeout(self: *IosDevice, shim_command: ios_shim.Command, timeout_ms: u64) ![]u8 {
|
|
@@ -384,6 +395,25 @@ fn isTransientShimBootstrapFailure(result: command.ExecResult) bool {
|
|
|
384
395
|
std.mem.indexOf(u8, result.stderr, "operation never finished bootstrapping") != null;
|
|
385
396
|
}
|
|
386
397
|
|
|
398
|
+
fn shimTimeoutMs() u64 {
|
|
399
|
+
return parseShimTimeoutMs(std.posix.getenv(shim_timeout_env));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
fn parseShimTimeoutMs(raw: ?[]const u8) u64 {
|
|
403
|
+
const value = raw orelse return default_shim_timeout_ms;
|
|
404
|
+
const parsed = std.fmt.parseInt(u64, value, 10) catch return default_shim_timeout_ms;
|
|
405
|
+
if (parsed == 0) return default_shim_timeout_ms;
|
|
406
|
+
return parsed;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
fn urlMayNeedOpenConfirmation(url: []const u8) bool {
|
|
410
|
+
return startsWithIgnoreCase(url, "http://") or startsWithIgnoreCase(url, "https://");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
fn startsWithIgnoreCase(value: []const u8, prefix: []const u8) bool {
|
|
414
|
+
return value.len >= prefix.len and std.ascii.eqlIgnoreCase(value[0..prefix.len], prefix);
|
|
415
|
+
}
|
|
416
|
+
|
|
387
417
|
pub fn listDevices(allocator: std.mem.Allocator, xcrun_path: []const u8) ![]types.DeviceInfo {
|
|
388
418
|
return try ios_devices.listSimulators(allocator, xcrun_path);
|
|
389
419
|
}
|
|
@@ -401,6 +431,13 @@ pub fn parsePhysicalDevicesJson(allocator: std.mem.Allocator, content: []const u
|
|
|
401
431
|
}
|
|
402
432
|
|
|
403
433
|
test "ios xctest shim timeout allows cold xcodebuild startup" {
|
|
404
|
-
try std.testing.expect(
|
|
434
|
+
try std.testing.expect(default_shim_timeout_ms >= 5_400_000);
|
|
405
435
|
try std.testing.expect(shim_best_effort_timeout_ms <= 15_000);
|
|
406
436
|
}
|
|
437
|
+
|
|
438
|
+
test "ios xctest shim timeout env override" {
|
|
439
|
+
try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs(null));
|
|
440
|
+
try std.testing.expectEqual(@as(u64, 600_000), parseShimTimeoutMs("600000"));
|
|
441
|
+
try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs("not-a-number"));
|
|
442
|
+
try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs("0"));
|
|
443
|
+
}
|
package/src/ios_shim.zig
CHANGED
|
@@ -32,6 +32,16 @@ pub const Command = struct {
|
|
|
32
32
|
max_chars: ?u32 = null,
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
pub const SnapshotResponse = struct {
|
|
36
|
+
nodes: []types.UiNode,
|
|
37
|
+
viewport: types.Viewport = .{},
|
|
38
|
+
|
|
39
|
+
pub fn deinit(self: SnapshotResponse, allocator: std.mem.Allocator) void {
|
|
40
|
+
for (self.nodes) |node| node.deinit(allocator);
|
|
41
|
+
allocator.free(self.nodes);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
35
45
|
pub fn writeCommandJson(writer: anytype, command: Command) !void {
|
|
36
46
|
try writer.writeAll("{\"cmd\":");
|
|
37
47
|
try trace.writeJsonString(writer, commandName(command.kind));
|
|
@@ -54,12 +64,13 @@ pub fn writeCommandJson(writer: anytype, command: Command) !void {
|
|
|
54
64
|
try writer.writeAll("}\n");
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
pub fn
|
|
67
|
+
pub fn parseSnapshotResponse(allocator: std.mem.Allocator, content: []const u8) !SnapshotResponse {
|
|
58
68
|
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, content, .{});
|
|
59
69
|
defer parsed.deinit();
|
|
60
70
|
if (parsed.value != .object) return error.IosShimResponseMustBeObject;
|
|
61
71
|
const status = fieldString(parsed.value.object, "status") orelse return error.IosShimMissingStatus;
|
|
62
72
|
if (!std.mem.eql(u8, status, "ok")) return error.IosShimResponseNotOk;
|
|
73
|
+
const viewport = parseViewport(parsed.value.object.get("viewport"));
|
|
63
74
|
const nodes_value = parsed.value.object.get("nodes") orelse return error.IosShimMissingNodes;
|
|
64
75
|
if (nodes_value != .array) return error.IosShimNodesMustBeArray;
|
|
65
76
|
|
|
@@ -97,7 +108,15 @@ pub fn parseSnapshotNodes(allocator: std.mem.Allocator, content: []const u8) ![]
|
|
|
97
108
|
});
|
|
98
109
|
}
|
|
99
110
|
|
|
100
|
-
return
|
|
111
|
+
return .{
|
|
112
|
+
.nodes = try nodes.toOwnedSlice(allocator),
|
|
113
|
+
.viewport = viewport,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pub fn parseSnapshotNodes(allocator: std.mem.Allocator, content: []const u8) ![]types.UiNode {
|
|
118
|
+
const snapshot = try parseSnapshotResponse(allocator, content);
|
|
119
|
+
return snapshot.nodes;
|
|
101
120
|
}
|
|
102
121
|
|
|
103
122
|
pub fn parseOkResponse(content: []const u8) !void {
|
|
@@ -278,6 +297,21 @@ fn intField(object: std.json.ObjectMap, key: []const u8) !i32 {
|
|
|
278
297
|
return @intCast(value.integer);
|
|
279
298
|
}
|
|
280
299
|
|
|
300
|
+
fn parseViewport(value: ?std.json.Value) types.Viewport {
|
|
301
|
+
const actual = value orelse return .{};
|
|
302
|
+
if (actual != .object) return .{};
|
|
303
|
+
const width = optionalU32Field(actual.object, "width") orelse return .{};
|
|
304
|
+
const height = optionalU32Field(actual.object, "height") orelse return .{};
|
|
305
|
+
return .{ .width = width, .height = height };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
fn optionalU32Field(object: std.json.ObjectMap, key: []const u8) ?u32 {
|
|
309
|
+
const value = object.get(key) orelse return null;
|
|
310
|
+
if (value != .integer) return null;
|
|
311
|
+
if (value.integer < 0 or value.integer > std.math.maxInt(u32)) return null;
|
|
312
|
+
return @intCast(value.integer);
|
|
313
|
+
}
|
|
314
|
+
|
|
281
315
|
fn dupeOptional(allocator: std.mem.Allocator, value: ?[]const u8) !?[]const u8 {
|
|
282
316
|
if (value) |actual| return try allocator.dupe(u8, actual);
|
|
283
317
|
return null;
|
package/src/main.zig
CHANGED
|
@@ -88,6 +88,9 @@ fn writeTopLevelError(err: anyerror) void {
|
|
|
88
88
|
if (err == error.CommandFailed) {
|
|
89
89
|
stderr.writeAll("hint: run `zmr doctor --json` for setup diagnostics.\n") catch {};
|
|
90
90
|
}
|
|
91
|
+
if (err == error.UnknownFlag) {
|
|
92
|
+
stderr.writeAll("hint: run `zmr help` for each command's flags and arguments.\n") catch {};
|
|
93
|
+
}
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
fn exitCodeForError(err: anyerror) u8 {
|
package/src/version.zig
CHANGED
package/viewer/app.js
CHANGED
|
@@ -73,22 +73,42 @@ els.dropTarget.addEventListener("drop", async (event) => {
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
async function loadBundleFile(file) {
|
|
76
|
-
|
|
76
|
+
await loadBundle(file.name, () => file.arrayBuffer());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function loadBundleFromUrl(url) {
|
|
80
|
+
const name = url.split("/").pop() || url;
|
|
81
|
+
await loadBundle(name, async () => {
|
|
82
|
+
const response = await fetch(url);
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
|
85
|
+
}
|
|
86
|
+
return await response.arrayBuffer();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function loadBundle(name, getBuffer) {
|
|
91
|
+
setStatus(`Loading ${name}`);
|
|
77
92
|
try {
|
|
78
93
|
stopReplay();
|
|
79
94
|
revokeObjectUrls();
|
|
80
|
-
const entries = parseTarArchive(await
|
|
95
|
+
const entries = parseTarArchive(await getBuffer());
|
|
81
96
|
const model = buildTraceModel(entries);
|
|
82
97
|
state.model = model;
|
|
83
98
|
state.selectedFrameIndex = 0;
|
|
84
99
|
state.selectedEvent = model.replayFrames[0]?.event ?? model.events[0] ?? null;
|
|
85
|
-
renderModel(
|
|
100
|
+
renderModel(name);
|
|
86
101
|
} catch (error) {
|
|
87
102
|
console.error(error);
|
|
88
103
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
|
89
104
|
}
|
|
90
105
|
}
|
|
91
106
|
|
|
107
|
+
const bundleParam = new URLSearchParams(window.location.search).get("bundle");
|
|
108
|
+
if (bundleParam) {
|
|
109
|
+
loadBundleFromUrl(bundleParam);
|
|
110
|
+
}
|
|
111
|
+
|
|
92
112
|
function renderModel(fileName) {
|
|
93
113
|
const { summary } = state.model;
|
|
94
114
|
els.emptyState.classList.add("hidden");
|