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/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}