Commit dba776f59e

dba776f59e17130a701c7fbc4acd8f110d9c7d8c

parent: 1b9856d2d1

Unsigned

cmc <hello@cleberg.net> · 2026-09-30 02:15 UTC

Build log: lines per step, jump, search, polling (!137)

- Log rendered as wrapped monospaced lines in a LazyVStack; the single Text in a two-axis ScrollView drew nothing at 1,300+ lines.
- Split per `$ <step>` line with the same rules as `SplitBuildLog`/`FailedSection`; pinned step headers, failed step open (with `failed_reason`), others collapsible. Live builds open their latest step; passed builds start folded.
- Toolbar: Jump to failure (end of the failed step), Jump to end. `.searchable` filters lines under their step.
- Polls every 3s while pending/queued/running; the `.task` cancels it on disappear. A failed poll keeps the log.
- `ran <duration>` after finished, from `duration_s`.
- VoiceOver: each line is one element (blank lines hidden); step headers are header buttons with step, command, failed and expanded state.

Closes #15

Layout: unified · split

gitbay/Builds/BuildDetailViewModel.swift +34 −6
@@ -4,6 +4,13 @@ import Observation
4nonisolated struct BuildDetail: Sendable { 4nonisolated struct BuildDetail: Sendable {
5 let build: Build 5 let build: Build
6 let log: String 6 let log: String
7 let lines: BuildLog
8
9 init(build: Build, log: String) {
10 self.build = build
11 self.log = log
12 self.lines = BuildLog(log, build: build)
13 }
7} 14}
8 15
9/// One build: `build show` for what it was, `build log` for what it did. 16/// One build: `build show` for what it was, `build log` for what it did.
@@ -20,25 +27,46 @@ final class BuildDetailViewModel {
20 private let client: GitbayClient 27 private let client: GitbayClient
21 let repoPath: String 28 let repoPath: String
22 let number: Int64 29 let number: Int64
30 private let pollInterval: Duration
23 31
24 init(client: GitbayClient, repoPath: String, number: Int64) { 32 init(client: GitbayClient, repoPath: String, number: Int64, pollInterval: Duration = .seconds(3)) {
25 self.client = client 33 self.client = client
26 self.repoPath = repoPath 34 self.repoPath = repoPath
27 self.number = number 35 self.number = number
36 self.pollInterval = pollInterval
28 } 37 }
29 38
30 func load() async { 39 func load() async {
31 let ref = [repoPath, String(number)]
32 do { 40 do {
33 async let build = client.read(["build", "show"] + ref, as: Build.self) 41 state = .loaded(try await fetch())
34 // A queued build has no log yet; that is not a failure.
35 async let log = client.readText(["build", "log"] + ref)
36 state = .loaded(BuildDetail(build: try await build, log: (try? await log) ?? ""))
37 } catch { 42 } catch {
38 state = .from(error) 43 state = .from(error)
39 } 44 }
40 } 45 }
41 46
47 private func fetch() async throws -> BuildDetail {
48 let ref = [repoPath, String(number)]
49 async let build = client.read(["build", "show"] + ref, as: Build.self)
50 // A queued build has no log yet; that is not a failure.
51 async let log = client.readText(["build", "log"] + ref)
52 return BuildDetail(build: try await build, log: (try? await log) ?? "")
53 }
54
55 static func isLive(status: String) -> Bool {
56 status == "pending" || status == "queued" || status == "running"
57 }
58
59 /// Reload every `pollInterval` until the build finishes or the task is
60 /// cancelled. A failed reload keeps what is on screen and tries again.
61 func poll() async {
62 while let build = state.value?.build, Self.isLive(status: build.status) {
63 guard (try? await Task.sleep(for: pollInterval)) != nil else { return }
64 if let detail = try? await fetch() {
65 state = .loaded(detail)
66 }
67 }
68 }
69
42 /// Only `pending` or `running` builds can be cancelled server-side 70 /// Only `pending` or `running` builds can be cancelled server-side
43 /// (`internal/control/build.go:750`). The refusal message there says 71 /// (`internal/control/build.go:750`). The refusal message there says
44 /// "queued", but the wire status is always "pending" — matching on 72 /// "queued", but the wire status is always "pending" — matching on
gitbay/Builds/BuildLog.swift added +99
@@ -0,0 +1,99 @@
1import Foundation
2
3/// A build log cut into lines and grouped by step, the way the web folds
4/// it (`SplitBuildLog` and `FailedSection` in gitbay's
5/// internal/control/buildlog.go).
6nonisolated struct BuildLog: Sendable {
7
8 struct Line: Sendable, Identifiable, Equatable {
9 /// Position in the whole log, so any line can be scrolled to.
10 let id: Int
11 let text: String
12 }
13
14 /// The setup before the first step (`n` 0), or one step and its
15 /// output.
16 struct Section: Sendable, Identifiable {
17 let n: Int
18 /// The step's command; "" for the setup.
19 let step: String
20 let lines: [Line]
21
22 var id: Int { n }
23 var head: String { String(step.prefix { $0 != "\n" }) }
24 }
25
26 let sections: [Section]
27 /// Whether any step's line is in the log. A build that stopped in the
28 /// clone has none, and its log is one block.
29 let stepped: Bool
30 /// Index into `sections` of the step a failed build stopped in.
31 let failed: Int?
32
33 init(_ text: String, build: Build) {
34 var cuts: [(n: Int, step: String, text: Substring)] = []
35 var cur = (n: 0, step: "")
36 var start = text.startIndex
37 for (i, step) in (build.steps ?? []).enumerated() {
38 let marker = "$ " + step + "\n"
39 guard let at = Self.findLine(text, marker, from: start) else { break }
40 if cur.n > 0 || at.lowerBound > start {
41 cuts.append((cur.n, cur.step, text[start..<at.lowerBound]))
42 }
43 cur = (i + 1, step)
44 start = at.upperBound
45 }
46 if cur.n > 0 || start < text.endIndex {
47 cuts.append((cur.n, cur.step, text[start...]))
48 }
49
50 var next = 0
51 sections = cuts.map { cut in
52 var body = cut.text
53 if body.last?.isNewline == true { body = body.dropLast() }
54 // isNewline also splits "\r\n", which Swift keeps as one
55 // Character, and a progress line's bare "\r".
56 let lines: [Line] = cut.text.isEmpty ? [] : body
57 .split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)
58 .map { text in
59 defer { next += 1 }
60 return Line(id: next, text: String(text))
61 }
62 return Section(n: cut.n, step: cut.step, lines: lines)
63 }
64 stepped = sections.contains { $0.n > 0 }
65
66 if build.status == "failure", !sections.isEmpty {
67 let step = build.failedStep ?? 0
68 failed = sections.firstIndex { step > 0 && $0.n == step } ?? sections.count - 1
69 } else {
70 failed = nil
71 }
72 }
73
74 var lastLineID: Int? { sections.last { !$0.lines.isEmpty }?.lines.last?.id }
75
76 /// The sections holding a line that contains `query`, with only those
77 /// lines. All of them when `query` is empty.
78 func filtered(_ query: String) -> [Section] {
79 guard !query.isEmpty else { return sections }
80 return sections.compactMap { section in
81 let hits = section.lines.filter { $0.text.localizedCaseInsensitiveContains(query) }
82 return hits.isEmpty ? nil : Section(n: section.n, step: section.step, lines: hits)
83 }
84 }
85
86 /// Where `line` occurs at the start of a line of `text`, at or after
87 /// `from`. Literal, so "\n" matches inside "\r\n" as the server's does.
88 private static func findLine(_ text: String, _ line: String, from: String.Index) -> Range<String.Index>? {
89 var from = from
90 while let range = text.range(of: line, options: .literal, range: from..<text.endIndex) {
91 let at = range.lowerBound
92 if at == text.startIndex || text.utf8[text.utf8.index(before: at)] == UInt8(ascii: "\n") {
93 return range
94 }
95 from = text.utf8.index(after: at)
96 }
97 return nil
98 }
99}
gitbay/Builds/BuildModels.swift +17 −1
@@ -13,11 +13,20 @@ nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable {
13 /// The built commit's subject, which `build list` resolves since 13 /// The built commit's subject, which `build list` resolves since
14 /// gitbay v1.33.0. Absent when the commit is gone. 14 /// gitbay v1.33.0. Absent when the commit is gone.
15 let subject: String? 15 let subject: String?
16 /// `build show` only (krz/gitbay#266). `failedStep` is 1-based and
17 /// absent when the runner named none.
18 var durationS: Int64? = nil
19 var steps: [String]? = nil
20 var failedStep: Int? = nil
21 var failedReason: String? = nil
16 22
17 enum CodingKeys: String, CodingKey { 23 enum CodingKeys: String, CodingKey {
18 case number, job, status, sha, ref, subject 24 case number, job, status, sha, ref, subject, steps
19 case createdAt = "created_at" 25 case createdAt = "created_at"
20 case finishedAt = "finished_at" 26 case finishedAt = "finished_at"
27 case durationS = "duration_s"
28 case failedStep = "failed_step"
29 case failedReason = "failed_reason"
21 } 30 }
22 31
23 var id: Int64 { number } 32 var id: Int64 { number }
@@ -60,6 +69,13 @@ extension Build {
60 } 69 }
61 } 70 }
62 71
72 /// How long a build ran, as the web's "ran" puts it.
73 static func ran(seconds: Int64?) -> String? {
74 guard let seconds, seconds > 0 else { return nil }
75 return Duration.seconds(seconds)
76 .formatted(.units(allowed: [.hours, .minutes, .seconds], width: .narrow))
77 }
78
63 var statusColor: Color { 79 var statusColor: Color {
64 switch status { 80 switch status {
65 case "success": .gbOK 81 case "success": .gbOK
gitbay/Views/Builds/BuildDetailView.swift +142 −10
@@ -5,6 +5,12 @@ struct BuildDetailView: View {
5 5
6 @State private var model: BuildDetailViewModel 6 @State private var model: BuildDetailViewModel
7 @State private var confirmingCancel = false 7 @State private var confirmingCancel = false
8 @State private var query = ""
9 /// Steps the reader opened or closed, against `isOpenByDefault`.
10 @State private var toggled: Set<Int> = []
11 @State private var jump: Jump?
12
13 private enum Jump { case failure, end }
8 14
9 init(client: GitbayClient, repo: String, number: Int64) { 15 init(client: GitbayClient, repo: String, number: Int64) {
10 _model = State(initialValue: BuildDetailViewModel( 16 _model = State(initialValue: BuildDetailViewModel(
@@ -24,14 +30,29 @@ struct BuildDetailView: View {
24 } 30 }
25 header(detail.build) 31 header(detail.build)
26 Divider() 32 Divider()
27 log(detail.log) 33 log(detail)
28 } 34 }
29 } 35 }
30 } 36 }
31 .overlay { LoadStateOverlay(state: model.state) } 37 .overlay { LoadStateOverlay(state: model.state) }
32 .navigationTitle("Build #\(model.number)") 38 .navigationTitle("Build #\(model.number)")
33 .navigationBarTitleDisplayMode(.inline) 39 .navigationBarTitleDisplayMode(.inline)
40 .searchable(text: $query, prompt: "Filter log lines")
34 .toolbar { 41 .toolbar {
42 ToolbarItemGroup(placement: .topBarTrailing) {
43 if let detail = model.state.value, !detail.log.isEmpty {
44 if detail.lines.failed != nil {
45 Button("Jump to failure", systemImage: "exclamationmark.triangle") {
46 jump = .failure
47 }
48 .accessibilityIdentifier("build-jump-failure")
49 }
50 Button("Jump to end", systemImage: "arrow.down.to.line") {
51 jump = .end
52 }
53 .accessibilityIdentifier("build-jump-end")
54 }
55 }
35 ToolbarItem(placement: .topBarTrailing) { 56 ToolbarItem(placement: .topBarTrailing) {
36 if model.isCancellable { 57 if model.isCancellable {
37 if model.working { 58 if model.working {
@@ -45,7 +66,10 @@ struct BuildDetailView: View {
45 } 66 }
46 } 67 }
47 } 68 }
48 .task { await model.load() } 69 .task {
70 await model.load()
71 await model.poll()
72 }
49 .refreshable { await model.load() } 73 .refreshable { await model.load() }
50 .confirmationDialog( 74 .confirmationDialog(
51 "Cancel this build?", 75 "Cancel this build?",
@@ -96,23 +120,131 @@ struct BuildDetailView: View {
96 private func timing(_ build: Build) -> String { 120 private func timing(_ build: Build) -> String {
97 let queued = build.createdAt.formatted(date: .abbreviated, time: .shortened) 121 let queued = build.createdAt.formatted(date: .abbreviated, time: .shortened)
98 guard let finished = build.finishedAt else { return "queued \(queued)" } 122 guard let finished = build.finishedAt else { return "queued \(queued)" }
99 return "queued \(queued) · finished \(finished.formatted(date: .omitted, time: .shortened))" 123 let done = "queued \(queued) · finished \(finished.formatted(date: .omitted, time: .shortened))"
124 guard let ran = Build.ran(seconds: build.durationS) else { return done }
125 return "\(done) · ran \(ran)"
100 } 126 }
101 127
102 @ViewBuilder 128 @ViewBuilder
103 private func log(_ text: String) -> some View { 129 private func log(_ detail: BuildDetail) -> some View {
104 if text.isEmpty { 130 let log = detail.lines
131 let sections = log.filtered(query)
132 if detail.log.isEmpty {
105 ContentUnavailableView { 133 ContentUnavailableView {
106 Label("No log yet", systemImage: "doc.text") 134 Label("No log yet", systemImage: "doc.text")
107 } 135 }
136 } else if sections.isEmpty {
137 ContentUnavailableView.search(text: query)
108 } else { 138 } else {
109 ScrollView([.horizontal, .vertical]) { 139 // Wrapped lines in one lazy stack: a single Text of the whole
110 Text(text) 140 // log, or a horizontal scroll view per line, does not draw at
111 .font(.gbMono(.caption2)) 141 // a thousand lines and more.
112 .frame(maxWidth: .infinity, alignment: .leading) 142 ScrollViewReader { proxy in
113 .padding(12) 143 ScrollView {
144 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
145 ForEach(sections) { section in
146 if log.stepped {
147 Section {
148 if !query.isEmpty || isOpen(section, in: detail) {
149 lines(section)
150 }
151 } header: {
152 stepHeader(section, in: detail)
153 }
154 } else {
155 lines(section)
156 }
157 }
158 }
159 .padding(.bottom, 12)
114 .textSelection(.enabled) 160 .textSelection(.enabled)
161 }
162 .onChange(of: jump) { _, target in
163 guard let target else { return }
164 jump = nil
165 scroll(to: target, in: detail, proxy: proxy)
166 }
167 }
168 }
169 }
170
171 private func lines(_ section: BuildLog.Section) -> some View {
172 ForEach(section.lines) { line in
173 Text(line.text.isEmpty ? " " : line.text)
174 .font(.gbMono(.caption2))
175 .frame(maxWidth: .infinity, alignment: .leading)
176 .padding(.horizontal, 12)
177 .id(line.id)
178 .accessibilityHidden(line.text.isEmpty)
179 }
180 }
181
182 private func stepHeader(_ section: BuildLog.Section, in detail: BuildDetail) -> some View {
183 let failed = detail.lines.failed.map { detail.lines.sections[$0].n } == section.n
184 let open = !query.isEmpty || isOpen(section, in: detail)
185 let label = section.n == 0 ? "setup" : "step \(section.n)/\(detail.build.steps?.count ?? 0)"
186 return Button {
187 toggled.formSymmetricDifference([section.n])
188 } label: {
189 HStack(spacing: 8) {
190 Image(systemName: open ? "chevron.down" : "chevron.right")
191 .font(.caption2)
192 .foregroundStyle(.secondary)
193 .frame(width: 12)
194 Text(label)
195 .font(.gbSans(.caption))
196 .foregroundStyle(.secondary)
197 if section.n > 0 {
198 Text(section.head)
199 .font(.gbMono(.caption))
200 .lineLimit(1)
201 .truncationMode(.tail)
202 }
203 Spacer(minLength: 0)
204 if failed {
205 GBChip(detail.build.failedReason.map { "failed: \($0)" } ?? "failed", .gbBad)
206 }
115 } 207 }
208 .padding(.horizontal, 12)
209 .padding(.vertical, 8)
210 .frame(maxWidth: .infinity, alignment: .leading)
211 .background(Color.gbFillSubtle)
212 .contentShape(Rectangle())
213 }
214 .buttonStyle(.plain)
215 .disabled(!query.isEmpty)
216 .id("step-\(section.n)")
217 .accessibilityElement(children: .ignore)
218 .accessibilityLabel([label, section.n > 0 ? section.head : nil, failed ? "failed" : nil]
219 .compactMap { $0 }.joined(separator: ", "))
220 .accessibilityValue(open ? "expanded" : "collapsed")
221 .accessibilityAddTraits(.isHeader)
222 }
223
224 /// A failed build opens at its failed step, a live one at its latest;
225 /// a passed build starts folded, as on the web.
226 private func isOpenByDefault(_ section: BuildLog.Section, in detail: BuildDetail) -> Bool {
227 let log = detail.lines
228 if let failed = log.failed { return log.sections[failed].n == section.n }
229 return BuildDetailViewModel.isLive(status: detail.build.status) && log.sections.last?.n == section.n
230 }
231
232 private func isOpen(_ section: BuildLog.Section, in detail: BuildDetail) -> Bool {
233 isOpenByDefault(section, in: detail) != toggled.contains(section.n)
234 }
235
236 /// To the end of the failed step, where its error is, or of the log.
237 private func scroll(to target: Jump, in detail: BuildDetail, proxy: ScrollViewProxy) {
238 let log = detail.lines
239 let index = target == .failure ? log.failed : log.sections.indices.last
240 guard let index else { return }
241 let section = log.sections[index]
242 query = ""
243 if !isOpen(section, in: detail) { toggled.formSymmetricDifference([section.n]) }
244 let anchor: AnyHashable = section.lines.last.map { AnyHashable($0.id) } ?? AnyHashable("step-\(section.n)")
245 // Let the opened section lay out before scrolling into it.
246 Task { @MainActor in
247 proxy.scrollTo(anchor, anchor: .bottom)
116 } 248 }
117 } 249 }
118} 250}
gitbayTests/BuildLogTests.swift added +194
@@ -0,0 +1,194 @@
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}