zeno-mobile-runner 0.2.1 → 0.2.2
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 +29 -0
- package/FEATURES.md +1 -1
- package/README.md +1 -1
- package/build.zig.zon +2 -2
- 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/protocol-fixtures/core-session.responses.jsonl +1 -1
- package/docs/protocol.md +10 -10
- package/package.json +3 -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/create-react-native-expo-demo-app.sh +11 -13
- package/shims/ios/ZMRShim.swift +40 -12
- package/shims/ios/ZMRShimUITestCase.swift +135 -15
- package/src/android.zig +10 -9
- package/src/android_emulator.zig +22 -11
- package/src/android_screen_recording.zig +11 -7
- package/src/bundle.zig +10 -9
- package/src/bundle_redaction.zig +29 -28
- package/src/bundle_tar.zig +15 -12
- package/src/cli_devices.zig +7 -3
- package/src/cli_discover.zig +7 -3
- package/src/cli_doctor.zig +7 -3
- package/src/cli_draft.zig +51 -47
- package/src/cli_explore.zig +7 -3
- package/src/cli_import.zig +8 -4
- package/src/cli_info.zig +13 -6
- package/src/cli_init.zig +9 -5
- package/src/cli_inspect.zig +8 -4
- package/src/cli_run.zig +22 -16
- package/src/cli_serve.zig +3 -3
- package/src/cli_trace.zig +25 -12
- package/src/cli_validate.zig +8 -4
- package/src/command.zig +81 -99
- package/src/config.zig +2 -1
- package/src/config_diagnostics.zig +2 -1
- package/src/config_paths.zig +2 -1
- package/src/doctor.zig +8 -7
- package/src/doctor_hints.zig +1 -1
- package/src/errors.zig +5 -5
- package/src/importer.zig +8 -7
- package/src/ios.zig +19 -18
- package/src/ios_devices.zig +6 -5
- package/src/ios_lifecycle.zig +4 -4
- package/src/json_rpc.zig +39 -40
- package/src/json_rpc_methods.zig +8 -8
- package/src/json_rpc_observation.zig +9 -8
- package/src/json_rpc_params.zig +1 -1
- package/src/json_rpc_trace.zig +22 -21
- package/src/main.zig +19 -10
- package/src/mcp.zig +28 -19
- package/src/mcp_trace.zig +30 -29
- package/src/report.zig +39 -36
- package/src/report_html.zig +5 -4
- package/src/runner.zig +2 -1
- package/src/runner_actions.zig +20 -17
- package/src/runner_diagnostics.zig +4 -4
- package/src/runner_events.zig +55 -51
- package/src/runner_native.zig +21 -19
- package/src/runner_waits.zig +46 -41
- package/src/scaffold.zig +25 -24
- package/src/scenario.zig +4 -3
- package/src/stdio.zig +129 -0
- package/src/trace.zig +34 -26
- package/src/trace_summary.zig +3 -2
- package/src/trace_summary_diagnostic.zig +15 -13
- package/src/validation.zig +5 -4
- package/src/version.zig +1 -1
|
@@ -187,7 +187,7 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
187
187
|
case "appState":
|
|
188
188
|
return ["status": "ok", "state": app.state.rawValue]
|
|
189
189
|
case "acceptSystemAlert":
|
|
190
|
-
return acceptSystemAlert(buttonText: command.text ?? "Open")
|
|
190
|
+
return acceptSystemAlert(buttonText: command.text ?? "Open", app: app)
|
|
191
191
|
default:
|
|
192
192
|
return error("unknown.command", "unsupported command: \(command.cmd)")
|
|
193
193
|
}
|
|
@@ -229,7 +229,7 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
229
229
|
["status": "error", "code": code, "message": message]
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
private func acceptSystemAlert(buttonText: String) -> [String: Any] {
|
|
232
|
+
private func acceptSystemAlert(buttonText: String, app: XCUIApplication) -> [String: Any] {
|
|
233
233
|
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
234
234
|
var labels = [buttonText, "Open", "Allow", "OK", "Continue"]
|
|
235
235
|
labels = labels.reduce(into: [String]()) { unique, label in
|
|
@@ -264,12 +264,121 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
let expoDeepLinkSelection = acceptExpoDevClientDeepLink(app: app)
|
|
268
|
+
if expoDeepLinkSelection.accepted {
|
|
269
|
+
acceptedCount += 1
|
|
270
|
+
lastAcceptedLabel = expoDeepLinkSelection.label
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let expoHomeSelection = resumeExpoDevClientHome(app: app)
|
|
274
|
+
if expoHomeSelection.accepted {
|
|
275
|
+
acceptedCount += 1
|
|
276
|
+
lastAcceptedLabel = expoHomeSelection.label
|
|
277
|
+
}
|
|
278
|
+
|
|
267
279
|
if acceptedCount > 0 {
|
|
268
280
|
return ["status": "ok", "accepted": true, "label": lastAcceptedLabel, "count": acceptedCount]
|
|
269
281
|
}
|
|
270
282
|
return ["status": "ok", "accepted": false, "count": 0]
|
|
271
283
|
}
|
|
272
284
|
|
|
285
|
+
private func acceptExpoDevClientDeepLink(app: XCUIApplication) -> (accepted: Bool, label: String) {
|
|
286
|
+
guard app.staticTexts["Deep link received:"].waitForExistence(timeout: 1) else {
|
|
287
|
+
return (false, "")
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let candidateQueries = [
|
|
291
|
+
app.buttons.allElementsBoundByIndex,
|
|
292
|
+
app.cells.allElementsBoundByIndex,
|
|
293
|
+
app.staticTexts.allElementsBoundByIndex
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
for elements in candidateQueries {
|
|
297
|
+
for element in elements {
|
|
298
|
+
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
299
|
+
guard isExpoDevClientDeepLinkTarget(label: label), element.isHittable else {
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
element.tap()
|
|
304
|
+
Thread.sleep(forTimeInterval: 1.0)
|
|
305
|
+
return (true, label)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return (false, "")
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private func isExpoDevClientDeepLinkTarget(label: String) -> Bool {
|
|
313
|
+
if label.isEmpty {
|
|
314
|
+
return false
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let rejectedExactLabels = [
|
|
318
|
+
"Deep link received:",
|
|
319
|
+
"Select an app to open it:",
|
|
320
|
+
"Go back"
|
|
321
|
+
]
|
|
322
|
+
if rejectedExactLabels.contains(label) {
|
|
323
|
+
return false
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if label.contains("://") || label.hasPrefix("Note:") || label.contains("next app you open") {
|
|
327
|
+
return false
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return true
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private func resumeExpoDevClientHome(app: XCUIApplication) -> (accepted: Bool, label: String) {
|
|
334
|
+
guard app.staticTexts["Development servers"].waitForExistence(timeout: 1) else {
|
|
335
|
+
return (false, "")
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let candidateQueries = [
|
|
339
|
+
app.buttons.allElementsBoundByIndex,
|
|
340
|
+
app.cells.allElementsBoundByIndex,
|
|
341
|
+
app.staticTexts.allElementsBoundByIndex
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
for elements in candidateQueries {
|
|
345
|
+
for element in elements {
|
|
346
|
+
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
347
|
+
guard isExpoDevClientProjectTarget(label: label), element.exists else {
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
352
|
+
Thread.sleep(forTimeInterval: 1.0)
|
|
353
|
+
return (true, label)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return (false, "")
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private func isExpoDevClientProjectTarget(label: String) -> Bool {
|
|
361
|
+
if label.isEmpty {
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let rejectedExactLabels = [
|
|
366
|
+
"Development servers",
|
|
367
|
+
"Recently opened",
|
|
368
|
+
"Fetch development servers",
|
|
369
|
+
"Enter URL manually"
|
|
370
|
+
]
|
|
371
|
+
if rejectedExactLabels.contains(label) {
|
|
372
|
+
return false
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if label.hasPrefix("http://") || label.hasPrefix("https://") {
|
|
376
|
+
return false
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return label.contains(" http://") || label.contains(" https://")
|
|
380
|
+
}
|
|
381
|
+
|
|
273
382
|
private func hideKeyboard(app: XCUIApplication) -> [String: Any] {
|
|
274
383
|
guard app.keyboards.firstMatch.exists else {
|
|
275
384
|
return ok()
|
|
@@ -375,41 +484,39 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
375
484
|
return nil
|
|
376
485
|
}
|
|
377
486
|
|
|
378
|
-
let
|
|
487
|
+
let queries: [XCUIElementQuery]
|
|
379
488
|
switch parts.field {
|
|
380
489
|
case "text", "label":
|
|
381
490
|
let predicate = parts.contains
|
|
382
491
|
? NSPredicate(format: "label CONTAINS[c] %@", parts.value)
|
|
383
492
|
: NSPredicate(format: "label == %@", parts.value)
|
|
384
|
-
|
|
493
|
+
queries = allDescendantQueries(app: app, type: .any).map { $0.matching(predicate) }
|
|
385
494
|
case "identifier", "resourceId":
|
|
386
495
|
let predicate = parts.contains
|
|
387
496
|
? NSPredicate(format: "identifier CONTAINS[c] %@", parts.value)
|
|
388
497
|
: NSPredicate(format: "identifier == %@", parts.value)
|
|
389
|
-
|
|
498
|
+
queries = allDescendantQueries(app: app, type: .any).map { $0.matching(predicate) }
|
|
390
499
|
case "value":
|
|
391
500
|
let predicate = parts.contains
|
|
392
501
|
? NSPredicate(format: "value CONTAINS[c] %@", parts.value)
|
|
393
502
|
: NSPredicate(format: "value == %@", parts.value)
|
|
394
|
-
|
|
503
|
+
queries = allDescendantQueries(app: app, type: .any).map { $0.matching(predicate) }
|
|
395
504
|
case "id":
|
|
396
505
|
if parts.value.hasPrefix("id:") {
|
|
397
506
|
let identifier = String(parts.value.dropFirst("id:".count))
|
|
398
|
-
|
|
507
|
+
queries = allDescendantQueries(app: app, type: .any).map { $0.matching(identifier: identifier) }
|
|
399
508
|
} else if parts.value.hasPrefix("label:") {
|
|
400
509
|
let label = String(parts.value.dropFirst("label:".count))
|
|
401
|
-
|
|
510
|
+
let predicate = NSPredicate(format: "label == %@", label)
|
|
511
|
+
queries = allDescendantQueries(app: app, type: .any).map { $0.matching(predicate) }
|
|
402
512
|
} else {
|
|
403
|
-
|
|
513
|
+
queries = []
|
|
404
514
|
}
|
|
405
515
|
default:
|
|
406
|
-
|
|
516
|
+
queries = []
|
|
407
517
|
}
|
|
408
518
|
|
|
409
|
-
|
|
410
|
-
return nil
|
|
411
|
-
}
|
|
412
|
-
return element
|
|
519
|
+
return firstExistingElement(queries: queries)
|
|
413
520
|
}
|
|
414
521
|
|
|
415
522
|
private func resolveFastElement(selector: String, app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> XCUIElement? {
|
|
@@ -449,9 +556,15 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
449
556
|
private func fastTextQueries(app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> [XCUIElementQuery] {
|
|
450
557
|
var queries: [XCUIElementQuery] = []
|
|
451
558
|
if !preferredTypes.isEmpty {
|
|
452
|
-
queries.append(contentsOf: preferredTypes.
|
|
559
|
+
queries.append(contentsOf: preferredTypes.flatMap { allDescendantQueries(app: app, type: $0) })
|
|
453
560
|
}
|
|
454
561
|
queries.append(contentsOf: [
|
|
562
|
+
app.windows.descendants(matching: .button),
|
|
563
|
+
app.windows.descendants(matching: .staticText),
|
|
564
|
+
app.windows.descendants(matching: .textField),
|
|
565
|
+
app.windows.descendants(matching: .secureTextField),
|
|
566
|
+
app.windows.descendants(matching: .textView),
|
|
567
|
+
app.windows.descendants(matching: .image),
|
|
455
568
|
app.buttons,
|
|
456
569
|
app.staticTexts,
|
|
457
570
|
app.textFields,
|
|
@@ -462,6 +575,13 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
462
575
|
return queries
|
|
463
576
|
}
|
|
464
577
|
|
|
578
|
+
private func allDescendantQueries(app: XCUIApplication, type: XCUIElement.ElementType) -> [XCUIElementQuery] {
|
|
579
|
+
[
|
|
580
|
+
app.windows.descendants(matching: type),
|
|
581
|
+
app.descendants(matching: type)
|
|
582
|
+
]
|
|
583
|
+
}
|
|
584
|
+
|
|
465
585
|
private func fastIdentifierQueries(
|
|
466
586
|
app: XCUIApplication,
|
|
467
587
|
preferredTypes: [XCUIElement.ElementType],
|
package/src/android.zig
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const std = @import("std");
|
|
2
|
+
const stdio = @import("stdio.zig");
|
|
2
3
|
const command = @import("command.zig");
|
|
3
4
|
const android_device_info = @import("android_device_info.zig");
|
|
4
5
|
const android_shell = @import("android_shell.zig");
|
|
@@ -93,7 +94,7 @@ pub const AndroidDevice = struct {
|
|
|
93
94
|
|
|
94
95
|
if (self.isAppForeground() catch false) return;
|
|
95
96
|
if (attempt + 1 < open_link_attempts) {
|
|
96
|
-
|
|
97
|
+
stdio.sleepNs(open_link_retry_delay_ms * std.time.ns_per_ms);
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
return error.AppDidNotOpen;
|
|
@@ -164,11 +165,11 @@ pub const AndroidDevice = struct {
|
|
|
164
165
|
.duration_ms = @as(u32, @intCast(@min(timeout_ms, std.math.maxInt(u32)))),
|
|
165
166
|
});
|
|
166
167
|
}
|
|
167
|
-
|
|
168
|
+
stdio.sleepNs(timeout_ms * std.time.ns_per_ms);
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
pub fn snapshot(self: *AndroidDevice, writer: ?*trace.TraceWriter) !types.ObservationSnapshot {
|
|
171
|
-
const id = if (writer) |tw| try tw.nextSnapshotId() else try std.fmt.allocPrint(self.allocator, "snapshot-{d}", .{
|
|
172
|
+
const id = if (writer) |tw| try tw.nextSnapshotId() else try std.fmt.allocPrint(self.allocator, "snapshot-{d}", .{stdio.nowMs()});
|
|
172
173
|
errdefer self.allocator.free(id);
|
|
173
174
|
|
|
174
175
|
const xml = if (self.shim_path == null) try self.dumpHierarchy() else null;
|
|
@@ -215,7 +216,7 @@ pub const AndroidDevice = struct {
|
|
|
215
216
|
|
|
216
217
|
return .{
|
|
217
218
|
.id = id,
|
|
218
|
-
.timestamp_ms =
|
|
219
|
+
.timestamp_ms = stdio.nowMs(),
|
|
219
220
|
.viewport = screen,
|
|
220
221
|
.display_density_dpi = display_density_dpi,
|
|
221
222
|
.active_package = active.package,
|
|
@@ -273,7 +274,7 @@ pub const AndroidDevice = struct {
|
|
|
273
274
|
fn logDelta(self: *AndroidDevice) !?[]const u8 {
|
|
274
275
|
const result = try self.runAdb(&.{ "logcat", "-d", "-t", "80" }, 1024 * 1024);
|
|
275
276
|
defer result.deinit(self.allocator);
|
|
276
|
-
if (result.term != .
|
|
277
|
+
if (result.term != .exited or result.term.exited != 0) return null;
|
|
277
278
|
return try self.allocator.dupe(u8, result.stdout);
|
|
278
279
|
}
|
|
279
280
|
|
|
@@ -292,11 +293,11 @@ pub const AndroidDevice = struct {
|
|
|
292
293
|
fn runShim(self: *AndroidDevice, shim_command: ios_shim.Command) ![]u8 {
|
|
293
294
|
const path = self.shim_path orelse return error.AndroidShimRequired;
|
|
294
295
|
|
|
295
|
-
var input =
|
|
296
|
-
defer input.deinit(
|
|
297
|
-
try ios_shim.writeCommandJson(input.writer
|
|
296
|
+
var input: std.Io.Writer.Allocating = .init(self.allocator);
|
|
297
|
+
defer input.deinit();
|
|
298
|
+
try ios_shim.writeCommandJson(&input.writer, shim_command);
|
|
298
299
|
|
|
299
|
-
const result = try command.runWithInputTimeout(self.allocator, &.{path}, input.
|
|
300
|
+
const result = try command.runWithInputTimeout(self.allocator, &.{path}, input.writer.buffered(), 4 * 1024 * 1024, shim_timeout_ms);
|
|
300
301
|
defer result.deinit(self.allocator);
|
|
301
302
|
try result.ensureSuccess();
|
|
302
303
|
return try self.allocator.dupe(u8, result.stdout);
|
package/src/android_emulator.zig
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const std = @import("std");
|
|
2
|
+
const stdio = @import("stdio.zig");
|
|
2
3
|
const command = @import("command.zig");
|
|
3
4
|
|
|
4
5
|
const default_timeout_ms = 15_000;
|
|
@@ -100,11 +101,12 @@ fn startEmulator(allocator: std.mem.Allocator, options: PreflightOptions, avd: [
|
|
|
100
101
|
try argv.appendSlice(allocator, &.{ "-netdelay", "none", "-netspeed", "full" });
|
|
101
102
|
try recordCommand(allocator, options.event_log_path, argv.items);
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
_ = try std.process.spawn(stdio.io(), .{
|
|
105
|
+
.argv = argv.items,
|
|
106
|
+
.stdin = .ignore,
|
|
107
|
+
.stdout = .ignore,
|
|
108
|
+
.stderr = .ignore,
|
|
109
|
+
});
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
fn waitReady(allocator: std.mem.Allocator, options: PreflightOptions) !void {
|
|
@@ -118,7 +120,7 @@ fn waitReady(allocator: std.mem.Allocator, options: PreflightOptions) !void {
|
|
|
118
120
|
try prop.ensureSuccess();
|
|
119
121
|
const value = std.mem.trim(u8, prop.stdout, " \t\r\n");
|
|
120
122
|
if (std.mem.eql(u8, value, "1")) return;
|
|
121
|
-
|
|
123
|
+
stdio.sleepNs(2 * std.time.ns_per_s);
|
|
122
124
|
}
|
|
123
125
|
return error.AndroidEmulatorBootTimedOut;
|
|
124
126
|
}
|
|
@@ -137,12 +139,20 @@ fn runAdb(allocator: std.mem.Allocator, options: PreflightOptions, extra: []cons
|
|
|
137
139
|
|
|
138
140
|
fn recordCommand(allocator: std.mem.Allocator, maybe_path: ?[]const u8, argv: []const []const u8) !void {
|
|
139
141
|
const path = maybe_path orelse return;
|
|
140
|
-
|
|
141
|
-
error.FileNotFound =>
|
|
142
|
+
const existing = stdio.readFileAlloc(allocator, path, 4 * 1024 * 1024) catch |err| switch (err) {
|
|
143
|
+
error.FileNotFound => "",
|
|
142
144
|
else => return err,
|
|
143
145
|
};
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
const had_existing_file = existing.ptr != "".ptr;
|
|
147
|
+
defer if (had_existing_file) allocator.free(existing);
|
|
148
|
+
|
|
149
|
+
var file = try std.Io.Dir.cwd().createFile(stdio.io(), path, .{ .truncate = true });
|
|
150
|
+
defer file.close(stdio.io());
|
|
151
|
+
var write_buffer: [8192]u8 = undefined;
|
|
152
|
+
var file_writer = file.writerStreaming(stdio.io(), &write_buffer);
|
|
153
|
+
const writer = &file_writer.interface;
|
|
154
|
+
if (existing.len > 0) try writer.writeAll(existing);
|
|
155
|
+
|
|
146
156
|
var line = std.ArrayList(u8).empty;
|
|
147
157
|
defer line.deinit(allocator);
|
|
148
158
|
for (argv, 0..) |arg, index| {
|
|
@@ -150,5 +160,6 @@ fn recordCommand(allocator: std.mem.Allocator, maybe_path: ?[]const u8, argv: []
|
|
|
150
160
|
try line.appendSlice(allocator, arg);
|
|
151
161
|
}
|
|
152
162
|
try line.append(allocator, '\n');
|
|
153
|
-
try
|
|
163
|
+
try writer.writeAll(line.items);
|
|
164
|
+
try writer.flush();
|
|
154
165
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const std = @import("std");
|
|
2
|
+
const stdio = @import("stdio.zig");
|
|
2
3
|
const command = @import("command.zig");
|
|
3
4
|
const trace = @import("trace.zig");
|
|
4
5
|
const types = @import("types.zig");
|
|
@@ -27,11 +28,12 @@ pub fn start(
|
|
|
27
28
|
const owned_remote_path = try allocator.dupe(u8, remote_path);
|
|
28
29
|
errdefer allocator.free(owned_remote_path);
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
const child = try std.process.spawn(stdio.io(), .{
|
|
32
|
+
.argv = argv.items,
|
|
33
|
+
.stdin = .ignore,
|
|
34
|
+
.stdout = .ignore,
|
|
35
|
+
.stderr = .ignore,
|
|
36
|
+
});
|
|
35
37
|
|
|
36
38
|
return .{
|
|
37
39
|
.allocator = allocator,
|
|
@@ -74,8 +76,10 @@ pub const AndroidScreenRecording = struct {
|
|
|
74
76
|
|
|
75
77
|
fn stopProcess(self: *AndroidScreenRecording) !void {
|
|
76
78
|
if (self.stopped) return;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
if (self.child.id) |child_id| {
|
|
80
|
+
std.posix.kill(child_id, std.posix.SIG.INT) catch {};
|
|
81
|
+
_ = try self.child.wait(stdio.io());
|
|
82
|
+
}
|
|
79
83
|
self.stopped = true;
|
|
80
84
|
}
|
|
81
85
|
|
package/src/bundle.zig
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const std = @import("std");
|
|
2
2
|
const bundle_redaction = @import("bundle_redaction.zig");
|
|
3
3
|
const bundle_tar = @import("bundle_tar.zig");
|
|
4
|
+
const stdio = @import("stdio.zig");
|
|
4
5
|
|
|
5
6
|
pub const ExportOptions = bundle_redaction.Options;
|
|
6
7
|
|
|
@@ -40,8 +41,8 @@ pub fn exportTraceBundleWithOptions(
|
|
|
40
41
|
try entries.append(allocator, try allocator.dupe(u8, entry));
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
var out_file = try std.
|
|
44
|
-
defer out_file.close();
|
|
44
|
+
var out_file = try std.Io.Dir.cwd().createFile(stdio.io(), out_path, .{ .truncate = true });
|
|
45
|
+
defer out_file.close(stdio.io());
|
|
45
46
|
|
|
46
47
|
for (entries.items) |archive_path| {
|
|
47
48
|
if (options.redact) {
|
|
@@ -60,7 +61,7 @@ pub fn exportTraceBundleWithOptions(
|
|
|
60
61
|
try bundle_tar.writeFile(allocator, trace_dir, archive_path, &out_file);
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
|
-
try out_file.
|
|
64
|
+
try std.Io.File.writeStreamingAll(out_file, stdio.io(), &([_]u8{0} ** 1024));
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
fn requireTraceFile(
|
|
@@ -75,18 +76,18 @@ fn requireTraceFile(
|
|
|
75
76
|
fn traceFileExists(allocator: std.mem.Allocator, trace_dir: []const u8, archive_path: []const u8) !bool {
|
|
76
77
|
const path = try std.fs.path.join(allocator, &.{ trace_dir, archive_path });
|
|
77
78
|
defer allocator.free(path);
|
|
78
|
-
const file = std.
|
|
79
|
+
const file = std.Io.Dir.cwd().openFile(stdio.io(), path, .{}) catch |err| switch (err) {
|
|
79
80
|
error.FileNotFound => return false,
|
|
80
81
|
else => return err,
|
|
81
82
|
};
|
|
82
|
-
file.close();
|
|
83
|
+
file.close(stdio.io());
|
|
83
84
|
return true;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
fn readTraceFile(allocator: std.mem.Allocator, trace_dir: []const u8, archive_path: []const u8) ![]u8 {
|
|
87
88
|
const path = try std.fs.path.join(allocator, &.{ trace_dir, archive_path });
|
|
88
89
|
defer allocator.free(path);
|
|
89
|
-
return try
|
|
90
|
+
return try stdio.readFileAlloc(allocator, path, 64 * 1024 * 1024);
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
fn collectArtifactEntries(
|
|
@@ -98,14 +99,14 @@ fn collectArtifactEntries(
|
|
|
98
99
|
const fs_dir = try std.fs.path.join(allocator, &.{ trace_dir, archive_dir });
|
|
99
100
|
defer allocator.free(fs_dir);
|
|
100
101
|
|
|
101
|
-
var dir = std.
|
|
102
|
+
var dir = std.Io.Dir.cwd().openDir(stdio.io(), fs_dir, .{ .iterate = true }) catch |err| switch (err) {
|
|
102
103
|
error.FileNotFound => return,
|
|
103
104
|
else => return err,
|
|
104
105
|
};
|
|
105
|
-
defer dir.close();
|
|
106
|
+
defer dir.close(stdio.io());
|
|
106
107
|
|
|
107
108
|
var iterator = dir.iterate();
|
|
108
|
-
while (try iterator.next()) |entry| {
|
|
109
|
+
while (try iterator.next(stdio.io())) |entry| {
|
|
109
110
|
const archive_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ archive_dir, entry.name });
|
|
110
111
|
errdefer allocator.free(archive_path);
|
|
111
112
|
switch (entry.kind) {
|
package/src/bundle_redaction.zig
CHANGED
|
@@ -24,21 +24,21 @@ fn redactTraceManifest(allocator: std.mem.Allocator, bytes: []const u8, options:
|
|
|
24
24
|
if (parsed.value != .object) return try redactJsonishText(allocator, bytes);
|
|
25
25
|
|
|
26
26
|
const arena_allocator = parsed.arena.allocator();
|
|
27
|
-
var redaction = std.json.ObjectMap
|
|
28
|
-
try redaction.put("enabled", .{ .bool = true });
|
|
29
|
-
try redaction.put("screenshots", .{ .string = if (options.omit_screenshots) "omitted" else "placeholder" });
|
|
30
|
-
try redaction.put("screenRecordings", .{ .string = "omitted" });
|
|
31
|
-
try redaction.put("textArtifacts", .{ .string = "scrubbed" });
|
|
32
|
-
try redaction.put("screenshotsOmitted", .{ .bool = options.omit_screenshots });
|
|
33
|
-
try redaction.put("screenshotsRedacted", .{ .bool = !options.omit_screenshots });
|
|
34
|
-
try redaction.put("screenRecordingsOmitted", .{ .bool = true });
|
|
35
|
-
try parsed.value.object.put("redaction", .{ .object = redaction });
|
|
27
|
+
var redaction = std.json.ObjectMap{};
|
|
28
|
+
try redaction.put(arena_allocator, "enabled", .{ .bool = true });
|
|
29
|
+
try redaction.put(arena_allocator, "screenshots", .{ .string = if (options.omit_screenshots) "omitted" else "placeholder" });
|
|
30
|
+
try redaction.put(arena_allocator, "screenRecordings", .{ .string = "omitted" });
|
|
31
|
+
try redaction.put(arena_allocator, "textArtifacts", .{ .string = "scrubbed" });
|
|
32
|
+
try redaction.put(arena_allocator, "screenshotsOmitted", .{ .bool = options.omit_screenshots });
|
|
33
|
+
try redaction.put(arena_allocator, "screenshotsRedacted", .{ .bool = !options.omit_screenshots });
|
|
34
|
+
try redaction.put(arena_allocator, "screenRecordingsOmitted", .{ .bool = true });
|
|
35
|
+
try parsed.value.object.put(arena_allocator, "redaction", .{ .object = redaction });
|
|
36
36
|
|
|
37
|
-
var out =
|
|
38
|
-
errdefer out.deinit(
|
|
39
|
-
try writeJsonValueRedacted(out.writer
|
|
40
|
-
try out.writer
|
|
41
|
-
return try out.toOwnedSlice(
|
|
37
|
+
var out: std.Io.Writer.Allocating = .init(allocator);
|
|
38
|
+
errdefer out.deinit();
|
|
39
|
+
try writeJsonValueRedacted(&out.writer, parsed.value, null);
|
|
40
|
+
try out.writer.writeByte('\n');
|
|
41
|
+
return try out.toOwnedSlice();
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
fn redactJsonishText(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
|
|
@@ -46,11 +46,11 @@ fn redactJsonishText(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
|
|
|
46
46
|
return redactFreeText(allocator, bytes);
|
|
47
47
|
};
|
|
48
48
|
defer parsed.deinit();
|
|
49
|
-
var out =
|
|
50
|
-
errdefer out.deinit(
|
|
51
|
-
try writeJsonValueRedacted(out.writer
|
|
52
|
-
try out.writer
|
|
53
|
-
return try out.toOwnedSlice(
|
|
49
|
+
var out: std.Io.Writer.Allocating = .init(allocator);
|
|
50
|
+
errdefer out.deinit();
|
|
51
|
+
try writeJsonValueRedacted(&out.writer, parsed.value, null);
|
|
52
|
+
try out.writer.writeByte('\n');
|
|
53
|
+
return try out.toOwnedSlice();
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
fn writeJsonValueRedacted(writer: anytype, value: std.json.Value, key_context: ?[]const u8) !void {
|
|
@@ -98,8 +98,9 @@ fn writeRedactedJsonStringForKey(writer: anytype, key: []const u8, value: []cons
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
fn redactFreeText(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
|
|
101
|
-
var out =
|
|
102
|
-
errdefer out.deinit(
|
|
101
|
+
var out: std.Io.Writer.Allocating = .init(allocator);
|
|
102
|
+
errdefer out.deinit();
|
|
103
|
+
const writer = &out.writer;
|
|
103
104
|
var sensitive_tag = false;
|
|
104
105
|
var index: usize = 0;
|
|
105
106
|
while (index < bytes.len) {
|
|
@@ -111,31 +112,31 @@ fn redactFreeText(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
|
|
|
111
112
|
|
|
112
113
|
if (sensitive_tag and (startsWithAt(bytes, index, "text=\"") or startsWithAt(bytes, index, "content-desc=\""))) {
|
|
113
114
|
const prefix_len: usize = if (startsWithAt(bytes, index, "text=\"")) 6 else 14;
|
|
114
|
-
try
|
|
115
|
-
try
|
|
115
|
+
try writer.writeAll(bytes[index .. index + prefix_len]);
|
|
116
|
+
try writer.writeAll("[REDACTED:secret]");
|
|
116
117
|
index += prefix_len;
|
|
117
118
|
while (index < bytes.len and bytes[index] != '"') : (index += 1) {}
|
|
118
119
|
if (index < bytes.len) {
|
|
119
|
-
try
|
|
120
|
+
try writer.writeByte('"');
|
|
120
121
|
index += 1;
|
|
121
122
|
}
|
|
122
123
|
continue;
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
if (emailEnd(bytes, index)) |end| {
|
|
126
|
-
try
|
|
127
|
+
try writer.writeAll("[REDACTED:email]");
|
|
127
128
|
index = end;
|
|
128
129
|
continue;
|
|
129
130
|
}
|
|
130
131
|
if (bearerEnd(bytes, index)) |end| {
|
|
131
|
-
try
|
|
132
|
+
try writer.writeAll("[REDACTED:token]");
|
|
132
133
|
index = end;
|
|
133
134
|
continue;
|
|
134
135
|
}
|
|
135
|
-
try
|
|
136
|
+
try writer.writeByte(bytes[index]);
|
|
136
137
|
index += 1;
|
|
137
138
|
}
|
|
138
|
-
return try out.toOwnedSlice(
|
|
139
|
+
return try out.toOwnedSlice();
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
fn emailEnd(bytes: []const u8, start: usize) ?usize {
|
package/src/bundle_tar.zig
CHANGED
|
@@ -1,42 +1,45 @@
|
|
|
1
1
|
const std = @import("std");
|
|
2
|
+
const stdio = @import("stdio.zig");
|
|
2
3
|
|
|
3
4
|
pub fn writeFile(
|
|
4
5
|
allocator: std.mem.Allocator,
|
|
5
6
|
trace_dir: []const u8,
|
|
6
7
|
archive_path: []const u8,
|
|
7
|
-
out_file: *std.
|
|
8
|
+
out_file: *std.Io.File,
|
|
8
9
|
) !void {
|
|
9
10
|
if (isUnsafeArchivePath(archive_path)) return error.UnsafeArchivePath;
|
|
10
11
|
const fs_path = try std.fs.path.join(allocator, &.{ trace_dir, archive_path });
|
|
11
12
|
defer allocator.free(fs_path);
|
|
12
13
|
|
|
13
|
-
var in_file = try std.
|
|
14
|
-
defer in_file.close();
|
|
15
|
-
const stat = try in_file.stat();
|
|
14
|
+
var in_file = try std.Io.Dir.cwd().openFile(stdio.io(), fs_path, .{});
|
|
15
|
+
defer in_file.close(stdio.io());
|
|
16
|
+
const stat = try in_file.stat(stdio.io());
|
|
16
17
|
|
|
17
18
|
var header = [_]u8{0} ** 512;
|
|
18
19
|
try writeHeader(&header, archive_path, stat.size);
|
|
19
20
|
|
|
20
|
-
try out_file.
|
|
21
|
+
try std.Io.File.writeStreamingAll(out_file.*, stdio.io(), &header);
|
|
22
|
+
var reader_buffer: [16 * 1024]u8 = undefined;
|
|
23
|
+
var reader = in_file.readerStreaming(stdio.io(), &reader_buffer);
|
|
21
24
|
var buffer: [16 * 1024]u8 = undefined;
|
|
22
25
|
var remaining = stat.size;
|
|
23
26
|
while (remaining > 0) {
|
|
24
27
|
const read_len = @min(buffer.len, remaining);
|
|
25
|
-
const n = try
|
|
28
|
+
const n = try reader.interface.readSliceShort(buffer[0..read_len]);
|
|
26
29
|
if (n == 0) return error.UnexpectedEndOfStream;
|
|
27
|
-
try out_file.
|
|
30
|
+
try std.Io.File.writeStreamingAll(out_file.*, stdio.io(), buffer[0..n]);
|
|
28
31
|
remaining -= n;
|
|
29
32
|
}
|
|
30
33
|
try writePadding(out_file, stat.size);
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
pub fn writeBytes(archive_path: []const u8, bytes: []const u8, out_file: *std.
|
|
36
|
+
pub fn writeBytes(archive_path: []const u8, bytes: []const u8, out_file: *std.Io.File) !void {
|
|
34
37
|
if (isUnsafeArchivePath(archive_path)) return error.UnsafeArchivePath;
|
|
35
38
|
var header = [_]u8{0} ** 512;
|
|
36
39
|
try writeHeader(&header, archive_path, bytes.len);
|
|
37
40
|
|
|
38
|
-
try out_file.
|
|
39
|
-
try out_file.
|
|
41
|
+
try std.Io.File.writeStreamingAll(out_file.*, stdio.io(), &header);
|
|
42
|
+
try std.Io.File.writeStreamingAll(out_file.*, stdio.io(), bytes);
|
|
40
43
|
try writePadding(out_file, bytes.len);
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -57,9 +60,9 @@ fn writeHeader(header: *[512]u8, archive_path: []const u8, size: u64) !void {
|
|
|
57
60
|
writeChecksum(header[148..156], checksum);
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
fn writePadding(out_file: *std.
|
|
63
|
+
fn writePadding(out_file: *std.Io.File, size: u64) !void {
|
|
61
64
|
const padding = (512 - (size % 512)) % 512;
|
|
62
|
-
if (padding > 0) try out_file.
|
|
65
|
+
if (padding > 0) try std.Io.File.writeStreamingAll(out_file.*, stdio.io(), (&([_]u8{0} ** 512))[0..padding]);
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
fn isUnsafeArchivePath(archive_path: []const u8) bool {
|
package/src/cli_devices.zig
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const std = @import("std");
|
|
2
|
+
const stdio = @import("stdio.zig");
|
|
2
3
|
const android = @import("android.zig");
|
|
3
4
|
const device_registry = @import("device_registry.zig");
|
|
4
5
|
const ios = @import("ios.zig");
|
|
@@ -11,7 +12,7 @@ pub const IosDevicesScope = enum {
|
|
|
11
12
|
all,
|
|
12
13
|
};
|
|
13
14
|
|
|
14
|
-
pub fn run(allocator: std.mem.Allocator, args: *std.process.
|
|
15
|
+
pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void {
|
|
15
16
|
var platform: run_options.Platform = .android;
|
|
16
17
|
var ios_devices_scope: IosDevicesScope = .simulator;
|
|
17
18
|
var adb_path: []const u8 = "adb";
|
|
@@ -30,7 +31,7 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !void {
|
|
|
30
31
|
} else if (std.mem.eql(u8, arg, "--json")) {
|
|
31
32
|
json = true;
|
|
32
33
|
} else {
|
|
33
|
-
return error.
|
|
34
|
+
return error.unknownFlag;
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -43,7 +44,10 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !void {
|
|
|
43
44
|
allocator.free(devices);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
var stdout_io: stdio.Output = .{};
|
|
48
|
+
stdout_io.init(.stdout());
|
|
49
|
+
defer stdout_io.deinit();
|
|
50
|
+
const stdout = stdout_io.writer();
|
|
47
51
|
if (json) return try device_registry.writeJson(stdout, registryPlatform(platform), devices);
|
|
48
52
|
if (devices.len == 0) return try stdout.print("No {s} devices found.\n", .{@tagName(platform)});
|
|
49
53
|
for (devices) |device| {
|