also looking at this
test(window): make "every window can be reopened" a property the build checks
#76
4 files
+432
-23
| @@ -0,0 +1,232 @@ | ||
| 1 | //! Every window craft opens must be reopenable. | |
| 2 | //! | |
| 3 | //! This test exists because of a bug that nothing else could have caught. | |
| 4 | //! `isCraftWindow` decided whether a window was craft's by asking whether its | |
| 5 | //! content view was a `WKWebView`. That is true of the plain window and false | |
| 6 | //! of the other three: `--web-sidebar-material` installs a backdrop container, | |
| 7 | //! `createWindowWithSidebar` sets a split view controller, and | |
| 8 | //! `createWindowWithSidebarURL` installs its own container. All three keep | |
| 9 | //! their closed window alive, so the reopen handler found them, skipped them, | |
| 10 | //! and left the app activated with nothing on screen — the exact dead end the | |
| 11 | //! handler was added to remove. | |
| 12 | //! | |
| 13 | //! It compiled. Every test passed. It failed only against a real | |
| 14 | //! `kAEReopenApplication` on a window style no test constructs, and **no test | |
| 15 | //! in this repository constructs a window at all**. | |
| 16 | //! | |
| 17 | //! ## Why this is a source test | |
| 18 | //! | |
| 19 | //! Constructing a window means a live `NSApplication`, a `WKWebView` and the | |
| 20 | //! WebContent process behind it — heavy, and flaky on a runner with no | |
| 21 | //! display. The properties worth defending do not need any of that: | |
| 22 | //! | |
| 23 | //! 1. every window constructor registers its window, and | |
| 24 | //! 2. nothing infers craft-ness from the window's appearance again. | |
| 25 | //! | |
| 26 | //! Both are properties of the source, and they are only checkable *because* | |
| 27 | //! the fix replaced an inference with a registration. "Does this window look | |
| 28 | //! like ours?" can only be answered by holding a window. "Did every | |
| 29 | //! constructor register?" can be answered by reading. Making the property | |
| 30 | //! checkable was worth as much as fixing the bug. | |
| 31 | //! | |
| 32 | //! Each check carries a floor, because a scan that finds nothing passes | |
| 33 | //! vacuously, and a vacuous pass is how this class of bug returns. | |
| 34 | ||
| 35 | const std = ; | |
| 36 | const testing = std.testing; | |
| 37 | ||
| 38 | const macos_source = @embedFile("src/macos.zig"); | |
| 39 | ||
| 40 | /// The Objective-C initialiser every `NSWindow` in craft goes through. | |
| 41 | const window_init = "initWithContentRect:styleMask:backing:defer:"; | |
| 42 | ||
| 43 | /// What a constructor must call on the window it just made. | |
| 44 | const register_call = "keepWindowAfterClose("; | |
| 45 | ||
| 46 | /// Whether `body` really calls `needle`, ignoring commented-out occurrences. | |
| 47 | /// | |
| 48 | /// Substring matching alone cannot tell a call from a mention. Commenting a | |
| 49 | /// registration out would leave the text in place and satisfy a naive scan — | |
| 50 | /// which is exactly how this check was first found to be toothless, by | |
| 51 | /// commenting one out and watching it pass. | |
| 52 | fn callsFunction(body: []const u8, needle: []const u8) bool { | |
| 53 | var search: usize = 0; | |
| 54 | while (std.mem.indexOfPos(u8, body, search, needle)) |hit| { | |
| 55 | search = hit + needle.len; | |
| 56 | const start = lineStart(body, hit); | |
| 57 | const before = std.mem.trimStart(u8, body[start..hit], " \t"); | |
| 58 | // `//` anywhere before it on the line makes it a comment, and `///` | |
| 59 | // makes it documentation. | |
| 60 | if (std.mem.startsWith(u8, before, "//")) continue; | |
| 61 | if (std.mem.indexOf(u8, body[start..hit], "//") != null) continue; | |
| 62 | return true; | |
| 63 | } | |
| 64 | return false; | |
| 65 | } | |
| 66 | ||
| 67 | /// Start of the line containing `needle`. | |
| 68 | fn lineStart(source: []const u8, at: usize) usize { | |
| 69 | var i = at; | |
| 70 | while (i > 0 and source[i - 1] != '\n') i -= 1; | |
| 71 | return i; | |
| 72 | } | |
| 73 | ||
| 74 | /// The `fn` declaration enclosing byte `at`. | |
| 75 | /// | |
| 76 | /// Zig indents declarations at column zero inside a file, so the nearest | |
| 77 | /// preceding line beginning with `fn ` or `pub fn ` is the enclosing | |
| 78 | /// top-level function. Container-level methods are indented and belong to a | |
| 79 | /// struct whose own declaration is found the same way. | |
| 80 | fn enclosingFn(source: []const u8, at: usize) ?[]const u8 { | |
| 81 | var i = lineStart(source, at); | |
| 82 | while (true) { | |
| 83 | const line_end = std.mem.indexOfScalarPos(u8, source, i, '\n') orelse source.len; | |
| 84 | const line = source[i..line_end]; | |
| 85 | if (std.mem.startsWith(u8, line, "fn ") or std.mem.startsWith(u8, line, "pub fn ")) return line; | |
| 86 | if (i == 0) return null; | |
| 87 | i = lineStart(source, i - 1); | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | /// Byte range of the function body enclosing `at`, from its `fn` line to the | |
| 92 | /// next top-level `fn` line (or end of file). | |
| 93 | fn enclosingFnBody(source: []const u8, at: usize) []const u8 { | |
| 94 | var start = lineStart(source, at); | |
| 95 | while (start > 0) { | |
| 96 | const line_end = std.mem.indexOfScalarPos(u8, source, start, '\n') orelse source.len; | |
| 97 | const line = source[start..line_end]; | |
| 98 | if (std.mem.startsWith(u8, line, "fn ") or std.mem.startsWith(u8, line, "pub fn ")) break; | |
| 99 | start = lineStart(source, start - 1); | |
| 100 | } | |
| 101 | ||
| 102 | var end = at; | |
| 103 | while (end < source.len) { | |
| 104 | const line_end = std.mem.indexOfScalarPos(u8, source, end, '\n') orelse source.len; | |
| 105 | const line = source[end..line_end]; | |
| 106 | if (end != start and (std.mem.startsWith(u8, line, "fn ") or std.mem.startsWith(u8, line, "pub fn "))) break; | |
| 107 | if (line_end >= source.len) { | |
| 108 | end = source.len; | |
| 109 | break; | |
| 110 | } | |
| 111 | end = line_end + 1; | |
| 112 | } | |
| 113 | return source[start..end]; | |
| 114 | } | |
| 115 | ||
| 116 | test "every window craft constructs is registered as one of its own" { | |
| 117 | // The check that would have caught the bug this file exists for — had the | |
| 118 | // answer been recorded rather than inferred. It is recorded now, so this | |
| 119 | // is the property that keeps it recorded. | |
| 120 | var constructors: usize = 0; | |
| 121 | var unregistered: usize = 0; | |
| 122 | ||
| 123 | var search: usize = 0; | |
| 124 | while (std.mem.indexOfPos(u8, macos_source, search, window_init)) |hit| { | |
| 125 | search = hit + window_init.len; | |
| 126 | ||
| 127 | // The name is mentioned in prose as well as called. Only a real | |
| 128 | // `msgSend4(...)` call constructs a window. | |
| 129 | const line = macos_source[lineStart(macos_source, hit)..hit]; | |
| 130 | if (std.mem.indexOf(u8, line, "msgSend4") == null) continue; | |
| 131 | ||
| 132 | constructors += 1; | |
| 133 | const body = enclosingFnBody(macos_source, hit); | |
| 134 | if (!callsFunction(body, register_call)) { | |
| 135 | unregistered += 1; | |
| 136 | const name = enclosingFn(macos_source, hit) orelse "(unknown)"; | |
| 137 | std.debug.print( | |
| 138 | "\nwindow constructor does not call {s}: {s}\n", | |
| 139 | .{ register_call, name }, | |
| 140 | ); | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | // Floor: craft has three window constructors. If a refactor renames the | |
| 145 | // initialiser this scan finds none and would otherwise pass having checked | |
| 146 | // nothing at all. | |
| 147 | try testing.expect(constructors >= 3); | |
| 148 | try testing.expectEqual(@as(usize, 0), unregistered); | |
| 149 | } | |
| 150 | ||
| 151 | test "whether a window is craft's is recorded, never inferred from the window" { | |
| 152 | // The specific regression. `isCraftWindow` asked the window what its | |
| 153 | // content view was; three of craft's four window styles answer with a | |
| 154 | // container rather than the webview, so those windows were silently | |
| 155 | // skipped by reopen. | |
| 156 | // | |
| 157 | // Any re-introduction reads the window instead of the registry, and every | |
| 158 | // way of doing that goes through one of these. | |
| 159 | const start = std.mem.indexOf(u8, macos_source, "fn isCraftWindow(") orelse { | |
| 160 | // Renamed or removed: this test can no longer defend anything and must | |
| 161 | // say so rather than pass. | |
| 162 | return error.IsCraftWindowNotFound; | |
| 163 | }; | |
| 164 | const body = enclosingFnBody(macos_source, start); | |
| 165 | ||
| 166 | for ([_][]const u8{ | |
| 167 | "contentView", | |
| 168 | "isKindOfClass:", | |
| 169 | "contentViewController", | |
| 170 | "subviews", | |
| 171 | }) |inference| { | |
| 172 | if (std.mem.indexOf(u8, body, inference) != null) { | |
| 173 | std.debug.print("\nisCraftWindow inspects the window ({s}) instead of the registry\n", .{inference}); | |
| 174 | return error.WindowIdentityInferred; | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | // And it must actually consult the registry, rather than being stubbed to | |
| 179 | // a constant that passes the check above. | |
| 180 | try testing.expect(callsFunction(body, "window_registry.isKnown")); | |
| 181 | } | |
| 182 | ||
| 183 | test "the reopen handler filters by that answer" { | |
| 184 | // Registration is worth nothing if the reopen loop stops asking. This | |
| 185 | // pins the one place the two meet. | |
| 186 | const start = std.mem.indexOf(u8, macos_source, "fn appShouldHandleReopen(") orelse | |
| 187 | return error.ReopenHandlerNotFound; | |
| 188 | const body = enclosingFnBody(macos_source, start); | |
| 189 | ||
| 190 | try testing.expect(callsFunction(body, "isCraftWindow(")); | |
| 191 | try testing.expect(callsFunction(body, "makeKeyAndOrderFront:")); | |
| 192 | } | |
| 193 | ||
| 194 | test "a closed window survives long enough to be reopened" { | |
| 195 | // `releasedWhenClosed` defaults to YES for a window built with | |
| 196 | // `initWithContentRect:`, so without this the window is deallocated on | |
| 197 | // close and reopening it is a use-after-free rather than a missing | |
| 198 | // feature. It is set in the same helper that registers, so that a | |
| 199 | // constructor cannot get one without the other. | |
| 200 | const start = std.mem.indexOf(u8, macos_source, "fn keepWindowAfterClose(") orelse | |
| 201 | return error.HelperNotFound; | |
| 202 | const body = enclosingFnBody(macos_source, start); | |
| 203 | ||
| 204 | try testing.expect(callsFunction(body, "setReleasedWhenClosed:")); | |
| 205 | try testing.expect(callsFunction(body, "rememberCraftWindow(")); | |
| 206 | } | |
| 207 | ||
| 208 | test "the scan can actually find the constructors it claims to check" { | |
| 209 | // Guards the guard: every test above depends on `enclosingFnBody` locating | |
| 210 | // real function bodies. If the file's formatting changes such that it | |
| 211 | // returns the whole file, the checks above pass regardless of the code. | |
| 212 | const start = std.mem.indexOf(u8, macos_source, "fn isCraftWindow(").?; | |
| 213 | const body = enclosingFnBody(macos_source, start); | |
| 214 | ||
| 215 | try testing.expect(body.len > 0); | |
| 216 | // A body that large means the scan lost its bearings and every check above | |
| 217 | // is now looking at unrelated code. | |
| 218 | try testing.expect(body.len < macos_source.len / 10); | |
| 219 | try testing.expect(std.mem.startsWith(u8, body, "fn isCraftWindow(")); | |
| 220 | } | |
| 221 | ||
| 222 | test "a commented-out call does not count as a call" { | |
| 223 | // The weakness that made the constructor check toothless: substring | |
| 224 | // matching cannot tell a call from a mention, so commenting a registration | |
| 225 | // out satisfied it. Found by doing exactly that and watching the test pass. | |
| 226 | try testing.expect(callsFunction(" keepWindowAfterClose(window);", "keepWindowAfterClose(")); | |
| 227 | try testing.expect(!callsFunction(" // keepWindowAfterClose(window);", "keepWindowAfterClose(")); | |
| 228 | try testing.expect(!callsFunction(" /// keepWindowAfterClose(window);", "keepWindowAfterClose(")); | |
| 229 | try testing.expect(!callsFunction(" _ = x; // keepWindowAfterClose(window);", "keepWindowAfterClose(")); | |
| 230 | // A real call on a line that also carries a trailing comment still counts. | |
| 231 | try testing.expect(callsFunction(" keepWindowAfterClose(window); // why", "keepWindowAfterClose(")); | |
| 232 | } | |