gitbayTests/CompareTests.swift
355 lines · 13948 bytes
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvFrom(_ url: URL) -> [String] {
16 URLComponents(url: url, resolvingAgainstBaseURL: false)?
17 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
18}
19
20nonisolated private let patchJSON = """
21 {"protocol_version":1,"data":{"base":"aaa111","head":"bbb222","merge_base":"ccc333",\
22 "patch":"diff --git a/x.txt b/x.txt\\n--- a/x.txt\\n+++ b/x.txt\\n@@ -1 +1 @@\\n-old\\n+new\\n",\
23 "truncated":false},"exit_code":0}
24 """
25
26/// `StubProtocol` answers a request as soon as it arrives, and the actual
27/// delivery back onto the main actor still crosses a real thread — so
28/// polling its request log cannot promise "the response has not landed
29/// yet" at the instant a test checks it. `GatedProtocol` pins that instant
30/// down: `startLoading` blocks the request until the test explicitly
31/// releases it, so a ref mutation made between arrival and release is
32/// provably made while the request is still outstanding. Scoped per test
33/// the same way `StubProtocol` is, via a marker header, so parallel tests
34/// don't share a gate.
35nonisolated private final class GatedProtocol: URLProtocol, @unchecked Sendable {
36
37 final class Box: Sendable {
38 fileprivate let id = UUID().uuidString
39 private let gate = Mutex<(continuation: CheckedContinuation<Void, Never>?, release: DispatchSemaphore?)>((nil, nil))
40
41 func session() -> URLSession {
42 let configuration = URLSessionConfiguration.ephemeral
43 configuration.protocolClasses = [GatedProtocol.self]
44 configuration.httpAdditionalHeaders = [GatedProtocol.marker: id]
45 return URLSession(configuration: configuration)
46 }
47
48 /// Suspends until the in-flight request reaches `startLoading`,
49 /// then returns the semaphore that must be signaled to let its
50 /// (canned, successful) response through.
51 func awaitArrival() async -> DispatchSemaphore {
52 let release = DispatchSemaphore(value: 0)
53 await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
54 gate.withLock { $0 = (continuation, release) }
55 }
56 return release
57 }
58
59 fileprivate func arrived() -> (CheckedContinuation<Void, Never>?, DispatchSemaphore?) {
60 gate.withLock { state in
61 let result = state
62 state.continuation = nil
63 return result
64 }
65 }
66 }
67
68 private static let marker = "X-Gated-Box"
69 private static let boxes = Mutex<[String: Box]>([:])
70
71 static func box() -> Box {
72 let box = Box()
73 boxes.withLock { $0[box.id] = box }
74 return box
75 }
76
77 override static func canInit(with _: URLRequest) -> Bool { true }
78 override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
79
80 override func startLoading() {
81 guard let id = request.value(forHTTPHeaderField: Self.marker),
82 let box = Self.boxes.withLock({ $0[id] }) else {
83 client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
84 return
85 }
86 let (continuation, release) = box.arrived()
87 continuation?.resume()
88 release?.wait() // blocks this background thread until the test releases it
89
90 let response = HTTPURLResponse(
91 url: request.url!,
92 statusCode: 200,
93 httpVersion: "HTTP/1.1",
94 headerFields: ["Content-Type": "application/json"]
95 )!
96 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
97 client?.urlProtocol(self, didLoad: Data(patchJSON.utf8))
98 client?.urlProtocolDidFinishLoading(self)
99 }
100
101 override func stopLoading() {}
102}
103
104struct RepoDiffDecodingTests {
105
106 @Test func decodesEveryField() throws {
107 let decoder = JSONDecoder()
108 let json = """
109 {"base":"aaa111","head":"bbb222","merge_base":"ccc333","patch":"x","truncated":true}
110 """
111 let diff = try decoder.decode(RepoDiff.self, from: Data(json.utf8))
112 #expect(diff.base == "aaa111")
113 #expect(diff.head == "bbb222")
114 #expect(diff.mergeBase == "ccc333")
115 #expect(diff.patch == "x")
116 #expect(diff.truncated)
117 }
118}
119
120@MainActor
121struct CompareViewModelTests {
122
123 @Test func bothRefsArePositionalAndBaseComesFirst() async throws {
124 let (client, stub) = try makeClient()
125 stub.enqueue(.init(status: 200, json: patchJSON))
126 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
127 model.base = "main"
128 model.head = "feature"
129 await model.compare()
130
131 #expect(argvFrom(try #require(stub.seen.last).url)
132 == ["repo", "diff", "krz/gitbay", "main", "feature"])
133 }
134
135 @Test func theParsedPatchReachesTheScreen() async throws {
136 let (client, stub) = try makeClient()
137 stub.enqueue(.init(status: 200, json: patchJSON))
138 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
139 model.base = "main"
140 model.head = "feature"
141 await model.compare()
142
143 #expect(model.state.value?.files.count == 1)
144 #expect(model.mergeBase == "ccc333")
145 #expect(model.truncated == false)
146 }
147
148 /// A partial patch rendered as though whole is a bug the user cannot
149 /// see, so the flag must survive to the screen.
150 @Test func truncationIsCarriedThrough() async throws {
151 let (client, stub) = try makeClient()
152 stub.enqueue(.init(status: 200, json: """
153 {"protocol_version":1,"data":{"base":"a","head":"b","merge_base":"c",\
154 "patch":"diff --git a/x b/x\\n--- a/x\\n+++ b/x\\n@@ -1 +1 @@\\n-a\\n+b\\n",\
155 "truncated":true},"exit_code":0}
156 """))
157 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
158 model.base = "main"
159 model.head = "feature"
160 await model.compare()
161
162 #expect(model.truncated)
163 }
164
165 @Test func comparingARefWithItselfIsEmptyNotFailed() async throws {
166 let (client, stub) = try makeClient()
167 stub.enqueue(.init(status: 200, json: """
168 {"protocol_version":1,"data":{"base":"a","head":"a","merge_base":"a",\
169 "patch":"","truncated":false},"exit_code":0}
170 """))
171 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
172 model.base = "main"
173 model.head = "main"
174 await model.compare()
175
176 guard case .empty = model.state else {
177 Testing.Issue.record("expected empty, got \(model.state)")
178 return
179 }
180 }
181
182 @Test func aBlankRefComparesNothing() async throws {
183 let (client, stub) = try makeClient()
184 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
185 model.base = "main"
186 model.head = " "
187 await model.compare()
188
189 #expect(stub.seen.isEmpty)
190 }
191
192 @Test func refsComeFromRepoRefs() async throws {
193 let (client, stub) = try makeClient()
194 stub.enqueue(.init(status: 200, json: """
195 {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\
196 {"name":"feature","sha":"b"}],"tags":[{"name":"v1","sha":"c"}]},"exit_code":0}
197 """))
198 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
199 await model.loadRefs()
200
201 #expect(model.refs?.branches.map(\.name) == ["main", "feature"])
202 #expect(model.refs?.tags.map(\.name) == ["v1"])
203 #expect(argvFrom(try #require(stub.seen.last).url)
204 == ["repo", "refs", "krz/gitbay"])
205 }
206
207 /// The lesson from the retarget picker: a failed ref load must not
208 /// look like a repository with no branches.
209 @Test func aFailedRefLoadIsDistinguishableFromNoBranches() async throws {
210 let (client, stub) = try makeClient()
211 stub.enqueue(.init(status: 200, json: """
212 {"protocol_version":1,"error":"denied","exit_code":4}
213 """))
214 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
215 await model.loadRefs()
216
217 #expect(model.refs == nil)
218 #expect(model.refsError?.isEmpty == false)
219 }
220
221 @Test func aFailedCompareIsTheScreensState() async throws {
222 let (client, stub) = try makeClient()
223 stub.enqueue(.init(status: 200, json: """
224 {"protocol_version":1,"error":"unknown revision","exit_code":1}
225 """))
226 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
227 model.base = "main"
228 model.head = "nope"
229 await model.compare()
230
231 guard case .failed = model.state else {
232 Testing.Issue.record("expected failed, got \(model.state)")
233 return
234 }
235 }
236
237 /// The realistic failure from `repo diff` on an unresolvable ref is
238 /// exit 3, which `GitbayError.isEmptyState` maps to `.empty`, not
239 /// `.failed` — pinning that mapping rather than assuming it.
240 @Test func anUnresolvableRefIsEmptyNotFailed() async throws {
241 let (client, stub) = try makeClient()
242 stub.enqueue(.init(status: 200, json: """
243 {"protocol_version":1,"error":"unknown revision","exit_code":3}
244 """))
245 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
246 model.base = "main"
247 model.head = "nope"
248 await model.compare()
249
250 guard case .empty = model.state else {
251 Testing.Issue.record("expected empty, got \(model.state)")
252 return
253 }
254 }
255
256 /// The race from the retarget bug: a compare is outstanding, the user
257 /// changes a ref before it resolves, and the outstanding request's
258 /// result must not land — it belongs to a ref pairing nobody is
259 /// looking at any more. `GatedProtocol` makes the interleaving exact
260 /// rather than probable: the request is provably still unanswered when
261 /// the ref changes, because nothing has let its response through yet.
262 @Test func aStaleCompareDoesNotOverwriteANewerRef() async throws {
263 let box = GatedProtocol.box()
264 let client = GitbayClient(
265 instance: try GitbayInstance(url: "https://gitbay.org"),
266 token: "test-token",
267 session: box.session()
268 )
269 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
270 model.base = "aaa111"
271 model.head = "v1"
272
273 let task = Task { await model.compare() }
274 let release = await box.awaitArrival() // the v1 request has arrived and is now blocked
275
276 guard case .loading = model.state else {
277 Testing.Issue.record("expected .loading while the request is outstanding, got \(model.state)")
278 release.signal()
279 await task.value
280 return
281 }
282
283 model.head = "v2" // ref changed while the v1 request is still in flight
284 release.signal() // let the v1 response through now that it's stale
285 await task.value
286
287 #expect(model.state.value == nil)
288 #expect(model.mergeBase == nil)
289 #expect(model.truncated == false)
290 }
291
292 /// A superseded compare must not leave the screen presenting as
293 /// in-flight forever: the ref change has to clear `.loading`
294 /// immediately, and the discarded response arriving afterward must
295 /// not put it back.
296 @Test func aSupersededCompareStopsPresentingAsInFlight() async throws {
297 let box = GatedProtocol.box()
298 let client = GitbayClient(
299 instance: try GitbayInstance(url: "https://gitbay.org"),
300 token: "test-token",
301 session: box.session()
302 )
303 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
304 model.base = "aaa111"
305 model.head = "v1"
306
307 let task = Task { await model.compare() }
308 let release = await box.awaitArrival()
309
310 guard case .loading = model.state else {
311 Testing.Issue.record("expected .loading while the request is outstanding, got \(model.state)")
312 release.signal()
313 await task.value
314 return
315 }
316
317 model.head = "v2" // ref changed while the v1 request is still in flight
318
319 if case .loading = model.state {
320 Testing.Issue.record("ref change did not clear .loading immediately")
321 }
322 release.signal()
323 await task.value
324
325 if case .loading = model.state {
326 Testing.Issue.record("superseded compare's response left the screen stuck presenting as in-flight")
327 }
328 }
329
330 /// A second compare that fails must not show the first compare's
331 /// merge base or truncation notice beside the failed state.
332 @Test func aFailedRecompareDropsTheStaleResult() async throws {
333 let (client, stub) = try makeClient()
334 stub.enqueue(.init(status: 200, json: patchJSON))
335 let model = CompareViewModel(client: client, repoPath: "krz/gitbay")
336 model.base = "main"
337 model.head = "feature"
338 await model.compare()
339
340 #expect(model.mergeBase == "ccc333")
341 #expect(model.state.value != nil)
342
343 stub.enqueue(.init(status: 200, json: """
344 {"protocol_version":1,"error":"unknown revision","exit_code":1}
345 """))
346 await model.compare()
347
348 guard case .failed = model.state else {
349 Testing.Issue.record("expected failed, got \(model.state)")
350 return
351 }
352 #expect(model.mergeBase == nil)
353 #expect(model.truncated == false)
354 }
355}