also looking at this
test(android): the ratchet, and the two seams JNI leaves unchecked
#142
2 files
+360
-0
| @@ -411,6 +411,31 @@ pub fn build(b: *std.Build) void { | ||
| 411 | 411 | // pointers, so the tests build one out of Zig functions and run anywhere — |
| 412 | 412 | // no JVM, no emulator, no NDK. That is the whole reason the layer is shaped |
| 413 | 413 | // 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 | ||
| 414 | 439 | // The page surface, across both bridges. Not folded into the iOS |
| 415 | 440 | // conformance gate: it embeds three files and is about what `window.craft` |
| 416 | 441 | // offers rather than about iOS, and the iOS gate is already the largest |
| @@ -1597,6 +1622,7 @@ pub fn build(b: *std.Build) void { | ||
| 1597 | 1622 | test_step.dependOn(&run_ios_conformance_tests.step); |
| 1598 | 1623 | test_step.dependOn(&run_jni_tests.step); |
| 1599 | 1624 | test_step.dependOn(&run_bridge_surface_tests.step); |
| 1625 | test_step.dependOn(&run_android_conformance_tests.step); | |
| 1600 | 1626 | test_step.dependOn(&run_android_bridge_tests.step); |
| 1601 | 1627 | test_step.dependOn(&run_menubar_tests.step); |
| 1602 | 1628 | test_step.dependOn(&run_components_tests.step); |
| @@ -2325,6 +2351,7 @@ pub fn build(b: *std.Build) void { | ||
| 2325 | 2351 | // so a run that skipped it would not be an Android test run. |
| 2326 | 2352 | test_android_step.dependOn(&run_jni_tests.step); |
| 2327 | 2353 | test_android_step.dependOn(&run_bridge_surface_tests.step); |
| 2354 | test_android_step.dependOn(&run_android_conformance_tests.step); | |
| 2328 | 2355 | test_android_step.dependOn(&run_android_bridge_tests.step); |
| 2329 | 2356 | |
| 2330 | 2357 | // Add Android tests to the main test step |
| @@ -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 | ||
| 29 | const std = ; | |
| 30 | const 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. | |
| 34 | const android_spec = @embedFile("CraftBridge.kt"); | |
| 35 | const native_holder = @embedFile("CraftNative.kt"); | |
| 36 | const 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". | |
| 41 | const 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. | |
| 52 | const 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. | |
| 61 | fn 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. | |
| 88 | fn 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 | ||
| 117 | test "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 | ||
| 130 | test "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 | ||
| 154 | test "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`. | |
| 202 | fn 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. | |
| 224 | fn 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 | ||
| 242 | test "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 | ||
| 269 | test "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 | ||
| 291 | test "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 | } | |