Make the timing-sensitive tests deterministic !57

merged merged by cmc on 2026-09-07 02:23 UTC · krz/gitbay-ios:test-timing into main

12 files changed, +118 −35

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}