⋯ 827 lines Show the 827 lines above this hunk @@ -828,3 +828,264 @@ test "getDeviceInfo answers every field the spec answers" { 828 828 // A shape change that silently matched nothing would otherwise pass. 829 829 try testing.expectEqual(@as(usize, 14), checked); 830 830 } 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 + 858 + const 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. 869 + fn 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. 898 + fn 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. 911 + fn 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`. 916 + fn 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 + 938 + const 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. 952 + const 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 + 976 + test "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 + 996 + test "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 + 1026 + test "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 + 1059 + test "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 + }