gitbayTests/BuildLogTests.swift
262 lines · 10933 bytes
23 symbols in this file
makeClientbuildBuildLogTestssplitsAtEachStepLikeTheWebstopsAtAMissingStepaLogWithNoStepLineIsOneBlocktheFailedSectionIsTheNamedStepOrElseTheLastaMultilineStepIsHeadedByItsFirstLinefilteringKeepsMatchingLinesUnderTheirStepaLongLogSplitsIntoEveryLinedurationReadsInUnitsdecodesTheFailureFieldsFromBuildShowBuildPollingTestsshowonlyUnfinishedBuildsArePolledpollsUntilTheBuildFinishesaFinishedBuildIsNotPolledaFailedPollKeepsTheLogOnScreenlogEnvelopeBuildLogTailTeststheTailReadsTheLastLinesAndDrawsThemUnsplittheWholeLogComesBackSplitaFetchFromTheOtherModeIsNotShown
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}