Commit cc8a97bf86

cc8a97bf865f1ff2dabdeb1008fe9d59090c48a7

parent: 4d6ea462de

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 02:23 UTC

Make the timing-sensitive tests deterministic (!57)

Eleven tests changed something that fires an unstructured task, slept a
fixed interval, then asserted on whichever request happened to be last.
Two problems at once: a probabilistic wait, and asserting on "the last
one" rather than on the request the test is actually about.

Three of them were passing vacuously. Two assert that the reload after a
filter change carries no `--cursor` — but with the reload not yet landed
they were asserting against the initial request, which has no cursor
either, so they passed whatever the code did. The third asserted the
screen was not in a failed state, which is also true of a reload that
never started. Each was confirmed failing against injected faults before
being trusted.

They now poll for the request they mean, identified by its argv, and
assert on that one. A shared `until` helper replaces the sleeps: faster
in the common case, and deterministic rather than merely likely.

Separately, fifteen sites indexed `stub.seen` directly, so a regression
that produced fewer requests crashed the test process instead of failing.
They now go through `#require`, which reports and lets the rest of the
suite finish.

No assertion was weakened and no application code changed.

Layout: unified · split

gitbayTests/BlameEditTests.swift +2 −1
@@ -64,7 +64,8 @@ struct BlameViewModelTests {
64 await model.loadMore() 64 await model.loadMore()
65 #expect(model.hunks.count == 2) 65 #expect(model.hunks.count == 2)
66 #expect(!model.hasMore) 66 #expect(!model.hasMore)
67 #expect(stub.seen[1].url.query()?.contains("argv=--from&argv=1001") == true) 67 let second = try #require(stub.seen.dropFirst().first)
68 #expect(second.url.query()?.contains("argv=--from&argv=1001") == true)
68 69
69 // Nothing further to ask for. 70 // Nothing further to ask for.
70 await model.loadMore() 71 await model.loadMore()
gitbayTests/ExploreRefLogTests.swift +2 −1
@@ -84,7 +84,8 @@ struct ExploreTests {
84 await list.loadMore() 84 await list.loadMore()
85 #expect(list.state.value?.count == 3) 85 #expect(list.state.value?.count == 3)
86 #expect(!list.hasMore) 86 #expect(!list.hasMore)
87 #expect(stub.seen[1].url.query()?.contains("argv=--cursor&argv=ZXhwbG9yZTpr") == true) 87 let second = try #require(stub.seen.dropFirst().first)
88 #expect(second.url.query()?.contains("argv=--cursor&argv=ZXhwbG9yZTpr") == true)
88 } 89 }
89 90
90 @Test func ownerAndNameSplitForDisplay() { 91 @Test func ownerAndNameSplitForDisplay() {
gitbayTests/GitbayClientTests.swift +6 −3
@@ -101,8 +101,10 @@ struct GitbayClientReadTests {
101 #expect(first[0].path == "krz/gitbay") 101 #expect(first[0].path == "krz/gitbay")
102 let requests = stub.seen 102 let requests = stub.seen
103 #expect(requests.count == 2) 103 #expect(requests.count == 2)
104 #expect(requests[0].headers["If-None-Match"] == nil) 104 let firstRequest = try #require(requests.first)
105 #expect(requests[1].headers["If-None-Match"] == etag) 105 #expect(firstRequest.headers["If-None-Match"] == nil)
106 let secondRequest = try #require(requests.dropFirst().first)
107 #expect(secondRequest.headers["If-None-Match"] == etag)
106 } 108 }
107 109
108 @Test func changedBodyReplacesTheCachedValidator() async throws { 110 @Test func changedBodyReplacesTheCachedValidator() async throws {
@@ -117,7 +119,8 @@ struct GitbayClientReadTests {
117 119
118 #expect(refreshed.isEmpty) 120 #expect(refreshed.isEmpty)
119 #expect(revalidated.isEmpty) 121 #expect(revalidated.isEmpty)
120 #expect(stub.seen[2].headers["If-None-Match"] == "\"bb\"") 122 let third = try #require(stub.seen.dropFirst(2).first)
123 #expect(third.headers["If-None-Match"] == "\"bb\"")
121 } 124 }
122 125
123 @Test func absentDataDecodesAsEmptyList() async throws { 126 @Test func absentDataDecodesAsEmptyList() async throws {
gitbayTests/ListFilterTests.swift +39 −8
@@ -184,7 +184,7 @@ struct IssueListFilterWiringTests {
184 184
185 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) 185 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
186 model.filter.label = "bug" 186 model.filter.label = "bug"
187 try await Task.sleep(for: .milliseconds(150)) 187 await until { stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count == 2 }
188 188
189 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } 189 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
190 let last = try #require(reads.last) 190 let last = try #require(reads.last)
@@ -275,11 +275,18 @@ struct IssueListFilterWiringTests {
275 275
276 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) 276 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
277 model.filter.label = "bug" 277 model.filter.label = "bug"
278 try await Task.sleep(for: .milliseconds(150)) 278 // Poll for the read that actually carries the new filter, not
279 // whatever happens to be last: a reload that never landed would
280 // otherwise leave `last` pointing at the initial request, which
281 // also carries no cursor and would pass just as wrongly.
282 await until {
283 stub.seen.contains { $0.url.absoluteString.contains("argv=issue") && argvFrom($0.url).contains("bug") }
284 }
279 285
280 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") } 286 let reload = try #require(stub.seen.first {
281 let last = try #require(reads.last) 287 $0.url.absoluteString.contains("argv=issue") && argvFrom($0.url).contains("bug")
282 #expect(!argvFrom(last.url).contains("--cursor")) 288 })
289 #expect(!argvFrom(reload.url).contains("--cursor"))
283 } 290 }
284 291
285 /// Two rapid picker changes cancel the first reload's task. The 292 /// Two rapid picker changes cancel the first reload's task. The
@@ -296,7 +303,31 @@ struct IssueListFilterWiringTests {
296 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue")) 303 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
297 model.filter.label = "bug" 304 model.filter.label = "bug"
298 model.filter.label = "feature" 305 model.filter.label = "feature"
299 try await Task.sleep(for: .milliseconds(200)) 306 // Wait for the winning reload's read to land rather than a fixed
307 // sleep: checking `.failed` against a still-untouched state (the
308 // spawned task hasn't run yet) would pass no matter what the real
309 // behaviour is. Whether the cancelled first task's own read also
310 // reaches the stub before it notices cancellation is a race in
311 // its own right, so this only requires the winning one.
312 await until {
313 stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count >= 2
314 }
315 // The cancelled task's own completion can still land after the
316 // winning reload's; a poll alone stops the instant the count is
317 // satisfied and would miss a bogus overwrite arriving right
318 // after. A short settle catches it.
319 try await Task.sleep(for: .milliseconds(50))
320
321 // The state the poll waited for is "a reload's read landed", not
322 // "the screen settled" — a filter change that reloaded nothing at
323 // all would leave the pre-existing, unchanged, non-failed state
324 // sitting there and pass just as wrongly as a `.loading` screen
325 // checked too early would.
326 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }.count
327 guard reads >= 2 else {
328 Testing.Issue.record("rapid filter changes never reloaded")
329 return
330 }
300 331
301 if case .failed(let message) = model.state { 332 if case .failed(let message) = model.state {
302 Testing.Issue.record("rapid filter changes left the screen failed: \(message)") 333 Testing.Issue.record("rapid filter changes left the screen failed: \(message)")
@@ -329,7 +360,7 @@ struct MRListFilterWiringTests {
329 360
330 stub.enqueue(.init(status: 200, json: emptyPage)) 361 stub.enqueue(.init(status: 200, json: emptyPage))
331 model.filter.search = "retarget" 362 model.filter.search = "retarget"
332 try await Task.sleep(for: .milliseconds(500)) 363 await until { stub.seen.count == 2 }
333 364
334 let last = try #require(stub.seen.last) 365 let last = try #require(stub.seen.last)
335 let argv = argvFrom(last.url) 366 let argv = argvFrom(last.url)
@@ -366,7 +397,7 @@ struct MRListFilterWiringTests {
366 397
367 stub.enqueue(.init(status: 200, json: emptyPage)) 398 stub.enqueue(.init(status: 200, json: emptyPage))
368 model.filter.state = .sourceGone 399 model.filter.state = .sourceGone
369 try await Task.sleep(for: .milliseconds(150)) 400 await until { stub.seen.count == 2 }
370 401
371 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone")) 402 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone"))
372 } 403 }
gitbayTests/MRViewModelTests.swift +17 −7
@@ -172,11 +172,21 @@ struct MRListViewModelTests {
172 await model.load() 172 await model.load()
173 173
174 model.filter.state = .merged 174 model.filter.state = .merged
175 // The reload happens in a spawned task; give it a beat. 175 // The reload happens in a spawned task; wait for it to settle
176 try await Task.sleep(for: .milliseconds(300)) 176 // rather than for a fixed interval to pass. Checking `model.state`
177 // alone is not enough: right after the assignment, before the
178 // spawned task has even run, the state is still the *previous*
179 // load's non-loading result, which would satisfy a bare
180 // "not loading" check before the reload ever started.
181 await until {
182 guard stub.seen.count == 2 else { return false }
183 if case .loading = model.state { return false }
184 return true
185 }
177 186
178 #expect(stub.seen.count == 2) 187 #expect(stub.seen.count == 2)
179 #expect(stub.seen[1].url.query()?.contains("argv=merged") == true) 188 let second = try #require(stub.seen.dropFirst().first)
189 #expect(second.url.query()?.contains("argv=merged") == true)
180 guard case .empty = model.state else { 190 guard case .empty = model.state else {
181 Issue.record("expected .empty after filtering, got \(model.state)") 191 Issue.record("expected .empty after filtering, got \(model.state)")
182 return 192 return
@@ -255,7 +265,7 @@ struct MRDetailViewModelTests {
255 265
256 await model.review(.approve) 266 await model.review(.approve)
257 267
258 let write = stub.seen[3] 268 let write = try #require(stub.seen.dropFirst(3).first)
259 #expect(write.method == "POST") 269 #expect(write.method == "POST")
260 #expect(write.url.path() == "/api/v1/cmd") 270 #expect(write.url.path() == "/api/v1/cmd")
261 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) 271 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
@@ -273,7 +283,7 @@ struct MRDetailViewModelTests {
273 283
274 await model.comment("long review text\nwith lines") 284 await model.comment("long review text\nwith lines")
275 285
276 let write = stub.seen[3] 286 let write = try #require(stub.seen.dropFirst(3).first)
277 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) 287 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
278 #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"]) 288 #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"])
279 #expect(body["stdin"] as? String == "long review text\nwith lines") 289 #expect(body["stdin"] as? String == "long review text\nwith lines")
@@ -302,7 +312,7 @@ struct MRDetailViewModelTests {
302 312
303 await model.setResolved(thread, true) 313 await model.setResolved(thread, true)
304 314
305 let write = stub.seen[3] 315 let write = try #require(stub.seen.dropFirst(3).first)
306 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) 316 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
307 #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"]) 317 #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"])
308 } 318 }
@@ -317,7 +327,7 @@ struct MRDetailViewModelTests {
317 327
318 await model.reply(to: thread, "because 5xx is transient") 328 await model.reply(to: thread, "because 5xx is transient")
319 329
320 let write = stub.seen[3] 330 let write = try #require(stub.seen.dropFirst(3).first)
321 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) 331 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
322 #expect(body["argv"] as? [String] == 332 #expect(body["argv"] as? [String] ==
323 ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"]) 333 ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"])
gitbayTests/NotificationTests.swift +9 −5
@@ -127,7 +127,7 @@ struct NotificationsViewModelTests {
127 127
128 stub.enqueue(.init(status: 200, json: page)) 128 stub.enqueue(.init(status: 200, json: page))
129 model.showAll = true 129 model.showAll = true
130 try await Task.sleep(for: .milliseconds(150)) 130 await until { stub.seen.count == 2 }
131 131
132 #expect(argvFrom(try #require(stub.seen.last).url).contains("--all")) 132 #expect(argvFrom(try #require(stub.seen.last).url).contains("--all"))
133 } 133 }
@@ -143,10 +143,14 @@ struct NotificationsViewModelTests {
143 143
144 stub.enqueue(.init(status: 200, json: page)) 144 stub.enqueue(.init(status: 200, json: page))
145 model.showAll = true 145 model.showAll = true
146 try await Task.sleep(for: .milliseconds(150)) 146 // Poll for the read that actually carries the new filter, not
147 147 // whatever happens to be last: a reload that never landed would
148 let argv = argvFrom(try #require(stub.seen.last).url) 148 // otherwise leave `last` pointing at the initial request, which
149 #expect(argv.contains("--cursor") == false) 149 // also carries no cursor and would pass just as wrongly.
150 await until { stub.seen.contains { argvFrom($0.url).contains("--all") } }
151
152 let reload = try #require(stub.seen.first { argvFrom($0.url).contains("--all") })
153 #expect(!argvFrom(reload.url).contains("--cursor"))
150 } 154 }
151 155
152 @Test func markingOneReadSendsItsId() async throws { 156 @Test func markingOneReadSendsItsId() async throws {
gitbayTests/PagedListTests.swift +8 −4
@@ -38,9 +38,11 @@ struct ClientPagingTests {
38 #expect(first.next == "abc") 38 #expect(first.next == "abc")
39 #expect(second.items == [Row(number: 3)]) 39 #expect(second.items == [Row(number: 3)])
40 #expect(second.next == nil) 40 #expect(second.next == nil)
41 #expect(stub.seen[0].url.query() == 41 let firstSeen = try #require(stub.seen.first)
42 #expect(firstSeen.url.query() ==
42 "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2") 43 "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2")
43 #expect(stub.seen[1].url.query() == 44 let secondSeen = try #require(stub.seen.dropFirst().first)
45 #expect(secondSeen.url.query() ==
44 "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2&argv=--cursor&argv=abc") 46 "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2&argv=--cursor&argv=abc")
45 } 47 }
46} 48}
@@ -77,8 +79,10 @@ struct PagedListModelTests {
77 // Further calls are no-ops, not requests. 79 // Further calls are no-ops, not requests.
78 await list.loadMore() 80 await list.loadMore()
79 #expect(stub.seen.count == 3) 81 #expect(stub.seen.count == 3)
80 #expect(stub.seen[1].url.query()?.contains("argv=--cursor&argv=c1") == true) 82 let second = try #require(stub.seen.dropFirst().first)
81 #expect(stub.seen[2].url.query()?.contains("argv=--cursor&argv=c2") == true) 83 #expect(second.url.query()?.contains("argv=--cursor&argv=c1") == true)
84 let third = try #require(stub.seen.dropFirst(2).first)
85 #expect(third.url.query()?.contains("argv=--cursor&argv=c2") == true)
82 } 86 }
83 87
84 @Test func anEmptyFirstPageIsAnEmptyState() async throws { 88 @Test func anEmptyFirstPageIsAnEmptyState() async throws {
gitbayTests/ProfileCommitTests.swift +2 −1
@@ -59,7 +59,8 @@ struct ProfileAggregateTests {
59 #expect(profile.activityTotal == 15) 59 #expect(profile.activityTotal == 15)
60 // One read builds the whole page. 60 // One read builds the whole page.
61 #expect(stub.seen.count == 1) 61 #expect(stub.seen.count == 1)
62 #expect(stub.seen[0].url.query() == "argv=profile&argv=show&argv=cmc") 62 let first = try #require(stub.seen.first)
63 #expect(first.url.query() == "argv=profile&argv=show&argv=cmc")
63 } 64 }
64 65
65 @Test func anOrgProfileCarriesMembersInsteadOfOrgs() async throws { 66 @Test func anOrgProfileCarriesMembersInsteadOfOrgs() async throws {
gitbayTests/RefsMilestoneTests.swift +13 −2
@@ -107,10 +107,21 @@ struct MilestoneListViewModelTests {
107 await model.load() 107 await model.load()
108 108
109 model.filter = .closed 109 model.filter = .closed
110 try await Task.sleep(for: .milliseconds(300)) 110 // The reload happens in a spawned task; wait for it to settle
111 // rather than for a fixed interval to pass. Checking `model.state`
112 // alone is not enough: right after the assignment, before the
113 // spawned task has even run, the state is still the *previous*
114 // load's non-loading result, which would satisfy a bare
115 // "not loading" check before the reload ever started.
116 await until {
117 guard stub.seen.count == 2 else { return false }
118 if case .loading = model.state { return false }
119 return true
120 }
111 121
112 #expect(stub.seen.count == 2) 122 #expect(stub.seen.count == 2)
113 #expect(stub.seen[1].url.query()?.contains("argv=closed") == true) 123 let second = try #require(stub.seen.dropFirst().first)
124 #expect(second.url.query()?.contains("argv=closed") == true)
114 } 125 }
115} 126}
116 127
gitbayTests/SearchTests.swift +1 −1
@@ -137,7 +137,7 @@ struct SearchViewModelTests {
137 137
138 stub.enqueue(.init(status: 200, json: mixedResults)) 138 stub.enqueue(.init(status: 200, json: mixedResults))
139 model.kind = .issue 139 model.kind = .issue
140 try await Task.sleep(for: .milliseconds(150)) 140 await until { stub.seen.count == 2 }
141 141
142 #expect(argvFrom(try #require(stub.seen.last).url) 142 #expect(argvFrom(try #require(stub.seen.last).url)
143 == ["search", "parity", "--kind", "issue"]) 143 == ["search", "parity", "--kind", "issue"])
gitbayTests/SessionStoreTests.swift +3 −2
@@ -162,8 +162,9 @@ struct SessionStoreTests {
162 await #expect(throws: GitbayError.self) { 162 await #expect(throws: GitbayError.self) {
163 _ = try await client.readList(["repo", "list"], of: RepoSummary.self) 163 _ = try await client.readList(["repo", "list"], of: RepoSummary.self)
164 } 164 }
165 // The sign-out hops through the main actor; give it a beat. 165 // The sign-out hops through the main actor; wait for it rather
166 try await Task.sleep(for: .milliseconds(200)) 166 // than for a fixed interval to pass.
167 await until { session.current == nil }
167 168
168 #expect(session.current == nil) 169 #expect(session.current == nil)
169 #expect(session.client == nil) 170 #expect(session.client == nil)
gitbayTests/StubProtocol.swift +16
@@ -161,3 +161,19 @@ nonisolated final class Mutex<Value>: @unchecked Sendable {
161 return body(&value) 161 return body(&value)
162 } 162 }
163} 163}
164
165/// Wait for a condition rather than sleeping a fixed interval. The view
166/// models reload through unstructured Tasks, so a fixed sleep is a race:
167/// under load the reload has not landed when it expires. Polling is both
168/// faster in the common case and deterministic.
169@MainActor
170func until(
171 _ timeout: Duration = .seconds(2),
172 _ condition: () -> Bool
173) async {
174 let deadline = ContinuousClock.now + timeout
175 while !condition() {
176 guard ContinuousClock.now < deadline else { return }
177 try? await Task.sleep(for: .milliseconds(10))
178 }
179}