also looking at this
test(ios): check the event channel, the half nothing was checking
#128
4 files
+280
-0
| @@ -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 | } | |