ReviewOS

also looking at this

craft-native/craft

test(ios): check the event channel, the half nothing was checking

#128
Merged glennmichael123 wants to merge test/ios-event-channel-conformance into main
4 files +280 -0
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -405,6 +405,9 @@ pub fn build(b: *std.Build) void {
405405 ios_conformance_tests.root_module.addAnonymousImport("src/ios_config.zig", .{
406406 .root_source_file = b.path("src/ios_config.zig"),
407407 });
408 ios_conformance_tests.root_module.addAnonymousImport("src/ios_events.zig", .{
409 .root_source_file = b.path("src/ios_events.zig"),
410 });
408411 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_vision.zig", .{
409412 .root_source_file = b.path("src/bridge_mobile_vision.zig"),
410413 });
packages/zig/src/bridge_mobile_shortcuts.zigmodified+7-0
Changes to packages/zig/src/bridge_mobile_shortcuts.zig
@@ -67,6 +67,13 @@
6767//! equivalent, so nothing ever dispatches that event. Tapping launches or
6868//! foregrounds the app and stops there. Pre-existing, identical under the
6969//! Swift shim, and outside these two actions' scope.
70//!
71//! That paragraph was the only record of it for several phases, which is the
72//! problem with recording a gap in the header of the one module that noticed:
73//! nothing else can see it and nothing re-checks it. `craftShortcut` is now a
74//! row in `ios_conformance_test.zig`'s `dead_subscriptions`, alongside three
75//! more of the same shape found by looking — the build fails if it quietly
76//! gains an emitter, and fails if a fifth appears. Tracked in issue #127.
7077
7178const std = ;
7279const builtin = ;
packages/zig/src/ios_events.zigmodified+9-0
Changes to packages/zig/src/ios_events.zig
@@ -45,6 +45,15 @@ const compat_mutex = ;
4545/// `packages/ios/templates/CraftApp.swift` — that is the contract, and a page
4646/// written against the Swift app must keep working when Zig serves the same
4747/// action. `eventName` is what lands in the `CustomEvent` constructor.
48///
49/// "Copied" was the whole of the guarantee until
50/// `ios_conformance_test.zig`'s event scans landed. A wrong character here
51/// costs nothing at compile time and produces a `CustomEvent` no page is
52/// listening for: no rejected promise, no timeout, no log line — the stream
53/// simply never starts, which from the page's side is what a device with
54/// nothing to report looks like. That test now reads these arms and fails
55/// the build for a name the spec does not dispatch, so the list is checked
56/// against its source rather than trusted to have been transcribed once.
4857pub const Event = enum {
4958 location_update,
5059 location_error,
packages/zig/test/ios_conformance_test.zigmodified+261-0
Changes to packages/zig/test/ios_conformance_test.zig
@@ -828,3 +828,264 @@ test "getDeviceInfo answers every field the spec answers" {
828828 // A shape change that silently matched nothing would otherwise pass.
829829 try testing.expectEqual(@as(usize, 14), checked);
830830}
831
832// ---------------------------------------------------------------------------
833// The event channel
834//
835// Everything above this line is about the *call* channel: the page asks, native
836// answers. The other half of the contract runs the other way — native talks
837// first, and the page hears it as a `CustomEvent` on `window`. Nothing checked
838// that half, and it carries the same two failure modes the action scans exist
839// to catch, in a form that is quieter still:
840//
841// * A name Zig emits that nothing subscribes to. `craftLocationUpate` for
842// `craftLocationUpdate` compiles, dispatches, and reaches no one. There is
843// no promise to hang and no error to log — the stream simply never starts,
844// which is indistinguishable from a device that has nothing to report.
845// `ios_events.zig`'s own header records this hazard and calls the names
846// "copied from a `sendToWeb` call"; copied, and then never re-checked.
847//
848// * A name the page subscribes to that nothing dispatches. This is exactly
849// the `ota*` bug — a surface that reads as implemented and is not — and
850// the OTA surface is where it still lives: when the five unhandled
851// `ota*` methods were fixed to reject through `_unavailable`, the two
852// *subscription* methods beside them were left as they were, because the
853// scan that found them only knew about actions.
854//
855// Both scans are textual and carry the usual hazard, so both have floors.
856// ---------------------------------------------------------------------------
857
858const zig_events_source = @embedFile("src/ios_events.zig");
859
860/// Extract single-or-double-quoted event names following `needle`, keeping
861/// only the `craft`-prefixed identifiers.
862///
863/// The filter is doing real work rather than tidying. `sendToWeb` is also
864/// called through an interpolation — `CustomEvent('\(event)'` in the sink
865/// itself — and `addEventListener` is used for `loadend` and
866/// `visibilitychange`, neither of which is a bridge event. Restricting to
867/// `craft`-prefixed identifiers drops all three, and the prefix is not an
868/// assumption: the test below asserts every dispatched name has it.
869fn collectQuotedEvents(
870 allocator: std.mem.Allocator,
871 needle: []const u8,
872 quote: u8,
873) !std.StringHashMap(void) {
874 var set = std.StringHashMap(void).init(allocator);
875 errdefer set.deinit();
876
877 var search: usize = 0;
878 while (std.mem.indexOfPos(u8, swift_spec, search, needle)) |at| {
879 const start = at + needle.len;
880 const end = std.mem.indexOfScalarPos(u8, swift_spec, start, quote) orelse break;
881 search = end;
882
883 const name = swift_spec[start..end];
884 if (!std.mem.startsWith(u8, name, "craft")) continue;
885 var ok = true;
886 for (name) |c| {
887 if (!std.ascii.isAlphanumeric(c)) ok = false;
888 }
889 if (ok) try set.put(name, {});
890 }
891 return set;
892}
893
894/// Every event the spec dispatches: `sendToWeb("craftX", ...)` from native,
895/// plus the `CustomEvent('craftX'` literals — one in Swift's deep-link path
896/// and two inside the injected JavaScript, which dispatches `craftError` and
897/// `craftReady` itself.
898fn collectSpecDispatchedEvents(allocator: std.mem.Allocator) !std.StringHashMap(void) {
899 var set = try collectQuotedEvents(allocator, "sendToWeb(\"", '"');
900 errdefer set.deinit();
901
902 var literal = try collectQuotedEvents(allocator, "CustomEvent('", '\'');
903 defer literal.deinit();
904
905 var it = literal.keyIterator();
906 while (it.next()) |name| try set.put(name.*, {});
907 return set;
908}
909
910/// Every event the injected JavaScript subscribes to on the page's behalf.
911fn collectSpecSubscribedEvents(allocator: std.mem.Allocator) !std.StringHashMap(void) {
912 return collectQuotedEvents(allocator, "addEventListener('", '\'');
913}
914
915/// The `.member => "craftX",` arms of `ios_events.Event.eventName`.
916fn collectZigEventNames(allocator: std.mem.Allocator) !std.StringHashMap(void) {
917 var set = std.StringHashMap(void).init(allocator);
918 errdefer set.deinit();
919
920 const start = std.mem.indexOf(u8, zig_events_source, "pub fn eventName(") orelse
921 return error.EventNameFnNotFound;
922 const body = zig_events_source[start..];
923 // `eventName` is a method on the enum, so it closes at four-space indent.
924 const end = std.mem.indexOf(u8, body, "\n }") orelse return error.EventNameFnNotFound;
925
926 var it = std.mem.splitScalar(u8, body[0..end], '\n');
927 while (it.next()) |line| {
928 const trimmed = std.mem.trim(u8, line, " \t\r");
929 if (!std.mem.startsWith(u8, trimmed, ".")) continue;
930 const arrow = std.mem.indexOf(u8, trimmed, " => \"") orelse continue;
931 const name_start = arrow + " => \"".len;
932 const name_end = std.mem.indexOfScalarPos(u8, trimmed, name_start, '"') orelse continue;
933 try set.put(trimmed[name_start..name_end], {});
934 }
935 return set;
936}
937
938const DeadSubscription = struct {
939 event: []const u8,
940 reason: []const u8,
941};
942
943/// Events the injected JavaScript subscribes to that nothing in this repository
944/// ever dispatches — so the callback a page hands to the method beside them is
945/// registered, retained, and never called.
946///
947/// Recorded here for the reason the deferral table above exists: a gap nothing
948/// checks is a gap that outlives its own explanation. All four are the same
949/// shape — the *sending* half of a surface whose receiving half shipped — and
950/// none is fixable from Zig alone, which is why they are a table rather than a
951/// commit. Tracked in issue #127.
952const dead_subscriptions = [_]DeadSubscription{
953 // `setShortcuts` really does install home-screen items; tapping one
954 // launches the app and stops there. No template implements
955 // `application(_:performActionFor:completionHandler:)` or a scene-delegate
956 // equivalent, so the `UIApplicationShortcutItem` never reaches the page.
957 // `bridge_mobile_shortcuts.zig` documents this from the other side.
958 .{ .event = "craftShortcut", .reason = "nothing implements performActionFor:, so a tapped shortcut only launches the app" },
959
960 // `donateSiriShortcut` donates an `NSUserActivity` typed
961 // `.<action>`. The only `onContinueUserActivity` in the
962 // template is bound to `NSUserActivityTypeBrowsingWeb` and routes to
963 // `DeepLinkManager`, so an invocation of a donated shortcut relaunches the
964 // app and is dropped.
965 .{ .event = "craftSiriShortcut", .reason = "the only onContinueUserActivity handles browsing-web activities, so a donated shortcut's invocation is dropped" },
966
967 // The `ota*` half-fix. `checkForUpdate`, `downloadUpdate`, `applyUpdate`
968 // and `rollback` were changed to reject through `_unavailable`; these two
969 // register a callback against a name no OTA implementation exists to
970 // dispatch, and were missed because the scan that found the others only
971 // looked at actions.
972 .{ .event = "craftOTAProgress", .reason = "the OTA surface is _unavailable; its two subscription methods were left behind" },
973 .{ .event = "craftOTAStatus", .reason = "the OTA surface is _unavailable; its two subscription methods were left behind" },
974};
975
976test "the event scans find both halves of the channel" {
977 // Non-vacuity. Every assertion below is a membership check, and a needle
978 // that stopped matching would satisfy all of them at once.
979 var dispatched = try collectSpecDispatchedEvents(testing.allocator);
980 defer dispatched.deinit();
981 try testing.expect(dispatched.count() >= 17);
982
983 var subscribed = try collectSpecSubscribedEvents(testing.allocator);
984 defer subscribed.deinit();
985 try testing.expect(subscribed.count() >= 10);
986
987 var zig = try collectZigEventNames(testing.allocator);
988 defer zig.deinit();
989 try testing.expect(zig.count() >= 13);
990
991 // The prefix the two filters rely on, checked rather than assumed.
992 var it = dispatched.keyIterator();
993 while (it.next()) |name| try testing.expect(std.mem.startsWith(u8, name.*, "craft"));
994}
995
996test "every event Zig emits is one the spec dispatches" {
997 // The typo guard, and the direction that matters most: `ios_events.Event`
998 // is a fixed vocabulary transcribed by hand from the spec's `sendToWeb`
999 // call sites, and a single wrong character produces a `CustomEvent` with
1000 // no subscriber. Nothing fails, nothing logs; the page just never hears
1001 // from the device.
1002 //
1003 // The spec is never edited to remove an arm a Zig module has taken over —
1004 // `CraftApp.swift` stays the specification and the shim — so Zig's
1005 // vocabulary being a subset of the spec's is a property that holds for as
1006 // long as this migration runs.
1007 var dispatched = try collectSpecDispatchedEvents(testing.allocator);
1008 defer dispatched.deinit();
1009
1010 var zig = try collectZigEventNames(testing.allocator);
1011 defer zig.deinit();
1012
1013 var it = zig.keyIterator();
1014 while (it.next()) |name| {
1015 if (!dispatched.contains(name.*)) {
1016 std.debug.print(
1017 "ios_events.Event spells '{s}', which the spec never dispatches.\n" ++
1018 " A page written against the Swift app is listening for a different name.\n",
1019 .{name.*},
1020 );
1021 return error.ZigEmitsAnEventTheSpecDoesNotHave;
1022 }
1023 }
1024}
1025
1026test "every event the page subscribes to is one something dispatches" {
1027 // The `ota*` check, on the other channel. A subscription with no emitter
1028 // is quieter than a promise with no handler — there is nothing to await,
1029 // so the page reports no error and simply behaves as if the device were
1030 // idle — which makes it likelier to ship and likelier to survive.
1031 //
1032 // Zig's emitters are not consulted here on purpose: the test above pins
1033 // `ios_events.Event` as a subset of what the spec dispatches, so a union
1034 // with it could not admit a name this set lacks. Adding one anyway would
1035 // read as though Zig could rescue a dead subscription, and it cannot.
1036 var dispatched = try collectSpecDispatchedEvents(testing.allocator);
1037 defer dispatched.deinit();
1038
1039 var subscribed = try collectSpecSubscribedEvents(testing.allocator);
1040 defer subscribed.deinit();
1041
1042 var recorded = std.StringHashMap(void).init(testing.allocator);
1043 defer recorded.deinit();
1044 for (dead_subscriptions) |d| try recorded.put(d.event, {});
1045
1046 var it = subscribed.keyIterator();
1047 while (it.next()) |name| {
1048 if (dispatched.contains(name.*)) continue;
1049 if (recorded.contains(name.*)) continue;
1050 std.debug.print(
1051 "the injected JS subscribes to '{s}', which nothing dispatches.\n" ++
1052 " The callback a page registers for it can never be called.\n",
1053 .{name.*},
1054 );
1055 return error.PageSubscribesToAnEventNothingEmits;
1056 }
1057}
1058
1059test "every recorded dead subscription is real, and still dead" {
1060 // The anti-rot half, in the shape the deferral table already uses. A row
1061 // that has grown an emitter must fail here rather than sit in a list
1062 // telling the next reader the surface is broken when it works.
1063 var dispatched = try collectSpecDispatchedEvents(testing.allocator);
1064 defer dispatched.deinit();
1065
1066 var subscribed = try collectSpecSubscribedEvents(testing.allocator);
1067 defer subscribed.deinit();
1068
1069 for (dead_subscriptions) |d| {
1070 if (!subscribed.contains(d.event)) {
1071 std.debug.print(
1072 "'{s}' is recorded as a dead subscription, but the injected JS no longer subscribes to it — " ++
1073 "delete the row.\n",
1074 .{d.event},
1075 );
1076 return error.DeadSubscriptionNoLongerSubscribed;
1077 }
1078 if (dispatched.contains(d.event)) {
1079 std.debug.print(
1080 "'{s}' is recorded as having no emitter, and something dispatches it now — " ++
1081 "delete the row, its reason is spent.\n",
1082 .{d.event},
1083 );
1084 return error.DeadSubscriptionIsAliveNow;
1085 }
1086 try testing.expect(d.reason.len > 0);
1087 }
1088
1089 // Non-vacuity: the loop above is satisfied by an empty table.
1090 try testing.expect(dead_subscriptions.len >= 4);
1091}