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 build( status: String = "failure", steps: [String]? = ["go build ./...", "go test ./..."], failedStep: Int? = nil ) -> Build { Build( number: 1, job: "test", status: status, sha: "abc", ref: "main", createdAt: Date(), finishedAt: nil, subject: nil, durationS: nil, steps: steps, failedStep: failedStep, failedReason: nil ) } private let failedLog = "$ git clone ssh://x/a.git (abc)\n" + "$ go build ./...\n" + "built\n" + "$ go test ./...\n" + "--- FAIL: TestX\n" + "step 2/2 failed: exit 1\n" struct BuildLogTests { @Test func splitsAtEachStepLikeTheWeb() { let log = BuildLog(failedLog, build: build()) #expect(log.stepped) #expect(log.sections.map(\.n) == [0, 1, 2]) #expect(log.sections.map(\.step) == ["", "go build ./...", "go test ./..."]) #expect(log.sections[0].lines.map(\.text) == ["$ git clone ssh://x/a.git (abc)"]) #expect(log.sections[1].lines.map(\.text) == ["built"]) #expect(log.sections[2].lines.map(\.text) == ["--- FAIL: TestX", "step 2/2 failed: exit 1"]) // Line ids run across sections so each can be scrolled to. #expect(log.sections.flatMap(\.lines).map(\.id) == [0, 1, 2, 3]) #expect(log.lastLineID == 3) } /// A step's line not at a line start does not cut, a step missing /// from the log has no section, and an empty setup is left out. @Test func stopsAtAMissingStep() { let log = BuildLog("$ make\nrunning: $ make test\nerror\n", build: build(steps: ["make", "make test"])) #expect(log.sections.map(\.n) == [1]) #expect(log.sections[0].lines.map(\.text) == ["running: $ make test", "error"]) } @Test func aLogWithNoStepLineIsOneBlock() { let log = BuildLog("$ git clone x\nfatal: nope\n", build: build(steps: ["make"])) #expect(!log.stepped) #expect(log.sections.count == 1) #expect(log.sections[0].lines.count == 2) } @Test func theFailedSectionIsTheNamedStepOrElseTheLast() { #expect(BuildLog(failedLog, build: build(failedStep: 1)).failed == 1) #expect(BuildLog(failedLog, build: build(failedStep: nil)).failed == 2) #expect(BuildLog(failedLog, build: build(status: "success")).failed == nil) #expect(BuildLog("", build: build()).failed == nil) } @Test func aMultilineStepIsHeadedByItsFirstLine() { let log = BuildLog("$ echo a\necho b\na\nb\n", build: build(steps: ["echo a\necho b"])) #expect(log.sections[0].head == "echo a") #expect(log.sections[0].lines.map(\.text) == ["a", "b"]) } @Test func filteringKeepsMatchingLinesUnderTheirStep() { let log = BuildLog(failedLog, build: build()) let hits = log.filtered("fail") #expect(hits.map(\.n) == [2]) #expect(hits[0].lines.map(\.id) == [2, 3]) #expect(log.filtered("").count == 3) #expect(log.filtered("nothing like this").isEmpty) } @Test func aLongLogSplitsIntoEveryLine() { let body = (1...1400).map { "line \($0)" }.joined(separator: "\n") let log = BuildLog("$ go test ./...\n" + body + "\n", build: build(steps: ["go test ./..."])) #expect(log.sections.count == 1) #expect(log.sections[0].lines.count == 1400) #expect(log.sections[0].lines.last?.text == "line 1400") } @Test func durationReadsInUnits() { #expect(Build.ran(seconds: 197) == "3m 17s") #expect(Build.ran(seconds: nil) == nil) } @Test func decodesTheFailureFieldsFromBuildShow() throws { let json = """ {"number":1510,"job":"test","status":"failure","sha":"1f68760006","ref":"x",\ "created_at":"2026-09-24T14:47:48Z","finished_at":"2026-09-24T14:58:44Z",\ "duration_s":197,"steps":["make","make test"],"failed_step":2,"failed_reason":"exit 1"} """ let build = try GitbayClient.decoder().decode(Build.self, from: Data(json.utf8)) #expect(build.durationS == 197) #expect(build.steps == ["make", "make test"]) #expect(build.failedStep == 2) #expect(build.failedReason == "exit 1") } } @MainActor struct BuildPollingTests { private func show(_ status: String) -> String { """ {"protocol_version":1,"data":{"number":62,"job":"build","status":"\(status)",\ "sha":"aa00000000","ref":"main","created_at":"2026-08-28T05:00:00.000Z"},\ "exit_code":0} """ } @Test func onlyUnfinishedBuildsArePolled() { #expect(BuildDetailViewModel.isLive(status: "pending")) #expect(BuildDetailViewModel.isLive(status: "queued")) #expect(BuildDetailViewModel.isLive(status: "running")) #expect(!BuildDetailViewModel.isLive(status: "success")) #expect(!BuildDetailViewModel.isLive(status: "failure")) } @Test func pollsUntilTheBuildFinishes() async throws { let (client, stub) = try makeClient() for (status, output) in [("running", "a\n"), ("running", "a\nb\n"), ("success", "a\nb\nc\n")] { stub.enqueue(.init(status: 200, json: show(status), match: "argv=show")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"\#(output)","exit_code":0}"#.replacingOccurrences(of: "\n", with: "\\n"), match: "argv=log")) } let model = BuildDetailViewModel( client: client, repoPath: "krz/gitbay", number: 62, pollInterval: .milliseconds(10) ) await model.load() await model.poll() let detail = try #require(model.state.value) #expect(detail.build.status == "success") #expect(detail.log == "a\nb\nc\n") #expect(stub.seen.count == 6) } @Test func aFinishedBuildIsNotPolled() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: show("success"), match: "argv=show")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"ok","exit_code":0}"#, match: "argv=log")) let model = BuildDetailViewModel( client: client, repoPath: "krz/gitbay", number: 62, pollInterval: .milliseconds(10) ) await model.load() await model.poll() #expect(stub.seen.count == 2) } @Test func aFailedPollKeepsTheLogOnScreen() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: show("running"), match: "argv=show")) stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"a","exit_code":0}"#, match: "argv=log")) // Nothing else is queued: every poll after the first load fails. let model = BuildDetailViewModel( client: client, repoPath: "krz/gitbay", number: 62, pollInterval: .milliseconds(10) ) await model.load() let polling = Task { await model.poll() } try await Task.sleep(for: .milliseconds(300)) polling.cancel() await polling.value #expect(model.state.value?.log == "a") } } private let buildShowJSON = """ {"protocol_version":1,"data":{"number":1,"job":"test","status":"failure","sha":"abc",\ "ref":"main","created_at":"2026-08-20T10:00:00.000Z","steps":["go build ./...","go test ./..."],\ "failed_step":2},"exit_code":0} """ private func logEnvelope(_ text: String) -> String { let data = try! JSONSerialization.data(withJSONObject: ["protocol_version": 1, "output": text, "exit_code": 0]) return String(decoding: data, as: UTF8.self) } @MainActor struct BuildLogTailTests { @Test func theTailReadsTheLastLinesAndDrawsThemUnsplit() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log")) stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: logEnvelope("--- FAIL: TestX\nstep 2/2 failed: exit 1\n"), match: "argv=log")) let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1) await model.load() #expect(model.state.value?.lines.stepped == true) await model.showTail() let log = try #require(stub.seen.last { $0.url.absoluteString.contains("argv=log") }) #expect(log.url.query()?.hasSuffix("argv=--tail&argv=\(BuildDetailViewModel.tailLines)") == true) let detail = try #require(model.state.value) #expect(model.tail) // A step's marker may be cut off, so the tail is one plain block. #expect(!detail.lines.stepped) // A failed build's tail is where it failed. #expect(detail.lines.failed == 0) #expect(detail.lines.sections.first?.lines.map(\.text) == ["--- FAIL: TestX", "step 2/2 failed: exit 1"]) } @Test func theWholeLogComesBackSplit() async throws { let (client, stub) = try makeClient() for _ in 0..<3 { stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show")) } stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log")) stub.enqueue(.init(status: 200, json: logEnvelope("step 2/2 failed: exit 1\n"), match: "argv=log")) stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log")) let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1) await model.load() await model.showTail() await model.showWholeLog() #expect(!model.tail) #expect(stub.seen.last { $0.url.absoluteString.contains("argv=log") }?.url.query()?.contains("tail") == false) #expect(model.state.value?.lines.stepped == true) } @Test func aFetchFromTheOtherModeIsNotShown() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show")) stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log")) let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1) await model.load() // The detail records the mode it was fetched in. #expect(model.state.value?.tail == false) #expect(BuildDetail(build: try #require(model.state.value).build, log: "x\n", tail: true).tail) } }