ReviewOS

also looking at this

craft-native/craft

test(android): the ratchet, and the two seams JNI leaves unchecked

#142
Merged chris wants to merge test/android-conformance-ratchet into main
2 files +360 -0
packages/zig/build.zigmodified+27-0
Changes to packages/zig/build.zig
@@ -411,6 +411,31 @@ pub fn build(b: *std.Build) void {
411411 // pointers, so the tests build one out of Zig functions and run anywhere —
412412 // no JVM, no emulator, no NDK. That is the whole reason the layer is shaped
413413 // the way it is.
414 // The Android action ratchet, and the JNI binding contract. Separate from
415 // the iOS gate for the obvious reason and from the surface gate for a
416 // subtler one: this is about what Zig owes Kotlin, not about what either
417 // owes the page.
418 const android_conformance_tests = b.addTest(.{
419 .root_module = b.createModule(.{
420 .root_source_file = b.path("test/android_conformance_test.zig"),
421 .target = target,
422 .optimize = optimize,
423 }),
424 });
425 android_conformance_tests.root_module.addAnonymousImport("CraftBridge.kt", .{
426 .root_source_file = b.path("../android/templates/CraftBridge.kt.template"),
427 });
428 android_conformance_tests.root_module.addAnonymousImport("CraftNative.kt", .{
429 .root_source_file = b.path("../android/templates/CraftNative.kt.template"),
430 });
431 android_conformance_tests.root_module.addAnonymousImport("src/android_dispatch.zig", .{
432 .root_source_file = b.path("src/android_dispatch.zig"),
433 });
434 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_device.zig", .{
435 .root_source_file = b.path("src/bridge_android_device.zig"),
436 });
437 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
438
414439 // The page surface, across both bridges. Not folded into the iOS
415440 // conformance gate: it embeds three files and is about what `window.craft`
416441 // offers rather than about iOS, and the iOS gate is already the largest
@@ -1597,6 +1622,7 @@ pub fn build(b: *std.Build) void {
15971622 test_step.dependOn(&run_ios_conformance_tests.step);
15981623 test_step.dependOn(&run_jni_tests.step);
15991624 test_step.dependOn(&run_bridge_surface_tests.step);
1625 test_step.dependOn(&run_android_conformance_tests.step);
16001626 test_step.dependOn(&run_android_bridge_tests.step);
16011627 test_step.dependOn(&run_menubar_tests.step);
16021628 test_step.dependOn(&run_components_tests.step);
@@ -2325,6 +2351,7 @@ pub fn build(b: *std.Build) void {
23252351 // so a run that skipped it would not be an Android test run.
23262352 test_android_step.dependOn(&run_jni_tests.step);
23272353 test_android_step.dependOn(&run_bridge_surface_tests.step);
2354 test_android_step.dependOn(&run_android_conformance_tests.step);
23282355 test_android_step.dependOn(&run_android_bridge_tests.step);
23292356
23302357 // Add Android tests to the main test step
packages/zig/test/android_conformance_test.zigadded+333-0
Changes to packages/zig/test/android_conformance_test.zig
@@ -0,0 +1,333 @@
1//! What Android owes the page, and who currently owes it.
2//!
3//! The counterpart to `ios_conformance_test.zig`, and it starts where that one
4//! started: `CraftBridge.kt` is the specification, its `@JavascriptInterface`
5//! methods are the list of actions a craft Android app answers today, and Zig
6//! has to end up answering all of them. This is what stops one being dropped
7//! on the way across.
8//!
9//! iOS reached this file's job with 105 actions unmigrated and is at 13.
10//! Android is at 102 of 103. The ratchet exists now rather than later for the
11//! reason the iOS one earned: the second migration is where a name quietly
12//! stops matching, and by then nothing remembers what the first one agreed to.
13//!
14//! ## Two seams, not one
15//!
16//! iOS has a single contract to check — an action string that must appear on
17//! both sides. Android has two, because the call arrives through JNI:
18//!
19//! 1. **the action**, `@JavascriptInterface fun getDeviceInfo()` against the
20//! `A` block of whichever Zig module serves it;
21//! 2. **the binding**, the `name` in `android_dispatch.natives` against the
22//! `external fun` that `CraftNative.kt` declares.
23//!
24//! The second has no compiler behind it at all. `RegisterNatives` matches by
25//! string at load, so renaming one side and not the other fails at
26//! `System.loadLibrary` — in the app, on a device, with a message that names
27//! the method and nothing about where the other half went.
28
29const std = ;
30const testing = std.testing;
31
32/// Embedded rather than read from disk, so the test is hermetic and reads
33/// exactly the bytes the build saw.
34const android_spec = @embedFile("CraftBridge.kt");
35const native_holder = @embedFile("CraftNative.kt");
36const dispatch_source = @embedFile("src/android_dispatch.zig");
37
38/// Every Zig module serving part of the Android bridge. A module migrating
39/// actions adds itself here — and the ratchet is what forces that, because
40/// migrated actions the scan cannot see read as "still owed".
41const zig_sources = [_][]const u8{
42 @embedFile("src/bridge_android_device.zig"),
43};
44
45/// How many spec actions Zig does not serve yet.
46///
47/// A ratchet: the number may only go down, and lowering it is what a migration
48/// costs. If it ever needs raising, something has left Zig without leaving the
49/// Kotlin, and that is the conversation this constant exists to force.
50///
51/// History: 103 at the seam; 102 with getDeviceInfo.
52const max_not_yet_migrated: usize = 102;
53
54/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
55///
56/// Anchored on the annotation, not on `fun`, because `CraftBridge.kt` has
57/// plenty of private helpers that are not page-callable — `sendEvent`,
58/// `taskReply`, the permission plumbing. Only an annotated method is part of
59/// the contract, which is the same reason the iOS scan is bounded to the
60/// dispatcher rather than the whole file.
61fn collectSpecActions(allocator: std.mem.Allocator) !std.StringHashMap(void) {
62 var set = std.StringHashMap(void).init(allocator);
63 errdefer set.deinit();
64
65 const marker = "@JavascriptInterface";
66 var search: usize = 0;
67 while (std.mem.indexOfPos(u8, android_spec, search, marker)) |at| {
68 search = at + marker.len;
69
70 const fun_at = std.mem.indexOfPos(u8, android_spec, search, "fun ") orelse break;
71 // Only the declaration that immediately follows: an annotation whose
72 // next `fun` is fifty lines away means the scan has lost its place.
73 if (fun_at - search > 200) continue;
74
75 const name_start = fun_at + "fun ".len;
76 var name_end = name_start;
77 while (name_end < android_spec.len and
78 (std.ascii.isAlphanumeric(android_spec[name_end]) or android_spec[name_end] == '_')) : (name_end += 1)
79 {}
80 if (name_end == name_start) continue;
81 try set.put(android_spec[name_start..name_end], {});
82 search = name_end;
83 }
84 return set;
85}
86
87/// Every action name declared in the `A` blocks of the Android modules.
88fn collectZigActions(allocator: std.mem.Allocator) !std.StringHashMap(void) {
89 var set = std.StringHashMap(void).init(allocator);
90 errdefer set.deinit();
91
92 for (zig_sources) |source| {
93 const block_start = std.mem.indexOf(u8, source, "pub const A = struct {") orelse continue;
94 var it = std.mem.splitScalar(u8, source[block_start..], '\n');
95 _ = it.next();
96 while (it.next()) |line| {
97 const trimmed = std.mem.trim(u8, line, " \t\r");
98 if (std.mem.eql(u8, trimmed, "};")) break;
99 const open = std.mem.indexOfScalar(u8, trimmed, '"') orelse continue;
100 const close = std.mem.indexOfScalarPos(u8, trimmed, open + 1, '"') orelse continue;
101
102 const name = trimmed[open + 1 .. close];
103 if (set.contains(name)) {
104 std.debug.print(
105 "action '{s}' is declared by more than one Android module.\n" ++
106 " Dispatch order would silently pick one.\n",
107 .{name},
108 );
109 return error.ActionDeclaredTwice;
110 }
111 try set.put(name, {});
112 }
113 }
114 return set;
115}
116
117test "the spec scan finds the bridge, and finds methods in it" {
118 // Non-vacuity, first. Every assertion below is a membership check, and a
119 // scan that silently matched nothing would satisfy all of them.
120 var spec = try collectSpecActions(testing.allocator);
121 defer spec.deinit();
122 try testing.expect(spec.count() >= 100);
123
124 // And it found real ones rather than fragments.
125 try testing.expect(spec.contains("getDeviceInfo"));
126 try testing.expect(spec.contains("clipboardRead"));
127 try testing.expect(spec.contains("vibrate"));
128}
129
130test "every action Zig declares is one the Kotlin bridge actually has" {
131 // The typo guard. `getDeviceinfo` for `getDeviceInfo` compiles on both
132 // sides and is simply never called — the page keeps reaching the Kotlin,
133 // and the Zig implementation sits there looking migrated.
134 var spec = try collectSpecActions(testing.allocator);
135 defer spec.deinit();
136
137 var zig = try collectZigActions(testing.allocator);
138 defer zig.deinit();
139 try testing.expect(zig.count() >= 1);
140
141 var it = zig.keyIterator();
142 while (it.next()) |name| {
143 if (!spec.contains(name.*)) {
144 std.debug.print(
145 "an Android module declares '{s}', which CraftBridge.kt does not expose.\n" ++
146 " Either it is misspelled, or it is a surface no page can reach.\n",
147 .{name.*},
148 );
149 return error.ZigServesAnActionTheSpecDoesNotHave;
150 }
151 }
152}
153
154test "nothing has been dropped on the way from Kotlin to Zig" {
155 // The migration guard. Without it, moving an action across is
156 // indistinguishable from losing it: both leave a page calling something
157 // nothing answers.
158 var spec = try collectSpecActions(testing.allocator);
159 defer spec.deinit();
160
161 var zig = try collectZigActions(testing.allocator);
162 defer zig.deinit();
163
164 var not_yet: usize = 0;
165 var it = spec.keyIterator();
166 while (it.next()) |name| {
167 if (!zig.contains(name.*)) not_yet += 1;
168 }
169
170 if (not_yet > max_not_yet_migrated) {
171 std.debug.print(
172 "{d} spec actions are not served by Zig, but the ratchet allows {d}.\n" ++
173 " An action has left Zig without leaving the Kotlin.\n",
174 .{ not_yet, max_not_yet_migrated },
175 );
176 return error.MigrationWentBackwards;
177 }
178
179 if (not_yet < max_not_yet_migrated) {
180 std.debug.print(
181 "note: {d} spec actions remain unmigrated; max_not_yet_migrated is {d} and can be lowered.\n",
182 .{ not_yet, max_not_yet_migrated },
183 );
184 }
185}
186
187// ---------------------------------------------------------------------------
188// The binding
189//
190// `RegisterNatives` matches by string at load time. Nothing checks that the
191// name Zig registers is the name Kotlin declared — not the Zig compiler, which
192// sees a string literal, and not the Kotlin compiler, which sees an `external
193// fun` it assumes someone will bind. The two agree until one is renamed, and
194// then `System.loadLibrary` fails on a device with a message that names the
195// method and says nothing about where its other half went.
196//
197// So the two lists are read out of the two files and compared here, on a host,
198// at build time.
199// ---------------------------------------------------------------------------
200
201/// The `name` field of every entry in `android_dispatch.natives`.
202fn collectRegisteredNatives(allocator: std.mem.Allocator) !std.StringHashMap(void) {
203 var set = std.StringHashMap(void).init(allocator);
204 errdefer set.deinit();
205
206 const table_start = std.mem.indexOf(u8, dispatch_source, "const natives = [_]jni.JNINativeMethod{") orelse
207 return error.NativesTableNotFound;
208 const table_end = std.mem.indexOfPos(u8, dispatch_source, table_start, "\n};") orelse
209 return error.NativesTableNotFound;
210 const table = dispatch_source[table_start..table_end];
211
212 const needle = ".name = \"";
213 var search: usize = 0;
214 while (std.mem.indexOfPos(u8, table, search, needle)) |at| {
215 const start = at + needle.len;
216 const end = std.mem.indexOfScalarPos(u8, table, start, '"') orelse break;
217 try set.put(table[start..end], {});
218 search = end;
219 }
220 return set;
221}
222
223/// Every `external fun <name>(` in the Kotlin holder.
224fn collectDeclaredNatives(allocator: std.mem.Allocator) !std.StringHashMap(void) {
225 var set = std.StringHashMap(void).init(allocator);
226 errdefer set.deinit();
227
228 const needle = "external fun ";
229 var search: usize = 0;
230 while (std.mem.indexOfPos(u8, native_holder, search, needle)) |at| {
231 const start = at + needle.len;
232 var end = start;
233 while (end < native_holder.len and
234 (std.ascii.isAlphanumeric(native_holder[end]) or native_holder[end] == '_')) : (end += 1)
235 {}
236 search = end;
237 if (end > start) try set.put(native_holder[start..end], {});
238 }
239 return set;
240}
241
242test "every native Zig registers is one the Kotlin holder declares" {
243 // This direction fails the library load: RegisterNatives is given a name
244 // the class does not have, refuses the whole batch, and JNI_OnLoad logs
245 // that every action stayed on the shim.
246 var registered = try collectRegisteredNatives(testing.allocator);
247 defer registered.deinit();
248 var declared = try collectDeclaredNatives(testing.allocator);
249 defer declared.deinit();
250
251 // Non-vacuity: two scans, either of which could stop matching and leave
252 // this comparing empty sets.
253 try testing.expect(registered.count() >= 1);
254 try testing.expect(declared.count() >= 1);
255
256 var it = registered.keyIterator();
257 while (it.next()) |name| {
258 if (!declared.contains(name.*)) {
259 std.debug.print(
260 "android_dispatch registers '{s}', which CraftNative.kt does not declare.\n" ++
261 " RegisterNatives refuses the batch, and every action stays on the shim.\n",
262 .{name.*},
263 );
264 return error.RegisteredNativeNotDeclared;
265 }
266 }
267}
268
269test "every native the Kotlin holder declares is one Zig registers" {
270 // The quieter direction, and the worse one. An `external fun` nobody binds
271 // loads fine and throws UnsatisfiedLinkError at the call — so it survives
272 // the app starting, and surfaces when a page first reaches the feature.
273 var registered = try collectRegisteredNatives(testing.allocator);
274 defer registered.deinit();
275 var declared = try collectDeclaredNatives(testing.allocator);
276 defer declared.deinit();
277
278 var it = declared.keyIterator();
279 while (it.next()) |name| {
280 if (!registered.contains(name.*)) {
281 std.debug.print(
282 "CraftNative.kt declares '{s}', which android_dispatch never registers.\n" ++
283 " It loads, and throws UnsatisfiedLinkError the first time a page calls it.\n",
284 .{name.*},
285 );
286 return error.DeclaredNativeNotRegistered;
287 }
288 }
289}
290
291test "the holder's package is the one JNI_OnLoad looks for" {
292 // The third string with no compiler behind it. `FindClass` takes a binary
293 // name with slashes; the Kotlin declares a package with dots. They are
294 // written in two files, in two languages, and a prebuilt library that
295 // cannot find its holder binds nothing at all.
296 const decl = "package ";
297 const at = std.mem.indexOf(u8, native_holder, decl) orelse return error.NoPackageDeclaration;
298 const start = at + decl.len;
299 var end = start;
300 while (end < native_holder.len and native_holder[end] != '\n' and native_holder[end] != '\r') : (end += 1) {}
301 const package = std.mem.trim(u8, native_holder[start..end], " \t\r");
302
303 const holder_at = std.mem.indexOf(u8, dispatch_source, "pub const holder_class = \"") orelse
304 return error.NoHolderClassConstant;
305 const h_start = holder_at + "pub const holder_class = \"".len;
306 const h_end = std.mem.indexOfScalarPos(u8, dispatch_source, h_start, '"') orelse
307 return error.NoHolderClassConstant;
308 const holder = dispatch_source[h_start..h_end];
309
310 // `com.craft.runtime` + `CraftNative` must spell `com/craft/runtime/CraftNative`.
311 var buf: [128]u8 = undefined;
312 var written: usize = 0;
313 for (package) |c| {
314 buf[written] = if (c == '.') '/' else c;
315 written += 1;
316 }
317 const expected = try std.fmt.bufPrint(buf[written..], "/CraftNative", .{});
318 const full = buf[0 .. written + expected.len];
319
320 if (!std.mem.eql(u8, full, holder)) {
321 std.debug.print(
322 "CraftNative.kt is in package '{s}', so JNI_OnLoad must look for '{s}'.\n" ++
323 " android_dispatch.holder_class says '{s}'.\n",
324 .{ package, full, holder },
325 );
326 return error.HolderClassDoesNotMatchPackage;
327 }
328
329 // And it stays untemplated: a substitution marker here would make the
330 // prebuilt library app-specific, which is the whole reason this class is
331 // separate from CraftBridge.
332 try testing.expect(std.mem.indexOf(u8, package, "{") == null);
333}