import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvFrom(_ url: URL) -> [String] { URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] } nonisolated private let patchJSON = """ {"protocol_version":1,"data":{"base":"aaa111","head":"bbb222","merge_base":"ccc333",\ "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",\ "truncated":false},"exit_code":0} """ /// `StubProtocol` answers a request as soon as it arrives, and the actual /// delivery back onto the main actor still crosses a real thread — so /// polling its request log cannot promise "the response has not landed /// yet" at the instant a test checks it. `GatedProtocol` pins that instant /// down: `startLoading` blocks the request until the test explicitly /// releases it, so a ref mutation made between arrival and release is /// provably made while the request is still outstanding. Scoped per test /// the same way `StubProtocol` is, via a marker header, so parallel tests /// don't share a gate. nonisolated private final class GatedProtocol: URLProtocol, @unchecked Sendable { final class Box: Sendable { fileprivate let id = UUID().uuidString private let gate = Mutex<(continuation: CheckedContinuation?, release: DispatchSemaphore?)>((nil, nil)) func session() -> URLSession { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [GatedProtocol.self] configuration.httpAdditionalHeaders = [GatedProtocol.marker: id] return URLSession(configuration: configuration) } /// Suspends until the in-flight request reaches `startLoading`, /// then returns the semaphore that must be signaled to let its /// (canned, successful) response through. func awaitArrival() async -> DispatchSemaphore { let release = DispatchSemaphore(value: 0) await withCheckedContinuation { (continuation: CheckedContinuation) in gate.withLock { $0 = (continuation, release) } } return release } fileprivate func arrived() -> (CheckedContinuation?, DispatchSemaphore?) { gate.withLock { state in let result = state state.continuation = nil return result } } } private static let marker = "X-Gated-Box" private static let boxes = Mutex<[String: Box]>([:]) static func box() -> Box { let box = Box() boxes.withLock { $0[box.id] = box } return box } override static func canInit(with _: URLRequest) -> Bool { true } override static func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { guard let id = request.value(forHTTPHeaderField: Self.marker), let box = Self.boxes.withLock({ $0[id] }) else { client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable)) return } let (continuation, release) = box.arrived() continuation?.resume() release?.wait() // blocks this background thread until the test releases it let response = HTTPURLResponse( url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"] )! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: Data(patchJSON.utf8)) client?.urlProtocolDidFinishLoading(self) } override func stopLoading() {} } struct RepoDiffDecodingTests { @Test func decodesEveryField() throws { let decoder = JSONDecoder() let json = """ {"base":"aaa111","head":"bbb222","merge_base":"ccc333","patch":"x","truncated":true} """ let diff = try decoder.decode(RepoDiff.self, from: Data(json.utf8)) #expect(diff.base == "aaa111") #expect(diff.head == "bbb222") #expect(diff.mergeBase == "ccc333") #expect(diff.patch == "x") #expect(diff.truncated) } } @MainActor struct CompareViewModelTests { @Test func bothRefsArePositionalAndBaseComesFirst() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: patchJSON)) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") model.base = "main" model.head = "feature" await model.compare() #expect(argvFrom(try #require(stub.seen.last).url) == ["repo", "diff", "krz/gitbay", "main", "feature"]) } @Test func theParsedPatchReachesTheScreen() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: patchJSON)) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") model.base = "main" model.head = "feature" await model.compare() #expect(model.state.value?.files.count == 1) #expect(model.mergeBase == "ccc333") #expect(model.truncated == false) } /// A partial patch rendered as though whole is a bug the user cannot /// see, so the flag must survive to the screen. @Test func truncationIsCarriedThrough() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"base":"a","head":"b","merge_base":"c",\ "patch":"diff --git a/x b/x\\n--- a/x\\n+++ b/x\\n@@ -1 +1 @@\\n-a\\n+b\\n",\ "truncated":true},"exit_code":0} """)) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") model.base = "main" model.head = "feature" await model.compare() #expect(model.truncated) } @Test func comparingARefWithItselfIsEmptyNotFailed() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"base":"a","head":"a","merge_base":"a",\ "patch":"","truncated":false},"exit_code":0} """)) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") model.base = "main" model.head = "main" await model.compare() guard case .empty = model.state else { Testing.Issue.record("expected empty, got \(model.state)") return } } @Test func aBlankRefComparesNothing() async throws { let (client, stub) = try makeClient() let model = CompareViewModel(client: client, repoPath: "krz/gitbay") model.base = "main" model.head = " " await model.compare() #expect(stub.seen.isEmpty) } @Test func refsComeFromRepoRefs() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\ {"name":"feature","sha":"b"}],"tags":[{"name":"v1","sha":"c"}]},"exit_code":0} """)) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") await model.loadRefs() #expect(model.refs?.branches.map(\.name) == ["main", "feature"]) #expect(model.refs?.tags.map(\.name) == ["v1"]) #expect(argvFrom(try #require(stub.seen.last).url) == ["repo", "refs", "krz/gitbay"]) } /// The lesson from the retarget picker: a failed ref load must not /// look like a repository with no branches. @Test func aFailedRefLoadIsDistinguishableFromNoBranches() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"denied","exit_code":4} """)) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") await model.loadRefs() #expect(model.refs == nil) #expect(model.refsError?.isEmpty == false) } @Test func aFailedCompareIsTheScreensState() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"unknown revision","exit_code":1} """)) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") model.base = "main" model.head = "nope" await model.compare() guard case .failed = model.state else { Testing.Issue.record("expected failed, got \(model.state)") return } } /// The realistic failure from `repo diff` on an unresolvable ref is /// exit 3, which `GitbayError.isEmptyState` maps to `.empty`, not /// `.failed` — pinning that mapping rather than assuming it. @Test func anUnresolvableRefIsEmptyNotFailed() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"unknown revision","exit_code":3} """)) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") model.base = "main" model.head = "nope" await model.compare() guard case .empty = model.state else { Testing.Issue.record("expected empty, got \(model.state)") return } } /// The race from the retarget bug: a compare is outstanding, the user /// changes a ref before it resolves, and the outstanding request's /// result must not land — it belongs to a ref pairing nobody is /// looking at any more. `GatedProtocol` makes the interleaving exact /// rather than probable: the request is provably still unanswered when /// the ref changes, because nothing has let its response through yet. @Test func aStaleCompareDoesNotOverwriteANewerRef() async throws { let box = GatedProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") model.base = "aaa111" model.head = "v1" let task = Task { await model.compare() } let release = await box.awaitArrival() // the v1 request has arrived and is now blocked guard case .loading = model.state else { Testing.Issue.record("expected .loading while the request is outstanding, got \(model.state)") release.signal() await task.value return } model.head = "v2" // ref changed while the v1 request is still in flight release.signal() // let the v1 response through now that it's stale await task.value #expect(model.state.value == nil) #expect(model.mergeBase == nil) #expect(model.truncated == false) } /// A superseded compare must not leave the screen presenting as /// in-flight forever: the ref change has to clear `.loading` /// immediately, and the discarded response arriving afterward must /// not put it back. @Test func aSupersededCompareStopsPresentingAsInFlight() async throws { let box = GatedProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") model.base = "aaa111" model.head = "v1" let task = Task { await model.compare() } let release = await box.awaitArrival() guard case .loading = model.state else { Testing.Issue.record("expected .loading while the request is outstanding, got \(model.state)") release.signal() await task.value return } model.head = "v2" // ref changed while the v1 request is still in flight if case .loading = model.state { Testing.Issue.record("ref change did not clear .loading immediately") } release.signal() await task.value if case .loading = model.state { Testing.Issue.record("superseded compare's response left the screen stuck presenting as in-flight") } } /// A second compare that fails must not show the first compare's /// merge base or truncation notice beside the failed state. @Test func aFailedRecompareDropsTheStaleResult() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: patchJSON)) let model = CompareViewModel(client: client, repoPath: "krz/gitbay") model.base = "main" model.head = "feature" await model.compare() #expect(model.mergeBase == "ccc333") #expect(model.state.value != nil) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"unknown revision","exit_code":1} """)) await model.compare() guard case .failed = model.state else { Testing.Issue.record("expected failed, got \(model.state)") return } #expect(model.mergeBase == nil) #expect(model.truncated == false) } }