zeno-mobile-runner 0.2.0 → 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.
Files changed (78) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/FEATURES.md +1 -1
  3. package/README.md +9 -1
  4. package/build.zig.zon +2 -2
  5. package/clients/kotlin/README.md +1 -1
  6. package/clients/kotlin/build.gradle.kts +1 -1
  7. package/clients/python/pyproject.toml +1 -1
  8. package/clients/rust/Cargo.lock +1 -1
  9. package/clients/rust/Cargo.toml +1 -1
  10. package/clients/typescript/package.json +1 -1
  11. package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
  12. package/docs/protocol.md +10 -10
  13. package/examples/ios-dev-client-open-link.json +24 -13
  14. package/examples/ios-dev-client-route-snapshot.json +33 -8
  15. package/npm/scenarios.mjs +15 -8
  16. package/npm/wizard.mjs +1 -1
  17. package/package.json +3 -1
  18. package/prebuilds/darwin-arm64/zmr +0 -0
  19. package/prebuilds/darwin-x64/zmr +0 -0
  20. package/prebuilds/linux-arm64/zmr +0 -0
  21. package/prebuilds/linux-x64/zmr +0 -0
  22. package/scripts/create-react-native-expo-demo-app.sh +11 -13
  23. package/shims/ios/ZMRShim.swift +40 -12
  24. package/shims/ios/ZMRShimUITestCase.swift +142 -16
  25. package/src/android.zig +10 -9
  26. package/src/android_emulator.zig +22 -11
  27. package/src/android_screen_recording.zig +11 -7
  28. package/src/bundle.zig +10 -9
  29. package/src/bundle_redaction.zig +29 -28
  30. package/src/bundle_tar.zig +15 -12
  31. package/src/cli_devices.zig +7 -3
  32. package/src/cli_discover.zig +7 -3
  33. package/src/cli_doctor.zig +7 -3
  34. package/src/cli_draft.zig +51 -47
  35. package/src/cli_explore.zig +7 -3
  36. package/src/cli_import.zig +8 -4
  37. package/src/cli_info.zig +13 -6
  38. package/src/cli_init.zig +9 -5
  39. package/src/cli_inspect.zig +8 -4
  40. package/src/cli_run.zig +22 -16
  41. package/src/cli_serve.zig +3 -3
  42. package/src/cli_trace.zig +25 -12
  43. package/src/cli_validate.zig +8 -4
  44. package/src/command.zig +81 -99
  45. package/src/config.zig +2 -1
  46. package/src/config_diagnostics.zig +2 -1
  47. package/src/config_paths.zig +2 -1
  48. package/src/doctor.zig +8 -7
  49. package/src/doctor_hints.zig +1 -1
  50. package/src/errors.zig +5 -5
  51. package/src/importer.zig +8 -7
  52. package/src/ios.zig +26 -29
  53. package/src/ios_devices.zig +6 -5
  54. package/src/ios_lifecycle.zig +4 -4
  55. package/src/json_rpc.zig +39 -40
  56. package/src/json_rpc_methods.zig +8 -8
  57. package/src/json_rpc_observation.zig +9 -8
  58. package/src/json_rpc_params.zig +1 -1
  59. package/src/json_rpc_trace.zig +22 -21
  60. package/src/main.zig +22 -10
  61. package/src/mcp.zig +28 -19
  62. package/src/mcp_trace.zig +30 -29
  63. package/src/report.zig +39 -36
  64. package/src/report_html.zig +5 -4
  65. package/src/runner.zig +2 -1
  66. package/src/runner_actions.zig +20 -17
  67. package/src/runner_diagnostics.zig +4 -4
  68. package/src/runner_events.zig +55 -51
  69. package/src/runner_native.zig +21 -19
  70. package/src/runner_waits.zig +46 -41
  71. package/src/scaffold.zig +25 -24
  72. package/src/scenario.zig +4 -3
  73. package/src/stdio.zig +129 -0
  74. package/src/trace.zig +34 -26
  75. package/src/trace_summary.zig +3 -2
  76. package/src/trace_summary_diagnostic.zig +15 -13
  77. package/src/validation.zig +5 -4
  78. package/src/version.zig +1 -1
@@ -46,21 +46,23 @@ enum ZMRShim {
46
46
  }
47
47
 
48
48
  static func snapshot(app: XCUIApplication) -> [ZMRShimNode] {
49
- let queries: [(XCUIElement.ElementType, XCUIElementQuery)] = [
50
- (.button, app.buttons),
51
- (.staticText, app.staticTexts),
52
- (.textField, app.textFields),
53
- (.secureTextField, app.secureTextFields),
54
- (.textView, app.textViews),
55
- (.image, app.images),
56
- (.switch, app.switches),
57
- (.cell, app.cells),
58
- (.scrollView, app.scrollViews),
59
- (.table, app.tables),
60
- (.collectionView, app.collectionViews)
49
+ let types: [XCUIElement.ElementType] = [
50
+ .button,
51
+ .staticText,
52
+ .textField,
53
+ .secureTextField,
54
+ .textView,
55
+ .image,
56
+ .switch,
57
+ .cell,
58
+ .scrollView,
59
+ .table,
60
+ .collectionView
61
61
  ]
62
+ let queries = types.flatMap { type in snapshotQueries(app: app, type: type) }
62
63
 
63
64
  var nodes: [ZMRShimNode] = []
65
+ var seen = Set<String>()
64
66
  nodes.reserveCapacity(128)
65
67
 
66
68
  for (type, query) in queries {
@@ -71,6 +73,11 @@ enum ZMRShim {
71
73
  guard element.exists else {
72
74
  continue
73
75
  }
76
+ let key = elementKey(type: type, element: element)
77
+ guard !seen.contains(key) else {
78
+ continue
79
+ }
80
+ seen.insert(key)
74
81
  nodes.append(node(index: nodes.count, type: type, element: element))
75
82
  }
76
83
  }
@@ -81,6 +88,13 @@ enum ZMRShim {
81
88
  return nodes
82
89
  }
83
90
 
91
+ private static func snapshotQueries(app: XCUIApplication, type: XCUIElement.ElementType) -> [(XCUIElement.ElementType, XCUIElementQuery)] {
92
+ [
93
+ (type, app.windows.descendants(matching: type)),
94
+ (type, app.descendants(matching: type))
95
+ ]
96
+ }
97
+
84
98
  private static func node(index: Int, type: XCUIElement.ElementType, element: XCUIElement) -> ZMRShimNode {
85
99
  let frame = element.frame
86
100
  return ZMRShimNode(
@@ -120,4 +134,18 @@ enum ZMRShim {
120
134
  }
121
135
  return "index:\(index)"
122
136
  }
137
+
138
+ private static func elementKey(type: XCUIElement.ElementType, element: XCUIElement) -> String {
139
+ let frame = element.frame
140
+ return [
141
+ String(describing: type),
142
+ element.identifier,
143
+ element.label,
144
+ elementValue(element),
145
+ String(Int(frame.origin.x)),
146
+ String(Int(frame.origin.y)),
147
+ String(Int(frame.size.width)),
148
+ String(Int(frame.size.height))
149
+ ].joined(separator: "|")
150
+ }
123
151
  }
@@ -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
@@ -241,10 +241,16 @@ final class ZMRShimUITestCase: XCTestCase {
241
241
  var acceptedCount = 0
242
242
  var lastAcceptedLabel = ""
243
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
+ }
244
250
  var tapped = false
245
251
  for label in labels {
246
252
  let button = springboard.buttons[label].firstMatch
247
- if button.waitForExistence(timeout: 2), button.isHittable {
253
+ if button.exists, button.isHittable {
248
254
  button.tap()
249
255
  acceptedCount += 1
250
256
  lastAcceptedLabel = label
@@ -258,12 +264,121 @@ final class ZMRShimUITestCase: XCTestCase {
258
264
  }
259
265
  }
260
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
+
261
279
  if acceptedCount > 0 {
262
280
  return ["status": "ok", "accepted": true, "label": lastAcceptedLabel, "count": acceptedCount]
263
281
  }
264
282
  return ["status": "ok", "accepted": false, "count": 0]
265
283
  }
266
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
+
267
382
  private func hideKeyboard(app: XCUIApplication) -> [String: Any] {
268
383
  guard app.keyboards.firstMatch.exists else {
269
384
  return ok()
@@ -369,41 +484,39 @@ final class ZMRShimUITestCase: XCTestCase {
369
484
  return nil
370
485
  }
371
486
 
372
- let query: XCUIElementQuery?
487
+ let queries: [XCUIElementQuery]
373
488
  switch parts.field {
374
489
  case "text", "label":
375
490
  let predicate = parts.contains
376
491
  ? NSPredicate(format: "label CONTAINS[c] %@", parts.value)
377
492
  : NSPredicate(format: "label == %@", parts.value)
378
- query = app.descendants(matching: .any).matching(predicate)
493
+ queries = allDescendantQueries(app: app, type: .any).map { $0.matching(predicate) }
379
494
  case "identifier", "resourceId":
380
495
  let predicate = parts.contains
381
496
  ? NSPredicate(format: "identifier CONTAINS[c] %@", parts.value)
382
497
  : NSPredicate(format: "identifier == %@", parts.value)
383
- query = app.descendants(matching: .any).matching(predicate)
498
+ queries = allDescendantQueries(app: app, type: .any).map { $0.matching(predicate) }
384
499
  case "value":
385
500
  let predicate = parts.contains
386
501
  ? NSPredicate(format: "value CONTAINS[c] %@", parts.value)
387
502
  : NSPredicate(format: "value == %@", parts.value)
388
- query = app.descendants(matching: .any).matching(predicate)
503
+ queries = allDescendantQueries(app: app, type: .any).map { $0.matching(predicate) }
389
504
  case "id":
390
505
  if parts.value.hasPrefix("id:") {
391
506
  let identifier = String(parts.value.dropFirst("id:".count))
392
- query = app.descendants(matching: .any).matching(identifier: identifier)
507
+ queries = allDescendantQueries(app: app, type: .any).map { $0.matching(identifier: identifier) }
393
508
  } else if parts.value.hasPrefix("label:") {
394
509
  let label = String(parts.value.dropFirst("label:".count))
395
- query = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label))
510
+ let predicate = NSPredicate(format: "label == %@", label)
511
+ queries = allDescendantQueries(app: app, type: .any).map { $0.matching(predicate) }
396
512
  } else {
397
- query = nil
513
+ queries = []
398
514
  }
399
515
  default:
400
- query = nil
516
+ queries = []
401
517
  }
402
518
 
403
- guard let element = query?.firstMatch, element.exists else {
404
- return nil
405
- }
406
- return element
519
+ return firstExistingElement(queries: queries)
407
520
  }
408
521
 
409
522
  private func resolveFastElement(selector: String, app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> XCUIElement? {
@@ -443,9 +556,15 @@ final class ZMRShimUITestCase: XCTestCase {
443
556
  private func fastTextQueries(app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> [XCUIElementQuery] {
444
557
  var queries: [XCUIElementQuery] = []
445
558
  if !preferredTypes.isEmpty {
446
- queries.append(contentsOf: preferredTypes.map { app.descendants(matching: $0) })
559
+ queries.append(contentsOf: preferredTypes.flatMap { allDescendantQueries(app: app, type: $0) })
447
560
  }
448
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),
449
568
  app.buttons,
450
569
  app.staticTexts,
451
570
  app.textFields,
@@ -456,6 +575,13 @@ final class ZMRShimUITestCase: XCTestCase {
456
575
  return queries
457
576
  }
458
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
+
459
585
  private func fastIdentifierQueries(
460
586
  app: XCUIApplication,
461
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
- std.Thread.sleep(open_link_retry_delay_ms * std.time.ns_per_ms);
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
- std.Thread.sleep(timeout_ms * std.time.ns_per_ms);
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}", .{std.time.milliTimestamp()});
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 = std.time.milliTimestamp(),
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 != .Exited or result.term.Exited != 0) return null;
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 = std.ArrayList(u8).empty;
296
- defer input.deinit(self.allocator);
297
- try ios_shim.writeCommandJson(input.writer(self.allocator), shim_command);
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.items, 4 * 1024 * 1024, shim_timeout_ms);
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);
@@ -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
- var child = std.process.Child.init(argv.items, allocator);
104
- child.stdin_behavior = .Ignore;
105
- child.stdout_behavior = .Ignore;
106
- child.stderr_behavior = .Ignore;
107
- try child.spawn();
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
- std.Thread.sleep(2 * std.time.ns_per_s);
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
- var file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch |err| switch (err) {
141
- error.FileNotFound => try std.fs.cwd().createFile(path, .{ .truncate = true }),
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
- defer file.close();
145
- try file.seekFromEnd(0);
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 file.writeAll(line.items);
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
- var child = std.process.Child.init(argv.items, allocator);
31
- child.stdin_behavior = .Ignore;
32
- child.stdout_behavior = .Ignore;
33
- child.stderr_behavior = .Ignore;
34
- try child.spawn();
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
- std.posix.kill(self.child.id, std.posix.SIG.INT) catch {};
78
- _ = try self.child.wait();
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.fs.cwd().createFile(out_path, .{ .truncate = true });
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.writeAll(&([_]u8{0} ** 1024));
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.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
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 std.fs.cwd().readFileAlloc(allocator, path, 64 * 1024 * 1024);
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.fs.cwd().openDir(fs_dir, .{ .iterate = true }) catch |err| switch (err) {
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) {
@@ -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.init(arena_allocator);
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 = std.ArrayList(u8).empty;
38
- errdefer out.deinit(allocator);
39
- try writeJsonValueRedacted(out.writer(allocator), parsed.value, null);
40
- try out.writer(allocator).writeByte('\n');
41
- return try out.toOwnedSlice(allocator);
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 = std.ArrayList(u8).empty;
50
- errdefer out.deinit(allocator);
51
- try writeJsonValueRedacted(out.writer(allocator), parsed.value, null);
52
- try out.writer(allocator).writeByte('\n');
53
- return try out.toOwnedSlice(allocator);
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 = std.ArrayList(u8).empty;
102
- errdefer out.deinit(allocator);
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 out.writer(allocator).writeAll(bytes[index .. index + prefix_len]);
115
- try out.writer(allocator).writeAll("[REDACTED:secret]");
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 out.writer(allocator).writeByte('"');
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 out.writer(allocator).writeAll("[REDACTED:email]");
127
+ try writer.writeAll("[REDACTED:email]");
127
128
  index = end;
128
129
  continue;
129
130
  }
130
131
  if (bearerEnd(bytes, index)) |end| {
131
- try out.writer(allocator).writeAll("[REDACTED:token]");
132
+ try writer.writeAll("[REDACTED:token]");
132
133
  index = end;
133
134
  continue;
134
135
  }
135
- try out.writer(allocator).writeByte(bytes[index]);
136
+ try writer.writeByte(bytes[index]);
136
137
  index += 1;
137
138
  }
138
- return try out.toOwnedSlice(allocator);
139
+ return try out.toOwnedSlice();
139
140
  }
140
141
 
141
142
  fn emailEnd(bytes: []const u8, start: usize) ?usize {