|
| 1 | +//! End-to-end tests that drive a real iOS Simulator through the built binary. |
| 2 | +//! |
| 3 | +//! Run with `zig build e2e-ios`. These are deliberately *not* part of |
| 4 | +//! `zig build test`: they need a booted simulator, which CI does not have. |
| 5 | +//! When no simulator is booted the suite reports SKIP and exits 0, so it can |
| 6 | +//! sit in a pipeline without becoming a flaky gate. |
| 7 | +//! |
| 8 | +//! By default it drives Settings (`com.apple.Preferences`), which ships on |
| 9 | +//! every simulator, so the suite is reproducible on any machine. Point it at |
| 10 | +//! your own app instead with: |
| 11 | +//! |
| 12 | +//! KURI_E2E_BUNDLE_ID=com.amiai.baiizy \ |
| 13 | +//! KURI_E2E_LABEL="Hello, world!" \ |
| 14 | +//! KURI_E2E_APP=/path/to/Build/Products/Debug-iphonesimulator/baiizy.app \ |
| 15 | +//! zig build e2e-ios |
| 16 | +//! |
| 17 | +//! Only non-input commands are exercised. `tap`/`swipe`/`gesture` post real |
| 18 | +//! CGEvents, which seize the host cursor and focus — unacceptable in a suite |
| 19 | +//! someone might run while working. |
| 20 | + |
| 21 | +const std = @import("std"); |
| 22 | +const io = @import("io"); |
| 23 | + |
| 24 | +var passed: usize = 0; |
| 25 | +var failed: usize = 0; |
| 26 | + |
| 27 | +const Result = struct { |
| 28 | + stdout: []u8, |
| 29 | + code: i32, |
| 30 | + elapsed_ms: i64, |
| 31 | + |
| 32 | + fn deinit(self: Result, gpa: std.mem.Allocator) void { |
| 33 | + gpa.free(self.stdout); |
| 34 | + } |
| 35 | +}; |
| 36 | + |
| 37 | +/// Invoke the built kuri-mobile with `args`, capturing output and timing. |
| 38 | +fn run(gpa: std.mem.Allocator, bin: []const u8, args: []const []const u8) !Result { |
| 39 | + var argv: std.ArrayList([]const u8) = .empty; |
| 40 | + defer argv.deinit(gpa); |
| 41 | + try argv.append(gpa, bin); |
| 42 | + try argv.appendSlice(gpa, args); |
| 43 | + |
| 44 | + const start = io.monotonicMs(); |
| 45 | + const r = try io.runCommand(gpa, argv.items, 32 * 1024 * 1024); |
| 46 | + return .{ |
| 47 | + .stdout = r.stdout, |
| 48 | + .code = (r.term >> 8) & 0xFF, |
| 49 | + .elapsed_ms = io.monotonicMs() - start, |
| 50 | + }; |
| 51 | +} |
| 52 | + |
| 53 | +fn report(arena: std.mem.Allocator, ok: bool, name: []const u8, detail: []const u8) void { |
| 54 | + if (ok) { |
| 55 | + passed += 1; |
| 56 | + io.printStdout(arena, " ok {s}\n", .{name}); |
| 57 | + } else { |
| 58 | + failed += 1; |
| 59 | + io.printStdout(arena, " FAIL {s}: {s}\n", .{ name, detail }); |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +fn expectCode(arena: std.mem.Allocator, name: []const u8, r: Result, want: i32) void { |
| 64 | + if (r.code == want) return report(arena, true, name, ""); |
| 65 | + const d = std.fmt.allocPrint(arena, "exit {d}, wanted {d}", .{ r.code, want }) catch "exit mismatch"; |
| 66 | + report(arena, false, name, d); |
| 67 | +} |
| 68 | + |
| 69 | +fn expectContains(arena: std.mem.Allocator, name: []const u8, haystack: []const u8, needle: []const u8) void { |
| 70 | + if (std.mem.indexOf(u8, haystack, needle) != null) return report(arena, true, name, ""); |
| 71 | + const d = std.fmt.allocPrint(arena, "output did not contain '{s}'", .{needle}) catch "missing substring"; |
| 72 | + report(arena, false, name, d); |
| 73 | +} |
| 74 | + |
| 75 | +fn getEnv(name: [*:0]const u8) ?[]const u8 { |
| 76 | + const v = std.c.getenv(name) orelse return null; |
| 77 | + const s = std.mem.span(v); |
| 78 | + return if (s.len == 0) null else s; |
| 79 | +} |
| 80 | + |
| 81 | +pub fn main(init: std.process.Init.Minimal) !void { |
| 82 | + var gpa_impl: std.heap.DebugAllocator(.{}) = .init; |
| 83 | + defer _ = gpa_impl.deinit(); |
| 84 | + const gpa = gpa_impl.allocator(); |
| 85 | + |
| 86 | + var arena_impl = std.heap.ArenaAllocator.init(gpa); |
| 87 | + defer arena_impl.deinit(); |
| 88 | + const arena = arena_impl.allocator(); |
| 89 | + |
| 90 | + const argv = try init.args.toSlice(arena); |
| 91 | + if (argv.len < 2) { |
| 92 | + io.writeStderr("usage: e2e-ios <path-to-kuri-mobile>\n"); |
| 93 | + std.process.exit(2); |
| 94 | + } |
| 95 | + const bin = argv[1]; |
| 96 | + |
| 97 | + const bundle_id = getEnv("KURI_E2E_BUNDLE_ID") orelse "com.apple.Preferences"; |
| 98 | + const label = getEnv("KURI_E2E_LABEL") orelse "General"; |
| 99 | + const app_path = getEnv("KURI_E2E_APP"); |
| 100 | + |
| 101 | + io.printStdout(arena, "e2e-ios against {s} (label: '{s}')\n\n", .{ bundle_id, label }); |
| 102 | + |
| 103 | + // --- Group 1: no device required --------------------------------------- |
| 104 | + io.writeStdout("registry (no device needed)\n"); |
| 105 | + inline for (.{ "ios", "android" }) |platform| { |
| 106 | + const r = try run(gpa, bin, &.{ platform, "tools", "--json" }); |
| 107 | + defer r.deinit(gpa); |
| 108 | + expectCode(arena, platform ++ " tools --json exits 0", r, 0); |
| 109 | + |
| 110 | + // Must be valid JSON, and every entry must carry the fields a caller |
| 111 | + // needs to build an invocation. |
| 112 | + if (std.json.parseFromSlice(std.json.Value, gpa, r.stdout, .{})) |parsed| { |
| 113 | + defer parsed.deinit(); |
| 114 | + const tools = parsed.value.object.get("tools").?.array; |
| 115 | + var complete = tools.items.len > 0; |
| 116 | + for (tools.items) |t| { |
| 117 | + if (t.object.get("name") == null or |
| 118 | + t.object.get("scope") == null or |
| 119 | + t.object.get("summary") == null) complete = false; |
| 120 | + } |
| 121 | + report(arena, complete, platform ++ " tools --json entries are complete", "missing name/scope/summary"); |
| 122 | + } else |_| { |
| 123 | + report(arena, false, platform ++ " tools --json parses", "invalid JSON"); |
| 124 | + } |
| 125 | + } |
| 126 | + |
| 127 | + { |
| 128 | + const r = try run(gpa, bin, &.{"doctor"}); |
| 129 | + defer r.deinit(gpa); |
| 130 | + // 0 = healthy, 3 = blocking problems found; both mean doctor ran. |
| 131 | + const ok = r.code == 0 or r.code == 3; |
| 132 | + report(arena, ok, "doctor runs and reports", "unexpected exit"); |
| 133 | + expectContains(arena, "doctor covers iOS and Android", r.stdout, "adb server"); |
| 134 | + } |
| 135 | + |
| 136 | + // --- Is a simulator available? ----------------------------------------- |
| 137 | + const devices = try run(gpa, bin, &.{ "ios", "list-devices" }); |
| 138 | + defer devices.deinit(gpa); |
| 139 | + if (std.mem.indexOf(u8, devices.stdout, "Booted") == null) { |
| 140 | + io.writeStdout("\nSKIP: no booted simulator; device-backed cases not run.\n"); |
| 141 | + io.printStdout(arena, "\n{d} passed, {d} failed\n", .{ passed, failed }); |
| 142 | + std.process.exit(if (failed == 0) 0 else 1); |
| 143 | + } |
| 144 | + |
| 145 | + io.writeStdout("\ndevice-backed\n"); |
| 146 | + |
| 147 | + // --- Group 2: install + launch ------------------------------------------ |
| 148 | + if (app_path) |p| { |
| 149 | + const r = try run(gpa, bin, &.{ "ios", "install", p }); |
| 150 | + defer r.deinit(gpa); |
| 151 | + expectCode(arena, "install the app bundle", r, 0); |
| 152 | + } |
| 153 | + { |
| 154 | + const r = try run(gpa, bin, &.{ "ios", "launch", bundle_id }); |
| 155 | + defer r.deinit(gpa); |
| 156 | + expectCode(arena, "launch by bundle id", r, 0); |
| 157 | + } |
| 158 | + |
| 159 | + // --- Group 3: observation ----------------------------------------------- |
| 160 | + { |
| 161 | + // The app needs a moment to render before its tree is populated; this |
| 162 | + // is exactly what wait-for-ui exists for, so use it as the barrier. |
| 163 | + const r = try run(gpa, bin, &.{ "ios", "wait-for-ui", "--label", label, "--timeout", "20000" }); |
| 164 | + defer r.deinit(gpa); |
| 165 | + expectCode(arena, "wait-for-ui finds a present element", r, 0); |
| 166 | + } |
| 167 | + { |
| 168 | + const r = try run(gpa, bin, &.{ "ios", "uitree" }); |
| 169 | + defer r.deinit(gpa); |
| 170 | + expectCode(arena, "uitree exits 0", r, 0); |
| 171 | + expectContains(arena, "uitree contains the expected label", r.stdout, label); |
| 172 | + expectContains(arena, "uitree reports device-pixel bounds", r.stdout, "@"); |
| 173 | + } |
| 174 | + { |
| 175 | + const r = try run(gpa, bin, &.{ "ios", "find", "--label", label }); |
| 176 | + defer r.deinit(gpa); |
| 177 | + expectCode(arena, "find locates a present element", r, 0); |
| 178 | + expectContains(arena, "find emits a tap-ready centroid", r.stdout, "tap="); |
| 179 | + } |
| 180 | + { |
| 181 | + const r = try run(gpa, bin, &.{ "ios", "find", "--label", "kuriE2ENoSuchElement" }); |
| 182 | + defer r.deinit(gpa); |
| 183 | + // Non-zero on no match is what lets `find` be used as an assertion. |
| 184 | + expectCode(arena, "find exits 4 when nothing matches", r, 4); |
| 185 | + } |
| 186 | + |
| 187 | + // --- Group 4: wait-for-ui semantics + timeout regression ---------------- |
| 188 | + { |
| 189 | + const r = try run(gpa, bin, &.{ "ios", "wait-for-ui", "--label", "kuriE2ENoSuchElement", "--absent", "--timeout", "5000" }); |
| 190 | + defer r.deinit(gpa); |
| 191 | + expectCode(arena, "wait-for-ui --absent passes for a missing element", r, 0); |
| 192 | + } |
| 193 | + { |
| 194 | + // Regression guard for the 0.4.7 bug: the deadline used to sum sleep |
| 195 | + // intervals while ignoring each poll's cost, so a 3s timeout waited |
| 196 | + // ~13.5s. Allow headroom for one final in-flight poll, but fail if we |
| 197 | + // drift back toward a multiple of the request. |
| 198 | + const want_ms: i64 = 3000; |
| 199 | + const r = try run(gpa, bin, &.{ "ios", "wait-for-ui", "--label", "kuriE2ENoSuchElement", "--timeout", "3000" }); |
| 200 | + defer r.deinit(gpa); |
| 201 | + expectCode(arena, "wait-for-ui times out with exit 4", r, 4); |
| 202 | + |
| 203 | + const ok = r.elapsed_ms >= want_ms and r.elapsed_ms < want_ms * 3; |
| 204 | + const d = std.fmt.allocPrint(arena, "took {d}ms for a {d}ms timeout", .{ r.elapsed_ms, want_ms }) catch "bad timing"; |
| 205 | + report(arena, ok, "wait-for-ui honours its wall-clock deadline", d); |
| 206 | + } |
| 207 | + |
| 208 | + // --- Group 5: screenshot ------------------------------------------------- |
| 209 | + { |
| 210 | + const path = "/tmp/kuri-e2e-shot.png"; |
| 211 | + const r = try run(gpa, bin, &.{ "ios", "screenshot", path }); |
| 212 | + defer r.deinit(gpa); |
| 213 | + expectCode(arena, "screenshot exits 0", r, 0); |
| 214 | + |
| 215 | + // Verify it is a real PNG, not an empty or truncated file. |
| 216 | + if (std.c.fopen(path, "rb")) |fh| { |
| 217 | + defer _ = std.c.fclose(fh); |
| 218 | + var magic: [8]u8 = undefined; |
| 219 | + const n = std.c.fread(&magic, 1, 8, fh); |
| 220 | + const ok = n == 8 and std.mem.eql(u8, magic[0..8], "\x89PNG\r\n\x1a\n"); |
| 221 | + report(arena, ok, "screenshot writes a valid PNG", "bad PNG magic"); |
| 222 | + } else { |
| 223 | + report(arena, false, "screenshot writes a valid PNG", "file not created"); |
| 224 | + } |
| 225 | + } |
| 226 | + |
| 227 | + io.printStdout(arena, "\n{d} passed, {d} failed\n", .{ passed, failed }); |
| 228 | + std.process.exit(if (failed == 0) 0 else 1); |
| 229 | +} |
0 commit comments