gitbayTests/BuildLogTests.swift

v1.11.0
gitbay-ios/gitbayTests/BuildLogTests.swift history · blame · raw

262 lines · 10933 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 build(
 16    status: String = "failure",
 17    steps: [String]? = ["go build ./...", "go test ./..."],
 18    failedStep: Int? = nil
 19) -> Build {
 20    Build(
 21        number: 1, job: "test", status: status, sha: "abc", ref: "main",
 22        createdAt: Date(), finishedAt: nil, subject: nil,
 23        durationS: nil, steps: steps, failedStep: failedStep, failedReason: nil
 24    )
 25}
 26
 27private let failedLog = "$ git clone ssh://x/a.git (abc)\n" +
 28    "$ go build ./...\n" +
 29    "built\n" +
 30    "$ go test ./...\n" +
 31    "--- FAIL: TestX\n" +
 32    "step 2/2 failed: exit 1\n"
 33
 34struct BuildLogTests {
 35
 36    @Test func splitsAtEachStepLikeTheWeb() {
 37        let log = BuildLog(failedLog, build: build())
 38
 39        #expect(log.stepped)
 40        #expect(log.sections.map(\.n) == [0, 1, 2])
 41        #expect(log.sections.map(\.step) == ["", "go build ./...", "go test ./..."])
 42        #expect(log.sections[0].lines.map(\.text) == ["$ git clone ssh://x/a.git (abc)"])
 43        #expect(log.sections[1].lines.map(\.text) == ["built"])
 44        #expect(log.sections[2].lines.map(\.text) == ["--- FAIL: TestX", "step 2/2 failed: exit 1"])
 45        // Line ids run across sections so each can be scrolled to.
 46        #expect(log.sections.flatMap(\.lines).map(\.id) == [0, 1, 2, 3])
 47        #expect(log.lastLineID == 3)
 48    }
 49
 50    /// A step's line not at a line start does not cut, a step missing
 51    /// from the log has no section, and an empty setup is left out.
 52    @Test func stopsAtAMissingStep() {
 53        let log = BuildLog("$ make\nrunning: $ make test\nerror\n",
 54                           build: build(steps: ["make", "make test"]))
 55
 56        #expect(log.sections.map(\.n) == [1])
 57        #expect(log.sections[0].lines.map(\.text) == ["running: $ make test", "error"])
 58    }
 59
 60    @Test func aLogWithNoStepLineIsOneBlock() {
 61        let log = BuildLog("$ git clone x\nfatal: nope\n", build: build(steps: ["make"]))
 62
 63        #expect(!log.stepped)
 64        #expect(log.sections.count == 1)
 65        #expect(log.sections[0].lines.count == 2)
 66    }
 67
 68    @Test func theFailedSectionIsTheNamedStepOrElseTheLast() {
 69        #expect(BuildLog(failedLog, build: build(failedStep: 1)).failed == 1)
 70        #expect(BuildLog(failedLog, build: build(failedStep: nil)).failed == 2)
 71        #expect(BuildLog(failedLog, build: build(status: "success")).failed == nil)
 72        #expect(BuildLog("", build: build()).failed == nil)
 73    }
 74
 75    @Test func aMultilineStepIsHeadedByItsFirstLine() {
 76        let log = BuildLog("$ echo a\necho b\na\nb\n", build: build(steps: ["echo a\necho b"]))
 77
 78        #expect(log.sections[0].head == "echo a")
 79        #expect(log.sections[0].lines.map(\.text) == ["a", "b"])
 80    }
 81
 82    @Test func filteringKeepsMatchingLinesUnderTheirStep() {
 83        let log = BuildLog(failedLog, build: build())
 84
 85        let hits = log.filtered("fail")
 86        #expect(hits.map(\.n) == [2])
 87        #expect(hits[0].lines.map(\.id) == [2, 3])
 88        #expect(log.filtered("").count == 3)
 89        #expect(log.filtered("nothing like this").isEmpty)
 90    }
 91
 92    @Test func aLongLogSplitsIntoEveryLine() {
 93        let body = (1...1400).map { "line \($0)" }.joined(separator: "\n")
 94        let log = BuildLog("$ go test ./...\n" + body + "\n", build: build(steps: ["go test ./..."]))
 95
 96        #expect(log.sections.count == 1)
 97        #expect(log.sections[0].lines.count == 1400)
 98        #expect(log.sections[0].lines.last?.text == "line 1400")
 99    }
100
101    @Test func durationReadsInUnits() {
102        #expect(Build.ran(seconds: 197) == "3m 17s")
103        #expect(Build.ran(seconds: nil) == nil)
104    }
105
106    @Test func decodesTheFailureFieldsFromBuildShow() throws {
107        let json = """
108            {"number":1510,"job":"test","status":"failure","sha":"1f68760006","ref":"x",\
109            "created_at":"2026-09-24T14:47:48Z","finished_at":"2026-09-24T14:58:44Z",\
110            "duration_s":197,"steps":["make","make test"],"failed_step":2,"failed_reason":"exit 1"}
111            """
112        let build = try GitbayClient.decoder().decode(Build.self, from: Data(json.utf8))
113
114        #expect(build.durationS == 197)
115        #expect(build.steps == ["make", "make test"])
116        #expect(build.failedStep == 2)
117        #expect(build.failedReason == "exit 1")
118    }
119}
120
121@MainActor
122struct BuildPollingTests {
123
124    private func show(_ status: String) -> String {
125        """
126        {"protocol_version":1,"data":{"number":62,"job":"build","status":"\(status)",\
127        "sha":"aa00000000","ref":"main","created_at":"2026-08-28T05:00:00.000Z"},\
128        "exit_code":0}
129        """
130    }
131
132    @Test func onlyUnfinishedBuildsArePolled() {
133        #expect(BuildDetailViewModel.isLive(status: "pending"))
134        #expect(BuildDetailViewModel.isLive(status: "queued"))
135        #expect(BuildDetailViewModel.isLive(status: "running"))
136        #expect(!BuildDetailViewModel.isLive(status: "success"))
137        #expect(!BuildDetailViewModel.isLive(status: "failure"))
138    }
139
140    @Test func pollsUntilTheBuildFinishes() async throws {
141        let (client, stub) = try makeClient()
142        for (status, output) in [("running", "a\n"), ("running", "a\nb\n"), ("success", "a\nb\nc\n")] {
143            stub.enqueue(.init(status: 200, json: show(status), match: "argv=show"))
144            stub.enqueue(.init(status: 200, json:
145                #"{"protocol_version":1,"output":"\#(output)","exit_code":0}"#.replacingOccurrences(of: "\n", with: "\\n"),
146                match: "argv=log"))
147        }
148        let model = BuildDetailViewModel(
149            client: client, repoPath: "krz/gitbay", number: 62, pollInterval: .milliseconds(10)
150        )
151
152        await model.load()
153        await model.poll()
154
155        let detail = try #require(model.state.value)
156        #expect(detail.build.status == "success")
157        #expect(detail.log == "a\nb\nc\n")
158        #expect(stub.seen.count == 6)
159    }
160
161    @Test func aFinishedBuildIsNotPolled() async throws {
162        let (client, stub) = try makeClient()
163        stub.enqueue(.init(status: 200, json: show("success"), match: "argv=show"))
164        stub.enqueue(.init(status: 200, json:
165            #"{"protocol_version":1,"output":"ok","exit_code":0}"#, match: "argv=log"))
166        let model = BuildDetailViewModel(
167            client: client, repoPath: "krz/gitbay", number: 62, pollInterval: .milliseconds(10)
168        )
169
170        await model.load()
171        await model.poll()
172
173        #expect(stub.seen.count == 2)
174    }
175
176    @Test func aFailedPollKeepsTheLogOnScreen() async throws {
177        let (client, stub) = try makeClient()
178        stub.enqueue(.init(status: 200, json: show("running"), match: "argv=show"))
179        stub.enqueue(.init(status: 200, json:
180            #"{"protocol_version":1,"output":"a","exit_code":0}"#, match: "argv=log"))
181        // Nothing else is queued: every poll after the first load fails.
182        let model = BuildDetailViewModel(
183            client: client, repoPath: "krz/gitbay", number: 62, pollInterval: .milliseconds(10)
184        )
185
186        await model.load()
187        let polling = Task { await model.poll() }
188        try await Task.sleep(for: .milliseconds(300))
189        polling.cancel()
190        await polling.value
191
192        #expect(model.state.value?.log == "a")
193    }
194}
195
196private let buildShowJSON = """
197    {"protocol_version":1,"data":{"number":1,"job":"test","status":"failure","sha":"abc",\
198    "ref":"main","created_at":"2026-08-20T10:00:00.000Z","steps":["go build ./...","go test ./..."],\
199    "failed_step":2},"exit_code":0}
200    """
201
202private func logEnvelope(_ text: String) -> String {
203    let data = try! JSONSerialization.data(withJSONObject: ["protocol_version": 1, "output": text, "exit_code": 0])
204    return String(decoding: data, as: UTF8.self)
205}
206
207@MainActor
208struct BuildLogTailTests {
209
210    @Test func theTailReadsTheLastLinesAndDrawsThemUnsplit() async throws {
211        let (client, stub) = try makeClient()
212        stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
213        stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
214        stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
215        stub.enqueue(.init(status: 200, json: logEnvelope("--- FAIL: TestX\nstep 2/2 failed: exit 1\n"), match: "argv=log"))
216        let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1)
217        await model.load()
218        #expect(model.state.value?.lines.stepped == true)
219
220        await model.showTail()
221
222        let log = try #require(stub.seen.last { $0.url.absoluteString.contains("argv=log") })
223        #expect(log.url.query()?.hasSuffix("argv=--tail&argv=\(BuildDetailViewModel.tailLines)") == true)
224        let detail = try #require(model.state.value)
225        #expect(model.tail)
226        // A step's marker may be cut off, so the tail is one plain block.
227        #expect(!detail.lines.stepped)
228        // A failed build's tail is where it failed.
229        #expect(detail.lines.failed == 0)
230        #expect(detail.lines.sections.first?.lines.map(\.text) == ["--- FAIL: TestX", "step 2/2 failed: exit 1"])
231    }
232
233    @Test func theWholeLogComesBackSplit() async throws {
234        let (client, stub) = try makeClient()
235        for _ in 0..<3 {
236            stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
237        }
238        stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
239        stub.enqueue(.init(status: 200, json: logEnvelope("step 2/2 failed: exit 1\n"), match: "argv=log"))
240        stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
241        let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1)
242        await model.load()
243        await model.showTail()
244
245        await model.showWholeLog()
246
247        #expect(!model.tail)
248        #expect(stub.seen.last { $0.url.absoluteString.contains("argv=log") }?.url.query()?.contains("tail") == false)
249        #expect(model.state.value?.lines.stepped == true)
250    }
251
252    @Test func aFetchFromTheOtherModeIsNotShown() async throws {
253        let (client, stub) = try makeClient()
254        stub.enqueue(.init(status: 200, json: buildShowJSON, match: "argv=show"))
255        stub.enqueue(.init(status: 200, json: logEnvelope(failedLog), match: "argv=log"))
256        let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 1)
257        await model.load()
258        // The detail records the mode it was fetched in.
259        #expect(model.state.value?.tail == false)
260        #expect(BuildDetail(build: try #require(model.state.value).build, log: "x\n", tail: true).tail)
261    }
262}