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.
@@ -20,6 +20,8 @@ pub const FakeDevice = struct {
20
20
  opened_link: ?[]const u8 = null,
21
21
  settles: usize = 0,
22
22
  last_settle_timeout_ms: u64 = 0,
23
+ location_sets: usize = 0,
24
+ last_location: ?LocationRecord = null,
23
25
 
24
26
  pub fn init(allocator: std.mem.Allocator, snapshots: []types.ObservationSnapshot) FakeDevice {
25
27
  return .{
@@ -71,6 +73,14 @@ pub const FakeDevice = struct {
71
73
  self.opened_link = try self.allocator.dupe(u8, url);
72
74
  }
73
75
 
76
+ pub fn setLocation(self: *FakeDevice, latitude: f64, longitude: f64) !void {
77
+ self.location_sets += 1;
78
+ self.last_location = .{
79
+ .latitude = latitude,
80
+ .longitude = longitude,
81
+ };
82
+ }
83
+
74
84
  pub fn tap(self: *FakeDevice, x: i32, y: i32) !void {
75
85
  _ = x;
76
86
  _ = y;
@@ -127,6 +137,11 @@ pub const SwipeRecord = struct {
127
137
  duration_ms: u32,
128
138
  };
129
139
 
140
+ pub const LocationRecord = struct {
141
+ latitude: f64,
142
+ longitude: f64,
143
+ };
144
+
130
145
  pub fn cloneSnapshot(allocator: std.mem.Allocator, source: types.ObservationSnapshot) !types.ObservationSnapshot {
131
146
  var nodes = try allocator.alloc(types.UiNode, source.nodes.len);
132
147
  errdefer allocator.free(nodes);
package/src/ios.zig CHANGED
@@ -33,6 +33,7 @@ pub const IosDevice = struct {
33
33
  app_id: []const u8,
34
34
  shim_path: ?[]const u8 = null,
35
35
  target_kind: TargetKind = .simulator,
36
+ expo_dev_client_open_link_mode: bool = false,
36
37
 
37
38
  pub fn init(
38
39
  allocator: std.mem.Allocator,
@@ -131,13 +132,31 @@ pub const IosDevice = struct {
131
132
  const result = try self.runSimctl(&.{ "openurl", self.target(), url }, default_max_output);
132
133
  defer result.deinit(self.allocator);
133
134
  try result.ensureSuccess();
135
+ if (isExpoDevClientOpenLink(url)) {
136
+ self.expo_dev_client_open_link_mode = true;
137
+ }
134
138
  // Opening a URL on the simulator can raise a SpringBoard "Open in <App>?"
135
139
  // confirmation for universal links (http/https) and, just as often, for
136
140
  // custom schemes — the common Expo dev-client case
137
141
  // (exp+scheme://expo-development-client/...). Attempt a best-effort accept
138
142
  // whenever a shim is configured; the shim probes briefly and returns fast
139
143
  // when no dialog is present, so this stays cheap on the no-prompt path.
140
- self.acceptOpenURLConfirmationBestEffort();
144
+ self.acceptOpenURLConfirmationBestEffort(url);
145
+ }
146
+
147
+ pub fn setLocation(self: *IosDevice, latitude: f64, longitude: f64) !void {
148
+ if (self.target_kind == .physical) return error.UnsupportedDeviceCapability;
149
+
150
+ const coordinate = try std.fmt.allocPrint(self.allocator, "{d:.6},{d:.6}", .{ latitude, longitude });
151
+ defer self.allocator.free(coordinate);
152
+
153
+ const grant = try self.runSimctl(&.{ "privacy", self.target(), "grant", "location", self.app_id }, default_max_output);
154
+ defer grant.deinit(self.allocator);
155
+ try grant.ensureSuccess();
156
+
157
+ const result = try self.runSimctl(&.{ "location", self.target(), "set", coordinate }, default_max_output);
158
+ defer result.deinit(self.allocator);
159
+ try result.ensureSuccess();
141
160
  }
142
161
 
143
162
  pub fn tap(self: *IosDevice, x: i32, y: i32) !void {
@@ -189,6 +208,12 @@ pub const IosDevice = struct {
189
208
  try self.runShimAction(.{ .kind = .swipe, .x1 = x1, .y1 = y1, .x2 = x2, .y2 = y2, .duration_ms = duration_ms });
190
209
  }
191
210
 
211
+ pub fn scrollViewport(self: *IosDevice) !types.Viewport {
212
+ const response = try self.runShim(.{ .kind = .viewport });
213
+ defer self.allocator.free(response);
214
+ return try ios_shim.parseViewportResponse(response);
215
+ }
216
+
192
217
  pub fn pressBack(self: *IosDevice) !void {
193
218
  try self.runShimAction(.{ .kind = .press_back });
194
219
  }
@@ -322,20 +347,25 @@ pub const IosDevice = struct {
322
347
  try ios_shim.parseOkResponse(response);
323
348
  }
324
349
 
325
- fn acceptOpenURLConfirmationBestEffort(self: *IosDevice) void {
350
+ fn acceptOpenURLConfirmationBestEffort(self: *IosDevice, url: []const u8) void {
326
351
  if (self.shim_path == null) return;
327
352
  var attempt: usize = 0;
328
353
  while (attempt < open_link_interruption_attempts) {
329
354
  attempt += 1;
330
- if (self.acceptOpenURLConfirmationOnce() catch return) return;
355
+ if (self.acceptOpenURLConfirmationOnce(url) catch return) return;
331
356
  if (attempt < open_link_interruption_attempts) {
332
357
  stdio.sleepNs(open_link_interruption_retry_delay_ms * std.time.ns_per_ms);
333
358
  }
334
359
  }
335
360
  }
336
361
 
337
- fn acceptOpenURLConfirmationOnce(self: *IosDevice) !bool {
338
- const response = try self.runShimWithTimeout(.{ .kind = .accept_system_alert, .text = "Open" }, shim_best_effort_timeout_ms);
362
+ fn acceptOpenURLConfirmationOnce(self: *IosDevice, url: []const u8) !bool {
363
+ const response = try self.runShimWithTimeout(.{
364
+ .kind = .accept_system_alert,
365
+ .text = "Open",
366
+ .url = url,
367
+ .expo_dev_client_fallback = self.expo_dev_client_open_link_mode,
368
+ }, shim_best_effort_timeout_ms);
339
369
  defer self.allocator.free(response);
340
370
  return try ios_shim.parseAcceptSystemAlertResponse(response);
341
371
  }
@@ -460,6 +490,11 @@ fn parseShimTimeoutMs(raw: ?[]const u8) u64 {
460
490
  return parsed;
461
491
  }
462
492
 
493
+ fn isExpoDevClientOpenLink(url: []const u8) bool {
494
+ return std.mem.startsWith(u8, url, "exp+") and
495
+ std.mem.indexOf(u8, url, "://expo-development-client/") != null;
496
+ }
497
+
463
498
  test "ios simulator openLink keeps sweeping delayed XCTest interruptions until accepted" {
464
499
  const allocator = std.heap.page_allocator;
465
500
  const argv = [_][*:0]const u8{"zmr-ios-test"};
@@ -523,6 +558,70 @@ test "ios simulator openLink keeps sweeping delayed XCTest interruptions until a
523
558
  try std.testing.expectEqual(@as(u8, 6), count);
524
559
  }
525
560
 
561
+ test "ios simulator setLocation grants app location permission and sets coordinates" {
562
+ const allocator = std.heap.page_allocator;
563
+ const argv = [_][*:0]const u8{"zmr-ios-test"};
564
+ stdio.initProcess(.{
565
+ .args = .{ .vector = argv[0..] },
566
+ .environ = .empty,
567
+ }, allocator);
568
+ defer stdio.deinitProcess();
569
+
570
+ var tmp = std.testing.tmpDir(.{});
571
+ defer tmp.cleanup();
572
+
573
+ var xcrun = try tmp.dir.createFile(stdio.io(), "fake-xcrun-location.sh", .{ .truncate = true });
574
+ {
575
+ var buffer: [4096]u8 = undefined;
576
+ var writer = xcrun.writerStreaming(stdio.io(), &buffer);
577
+ const script = try std.fmt.allocPrint(allocator,
578
+ \\#!/usr/bin/env bash
579
+ \\set -euo pipefail
580
+ \\printf '%s\n' "$*" >> ".zig-cache/tmp/{s}/xcrun.log"
581
+ \\if [[ "${{1:-}}" != "simctl" ]]; then
582
+ \\ echo "expected simctl" >&2
583
+ \\ exit 2
584
+ \\fi
585
+ \\shift
586
+ \\case "${{1:-}}" in
587
+ \\ privacy)
588
+ \\ [[ "${{2:-}}" == "fake-ios-1" && "${{3:-}}" == "grant" && "${{4:-}}" == "location" && "${{5:-}}" == "com.example.mobiletest" ]] || exit 2
589
+ \\ ;;
590
+ \\ location)
591
+ \\ [[ "${{2:-}}" == "fake-ios-1" && "${{3:-}}" == "set" && "${{4:-}}" == "51.507400,-0.127800" ]] || exit 2
592
+ \\ ;;
593
+ \\ *)
594
+ \\ echo "unsupported simctl command: $*" >&2
595
+ \\ exit 2
596
+ \\ ;;
597
+ \\esac
598
+ \\
599
+ , .{tmp.sub_path});
600
+ defer allocator.free(script);
601
+ try writer.interface.writeAll(script);
602
+ try writer.interface.flush();
603
+ }
604
+ xcrun.close(stdio.io());
605
+
606
+ const xcrun_path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}/fake-xcrun-location.sh", .{tmp.sub_path});
607
+ defer allocator.free(xcrun_path);
608
+ const xcrun_path_z = try allocator.dupeZ(u8, xcrun_path);
609
+ defer allocator.free(xcrun_path_z);
610
+ if (std.c.chmod(xcrun_path_z, 0o755) != 0) return error.ChmodFailed;
611
+
612
+ var device = try IosDevice.initWithShim(allocator, xcrun_path, "fake-ios-1", "com.example.mobiletest", null);
613
+ defer device.deinit();
614
+
615
+ try device.setLocation(51.5074, -0.1278);
616
+
617
+ const log_path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}/xcrun.log", .{tmp.sub_path});
618
+ defer allocator.free(log_path);
619
+ const log = try stdio.readFileAlloc(allocator, log_path, 1024);
620
+ defer allocator.free(log);
621
+ try std.testing.expect(std.mem.indexOf(u8, log, "simctl privacy fake-ios-1 grant location com.example.mobiletest") != null);
622
+ try std.testing.expect(std.mem.indexOf(u8, log, "simctl location fake-ios-1 set 51.507400,-0.127800") != null);
623
+ }
624
+
526
625
  pub fn listDevices(allocator: std.mem.Allocator, xcrun_path: []const u8) ![]types.DeviceInfo {
527
626
  return try ios_devices.listSimulators(allocator, xcrun_path);
528
627
  }
package/src/ios_shim.zig CHANGED
@@ -5,6 +5,7 @@ const types = @import("types.zig");
5
5
 
6
6
  pub const CommandKind = enum {
7
7
  snapshot,
8
+ viewport,
8
9
  screenshot,
9
10
  tap,
10
11
  type_text,
@@ -22,6 +23,8 @@ pub const Command = struct {
22
23
  kind: CommandKind,
23
24
  selector: ?[]const u8 = null,
24
25
  text: ?[]const u8 = null,
26
+ url: ?[]const u8 = null,
27
+ expo_dev_client_fallback: bool = false,
25
28
  x: ?i32 = null,
26
29
  y: ?i32 = null,
27
30
  x1: ?i32 = null,
@@ -42,6 +45,19 @@ pub const SnapshotResponse = struct {
42
45
  }
43
46
  };
44
47
 
48
+ pub fn parseViewportResponse(content: []const u8) !types.Viewport {
49
+ var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
50
+ defer arena.deinit();
51
+ const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), content, .{});
52
+ if (parsed.value != .object) return error.IosShimResponseMustBeObject;
53
+ const status = fieldString(parsed.value.object, "status") orelse return error.IosShimMissingStatus;
54
+ if (!std.mem.eql(u8, status, "ok")) return error.IosShimResponseNotOk;
55
+ const viewport_value = parsed.value.object.get("viewport") orelse return error.IosShimMissingViewport;
56
+ const viewport = parseViewport(viewport_value);
57
+ if (viewport.width == 0 or viewport.height == 0) return error.IosShimInvalidViewport;
58
+ return viewport;
59
+ }
60
+
45
61
  pub fn writeCommandJson(writer: anytype, command: Command) !void {
46
62
  try writer.writeAll("{\"cmd\":");
47
63
  try trace.writeJsonString(writer, commandName(command.kind));
@@ -53,6 +69,13 @@ pub fn writeCommandJson(writer: anytype, command: Command) !void {
53
69
  try writer.writeAll(",\"text\":");
54
70
  try trace.writeJsonString(writer, text);
55
71
  }
72
+ if (command.url) |url| {
73
+ try writer.writeAll(",\"url\":");
74
+ try trace.writeJsonString(writer, url);
75
+ }
76
+ if (command.expo_dev_client_fallback) {
77
+ try writer.writeAll(",\"expoDevClientFallback\":true");
78
+ }
56
79
  if (command.x) |value| try writer.print(",\"x\":{d}", .{value});
57
80
  if (command.y) |value| try writer.print(",\"y\":{d}", .{value});
58
81
  if (command.x1) |value| try writer.print(",\"x1\":{d}", .{value});
@@ -270,6 +293,7 @@ pub fn selectorString(allocator: std.mem.Allocator, wanted: selectors.Selector)
270
293
  fn commandName(kind: CommandKind) []const u8 {
271
294
  return switch (kind) {
272
295
  .snapshot => "snapshot",
296
+ .viewport => "viewport",
273
297
  .screenshot => "screenshot",
274
298
  .tap => "tap",
275
299
  .type_text => "type",
@@ -31,6 +31,11 @@ pub fn requiredI32FromObject(object: std.json.ObjectMap, key: []const u8, missin
31
31
  return i32Value(value, type_error);
32
32
  }
33
33
 
34
+ pub fn requiredF64FromObject(object: std.json.ObjectMap, key: []const u8, missing_error: anyerror, type_error: anyerror) !f64 {
35
+ const value = object.get(key) orelse return missing_error;
36
+ return f64Value(value, type_error);
37
+ }
38
+
34
39
  pub fn optionalU64(params: ?std.json.Value, key: []const u8, default_value: u64, type_error: anyerror) !u64 {
35
40
  const value = field(params, key) orelse return default_value;
36
41
  return u64Value(value, type_error);
@@ -72,6 +77,14 @@ fn u64Value(value: std.json.Value, type_error: anyerror) !u64 {
72
77
  };
73
78
  }
74
79
 
80
+ fn f64Value(value: std.json.Value, type_error: anyerror) !f64 {
81
+ return switch (value) {
82
+ .float => |actual| actual,
83
+ .integer => |actual| @as(f64, @floatFromInt(actual)),
84
+ else => type_error,
85
+ };
86
+ }
87
+
75
88
  fn boolValue(value: std.json.Value, type_error: anyerror) !bool {
76
89
  return switch (value) {
77
90
  .bool => |actual| actual,
package/src/runner.zig CHANGED
@@ -8,6 +8,7 @@ const scenario = @import("scenario.zig");
8
8
  const selector = @import("selector.zig");
9
9
  const trace = @import("trace.zig");
10
10
  const types = @import("types.zig");
11
+ const fake_device = @import("fake_device.zig");
11
12
 
12
13
  pub const RunOptions = runner_config.RunOptions;
13
14
 
@@ -86,6 +87,14 @@ pub fn executeStep(
86
87
  if (writer) |tw| try runner_events.recordActionStatus(tw, "app.openLink", "ok", null, url);
87
88
  try settleDevice(device, options);
88
89
  },
90
+ .set_location => |location| {
91
+ device.setLocation(location.latitude, location.longitude) catch |err| {
92
+ if (writer) |tw| try runner_events.recordSetLocation(tw, "failed", err, location.latitude, location.longitude);
93
+ return err;
94
+ };
95
+ if (writer) |tw| try runner_events.recordSetLocation(tw, "ok", null, location.latitude, location.longitude);
96
+ try settleDevice(device, options);
97
+ },
89
98
  .tap => |wanted| try tapSelector(device, wanted, writer, options),
90
99
  .type_text => |input| {
91
100
  if (input.selector) |wanted| return try typeTextSelector(device, wanted, input.text, writer, options);
@@ -309,6 +318,46 @@ fn settleDevice(device: anytype, options: RunOptions) !void {
309
318
  try device.settle(options.settle_ms);
310
319
  }
311
320
 
321
+ test "setLocation dispatches through the device, records trace evidence, and settles" {
322
+ const allocator = std.testing.allocator;
323
+ const dir = "zig-cache-test-runner-set-location";
324
+ std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
325
+ defer std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
326
+
327
+ const script_json =
328
+ \\{
329
+ \\ "name": "set location",
330
+ \\ "steps": [
331
+ \\ {"action": "setLocation", "latitude": 51.5074, "longitude": -0.1278}
332
+ \\ ]
333
+ \\}
334
+ ;
335
+ const script = try scenario.parseSlice(allocator, script_json);
336
+ defer script.deinit(allocator);
337
+
338
+ var device = fake_device.FakeDevice.init(allocator, &.{});
339
+ defer device.deinit();
340
+ var tw = try trace.TraceWriter.init(allocator, dir);
341
+ defer tw.deinit();
342
+
343
+ try runScenario(allocator, &device, script, &tw, .{ .settle_ms = 25 });
344
+
345
+ try std.testing.expectEqual(@as(usize, 1), device.location_sets);
346
+ try std.testing.expectApproxEqAbs(@as(f64, 51.5074), device.last_location.?.latitude, 0.000001);
347
+ try std.testing.expectApproxEqAbs(@as(f64, -0.1278), device.last_location.?.longitude, 0.000001);
348
+ try std.testing.expectEqual(@as(usize, 1), device.settles);
349
+ try std.testing.expectEqual(@as(u64, 25), device.last_settle_timeout_ms);
350
+
351
+ const events_path = try std.fs.path.join(allocator, &.{ dir, "events.jsonl" });
352
+ defer allocator.free(events_path);
353
+ const events = try stdio.readFileAlloc(allocator, events_path, 1024 * 1024);
354
+ defer allocator.free(events);
355
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"kind\":\"device.setLocation\"") != null);
356
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"status\":\"ok\"") != null);
357
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"latitude\":51.5074") != null);
358
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"longitude\":-0.1278") != null);
359
+ }
360
+
312
361
  test "whenVisible skips the conditional block when the visibility probe command fails" {
313
362
  const allocator = std.testing.allocator;
314
363
  const dir = "zig-cache-test-runner-when-visible-command-failed";
@@ -336,6 +385,12 @@ test "whenVisible skips the conditional block when the visibility probe command
336
385
  _ = url;
337
386
  }
338
387
 
388
+ pub fn setLocation(self: *@This(), latitude: f64, longitude: f64) !void {
389
+ _ = self;
390
+ _ = latitude;
391
+ _ = longitude;
392
+ }
393
+
339
394
  pub fn tap(self: *@This(), x: i32, y: i32) !void {
340
395
  _ = self;
341
396
  _ = x;
@@ -26,6 +26,23 @@ pub fn recordNativeWait(tw: *trace.TraceWriter, kind: []const u8, wanted: select
26
26
  try tw.recordEvent(kind, writer.buffered());
27
27
  }
28
28
 
29
+ pub fn recordNativeScrollUntilVisible(
30
+ tw: *trace.TraceWriter,
31
+ wanted: selector.Selector,
32
+ direction: []const u8,
33
+ timeout_ms: u64,
34
+ ) !void {
35
+ var payload: std.Io.Writer.Allocating = .init(tw.allocator);
36
+ defer payload.deinit();
37
+ const writer = &payload.writer;
38
+ try writer.writeAll("{\"status\":\"ok\",\"strategy\":\"nativeSelector\",\"selector\":");
39
+ try trace.writeSelectorJson(writer, wanted);
40
+ try writer.writeAll(",\"direction\":");
41
+ try trace.writeJsonString(writer, direction);
42
+ try writer.print(",\"timeoutMs\":{d}}}", .{timeout_ms});
43
+ try tw.recordEvent("ui.scrollUntilVisible", writer.buffered());
44
+ }
45
+
29
46
  pub fn recordNativeWaitTimeout(tw: *trace.TraceWriter, kind: []const u8, selectors: []const selector.Selector, timeout_ms: u64) !void {
30
47
  var payload: std.Io.Writer.Allocating = .init(tw.allocator);
31
48
  defer payload.deinit();
@@ -120,6 +137,20 @@ pub fn recordActionStatus(tw: *trace.TraceWriter, kind: []const u8, status: []co
120
137
  try tw.recordEvent(kind, out.buffered());
121
138
  }
122
139
 
140
+ pub fn recordSetLocation(tw: *trace.TraceWriter, status: []const u8, err: ?anyerror, latitude: f64, longitude: f64) !void {
141
+ var payload: std.Io.Writer.Allocating = .init(tw.allocator);
142
+ defer payload.deinit();
143
+ const out = &payload.writer;
144
+ try out.writeAll("{\"status\":");
145
+ try trace.writeJsonString(out, status);
146
+ if (err) |actual| {
147
+ try out.writeAll(",\"error\":");
148
+ try trace.writeJsonString(out, @errorName(actual));
149
+ }
150
+ try out.print(",\"latitude\":{d:.6},\"longitude\":{d:.6}}}", .{ latitude, longitude });
151
+ try tw.recordEvent("device.setLocation", out.buffered());
152
+ }
153
+
123
154
  pub fn recordSwipe(tw: *trace.TraceWriter, x1: i32, y1: i32, x2: i32, y2: i32, duration_ms: u32) !void {
124
155
  const payload = try std.fmt.allocPrint(
125
156
  tw.allocator,