ReviewOS

also looking at this

craft-native/craft

test(bridge): the other direction — what window.craft promises

#141
Merged chris wants to merge test/page-surface-conformance into main
2 files +220 -0
packages/zig/build.zigmodified+25-0
Changes to packages/zig/build.zig
@@ -411,6 +411,28 @@ 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 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
414436 const jni_tests = b.addTest(.{
415437 .root_module = b.createModule(.{
416438 .root_source_file = b.path("src/jni_runtime.zig"),
@@ -1574,6 +1596,7 @@ pub fn build(b: *std.Build) void {
15741596 }
15751597 test_step.dependOn(&run_ios_conformance_tests.step);
15761598 test_step.dependOn(&run_jni_tests.step);
1599 test_step.dependOn(&run_bridge_surface_tests.step);
15771600 test_step.dependOn(&run_android_bridge_tests.step);
15781601 test_step.dependOn(&run_menubar_tests.step);
15791602 test_step.dependOn(&run_components_tests.step);
@@ -1660,6 +1683,7 @@ pub fn build(b: *std.Build) void {
16601683 test_ios_step.dependOn(&run_ios_module_tests.step);
16611684 }
16621685 test_ios_step.dependOn(&run_ios_conformance_tests.step);
1686 test_ios_step.dependOn(&run_bridge_surface_tests.step);
16631687
16641688 const test_menubar_step = b.step("test:menubar", "Run Menubar tests");
16651689 test_menubar_step.dependOn(&run_menubar_tests.step);
@@ -2300,6 +2324,7 @@ pub fn build(b: *std.Build) void {
23002324 // second one: `jni_runtime.zig` is the only way anything here reaches Java,
23012325 // so a run that skipped it would not be an Android test run.
23022326 test_android_step.dependOn(&run_jni_tests.step);
2327 test_android_step.dependOn(&run_bridge_surface_tests.step);
23032328 test_android_step.dependOn(&run_android_bridge_tests.step);
23042329
23052330 // Add Android tests to the main test step
packages/zig/test/bridge_surface_test.zigadded+195-0
Changes to packages/zig/test/bridge_surface_test.zig
@@ -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
32const std = ;
33const testing = std.testing;
34
35const ios_spec = @embedFile("CraftApp.swift");
36const android_spec = @embedFile("CraftBridge.kt");
37const 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.
44const 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.
48const 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.
56fn 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.
81fn 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>`.
108fn 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
131test "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
150test "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}