ReviewOS

also looking at this

craft-native/craft

test(window): make "every window can be reopened" a property the build checks

#76
Merged glennmichael123 wants to merge feat/window-lifecycle-conformance into main
4 files +432 -23
packages/zig/test/window_lifecycle_test.zigadded+232-0
Changes to packages/zig/test/window_lifecycle_test.zig
@@ -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
35const std = ;
36const testing = std.testing;
37
38const macos_source = @embedFile("src/macos.zig");
39
40/// The Objective-C initialiser every `NSWindow` in craft goes through.
41const window_init = "initWithContentRect:styleMask:backing:defer:";
42
43/// What a constructor must call on the window it just made.
44const 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.
52fn 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`.
68fn 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.
80fn 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).
93fn 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
116test "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
151test "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
183test "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
194test "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
208test "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
222test "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}