Skip to content

Commit 8cf79dd

Browse files
authored
Merge pull request #188 from justrach/release/0.4.9
release: v0.4.9 — end-to-end suite against a real simulator, shell-escape tests
2 parents 50eb70a + 720e291 commit 8cf79dd

11 files changed

Lines changed: 333 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to kuri are documented here.
44

5+
## [0.4.9] — 2026-07-25
6+
7+
### Tests
8+
- **`zig build e2e-ios`** — an end-to-end suite that drives a real booted Simulator through the built binary, so it exercises the artifact that would actually ship rather than a test-only code path. 20 cases covering the registry, `doctor`, `install`, `launch`, `uitree`, `find`, `wait-for-ui` and `screenshot`
9+
- Deliberately excluded from `zig build test`, which must stay runnable without a device. With no simulator booted the suite prints SKIP and exits 0, so it can sit in a pipeline without becoming a flaky gate
10+
- Defaults to Settings (`com.apple.Preferences`), present on every simulator, so it is reproducible on any machine. Point it at your own app with `KURI_E2E_BUNDLE_ID`, `KURI_E2E_LABEL` and `KURI_E2E_APP`
11+
- **Timeout regression guard** — asserts `wait-for-ui` returns within 3x its requested deadline, locking in the 0.4.7 fix where a 3s timeout waited 13.5s
12+
- Assertions are behavioural, not just exit codes: `find` must emit a `tap=` centroid, `uitree` must contain the expected label and device-pixel bounds, and `screenshot` must produce a file with valid PNG magic rather than an empty one
13+
- **Shell-escaping tests** — the Android quoting added in 0.4.8 had no coverage. Now tested against command-injection payloads (`x; rm -rf /`, `$(whoami)`, backticks), embedded single quotes, and an exhaustive sweep asserting no byte in 1..126 can leave an unbalanced quote
14+
15+
### Verified
16+
- `ios install` had never been exercised until now; it installs a real `.app` bundle in ~2.9s and the app launches and reports its accessibility tree correctly
17+
518
## [0.4.8] — 2026-07-25
619

720
### Features — kuri-mobile Android reaches parity

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .kuri,
3-
.version = "0.4.8",
3+
.version = "0.4.9",
44
.dependencies = .{
55
.quickjs = .{
66
.url = "https://github.com/mitchellh/zig-quickjs-ng/archive/main.tar.gz",

kuri-mobile/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,26 @@ trees on real devices, you have to either:
193193
| iOS real device launch | shell out to `xcrun devicectl` |
194194
| Android `screencap`/`uiautomator dump` etc | server-side commands the device's own shell runs; we just frame them over adb in Zig |
195195

196+
## End-to-end tests
197+
198+
```sh
199+
zig build e2e-ios # drives a real booted Simulator
200+
```
201+
202+
Runs against Settings by default, so it works on any machine. With no
203+
simulator booted it prints SKIP and exits 0 rather than failing, which is why
204+
it is kept out of `zig build test`. Point it at your own app with:
205+
206+
```sh
207+
KURI_E2E_BUNDLE_ID=com.example.app \
208+
KURI_E2E_LABEL="Hello, world!" \
209+
KURI_E2E_APP=~/Library/Developer/Xcode/DerivedData/…/Debug-iphonesimulator/App.app \
210+
zig build e2e-ios
211+
```
212+
213+
Only non-input commands are exercised — `tap`/`swipe`/`gesture` post real
214+
CGEvents and would seize the cursor of whoever is running the suite.
215+
196216
## Tests
197217

198218
```sh

kuri-mobile/build.zig

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,28 @@ pub fn build(b: *std.Build) void {
4949
const unit_tests = b.addTest(.{ .root_module = test_mod });
5050
const test_step = b.step("test", "Run unit tests");
5151
test_step.dependOn(&b.addRunArtifact(unit_tests).step);
52+
53+
// End-to-end suite. Kept off `test` on purpose: it drives a real booted
54+
// simulator, which CI does not have. It receives the built binary's path
55+
// as argv[1] so it exercises exactly the artifact that would ship.
56+
const e2e_mod = b.createModule(.{
57+
.root_source_file = b.path("src/test/e2e_ios.zig"),
58+
.target = target,
59+
.optimize = optimize,
60+
.link_libc = true,
61+
});
62+
// 0.17 restricts imports to a module's own path, so the shared io helpers
63+
// come in as a named module rather than a relative path.
64+
e2e_mod.addImport("io", b.createModule(.{
65+
.root_source_file = b.path("src/common/io.zig"),
66+
.target = target,
67+
.optimize = optimize,
68+
.link_libc = true,
69+
}));
70+
const e2e = b.addExecutable(.{ .name = "e2e-ios", .root_module = e2e_mod });
71+
const run_e2e = b.addRunArtifact(e2e);
72+
run_e2e.addArtifactArg(exe);
73+
run_e2e.addPassthruArgs();
74+
const e2e_step = b.step("e2e-ios", "Run iOS end-to-end tests (needs a booted simulator)");
75+
e2e_step.dependOn(&run_e2e.step);
5276
}

kuri-mobile/build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .kuri_mobile,
3-
.version = "0.4.8",
3+
.version = "0.4.9",
44
.dependencies = .{},
55
.fingerprint = 0x41cfa5929f72d020,
66
.minimum_zig_version = "0.17.0-dev.813+2153f8143",

kuri-mobile/src/android/driver.zig

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,47 @@ fn escapeForInputText(gpa: std.mem.Allocator, src: []const u8) ![]u8 {
295295
return try out.toOwnedSlice(gpa);
296296
}
297297

298+
test "quote wraps values so shell metacharacters cannot escape" {
299+
const gpa = std.testing.allocator;
300+
301+
const plain = try Driver.quote(gpa, "com.example.app");
302+
defer gpa.free(plain);
303+
try std.testing.expectEqualStrings("'com.example.app'", plain);
304+
305+
// The injection this exists to stop: a package name that closes the quote
306+
// and appends its own command must stay inert inside one quoted word.
307+
const evil = try Driver.quote(gpa, "x; rm -rf /");
308+
defer gpa.free(evil);
309+
try std.testing.expectEqualStrings("'x; rm -rf /'", evil);
310+
311+
// A literal single quote is the only character that can terminate the
312+
// word, so it must be closed, escaped and reopened.
313+
const quoted = try Driver.quote(gpa, "it's");
314+
defer gpa.free(quoted);
315+
try std.testing.expectEqualStrings("'it'\\''s'", quoted);
316+
317+
// Backticks and $() are harmless once single-quoted — no expansion occurs.
318+
const subst = try Driver.quote(gpa, "$(whoami)`id`");
319+
defer gpa.free(subst);
320+
try std.testing.expectEqualStrings("'$(whoami)`id`'", subst);
321+
}
322+
323+
test "quote leaves no unbalanced quote for any byte" {
324+
const gpa = std.testing.allocator;
325+
var b: u8 = 1;
326+
while (b < 127) : (b += 1) {
327+
const s = [_]u8{b};
328+
const q = try Driver.quote(gpa, &s);
329+
defer gpa.free(q);
330+
// Must open and close, and every interior ' must be part of the
331+
// '\'' escape sequence rather than terminating the word early.
332+
try std.testing.expect(q.len >= 2);
333+
try std.testing.expectEqual(@as(u8, '\''), q[0]);
334+
try std.testing.expectEqual(@as(u8, '\''), q[q.len - 1]);
335+
if (b == '\'') try std.testing.expectEqualStrings("''\\'''", q);
336+
}
337+
}
338+
298339
test "mapButton known and unknown" {
299340
try std.testing.expectEqualStrings("KEYCODE_HOME", mapButton("home").?);
300341
try std.testing.expect(mapButton("nope") == null);

kuri-mobile/src/main.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ pub fn main(init: std.process.Init.Minimal) !void {
5353
return;
5454
}
5555
if (std.mem.eql(u8, sub, "--version")) {
56-
try writeStdout("kuri-mobile 0.4.8\n");
56+
try writeStdout("kuri-mobile 0.4.9\n");
5757
return;
5858
}
5959

kuri-mobile/src/test/e2e_ios.zig

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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+
}

src/browse_main.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const markdown = @import("crawler/markdown.zig");
44
const validator = @import("crawler/validator.zig");
55
const http_fetch = @import("util/http_fetch.zig");
66

7-
const version = "0.4.8";
7+
const version = "0.4.9";
88
const user_agent = "kuri-browse/" ++ version;
99

1010
pub fn main(init: std.process.Init.Minimal) !void {

src/fetch_main.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const markdown = @import("crawler/markdown.zig");
55
const js_engine = @import("js_engine.zig");
66
const http_fetch = @import("util/http_fetch.zig");
77

8-
const version = "0.4.8";
8+
const version = "0.4.9";
99

1010
pub fn main(init: std.process.Init.Minimal) !void {
1111
var gpa_impl: std.heap.DebugAllocator(.{}) = .init;

0 commit comments

Comments
 (0)