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 {
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}