zeno-mobile-runner 0.1.8 → 0.2.1
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 +72 -0
- package/FEATURES.md +1 -1
- package/README.md +175 -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-dev-client-open-link.json +24 -13
- package/examples/ios-dev-client-route-snapshot.json +33 -8
- package/examples/ios-shim-workflow.json +79 -0
- package/examples/react-native-expo-workflow.json +75 -0
- package/npm/scenarios.mjs +15 -8
- package/npm/wizard.mjs +1 -1
- 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 +49 -1
- 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 +44 -11
- package/src/ios_shim.zig +36 -2
- package/src/main.zig +6 -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
|
}
|
|
@@ -208,10 +241,16 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
208
241
|
var acceptedCount = 0
|
|
209
242
|
var lastAcceptedLabel = ""
|
|
210
243
|
for _ in 0..<3 {
|
|
244
|
+
// One existence probe on the alert container keeps the no-dialog
|
|
245
|
+
// path to a single short wait instead of a per-label wait, so the
|
|
246
|
+
// best-effort accept after every openLink stays cheap.
|
|
247
|
+
guard springboard.alerts.firstMatch.waitForExistence(timeout: 2) else {
|
|
248
|
+
break
|
|
249
|
+
}
|
|
211
250
|
var tapped = false
|
|
212
251
|
for label in labels {
|
|
213
252
|
let button = springboard.buttons[label].firstMatch
|
|
214
|
-
if button.
|
|
253
|
+
if button.exists, button.isHittable {
|
|
215
254
|
button.tap()
|
|
216
255
|
acceptedCount += 1
|
|
217
256
|
lastAcceptedLabel = label
|
|
@@ -532,6 +571,15 @@ private extension ZMRShimBounds {
|
|
|
532
571
|
}
|
|
533
572
|
}
|
|
534
573
|
|
|
574
|
+
private extension ZMRShimViewport {
|
|
575
|
+
var json: [String: Any] {
|
|
576
|
+
[
|
|
577
|
+
"width": width,
|
|
578
|
+
"height": height
|
|
579
|
+
]
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
535
583
|
private extension ZMRShimNode {
|
|
536
584
|
var json: [String: Any] {
|
|
537
585
|
[
|
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,6 +128,12 @@ 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();
|
|
131
|
+
// Opening a URL on the simulator can raise a SpringBoard "Open in <App>?"
|
|
132
|
+
// confirmation for universal links (http/https) and, just as often, for
|
|
133
|
+
// custom schemes — the common Expo dev-client case
|
|
134
|
+
// (exp+scheme://expo-development-client/...). Attempt a best-effort accept
|
|
135
|
+
// whenever a shim is configured; the shim probes briefly and returns fast
|
|
136
|
+
// when no dialog is present, so this stays cheap on the no-prompt path.
|
|
129
137
|
self.acceptOpenURLConfirmationBestEffort();
|
|
130
138
|
}
|
|
131
139
|
|
|
@@ -212,15 +220,22 @@ pub const IosDevice = struct {
|
|
|
212
220
|
|
|
213
221
|
const active_package = try self.allocator.dupe(u8, self.app_id);
|
|
214
222
|
errdefer self.allocator.free(active_package);
|
|
215
|
-
|
|
216
|
-
|
|
223
|
+
var shim_viewport: ?types.Viewport = null;
|
|
224
|
+
const nodes = if (self.shim_path != null) blk: {
|
|
225
|
+
const shim_snapshot = self.snapshotFromShim() catch |err| {
|
|
217
226
|
if (screenshot_artifact == null) return err;
|
|
218
227
|
if (writer) |tw| try self.recordSnapshotSemanticFailure(tw, screenshot_artifact.?, err);
|
|
219
228
|
break :blk try self.allocator.alloc(types.UiNode, 0);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
229
|
+
};
|
|
230
|
+
shim_viewport = shim_snapshot.viewport;
|
|
231
|
+
break :blk shim_snapshot.nodes;
|
|
232
|
+
} else try self.allocator.alloc(types.UiNode, 0);
|
|
223
233
|
errdefer self.allocator.free(nodes);
|
|
234
|
+
if (shim_viewport) |value| {
|
|
235
|
+
if (value.width > 0 and value.height > 0) {
|
|
236
|
+
viewport = value;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
224
239
|
|
|
225
240
|
return .{
|
|
226
241
|
.id = id,
|
|
@@ -260,10 +275,10 @@ pub const IosDevice = struct {
|
|
|
260
275
|
return try self.allocator.dupe(u8, result.stdout);
|
|
261
276
|
}
|
|
262
277
|
|
|
263
|
-
fn
|
|
278
|
+
fn snapshotFromShim(self: *IosDevice) !ios_shim.SnapshotResponse {
|
|
264
279
|
const response = try self.runShim(.{ .kind = .snapshot });
|
|
265
280
|
defer self.allocator.free(response);
|
|
266
|
-
return try ios_shim.
|
|
281
|
+
return try ios_shim.parseSnapshotResponse(self.allocator, response);
|
|
267
282
|
}
|
|
268
283
|
|
|
269
284
|
fn recordSnapshotSemanticFailure(self: *IosDevice, writer: *trace.TraceWriter, screenshot_artifact: []const u8, err: anyerror) !void {
|
|
@@ -319,7 +334,7 @@ pub const IosDevice = struct {
|
|
|
319
334
|
}
|
|
320
335
|
|
|
321
336
|
fn runShim(self: *IosDevice, shim_command: ios_shim.Command) ![]u8 {
|
|
322
|
-
return self.runShimWithTimeout(shim_command,
|
|
337
|
+
return self.runShimWithTimeout(shim_command, shimTimeoutMs());
|
|
323
338
|
}
|
|
324
339
|
|
|
325
340
|
fn runShimWithTimeout(self: *IosDevice, shim_command: ios_shim.Command, timeout_ms: u64) ![]u8 {
|
|
@@ -384,6 +399,17 @@ fn isTransientShimBootstrapFailure(result: command.ExecResult) bool {
|
|
|
384
399
|
std.mem.indexOf(u8, result.stderr, "operation never finished bootstrapping") != null;
|
|
385
400
|
}
|
|
386
401
|
|
|
402
|
+
fn shimTimeoutMs() u64 {
|
|
403
|
+
return parseShimTimeoutMs(std.posix.getenv(shim_timeout_env));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
fn parseShimTimeoutMs(raw: ?[]const u8) u64 {
|
|
407
|
+
const value = raw orelse return default_shim_timeout_ms;
|
|
408
|
+
const parsed = std.fmt.parseInt(u64, value, 10) catch return default_shim_timeout_ms;
|
|
409
|
+
if (parsed == 0) return default_shim_timeout_ms;
|
|
410
|
+
return parsed;
|
|
411
|
+
}
|
|
412
|
+
|
|
387
413
|
pub fn listDevices(allocator: std.mem.Allocator, xcrun_path: []const u8) ![]types.DeviceInfo {
|
|
388
414
|
return try ios_devices.listSimulators(allocator, xcrun_path);
|
|
389
415
|
}
|
|
@@ -401,6 +427,13 @@ pub fn parsePhysicalDevicesJson(allocator: std.mem.Allocator, content: []const u
|
|
|
401
427
|
}
|
|
402
428
|
|
|
403
429
|
test "ios xctest shim timeout allows cold xcodebuild startup" {
|
|
404
|
-
try std.testing.expect(
|
|
430
|
+
try std.testing.expect(default_shim_timeout_ms >= 5_400_000);
|
|
405
431
|
try std.testing.expect(shim_best_effort_timeout_ms <= 15_000);
|
|
406
432
|
}
|
|
433
|
+
|
|
434
|
+
test "ios xctest shim timeout env override" {
|
|
435
|
+
try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs(null));
|
|
436
|
+
try std.testing.expectEqual(@as(u64, 600_000), parseShimTimeoutMs("600000"));
|
|
437
|
+
try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs("not-a-number"));
|
|
438
|
+
try std.testing.expectEqual(@as(u64, default_shim_timeout_ms), parseShimTimeoutMs("0"));
|
|
439
|
+
}
|
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
|
@@ -16,6 +16,9 @@ const errors = @import("errors.zig");
|
|
|
16
16
|
|
|
17
17
|
pub fn main() void {
|
|
18
18
|
mainInner() catch |err| {
|
|
19
|
+
// stdout's consumer went away (e.g. `zmr ... | head`); exit quietly
|
|
20
|
+
// with the conventional SIGPIPE status instead of reporting an error.
|
|
21
|
+
if (err == error.BrokenPipe) std.process.exit(141);
|
|
19
22
|
writeTopLevelError(err);
|
|
20
23
|
std.process.exit(exitCodeForError(err));
|
|
21
24
|
};
|
|
@@ -88,6 +91,9 @@ fn writeTopLevelError(err: anyerror) void {
|
|
|
88
91
|
if (err == error.CommandFailed) {
|
|
89
92
|
stderr.writeAll("hint: run `zmr doctor --json` for setup diagnostics.\n") catch {};
|
|
90
93
|
}
|
|
94
|
+
if (err == error.UnknownFlag) {
|
|
95
|
+
stderr.writeAll("hint: run `zmr help` for each command's flags and arguments.\n") catch {};
|
|
96
|
+
}
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
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");
|