Commit dba776f59e
Unsigned
Layout: unified · split
gitbay/Builds/BuildDetailViewModel.swift +34 −6
| @@ -4,6 +4,13 @@ import Observation | ||
| 4 | 4 | nonisolated struct BuildDetail: Sendable { |
| 5 | 5 | let build: Build |
| 6 | 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 | 16 | /// One build: `build show` for what it was, `build log` for what it did. |
| @@ -20,25 +27,46 @@ final class BuildDetailViewModel { | ||
| 20 | 27 | private let client: GitbayClient |
| 21 | 28 | let repoPath: String |
| 22 | 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 | 33 | self.client = client |
| 26 | 34 | self.repoPath = repoPath |
| 27 | 35 | self.number = number |
| 36 | self.pollInterval = pollInterval | |
| 28 | 37 | } |
| 29 | 38 | |
| 30 | 39 | func load() async { |
| 31 | let ref = [repoPath, String(number)] | |
| 32 | 40 | do { |
| 33 | async let build = client.read(["build", "show"] + ref, as: Build.self) | |
| 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) ?? "")) | |
| 41 | state = .loaded(try await fetch()) | |
| 37 | 42 | } catch { |
| 38 | 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 | 70 | /// Only `pending` or `running` builds can be cancelled server-side |
| 43 | 71 | /// (`internal/control/build.go:750`). The refusal message there says |
| 44 | 72 | /// "queued", but the wire status is always "pending" — matching on |
gitbay/Builds/BuildLog.swift added +99
| @@ -0,0 +1,99 @@ | ||
| 1 | import 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). | |
| 6 | nonisolated 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 | 13 | /// The built commit's subject, which `build list` resolves since |
| 14 | 14 | /// gitbay v1.33.0. Absent when the commit is gone. |
| 15 | 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 | 23 | enum CodingKeys: String, CodingKey { |
| 18 | case number, job, status, sha, ref, subject | |
| 24 | case number, job, status, sha, ref, subject, steps | |
| 19 | 25 | case createdAt = "created_at" |
| 20 | 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 | 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 | 79 | var statusColor: Color { |
| 64 | 80 | switch status { |
| 65 | 81 | case "success": .gbOK |
gitbay/Views/Builds/BuildDetailView.swift +142 −10
| @@ -5,6 +5,12 @@ struct BuildDetailView: View { | ||
| 5 | 5 | |
| 6 | 6 | @State private var model: BuildDetailViewModel |
| 7 | 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 | 15 | init(client: GitbayClient, repo: String, number: Int64) { |
| 10 | 16 | _model = State(initialValue: BuildDetailViewModel( |
| @@ -24,14 +30,29 @@ struct BuildDetailView: View { | ||
| 24 | 30 | } |
| 25 | 31 | header(detail.build) |
| 26 | 32 | Divider() |
| 27 | log(detail.log) | |
| 33 | log(detail) | |
| 28 | 34 | } |
| 29 | 35 | } |
| 30 | 36 | } |
| 31 | 37 | .overlay { LoadStateOverlay(state: model.state) } |
| 32 | 38 | .navigationTitle("Build #\(model.number)") |
| 33 | 39 | .navigationBarTitleDisplayMode(.inline) |
| 40 | .searchable(text: $query, prompt: "Filter log lines") | |
| 34 | 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 | 56 | ToolbarItem(placement: .topBarTrailing) { |
| 36 | 57 | if model.isCancellable { |
| 37 | 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 | 73 | .refreshable { await model.load() } |
| 50 | 74 | .confirmationDialog( |
| 51 | 75 | "Cancel this build?", |
| @@ -96,23 +120,131 @@ struct BuildDetailView: View { | ||
| 96 | 120 | private func timing(_ build: Build) -> String { |
| 97 | 121 | let queued = build.createdAt.formatted(date: .abbreviated, time: .shortened) |
| 98 | 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 | 128 | @ViewBuilder |
| 103 | private func log(_ text: String) -> some View { | |
| 104 | if text.isEmpty { | |
| 129 | private func log(_ detail: BuildDetail) -> some View { | |
| 130 | let log = detail.lines | |
| 131 | let sections = log.filtered(query) | |
| 132 | if detail.log.isEmpty { | |
| 105 | 133 | ContentUnavailableView { |
| 106 | 134 | Label("No log yet", systemImage: "doc.text") |
| 107 | 135 | } |
| 136 | } else if sections.isEmpty { | |
| 137 | ContentUnavailableView.search(text: query) | |
| 108 | 138 | } else { |
| 109 | ScrollView([.horizontal, .vertical]) { | |
| 110 | Text(text) | |
| 111 | .font(.gbMono(.caption2)) | |
| 112 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 113 | .padding(12) | |
| 139 | // Wrapped lines in one lazy stack: a single Text of the whole | |
| 140 | // log, or a horizontal scroll view per line, does not draw at | |
| 141 | // a thousand lines and more. | |
| 142 | ScrollViewReader { proxy in | |
| 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 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private 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 | ||
| 27 | private 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 | ||
| 34 | struct 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 | |
| 122 | struct 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 | } | |