also looking at this
test(bridge): the other direction — what window.craft promises
#141
2 files
+220
-0
| @@ -411,6 +411,28 @@ 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 page surface, across both bridges. Not folded into the iOS | |
| 415 | // conformance gate: it embeds three files and is about what `window.craft` | |
| 416 | // offers rather than about iOS, and the iOS gate is already the largest | |
| 417 | // test in the tree. | |
| 418 | const bridge_surface_tests = b.addTest(.{ | |
| 419 | .root_module = b.createModule(.{ | |
| 420 | .root_source_file = b.path("test/bridge_surface_test.zig"), | |
| 421 | .target = target, | |
| 422 | .optimize = optimize, | |
| 423 | }), | |
| 424 | }); | |
| 425 | bridge_surface_tests.root_module.addAnonymousImport("CraftApp.swift", .{ | |
| 426 | .root_source_file = b.path("../ios/templates/CraftApp.swift"), | |
| 427 | }); | |
| 428 | bridge_surface_tests.root_module.addAnonymousImport("CraftBridge.kt", .{ | |
| 429 | .root_source_file = b.path("../android/templates/CraftBridge.kt.template"), | |
| 430 | }); | |
| 431 | bridge_surface_tests.root_module.addAnonymousImport("craft.d.ts", .{ | |
| 432 | .root_source_file = b.path("../typescript/types/craft.d.ts"), | |
| 433 | }); | |
| 434 | const run_bridge_surface_tests = b.addRunArtifact(bridge_surface_tests); | |
| 435 | ||
| 414 | 436 | const jni_tests = b.addTest(.{ |
| 415 | 437 | .root_module = b.createModule(.{ |
| 416 | 438 | .root_source_file = b.path("src/jni_runtime.zig"), |
| @@ -1574,6 +1596,7 @@ pub fn build(b: *std.Build) void { | ||
| 1574 | 1596 | } |
| 1575 | 1597 | test_step.dependOn(&run_ios_conformance_tests.step); |
| 1576 | 1598 | test_step.dependOn(&run_jni_tests.step); |
| 1599 | test_step.dependOn(&run_bridge_surface_tests.step); | |
| 1577 | 1600 | test_step.dependOn(&run_android_bridge_tests.step); |
| 1578 | 1601 | test_step.dependOn(&run_menubar_tests.step); |
| 1579 | 1602 | test_step.dependOn(&run_components_tests.step); |
| @@ -1660,6 +1683,7 @@ pub fn build(b: *std.Build) void { | ||
| 1660 | 1683 | test_ios_step.dependOn(&run_ios_module_tests.step); |
| 1661 | 1684 | } |
| 1662 | 1685 | test_ios_step.dependOn(&run_ios_conformance_tests.step); |
| 1686 | test_ios_step.dependOn(&run_bridge_surface_tests.step); | |
| 1663 | 1687 | |
| 1664 | 1688 | const test_menubar_step = b.step("test:menubar", "Run Menubar tests"); |
| 1665 | 1689 | test_menubar_step.dependOn(&run_menubar_tests.step); |
| @@ -2300,6 +2324,7 @@ pub fn build(b: *std.Build) void { | ||
| 2300 | 2324 | // second one: `jni_runtime.zig` is the only way anything here reaches Java, |
| 2301 | 2325 | // so a run that skipped it would not be an Android test run. |
| 2302 | 2326 | test_android_step.dependOn(&run_jni_tests.step); |
| 2327 | test_android_step.dependOn(&run_bridge_surface_tests.step); | |
| 2303 | 2328 | test_android_step.dependOn(&run_android_bridge_tests.step); |
| 2304 | 2329 | |
| 2305 | 2330 | // Add Android tests to the main test step |
| @@ -0,0 +1,195 @@ | ||
| 1 | //! What `window.craft` promises, and what each bridge actually puts on it. | |
| 2 | //! | |
| 3 | //! `ios_conformance_test.zig` checks one direction and says so: every action | |
| 4 | //! the injected JavaScript can *post* is one the dispatcher handles. That was | |
| 5 | //! written for the `ota*` bug — five methods whose promises could never settle. | |
| 6 | //! | |
| 7 | //! The reverse has never been checked, and it is where the surface has drifted | |
| 8 | //! furthest. `craft.d.ts` declares `window.craft` as `CraftBridge`, an | |
| 9 | //! interface of 78 direct methods; 33 of them are not on the object iOS | |
| 10 | //! injects and 17 are on neither platform's. They type-check and throw | |
| 11 | //! `TypeError`. | |
| 12 | //! | |
| 13 | //! ## What this is not claiming | |
| 14 | //! | |
| 15 | //! The *actions* work. Both bridges expose a generic escape hatch — | |
| 16 | //! `craft._invoke(action, payload)` on iOS (`CraftApp.swift:2543`) and | |
| 17 | //! `craft.invoke('namespace.action', params)` in `craft-bridge.js` — and the | |
| 18 | //! dispatcher arms behind these names are implemented, several of them in Zig. | |
| 19 | //! So the gap is a missing *named method* and a declaration promising one, not | |
| 20 | //! a missing capability. Worth stating because "unreachable" was the first | |
| 21 | //! reading and it was wrong. | |
| 22 | //! | |
| 23 | //! ## Ratchets rather than a table of reasons | |
| 24 | //! | |
| 25 | //! The unimplemented names get counts, not rows. A row here would have to | |
| 26 | //! carry a reason, and there is no honest reason to write for most of them: | |
| 27 | //! they are a backlog nobody has worked through, not a set of decisions — | |
| 28 | //! and `ios_conformance_test.zig` already records why demanding a reason for | |
| 29 | //! that state "would only invite an invented reason". The failure message | |
| 30 | //! names every offender, so the count losing detail costs nothing. | |
| 31 | ||
| 32 | const std = ; | |
| 33 | const testing = std.testing; | |
| 34 | ||
| 35 | const ios_spec = @embedFile("CraftApp.swift"); | |
| 36 | const android_spec = @embedFile("CraftBridge.kt"); | |
| 37 | const sdk_types = @embedFile("craft.d.ts"); | |
| 38 | ||
| 39 | /// How many `CraftBridge` methods iOS's injected object does not define. | |
| 40 | /// | |
| 41 | /// A ratchet in the shape `max_not_yet_migrated` already uses: it may only go | |
| 42 | /// down. Adding a declaration without the method fails here, which is the | |
| 43 | /// conversation this constant exists to force. | |
| 44 | const max_absent_from_ios: usize = 33; | |
| 45 | ||
| 46 | /// The same, for methods on neither bridge. A subset of the above by | |
| 47 | /// construction, and the worse half: nothing an app runs on provides them. | |
| 48 | const max_absent_from_both: usize = 17; | |
| 49 | ||
| 50 | /// The `window.craft = { … }` object literal, brace-balanced. | |
| 51 | /// | |
| 52 | /// Both bridges build one, which is what makes a single check meaningful: | |
| 53 | /// Android reaches native through `CraftAndroid.<method>()` and iOS through | |
| 54 | /// `postMessage`, but the object a page writes against is the same idea on | |
| 55 | /// both, and `craft.d.ts` describes exactly one of them. | |
| 56 | fn craftObject(source: []const u8) ?[]const u8 { | |
| 57 | const marker = "window.craft = {"; | |
| 58 | const at = std.mem.indexOf(u8, source, marker) orelse return null; | |
| 59 | var depth: usize = 0; | |
| 60 | var i = at + marker.len - 1; | |
| 61 | while (i < source.len) : (i += 1) { | |
| 62 | switch (source[i]) { | |
| 63 | '{' => depth += 1, | |
| 64 | '}' => { | |
| 65 | depth -= 1; | |
| 66 | if (depth == 0) return source[at .. i + 1]; | |
| 67 | }, | |
| 68 | else => {}, | |
| 69 | } | |
| 70 | } | |
| 71 | return null; | |
| 72 | } | |
| 73 | ||
| 74 | /// Is `name` defined as a function on this object, at any nesting depth? | |
| 75 | /// | |
| 76 | /// Depth is deliberately ignored. `craft.d.ts` declares these as direct | |
| 77 | /// methods, so a namespaced implementation is still a mismatch with the type — | |
| 78 | /// but it is a *different* mismatch from nothing existing at all, and calling | |
| 79 | /// the second one "missing" while quietly passing the first would hide the | |
| 80 | /// worse case behind the milder one. | |
| 81 | fn definesMethod(object: []const u8, name: []const u8) bool { | |
| 82 | var search: usize = 0; | |
| 83 | while (std.mem.indexOfPos(u8, object, search, name)) |at| { | |
| 84 | search = at + 1; | |
| 85 | ||
| 86 | // A whole word, not a suffix: `stopScan` must not match `startScan`. | |
| 87 | if (at > 0) { | |
| 88 | const before = object[at - 1]; | |
| 89 | if (std.ascii.isAlphanumeric(before) or before == '_') continue; | |
| 90 | } | |
| 91 | ||
| 92 | var i = at + name.len; | |
| 93 | while (i < object.len and (object[i] == ' ' or object[i] == '\n')) : (i += 1) {} | |
| 94 | if (i >= object.len or object[i] != ':') continue; | |
| 95 | ||
| 96 | i += 1; | |
| 97 | while (i < object.len and (object[i] == ' ' or object[i] == '\n')) : (i += 1) {} | |
| 98 | if (std.mem.startsWith(u8, object[i..], "function")) return true; | |
| 99 | } | |
| 100 | return false; | |
| 101 | } | |
| 102 | ||
| 103 | /// The method names declared directly on `interface CraftBridge`. | |
| 104 | /// | |
| 105 | /// Two-space indent is the discriminator: it is what separates a method on the | |
| 106 | /// interface itself from one nested inside a namespace object, and only the | |
| 107 | /// former is a promise about `window.craft.<name>`. | |
| 108 | fn collectDeclaredMethods(allocator: std.mem.Allocator) !std.ArrayListUnmanaged([]const u8) { | |
| 109 | var out: std.ArrayListUnmanaged([]const u8) = .empty; | |
| 110 | errdefer out.deinit(allocator); | |
| 111 | ||
| 112 | const start = std.mem.indexOf(u8, sdk_types, "export interface CraftBridge {") orelse | |
| 113 | return error.CraftBridgeInterfaceNotFound; | |
| 114 | const rest = sdk_types[start..]; | |
| 115 | const end = std.mem.indexOf(u8, rest, "\n}") orelse return error.CraftBridgeInterfaceNotFound; | |
| 116 | ||
| 117 | var it = std.mem.splitScalar(u8, rest[0..end], '\n'); | |
| 118 | while (it.next()) |line| { | |
| 119 | if (!std.mem.startsWith(u8, line, " ")) continue; | |
| 120 | if (std.mem.startsWith(u8, line, " ")) continue; | |
| 121 | ||
| 122 | const body = line[2..]; | |
| 123 | var i: usize = 0; | |
| 124 | while (i < body.len and (std.ascii.isAlphanumeric(body[i]) or body[i] == '_')) : (i += 1) {} | |
| 125 | if (i == 0 or i >= body.len or body[i] != '(') continue; | |
| 126 | try out.append(allocator, body[0..i]); | |
| 127 | } | |
| 128 | return out; | |
| 129 | } | |
| 130 | ||
| 131 | test "the surface scans find both objects and the interface" { | |
| 132 | // Non-vacuity, and the floors are the point: every assertion below counts | |
| 133 | // absences, so a scan that found nothing would report a perfect surface. | |
| 134 | const ios = craftObject(ios_spec) orelse return error.IosCraftObjectNotFound; | |
| 135 | const android = craftObject(android_spec) orelse return error.AndroidCraftObjectNotFound; | |
| 136 | try testing.expect(ios.len > 10_000); | |
| 137 | try testing.expect(android.len > 10_000); | |
| 138 | ||
| 139 | var declared = try collectDeclaredMethods(testing.allocator); | |
| 140 | defer declared.deinit(testing.allocator); | |
| 141 | try testing.expect(declared.items.len >= 70); | |
| 142 | ||
| 143 | // And the matcher works in both directions on a name each bridge really | |
| 144 | // does define — otherwise "absent everywhere" would be the vacuous answer. | |
| 145 | try testing.expect(definesMethod(ios, "getDeviceInfo")); | |
| 146 | try testing.expect(definesMethod(android, "getDeviceInfo")); | |
| 147 | try testing.expect(!definesMethod(ios, "noSuchMethodAnywhere")); | |
| 148 | } | |
| 149 | ||
| 150 | test "every method craft.d.ts declares exists on the bridges that ship it" { | |
| 151 | // Not `.?` — a broken scan should fail this test with a name, not abort the | |
| 152 | // whole runner on an unwrap and take the other tests' output with it. | |
| 153 | const ios = craftObject(ios_spec) orelse return error.IosCraftObjectNotFound; | |
| 154 | const android = craftObject(android_spec) orelse return error.AndroidCraftObjectNotFound; | |
| 155 | ||
| 156 | var declared = try collectDeclaredMethods(testing.allocator); | |
| 157 | defer declared.deinit(testing.allocator); | |
| 158 | ||
| 159 | var absent_ios: usize = 0; | |
| 160 | var absent_both: usize = 0; | |
| 161 | ||
| 162 | for (declared.items) |name| { | |
| 163 | const on_ios = definesMethod(ios, name); | |
| 164 | const on_android = definesMethod(android, name); | |
| 165 | if (on_ios and on_android) continue; | |
| 166 | ||
| 167 | if (!on_ios) absent_ios += 1; | |
| 168 | if (!on_ios and !on_android) { | |
| 169 | absent_both += 1; | |
| 170 | std.debug.print(" craft.{s}() is declared and exists on neither bridge\n", .{name}); | |
| 171 | } else if (!on_ios) { | |
| 172 | std.debug.print(" craft.{s}() is declared, exists on Android, TypeError on iOS\n", .{name}); | |
| 173 | } else { | |
| 174 | std.debug.print(" craft.{s}() is declared, exists on iOS, TypeError on Android\n", .{name}); | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | if (absent_ios > max_absent_from_ios or absent_both > max_absent_from_both) { | |
| 179 | std.debug.print( | |
| 180 | "\n{d} declared methods are absent from iOS (allowed {d}), {d} from both (allowed {d}).\n" ++ | |
| 181 | " A method on `CraftBridge` is a promise that `window.craft.<name>` is callable.\n" ++ | |
| 182 | " Either implement it on the bridge or stop declaring it.\n", | |
| 183 | .{ absent_ios, max_absent_from_ios, absent_both, max_absent_from_both }, | |
| 184 | ); | |
| 185 | return error.DeclaredMethodMissingFromBridge; | |
| 186 | } | |
| 187 | ||
| 188 | // The ratchet is only a ratchet if it is tightened. | |
| 189 | if (absent_ios < max_absent_from_ios or absent_both < max_absent_from_both) { | |
| 190 | std.debug.print( | |
| 191 | "note: {d} absent from iOS and {d} from both; the ratchets are {d}/{d} and can be lowered.\n", | |
| 192 | .{ absent_ios, absent_both, max_absent_from_ios, max_absent_from_both }, | |
| 193 | ); | |
| 194 | } | |
| 195 | } | |