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.
- package/CHANGELOG.md +52 -0
- package/FEATURES.md +1 -1
- package/README.md +9 -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/examples/ios-dev-client-open-link.json +24 -13
- package/examples/ios-dev-client-route-snapshot.json +33 -8
- package/npm/scenarios.mjs +15 -8
- package/npm/wizard.mjs +1 -1
- 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 +142 -16
- 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 +26 -29
- 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 +22 -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
package/shims/ios/ZMRShim.swift
CHANGED
|
@@ -46,21 +46,23 @@ enum ZMRShim {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
static func snapshot(app: XCUIApplication) -> [ZMRShimNode] {
|
|
49
|
-
let
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
+
let predicate = NSPredicate(format: "label == %@", label)
|
|
511
|
+
queries = allDescendantQueries(app: app, type: .any).map { $0.matching(predicate) }
|
|
396
512
|
} else {
|
|
397
|
-
|
|
513
|
+
queries = []
|
|
398
514
|
}
|
|
399
515
|
default:
|
|
400
|
-
|
|
516
|
+
queries = []
|
|
401
517
|
}
|
|
402
518
|
|
|
403
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|