zeno-mobile-runner 0.2.12 → 0.2.14

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.
@@ -9,6 +9,7 @@ const trace = @import("trace.zig");
9
9
 
10
10
  const RunOptions = runner_config.RunOptions;
11
11
  const native_health_probe_timeout_ms: u64 = 1000;
12
+ const native_selector_transient_retry_limit: usize = 1;
12
13
 
13
14
  pub fn waitUntilVisible(
14
15
  device: anytype,
@@ -39,23 +40,36 @@ fn untilVisibleKind(
39
40
  kind: []const u8,
40
41
  ) !bool {
41
42
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
43
+ var native_query_failures: usize = 0;
42
44
  while (true) {
43
- if (nativeSelectorQueryTimeoutMs(deadline)) |query_timeout_ms| {
44
- const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
45
- if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
45
+ if (nativeSelectorQueryTimeoutMs(deadline, options)) |query_timeout_ms| {
46
+ var native_query_failed = false;
47
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| blk: {
48
+ if (try recordTransientNativeSelectorObservation(err, kind, writer)) {
49
+ native_query_failures += 1;
50
+ if (native_query_failures <= native_selector_transient_retry_limit and stdio.nowMs() < deadline) {
51
+ try sleepMs(options.poll_ms);
52
+ continue;
53
+ }
54
+ native_query_failed = true;
55
+ break :blk null;
56
+ }
46
57
  return err;
47
58
  };
48
- if (native_result) |visible| {
49
- if (visible) {
50
- if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
51
- return true;
52
- }
53
- if (stdio.nowMs() >= deadline) {
54
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
55
- return false;
59
+ if (!native_query_failed) native_query_failures = 0;
60
+ if (!native_query_failed) {
61
+ if (native_result) |visible| {
62
+ if (visible) {
63
+ if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
64
+ return true;
65
+ }
66
+ if (stdio.nowMs() >= deadline) {
67
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
68
+ return false;
69
+ }
70
+ try sleepMs(options.poll_ms);
71
+ continue;
56
72
  }
57
- try sleepMs(options.poll_ms);
58
- continue;
59
73
  }
60
74
  } else if (hasNativeSelectorQuery(device)) {
61
75
  if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
@@ -119,23 +133,36 @@ fn untilNotVisibleKind(
119
133
  kind: []const u8,
120
134
  ) !bool {
121
135
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
136
+ var native_query_failures: usize = 0;
122
137
  while (true) {
123
- if (nativeSelectorQueryTimeoutMs(deadline)) |query_timeout_ms| {
124
- const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
125
- if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
138
+ if (nativeSelectorQueryTimeoutMs(deadline, options)) |query_timeout_ms| {
139
+ var native_query_failed = false;
140
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| blk: {
141
+ if (try recordTransientNativeSelectorObservation(err, kind, writer)) {
142
+ native_query_failures += 1;
143
+ if (native_query_failures <= native_selector_transient_retry_limit and stdio.nowMs() < deadline) {
144
+ try sleepMs(options.poll_ms);
145
+ continue;
146
+ }
147
+ native_query_failed = true;
148
+ break :blk null;
149
+ }
126
150
  return err;
127
151
  };
128
- if (native_result) |visible| {
129
- if (!visible) {
130
- if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
131
- return true;
132
- }
133
- if (stdio.nowMs() >= deadline) {
134
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
135
- return false;
152
+ if (!native_query_failed) native_query_failures = 0;
153
+ if (!native_query_failed) {
154
+ if (native_result) |visible| {
155
+ if (!visible) {
156
+ if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
157
+ return true;
158
+ }
159
+ if (stdio.nowMs() >= deadline) {
160
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
161
+ return false;
162
+ }
163
+ try sleepMs(options.poll_ms);
164
+ continue;
136
165
  }
137
- try sleepMs(options.poll_ms);
138
- continue;
139
166
  }
140
167
  } else if (hasNativeSelectorQuery(device)) {
141
168
  if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
@@ -178,10 +205,11 @@ pub fn waitUntilAnyVisible(
178
205
  options: RunOptions,
179
206
  ) !?usize {
180
207
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
181
- while (true) {
208
+ var native_query_failures: usize = 0;
209
+ native_poll: while (true) {
182
210
  var all_native = true;
183
211
  for (selectors, 0..) |wanted, index| {
184
- const query_timeout_ms = nativeSelectorQueryTimeoutMs(deadline) orelse {
212
+ const query_timeout_ms = nativeSelectorQueryTimeoutMs(deadline, options) orelse {
185
213
  if (hasNativeSelectorQuery(device)) {
186
214
  if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "wait.any", selectors, timeout_ms);
187
215
  return null;
@@ -190,9 +218,18 @@ pub fn waitUntilAnyVisible(
190
218
  break;
191
219
  };
192
220
  const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
193
- if (try retryTransientObservation(err, "wait.any", writer, deadline, options)) continue;
221
+ if (try recordTransientNativeSelectorObservation(err, "wait.any", writer)) {
222
+ native_query_failures += 1;
223
+ if (native_query_failures <= native_selector_transient_retry_limit and stdio.nowMs() < deadline) {
224
+ try sleepMs(options.poll_ms);
225
+ continue :native_poll;
226
+ }
227
+ all_native = false;
228
+ break;
229
+ }
194
230
  return err;
195
231
  };
232
+ native_query_failures = 0;
196
233
  if (native_result) |visible| {
197
234
  if (visible) {
198
235
  if (writer) |tw| try runner_events.recordNativeWait(tw, "wait.any", wanted, index, timeout_ms);
@@ -322,7 +359,7 @@ fn nativeAssertHealthy(
322
359
 
323
360
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
324
361
  native_probe: while (true) {
325
- const remaining_ms = nativeSelectorQueryTimeoutMs(deadline) orelse return null;
362
+ const remaining_ms = nativeSelectorRemainingTimeoutMs(deadline) orelse return null;
326
363
  const query_timeout_ms = @min(remaining_ms, nativeHealthProbeTimeoutMs(timeout_ms, options));
327
364
 
328
365
  for (health_selectors, 0..) |wanted, index| {
@@ -356,7 +393,47 @@ pub fn scrollUntilVisible(
356
393
  options: RunOptions,
357
394
  ) !bool {
358
395
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
396
+ var native_query_failures: usize = 0;
359
397
  while (true) {
398
+ if (nativeSelectorQueryTimeoutMs(deadline, options)) |query_timeout_ms| {
399
+ var native_query_failed = false;
400
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| blk: {
401
+ if (try recordTransientNativeSelectorObservation(err, "ui.scrollUntilVisible", writer)) {
402
+ native_query_failures += 1;
403
+ if (native_query_failures <= native_selector_transient_retry_limit and stdio.nowMs() < deadline) {
404
+ try sleepMs(options.poll_ms);
405
+ continue;
406
+ }
407
+ native_query_failed = true;
408
+ break :blk null;
409
+ }
410
+ return err;
411
+ };
412
+ if (!native_query_failed) native_query_failures = 0;
413
+ if (!native_query_failed) {
414
+ if (native_result) |visible| {
415
+ if (visible) {
416
+ if (writer) |tw| try runner_events.recordNativeScrollUntilVisible(
417
+ tw,
418
+ wanted,
419
+ if (direction == .down) "down" else "up",
420
+ timeout_ms,
421
+ );
422
+ return true;
423
+ }
424
+ if (stdio.nowMs() >= deadline) {
425
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "ui.scrollUntilVisible", &[_]selector.Selector{wanted}, timeout_ms);
426
+ return false;
427
+ }
428
+ try scrollDevice(device, direction, writer, options);
429
+ continue;
430
+ }
431
+ }
432
+ } else if (hasNativeSelectorQuery(device)) {
433
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "ui.scrollUntilVisible", &[_]selector.Selector{wanted}, timeout_ms);
434
+ return false;
435
+ }
436
+
360
437
  var snap = device.snapshot(writer) catch |err| {
361
438
  if (try retryTransientObservation(err, "ui.scrollUntilVisible", writer, deadline, options)) continue;
362
439
  return err;
@@ -385,29 +462,7 @@ pub fn scrollUntilVisible(
385
462
  return false;
386
463
  }
387
464
 
388
- const width = if (snap.viewport.width == 0) @as(i32, 720) else @as(i32, @intCast(snap.viewport.width));
389
- const height = if (snap.viewport.height == 0) @as(i32, 1280) else @as(i32, @intCast(snap.viewport.height));
390
- const x = @divTrunc(width, 2);
391
- const start_y = switch (direction) {
392
- .down => @divTrunc(height * 4, 5),
393
- .up => @divTrunc(height * 3, 10),
394
- };
395
- const end_y = switch (direction) {
396
- .down => @divTrunc(height * 3, 10),
397
- .up => @divTrunc(height * 4, 5),
398
- };
399
- try device.swipe(x, start_y, x, end_y, 350);
400
- if (writer) |tw| {
401
- const payload = try std.fmt.allocPrint(tw.allocator, "{{\"direction\":\"{s}\",\"x\":{d},\"y1\":{d},\"y2\":{d}}}", .{
402
- if (direction == .down) "down" else "up",
403
- x,
404
- start_y,
405
- end_y,
406
- });
407
- defer tw.allocator.free(payload);
408
- try tw.recordEvent("ui.scroll", payload);
409
- }
410
- try settleDevice(device, options);
465
+ try scrollDeviceWithViewport(device, direction, writer, options, snap.viewport);
411
466
  }
412
467
  }
413
468
 
@@ -421,7 +476,13 @@ fn nativeVisibleBySelector(device: anytype, wanted: selector.Selector, timeout_m
421
476
  return try device.visibleBySelector(wanted);
422
477
  }
423
478
 
424
- fn nativeSelectorQueryTimeoutMs(deadline: i64) ?u64 {
479
+ fn nativeSelectorQueryTimeoutMs(deadline: i64, options: RunOptions) ?u64 {
480
+ const remaining_ms = nativeSelectorRemainingTimeoutMs(deadline) orelse return null;
481
+ if (options.action_timeout_ms == 0) return remaining_ms;
482
+ return @max(@as(u64, 1), @min(remaining_ms, options.action_timeout_ms));
483
+ }
484
+
485
+ fn nativeSelectorRemainingTimeoutMs(deadline: i64) ?u64 {
425
486
  const now = stdio.nowMs();
426
487
  if (now >= deadline) return null;
427
488
  return @as(u64, @intCast(deadline - now));
@@ -434,6 +495,16 @@ fn nativeHealthProbeTimeoutMs(timeout_ms: u64, options: RunOptions) u64 {
434
495
  return @max(probe_timeout_ms, 1);
435
496
  }
436
497
 
498
+ fn recordTransientNativeSelectorObservation(
499
+ err: anyerror,
500
+ kind: []const u8,
501
+ writer: ?*trace.TraceWriter,
502
+ ) !bool {
503
+ if (err != error.CommandTimedOut and err != error.CommandFailed) return false;
504
+ if (writer) |tw| try runner_events.recordObservationRetry(tw, kind, err);
505
+ return true;
506
+ }
507
+
437
508
  fn retryTransientObservation(
438
509
  err: anyerror,
439
510
  kind: []const u8,
@@ -448,6 +519,54 @@ fn retryTransientObservation(
448
519
  return true;
449
520
  }
450
521
 
522
+ fn scrollDevice(
523
+ device: anytype,
524
+ direction: scenario.ScrollDirection,
525
+ writer: ?*trace.TraceWriter,
526
+ options: RunOptions,
527
+ ) !void {
528
+ try scrollDeviceWithViewport(device, direction, writer, options, try scrollViewport(device));
529
+ }
530
+
531
+ fn scrollViewport(device: anytype) !@import("types.zig").Viewport {
532
+ if (@hasDecl(@TypeOf(device.*), "scrollViewport")) {
533
+ return try device.scrollViewport();
534
+ }
535
+ return .{};
536
+ }
537
+
538
+ fn scrollDeviceWithViewport(
539
+ device: anytype,
540
+ direction: scenario.ScrollDirection,
541
+ writer: ?*trace.TraceWriter,
542
+ options: RunOptions,
543
+ viewport: @import("types.zig").Viewport,
544
+ ) !void {
545
+ const width = if (viewport.width == 0) @as(i32, 720) else @as(i32, @intCast(viewport.width));
546
+ const height = if (viewport.height == 0) @as(i32, 1280) else @as(i32, @intCast(viewport.height));
547
+ const x = @divTrunc(width, 2);
548
+ const start_y = switch (direction) {
549
+ .down => @divTrunc(height * 4, 5),
550
+ .up => @divTrunc(height * 3, 10),
551
+ };
552
+ const end_y = switch (direction) {
553
+ .down => @divTrunc(height * 3, 10),
554
+ .up => @divTrunc(height * 4, 5),
555
+ };
556
+ try device.swipe(x, start_y, x, end_y, 350);
557
+ if (writer) |tw| {
558
+ const payload = try std.fmt.allocPrint(tw.allocator, "{{\"direction\":\"{s}\",\"x\":{d},\"y1\":{d},\"y2\":{d}}}", .{
559
+ if (direction == .down) "down" else "up",
560
+ x,
561
+ start_y,
562
+ end_y,
563
+ });
564
+ defer tw.allocator.free(payload);
565
+ try tw.recordEvent("ui.scroll", payload);
566
+ }
567
+ try settleDevice(device, options);
568
+ }
569
+
451
570
  fn settleDevice(device: anytype, options: RunOptions) !void {
452
571
  try device.settle(options.settle_ms);
453
572
  }
package/src/scenario.zig CHANGED
@@ -11,6 +11,11 @@ pub const Swipe = struct {
11
11
  duration_ms: u32 = 300,
12
12
  };
13
13
 
14
+ pub const Location = struct {
15
+ latitude: f64,
16
+ longitude: f64,
17
+ };
18
+
14
19
  pub const WaitVisible = struct {
15
20
  selector: selector.Selector,
16
21
  timeout_ms: u64 = 5000,
@@ -106,6 +111,7 @@ pub const Step = union(enum) {
106
111
  clear_state,
107
112
  snapshot,
108
113
  open_link: []const u8,
114
+ set_location: Location,
109
115
  tap: selector.Selector,
110
116
  type_text: TypeText,
111
117
  press_back,
@@ -224,6 +230,10 @@ fn parseRawStep(allocator: std.mem.Allocator, object: std.json.ObjectMap) anyerr
224
230
  if (std.mem.eql(u8, action, "hideKeyboard")) return .hide_keyboard;
225
231
  if (std.mem.eql(u8, action, "sleep")) return .{ .sleep_ms = try fields.optionalU64(object, "ms", 500) };
226
232
  if (std.mem.eql(u8, action, "openLink")) return .{ .open_link = try fields.requiredStringOrError(allocator, object, "url", error.StepMissingUrl) };
233
+ if (std.mem.eql(u8, action, "setLocation")) return .{ .set_location = .{
234
+ .latitude = try parseLatitude(object),
235
+ .longitude = try parseLongitude(object),
236
+ } };
227
237
  if (std.mem.eql(u8, action, "tap")) return .{ .tap = try fields.parseSelectorField(allocator, object) };
228
238
  if (std.mem.eql(u8, action, "typeText")) {
229
239
  const wanted = if (object.get("selector")) |selector_value| try selector.parseFromJson(allocator, selector_value) else null;
@@ -342,6 +352,18 @@ fn parseRawStep(allocator: std.mem.Allocator, object: std.json.ObjectMap) anyerr
342
352
  return error.unknownScenarioAction;
343
353
  }
344
354
 
355
+ fn parseLatitude(object: std.json.ObjectMap) !f64 {
356
+ const latitude = try fields.requiredF64OrError(object, "latitude", error.StepMissingLatitude, error.StepLatitudeMustBeNumber);
357
+ if (latitude < -90.0 or latitude > 90.0) return error.StepLatitudeOutOfRange;
358
+ return latitude;
359
+ }
360
+
361
+ fn parseLongitude(object: std.json.ObjectMap) !f64 {
362
+ const longitude = try fields.requiredF64OrError(object, "longitude", error.StepMissingLongitude, error.StepLongitudeMustBeNumber);
363
+ if (longitude < -180.0 or longitude > 180.0) return error.StepLongitudeOutOfRange;
364
+ return longitude;
365
+ }
366
+
345
367
  fn appendParsedSteps(allocator: std.mem.Allocator, steps: *std.ArrayList(Step), value: std.json.Value) anyerror!void {
346
368
  if (value != .array) return error.ScenarioStepsMustBeArray;
347
369
  for (value.array.items) |step_value| {
@@ -373,3 +395,27 @@ fn optionalTimeoutMs(object: std.json.ObjectMap) !?u64 {
373
395
  if (object.get("timeoutMs") == null) return null;
374
396
  return try fields.optionalU64(object, "timeoutMs", 0);
375
397
  }
398
+
399
+ test "parses setLocation with latitude and longitude" {
400
+ const allocator = std.testing.allocator;
401
+ const script_json =
402
+ \\{
403
+ \\ "name": "set location smoke",
404
+ \\ "steps": [
405
+ \\ {"action": "setLocation", "latitude": 51.5074, "longitude": -0.1278}
406
+ \\ ]
407
+ \\}
408
+ ;
409
+
410
+ const script = try parseSlice(allocator, script_json);
411
+ defer script.deinit(allocator);
412
+
413
+ try std.testing.expectEqual(@as(usize, 1), script.steps.len);
414
+ switch (script.steps[0]) {
415
+ .set_location => |location| {
416
+ try std.testing.expectApproxEqAbs(@as(f64, 51.5074), location.latitude, 0.000001);
417
+ try std.testing.expectApproxEqAbs(@as(f64, -0.1278), location.longitude, 0.000001);
418
+ },
419
+ else => return error.ExpectedSetLocationStep,
420
+ }
421
+ }
@@ -21,6 +21,10 @@ pub fn requiredI32OrError(object: std.json.ObjectMap, key: []const u8, missing_e
21
21
  return try json_fields.requiredI32FromObject(object, key, missing_error, error.RequiredFieldMustBeInteger);
22
22
  }
23
23
 
24
+ pub fn requiredF64OrError(object: std.json.ObjectMap, key: []const u8, missing_error: anyerror, type_error: anyerror) !f64 {
25
+ return try json_fields.requiredF64FromObject(object, key, missing_error, type_error);
26
+ }
27
+
24
28
  pub fn optionalU64(object: std.json.ObjectMap, key: []const u8, default_value: u64) !u64 {
25
29
  return try json_fields.optionalU64FromObject(object, key, default_value, error.OptionalFieldMustBeInteger);
26
30
  }
@@ -109,6 +109,14 @@ fn diagnoseFailure(allocator: std.mem.Allocator, content: []const u8, err: anyer
109
109
  => try pathDiagnostic(allocator, content, "$.steps[].x2", "x2"),
110
110
  error.StepMissingY2,
111
111
  => try pathDiagnostic(allocator, content, "$.steps[].y2", "y2"),
112
+ error.StepMissingLatitude,
113
+ error.StepLatitudeMustBeNumber,
114
+ error.StepLatitudeOutOfRange,
115
+ => try pathDiagnostic(allocator, content, "$.steps[].latitude", "latitude"),
116
+ error.StepMissingLongitude,
117
+ error.StepLongitudeMustBeNumber,
118
+ error.StepLongitudeOutOfRange,
119
+ => try pathDiagnostic(allocator, content, "$.steps[].longitude", "longitude"),
112
120
  error.MissingSelector,
113
121
  error.StepMissingSelector,
114
122
  error.SelectorMustNotBeEmpty,
package/src/version.zig CHANGED
@@ -1,4 +1,4 @@
1
- pub const runner_version = "0.2.12";
1
+ pub const runner_version = "0.2.14";
2
2
  pub const protocol_version = "2026-04-28";
3
3
  pub const protocol_min_compatible_version = "2026-04-28";
4
4
  pub const protocol_stability = "dev-preview";