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 {
6464 await model.loadMore()
6565 #expect(model.hunks.count == 2)
6666 #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)
6869
6970 // Nothing further to ask for.
7071 await model.loadMore()
gitbayTests/ExploreRefLogTests.swift +2 −1
@@ -84,7 +84,8 @@ struct ExploreTests {
8484 await list.loadMore()
8585 #expect(list.state.value?.count == 3)
8686 #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)
8889 }
8990
9091 @Test func ownerAndNameSplitForDisplay() {
gitbayTests/GitbayClientTests.swift +6 −3
@@ -101,8 +101,10 @@ struct GitbayClientReadTests {
101101 #expect(first[0].path == "krz/gitbay")
102102 let requests = stub.seen
103103 #expect(requests.count == 2)
104 #expect(requests[0].headers["If-None-Match"] == nil)
105 #expect(requests[1].headers["If-None-Match"] == etag)
104 let firstRequest = try #require(requests.first)
105 #expect(firstRequest.headers["If-None-Match"] == nil)
106 let secondRequest = try #require(requests.dropFirst().first)
107 #expect(secondRequest.headers["If-None-Match"] == etag)
106108 }
107109
108110 @Test func changedBodyReplacesTheCachedValidator() async throws {
@@ -117,7 +119,8 @@ struct GitbayClientReadTests {
117119
118120 #expect(refreshed.isEmpty)
119121 #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\"")
121124 }
122125
123126 @Test func absentDataDecodesAsEmptyList() async throws {
gitbayTests/ListFilterTests.swift +39 −8
@@ -184,7 +184,7 @@ struct IssueListFilterWiringTests {
184184
185185 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
186186 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 }
188188
189189 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
190190 let last = try #require(reads.last)
@@ -275,11 +275,18 @@ struct IssueListFilterWiringTests {
275275
276276 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
277277 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 }
279285
280 let reads = stub.seen.filter { $0.url.absoluteString.contains("argv=issue") }
281 let last = try #require(reads.last)
282 #expect(!argvFrom(last.url).contains("--cursor"))
286 let reload = try #require(stub.seen.first {
287 $0.url.absoluteString.contains("argv=issue") && argvFrom($0.url).contains("bug")
288 })
289 #expect(!argvFrom(reload.url).contains("--cursor"))
283290 }
284291
285292 /// Two rapid picker changes cancel the first reload's task. The
@@ -296,7 +303,31 @@ struct IssueListFilterWiringTests {
296303 stub.enqueue(.init(status: 200, json: emptyPage, match: "argv=issue"))
297304 model.filter.label = "bug"
298305 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 }
300331
301332 if case .failed(let message) = model.state {
302333 Testing.Issue.record("rapid filter changes left the screen failed: \(message)")
@@ -329,7 +360,7 @@ struct MRListFilterWiringTests {
329360
330361 stub.enqueue(.init(status: 200, json: emptyPage))
331362 model.filter.search = "retarget"
332 try await Task.sleep(for: .milliseconds(500))
363 await until { stub.seen.count == 2 }
333364
334365 let last = try #require(stub.seen.last)
335366 let argv = argvFrom(last.url)
@@ -366,7 +397,7 @@ struct MRListFilterWiringTests {
366397
367398 stub.enqueue(.init(status: 200, json: emptyPage))
368399 model.filter.state = .sourceGone
369 try await Task.sleep(for: .milliseconds(150))
400 await until { stub.seen.count == 2 }
370401
371402 #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone"))
372403 }
gitbayTests/MRViewModelTests.swift +17 −7
@@ -172,11 +172,21 @@ struct MRListViewModelTests {
172172 await model.load()
173173
174174 model.filter.state = .merged
175 // The reload happens in a spawned task; give it a beat.
176 try await Task.sleep(for: .milliseconds(300))
175 // The reload happens in a spawned task; wait for it to settle
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 }
177186
178187 #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)
180190 guard case .empty = model.state else {
181191 Issue.record("expected .empty after filtering, got \(model.state)")
182192 return
@@ -255,7 +265,7 @@ struct MRDetailViewModelTests {
255265
256266 await model.review(.approve)
257267
258 let write = stub.seen[3]
268 let write = try #require(stub.seen.dropFirst(3).first)
259269 #expect(write.method == "POST")
260270 #expect(write.url.path() == "/api/v1/cmd")
261271 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
@@ -273,7 +283,7 @@ struct MRDetailViewModelTests {
273283
274284 await model.comment("long review text\nwith lines")
275285
276 let write = stub.seen[3]
286 let write = try #require(stub.seen.dropFirst(3).first)
277287 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
278288 #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"])
279289 #expect(body["stdin"] as? String == "long review text\nwith lines")
@@ -302,7 +312,7 @@ struct MRDetailViewModelTests {
302312
303313 await model.setResolved(thread, true)
304314
305 let write = stub.seen[3]
315 let write = try #require(stub.seen.dropFirst(3).first)
306316 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
307317 #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"])
308318 }
@@ -317,7 +327,7 @@ struct MRDetailViewModelTests {
317327
318328 await model.reply(to: thread, "because 5xx is transient")
319329
320 let write = stub.seen[3]
330 let write = try #require(stub.seen.dropFirst(3).first)
321331 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
322332 #expect(body["argv"] as? [String] ==
323333 ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"])
gitbayTests/NotificationTests.swift +9 −5
@@ -127,7 +127,7 @@ struct NotificationsViewModelTests {
127127
128128 stub.enqueue(.init(status: 200, json: page))
129129 model.showAll = true
130 try await Task.sleep(for: .milliseconds(150))
130 await until { stub.seen.count == 2 }
131131
132132 #expect(argvFrom(try #require(stub.seen.last).url).contains("--all"))
133133 }
@@ -143,10 +143,14 @@ struct NotificationsViewModelTests {
143143
144144 stub.enqueue(.init(status: 200, json: page))
145145 model.showAll = true
146 try await Task.sleep(for: .milliseconds(150))
147
148 let argv = argvFrom(try #require(stub.seen.last).url)
149 #expect(argv.contains("--cursor") == false)
146 // Poll for the read that actually carries the new filter, not
147 // whatever happens to be last: a reload that never landed would
148 // otherwise leave `last` pointing at the initial request, which
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"))
150154 }
151155
152156 @Test func markingOneReadSendsItsId() async throws {
gitbayTests/PagedListTests.swift +8 −4
@@ -38,9 +38,11 @@ struct ClientPagingTests {
3838 #expect(first.next == "abc")
3939 #expect(second.items == [Row(number: 3)])
4040 #expect(second.next == nil)
41 #expect(stub.seen[0].url.query() ==
41 let firstSeen = try #require(stub.seen.first)
42 #expect(firstSeen.url.query() ==
4243 "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() ==
4446 "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2&argv=--cursor&argv=abc")
4547 }
4648}
@@ -77,8 +79,10 @@ struct PagedListModelTests {
7779 // Further calls are no-ops, not requests.
7880 await list.loadMore()
7981 #expect(stub.seen.count == 3)
80 #expect(stub.seen[1].url.query()?.contains("argv=--cursor&argv=c1") == true)
81 #expect(stub.seen[2].url.query()?.contains("argv=--cursor&argv=c2") == true)
82 let second = try #require(stub.seen.dropFirst().first)
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)
8286 }
8387
8488 @Test func anEmptyFirstPageIsAnEmptyState() async throws {
gitbayTests/ProfileCommitTests.swift +2 −1
@@ -59,7 +59,8 @@ struct ProfileAggregateTests {
5959 #expect(profile.activityTotal == 15)
6060 // One read builds the whole page.
6161 #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")
6364 }
6465
6566 @Test func anOrgProfileCarriesMembersInsteadOfOrgs() async throws {
gitbayTests/RefsMilestoneTests.swift +13 −2
@@ -107,10 +107,21 @@ struct MilestoneListViewModelTests {
107107 await model.load()
108108
109109 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 }
111121
112122 #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)
114125 }
115126}
116127
gitbayTests/SearchTests.swift +1 −1
@@ -137,7 +137,7 @@ struct SearchViewModelTests {
137137
138138 stub.enqueue(.init(status: 200, json: mixedResults))
139139 model.kind = .issue
140 try await Task.sleep(for: .milliseconds(150))
140 await until { stub.seen.count == 2 }
141141
142142 #expect(argvFrom(try #require(stub.seen.last).url)
143143 == ["search", "parity", "--kind", "issue"])
gitbayTests/SessionStoreTests.swift +3 −2
@@ -162,8 +162,9 @@ struct SessionStoreTests {
162162 await #expect(throws: GitbayError.self) {
163163 _ = try await client.readList(["repo", "list"], of: RepoSummary.self)
164164 }
165 // The sign-out hops through the main actor; give it a beat.
166 try await Task.sleep(for: .milliseconds(200))
165 // The sign-out hops through the main actor; wait for it rather
166 // than for a fixed interval to pass.
167 await until { session.current == nil }
167168
168169 #expect(session.current == nil)
169170 #expect(session.client == nil)
gitbayTests/StubProtocol.swift +16
@@ -161,3 +161,19 @@ nonisolated final class Mutex<Value>: @unchecked Sendable {
161161 return body(&value)
162162 }
163163}
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}