Commit 1770efb55a
Unsigned
Layout: unified · split
gitbay/Builds/BuildDetailViewModel.swift added +39
| @@ -0,0 +1,39 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | nonisolated struct BuildDetail: Sendable { | ||
| 5 | let build: Build | ||
| 6 | let log: String | ||
| 7 | } | ||
| 8 | |||
| 9 | /// One build: `build show` for what it was, `build log` for what it did. | ||
| 10 | /// The web's build page shows both; the log alone leaves the screen | ||
| 11 | /// unable to say whether the build passed. | ||
| 12 | @Observable | ||
| 13 | @MainActor | ||
| 14 | final class BuildDetailViewModel { | ||
| 15 | |||
| 16 | private(set) var state: LoadState<BuildDetail> = .loading | ||
| 17 | |||
| 18 | private let client: GitbayClient | ||
| 19 | let repoPath: String | ||
| 20 | let number: Int64 | ||
| 21 | |||
| 22 | init(client: GitbayClient, repoPath: String, number: Int64) { | ||
| 23 | self.client = client | ||
| 24 | self.repoPath = repoPath | ||
| 25 | self.number = number | ||
| 26 | } | ||
| 27 | |||
| 28 | func load() async { | ||
| 29 | let ref = [repoPath, String(number)] | ||
| 30 | do { | ||
| 31 | async let build = client.read(["build", "show"] + ref, as: Build.self) | ||
| 32 | // A queued build has no log yet; that is not a failure. | ||
| 33 | async let log = client.readText(["build", "log"] + ref) | ||
| 34 | state = .loaded(BuildDetail(build: try await build, log: (try? await log) ?? "")) | ||
| 35 | } catch { | ||
| 36 | state = .from(error) | ||
| 37 | } | ||
| 38 | } | ||
| 39 | } | ||
gitbay/Builds/BuildListViewModel.swift +8 −3
| @@ -7,6 +7,9 @@ import Observation | |||
| 7 | final class BuildListViewModel { | 7 | final class BuildListViewModel { |
| 8 | 8 | ||
| 9 | private(set) var state: LoadState<[Build]> = .loading | 9 | private(set) var state: LoadState<[Build]> = .loading |
| 10 | /// The jobs a trigger can name. Empty when the repo has no CI | ||
| 11 | /// config — the web hides its trigger form in that case. | ||
| 12 | private(set) var jobs: [CIJob] = [] | ||
| 10 | private(set) var actionError: String? | 13 | private(set) var actionError: String? |
| 11 | private(set) var working = false | 14 | private(set) var working = false |
| 12 | 15 | ||
| @@ -29,9 +32,11 @@ final class BuildListViewModel { | |||
| 29 | } | 32 | } |
| 30 | } | 33 | } |
| 31 | 34 | ||
| 32 | /// The most recent job name, to prefill the trigger sheet. | 35 | /// `build jobs <owner/name>` — what the picker offers. A repo |
| 33 | var latestJob: String? { | 36 | /// without a CI config answers "not found"; that means no jobs, not |
| 34 | state.value?.first?.job | 37 | /// a failure worth showing. |
| 38 | func loadJobs() async { | ||
| 39 | jobs = (try? await client.readList(["build", "jobs", repoPath], of: CIJob.self)) ?? [] | ||
| 35 | } | 40 | } |
| 36 | 41 | ||
| 37 | /// `build trigger <owner/name> <job>` — queue a job now. | 42 | /// `build trigger <owner/name> <job>` — queue a job now. |
gitbay/Builds/BuildModels.swift +41
| @@ -1,4 +1,5 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import SwiftUI | ||
| 2 | 3 | ||
| 3 | /// One row of `build list`. | 4 | /// One row of `build list`. |
| 4 | nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable { | 5 | nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable { |
| @@ -19,3 +20,43 @@ nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable { | |||
| 19 | var id: Int64 { number } | 20 | var id: Int64 { number } |
| 20 | var shortSHA: String { String(sha.prefix(10)) } | 21 | var shortSHA: String { String(sha.prefix(10)) } |
| 21 | } | 22 | } |
| 23 | |||
| 24 | /// `build jobs` — what a trigger can name. A repo with no CI config has | ||
| 25 | /// none, which is an empty state rather than an error. | ||
| 26 | nonisolated struct CIJob: Decodable, Sendable, Hashable, Identifiable { | ||
| 27 | let name: String | ||
| 28 | let schedule: String? | ||
| 29 | let tags: String? | ||
| 30 | |||
| 31 | var id: String { name } | ||
| 32 | |||
| 33 | /// Why this job runs, in the words the server uses. | ||
| 34 | var trigger: String { | ||
| 35 | if let schedule, !schedule.isEmpty { return "schedule \(schedule)" } | ||
| 36 | if let tags, !tags.isEmpty { return "tags \(tags)" } | ||
| 37 | return "on push" | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | extension Build { | ||
| 42 | /// The list row and the detail header must agree on what a status | ||
| 43 | /// looks like; the strings come from the registry. | ||
| 44 | var statusIcon: String { | ||
| 45 | switch status { | ||
| 46 | case "success": "checkmark.circle.fill" | ||
| 47 | case "failure", "error": "xmark.circle.fill" | ||
| 48 | case "running": "circle.dotted" | ||
| 49 | case "queued", "pending": "clock" | ||
| 50 | default: "questionmark.circle" | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | var statusColor: Color { | ||
| 55 | switch status { | ||
| 56 | case "success": .gbOK | ||
| 57 | case "failure", "error": .gbBad | ||
| 58 | case "running", "queued", "pending": .gbWarn | ||
| 59 | default: .secondary | ||
| 60 | } | ||
| 61 | } | ||
| 62 | } | ||
gitbay/ContentView.swift +2 −2
| @@ -154,8 +154,8 @@ private struct RouteDestinations: ViewModifier { | |||
| 154 | switch route { | 154 | switch route { |
| 155 | case .list(let repo): | 155 | case .list(let repo): |
| 156 | BuildListView(client: client, repo: repo) | 156 | BuildListView(client: client, repo: repo) |
| 157 | case .log(let repo, let number): | 157 | case .detail(let repo, let number): |
| 158 | BuildLogView(client: client, repo: repo, number: number) | 158 | BuildDetailView(client: client, repo: repo, number: number) |
| 159 | } | 159 | } |
| 160 | } | 160 | } |
| 161 | } | 161 | } |
gitbay/Views/Builds/BuildDetailView.swift added +82
| @@ -0,0 +1,82 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// A build: what it was, then what it printed. | ||
| 4 | struct BuildDetailView: View { | ||
| 5 | |||
| 6 | @State private var model: BuildDetailViewModel | ||
| 7 | |||
| 8 | init(client: GitbayClient, repo: String, number: Int64) { | ||
| 9 | _model = State(initialValue: BuildDetailViewModel( | ||
| 10 | client: client, repoPath: repo, number: number | ||
| 11 | )) | ||
| 12 | } | ||
| 13 | |||
| 14 | var body: some View { | ||
| 15 | ZStack { | ||
| 16 | Color.clear | ||
| 17 | if let detail = model.state.value { | ||
| 18 | VStack(spacing: 0) { | ||
| 19 | header(detail.build) | ||
| 20 | Divider() | ||
| 21 | log(detail.log) | ||
| 22 | } | ||
| 23 | } | ||
| 24 | } | ||
| 25 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 26 | .navigationTitle("Build #\(model.number)") | ||
| 27 | .navigationBarTitleDisplayMode(.inline) | ||
| 28 | .task { await model.load() } | ||
| 29 | .refreshable { await model.load() } | ||
| 30 | } | ||
| 31 | |||
| 32 | private func header(_ build: Build) -> some View { | ||
| 33 | VStack(alignment: .leading, spacing: 6) { | ||
| 34 | HStack(spacing: 8) { | ||
| 35 | Text(build.job) | ||
| 36 | .font(.gbSans(.subheadline).weight(.medium)) | ||
| 37 | GBChip(build.status, build.statusColor) | ||
| 38 | Spacer() | ||
| 39 | } | ||
| 40 | HStack(spacing: 6) { | ||
| 41 | Text(build.ref) | ||
| 42 | Text("·") | ||
| 43 | NavigationLink(value: RepoRoute.commit(repo: model.repoPath, sha: build.sha)) { | ||
| 44 | Text(build.shortSHA).font(.gbMono(.caption)) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | .font(.gbSans(.caption)) | ||
| 48 | .foregroundStyle(.secondary) | ||
| 49 | |||
| 50 | Text(timing(build)) | ||
| 51 | .font(.gbSans(.caption)) | ||
| 52 | .foregroundStyle(.secondary) | ||
| 53 | } | ||
| 54 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 55 | .padding(.horizontal, 16) | ||
| 56 | .padding(.vertical, 12) | ||
| 57 | } | ||
| 58 | |||
| 59 | /// Queued always; finished only once it is. | ||
| 60 | private func timing(_ build: Build) -> String { | ||
| 61 | let queued = build.createdAt.formatted(date: .abbreviated, time: .shortened) | ||
| 62 | guard let finished = build.finishedAt else { return "queued \(queued)" } | ||
| 63 | return "queued \(queued) · finished \(finished.formatted(date: .omitted, time: .shortened))" | ||
| 64 | } | ||
| 65 | |||
| 66 | @ViewBuilder | ||
| 67 | private func log(_ text: String) -> some View { | ||
| 68 | if text.isEmpty { | ||
| 69 | ContentUnavailableView { | ||
| 70 | Label("No log yet", systemImage: "doc.text") | ||
| 71 | } | ||
| 72 | } else { | ||
| 73 | ScrollView([.horizontal, .vertical]) { | ||
| 74 | Text(text) | ||
| 75 | .font(.gbMono(.caption2)) | ||
| 76 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 77 | .padding(12) | ||
| 78 | .textSelection(.enabled) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
gitbay/Views/Builds/BuildListView.swift +21 −41
| @@ -3,8 +3,6 @@ import SwiftUI | |||
| 3 | struct BuildListView: View { | 3 | struct BuildListView: View { |
| 4 | 4 | ||
| 5 | @State private var model: BuildListViewModel | 5 | @State private var model: BuildListViewModel |
| 6 | @State private var triggering = false | ||
| 7 | @State private var jobName = "" | ||
| 8 | 6 | ||
| 9 | init(client: GitbayClient, repo: String) { | 7 | init(client: GitbayClient, repo: String) { |
| 10 | _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo)) | 8 | _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo)) |
| @@ -18,7 +16,7 @@ struct BuildListView: View { | |||
| 18 | } | 16 | } |
| 19 | } | 17 | } |
| 20 | ForEach(model.state.value ?? []) { build in | 18 | ForEach(model.state.value ?? []) { build in |
| 21 | NavigationLink(value: BuildRoute.log(repo: model.repoPath, number: build.number)) { | 19 | NavigationLink(value: BuildRoute.detail(repo: model.repoPath, number: build.number)) { |
| 22 | BuildRow(build: build) | 20 | BuildRow(build: build) |
| 23 | } | 21 | } |
| 24 | } | 22 | } |
| @@ -28,29 +26,29 @@ struct BuildListView: View { | |||
| 28 | .navigationBarTitleDisplayMode(.inline) | 26 | .navigationBarTitleDisplayMode(.inline) |
| 29 | .toolbar { | 27 | .toolbar { |
| 30 | ToolbarItem(placement: .topBarTrailing) { | 28 | ToolbarItem(placement: .topBarTrailing) { |
| 31 | Button { | 29 | // A menu of jobs, like the web's picker — not a name to |
| 32 | jobName = model.latestJob ?? "" | 30 | // type. Nothing to trigger without a job file. |
| 33 | triggering = true | 31 | Menu { |
| 32 | ForEach(model.jobs) { job in | ||
| 33 | Button("\(job.name) — \(job.trigger)") { | ||
| 34 | Task { await model.trigger(job: job.name) } | ||
| 35 | } | ||
| 36 | } | ||
| 34 | } label: { | 37 | } label: { |
| 35 | Image(systemName: "play.circle") | 38 | Image(systemName: "play.circle") |
| 36 | } | 39 | } |
| 37 | .disabled(model.working) | 40 | .disabled(model.working || model.jobs.isEmpty) |
| 38 | .accessibilityIdentifier("build-trigger-button") | 41 | .accessibilityIdentifier("build-trigger-button") |
| 39 | } | 42 | } |
| 40 | } | 43 | } |
| 41 | .alert("Trigger a build", isPresented: $triggering) { | 44 | .task { |
| 42 | TextField("Job name", text: $jobName) | 45 | await model.load() |
| 43 | .autocorrectionDisabled() | 46 | await model.loadJobs() |
| 44 | .textInputAutocapitalization(.never) | 47 | } |
| 45 | Button("Trigger") { | 48 | .refreshable { |
| 46 | Task { await model.trigger(job: jobName.trimmingCharacters(in: .whitespaces)) } | 49 | await model.load() |
| 47 | } | 50 | await model.loadJobs() |
| 48 | Button("Cancel", role: .cancel) {} | ||
| 49 | } message: { | ||
| 50 | Text("Runs the named job from .gitbay/ at the default branch head.") | ||
| 51 | } | 51 | } |
| 52 | .task { await model.load() } | ||
| 53 | .refreshable { await model.load() } | ||
| 54 | } | 52 | } |
| 55 | } | 53 | } |
| 56 | 54 | ||
| @@ -59,8 +57,8 @@ private struct BuildRow: View { | |||
| 59 | 57 | ||
| 60 | var body: some View { | 58 | var body: some View { |
| 61 | HStack(spacing: 10) { | 59 | HStack(spacing: 10) { |
| 62 | Image(systemName: icon) | 60 | Image(systemName: build.statusIcon) |
| 63 | .foregroundStyle(color) | 61 | .foregroundStyle(build.statusColor) |
| 64 | VStack(alignment: .leading, spacing: 2) { | 62 | VStack(alignment: .leading, spacing: 2) { |
| 65 | Text("#\(build.number) \(build.job)") | 63 | Text("#\(build.number) \(build.job)") |
| 66 | .font(.gbSans(.subheadline).weight(.medium)) | 64 | .font(.gbSans(.subheadline).weight(.medium)) |
| @@ -76,7 +74,7 @@ private struct BuildRow: View { | |||
| 76 | VStack(alignment: .trailing, spacing: 2) { | 74 | VStack(alignment: .trailing, spacing: 2) { |
| 77 | Text(build.status) | 75 | Text(build.status) |
| 78 | .font(.gbSans(.caption).weight(.medium)) | 76 | .font(.gbSans(.caption).weight(.medium)) |
| 79 | .foregroundStyle(color) | 77 | .foregroundStyle(build.statusColor) |
| 80 | Text(build.createdAt, format: .relative(presentation: .named)) | 78 | Text(build.createdAt, format: .relative(presentation: .named)) |
| 81 | .font(.gbSans(.caption)) | 79 | .font(.gbSans(.caption)) |
| 82 | .foregroundStyle(.secondary) | 80 | .foregroundStyle(.secondary) |
| @@ -85,27 +83,9 @@ private struct BuildRow: View { | |||
| 85 | .padding(.vertical, 2) | 83 | .padding(.vertical, 2) |
| 86 | } | 84 | } |
| 87 | 85 | ||
| 88 | private var icon: String { | ||
| 89 | switch build.status { | ||
| 90 | case "success": "checkmark.circle.fill" | ||
| 91 | case "failure", "error": "xmark.circle.fill" | ||
| 92 | case "running": "circle.dotted" | ||
| 93 | case "queued", "pending": "clock" | ||
| 94 | default: "questionmark.circle" | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | private var color: Color { | ||
| 99 | switch build.status { | ||
| 100 | case "success": .gbOK | ||
| 101 | case "failure", "error": .gbBad | ||
| 102 | case "running", "queued", "pending": .gbWarn | ||
| 103 | default: .secondary | ||
| 104 | } | ||
| 105 | } | ||
| 106 | } | 86 | } |
| 107 | 87 | ||
| 108 | nonisolated enum BuildRoute: Hashable { | 88 | nonisolated enum BuildRoute: Hashable { |
| 109 | case list(repo: String) | 89 | case list(repo: String) |
| 110 | case log(repo: String, number: Int64) | 90 | case detail(repo: String, number: Int64) |
| 111 | } | 91 | } |
gitbay/Views/Builds/BuildLogView.swift deleted −55
| @@ -1,55 +0,0 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// `build log` — plain text, can be large. Monospaced, both-axis scroll. | ||
| 4 | struct BuildLogView: View { | ||
| 5 | |||
| 6 | private let client: GitbayClient | ||
| 7 | private let repo: String | ||
| 8 | private let number: Int64 | ||
| 9 | @State private var state: LoadState<String> = .loading | ||
| 10 | |||
| 11 | init(client: GitbayClient, repo: String, number: Int64) { | ||
| 12 | self.client = client | ||
| 13 | self.repo = repo | ||
| 14 | self.number = number | ||
| 15 | } | ||
| 16 | |||
| 17 | var body: some View { | ||
| 18 | ZStack { | ||
| 19 | Color.clear | ||
| 20 | if let log = state.value { | ||
| 21 | if log.isEmpty { | ||
| 22 | ContentUnavailableView { | ||
| 23 | Label("No log yet", systemImage: "doc.text") | ||
| 24 | } | ||
| 25 | } else { | ||
| 26 | ScrollView([.horizontal, .vertical]) { | ||
| 27 | Text(log) | ||
| 28 | .font(.gbMono(.caption2)) | ||
| 29 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 30 | .padding(12) | ||
| 31 | .textSelection(.enabled) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | } | ||
| 35 | } | ||
| 36 | .overlay { LoadStateOverlay(state: state) } | ||
| 37 | .navigationTitle("Build #\(number)") | ||
| 38 | .navigationBarTitleDisplayMode(.inline) | ||
| 39 | .task { | ||
| 40 | do { | ||
| 41 | state = .loaded(try await client.readText(["build", "log", repo, String(number)])) | ||
| 42 | } catch { | ||
| 43 | state = .from(error) | ||
| 44 | } | ||
| 45 | } | ||
| 46 | .refreshable { | ||
| 47 | state = .loading | ||
| 48 | do { | ||
| 49 | state = .loaded(try await client.readText(["build", "log", repo, String(number)])) | ||
| 50 | } catch { | ||
| 51 | state = .from(error) | ||
| 52 | } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | } | ||
gitbay/Views/Discovery/FeedView.swift +1 −1
| @@ -48,7 +48,7 @@ struct FeedLink: View { | |||
| 48 | case .issue(let repo, let number): | 48 | case .issue(let repo, let number): |
| 49 | NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { FeedRow(event: event) } | 49 | NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { FeedRow(event: event) } |
| 50 | case .build(let repo, let number): | 50 | case .build(let repo, let number): |
| 51 | NavigationLink(value: BuildRoute.log(repo: repo, number: number)) { FeedRow(event: event) } | 51 | NavigationLink(value: BuildRoute.detail(repo: repo, number: number)) { FeedRow(event: event) } |
| 52 | case .release(let repo, let tag): | 52 | case .release(let repo, let tag): |
| 53 | NavigationLink(value: ReleaseRoute.release(repo: repo, tag: tag)) { FeedRow(event: event) } | 53 | NavigationLink(value: ReleaseRoute.release(repo: repo, tag: tag)) { FeedRow(event: event) } |
| 54 | case nil: | 54 | case nil: |
gitbayTests/IssueBuildViewModelTests.swift +84
| @@ -160,3 +160,87 @@ struct BuildListViewModelTests { | |||
| 160 | #expect(builds[1].finishedAt != nil) | 160 | #expect(builds[1].finishedAt != nil) |
| 161 | } | 161 | } |
| 162 | } | 162 | } |
| 163 | |||
| 164 | @MainActor | ||
| 165 | struct BuildJobsTests { | ||
| 166 | |||
| 167 | @Test func offersTheJobsTheServerNames() async throws { | ||
| 168 | let (client, stub) = try makeClient() | ||
| 169 | stub.enqueue(.init(status: 200, json: """ | ||
| 170 | {"protocol_version":1,"data":[{"name":"build"},\ | ||
| 171 | {"name":"nightly","schedule":"0 3 * * *"},\ | ||
| 172 | {"name":"release","tags":"v*"}],"exit_code":0} | ||
| 173 | """)) | ||
| 174 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 175 | |||
| 176 | await model.loadJobs() | ||
| 177 | |||
| 178 | #expect(model.jobs.map(\.name) == ["build", "nightly", "release"]) | ||
| 179 | // The picker says why a job is not push-triggered. | ||
| 180 | #expect(model.jobs[0].trigger == "on push") | ||
| 181 | #expect(model.jobs[1].trigger == "schedule 0 3 * * *") | ||
| 182 | #expect(model.jobs[2].trigger == "tags v*") | ||
| 183 | #expect(stub.seen.first?.url.query() == "argv=build&argv=jobs&argv=krz/gitbay") | ||
| 184 | } | ||
| 185 | |||
| 186 | @Test func aRepoWithNoCIConfigHasNoJobsRatherThanAnError() async throws { | ||
| 187 | let (client, stub) = try makeClient() | ||
| 188 | stub.enqueue(.init(status: 404, json: | ||
| 189 | #"{"protocol_version":1,"error":"krz/quiet has no .gitbay/ci.yml on main","exit_code":3}"#)) | ||
| 190 | let model = BuildListViewModel(client: client, repoPath: "krz/quiet") | ||
| 191 | |||
| 192 | await model.loadJobs() | ||
| 193 | |||
| 194 | #expect(model.jobs.isEmpty) | ||
| 195 | // The list itself must stay usable; only the trigger is gated. | ||
| 196 | #expect(model.actionError == nil) | ||
| 197 | } | ||
| 198 | } | ||
| 199 | |||
| 200 | @MainActor | ||
| 201 | struct BuildDetailViewModelTests { | ||
| 202 | |||
| 203 | private let showJSON = """ | ||
| 204 | {"protocol_version":1,"data":{"number":60,"job":"build","status":"success",\ | ||
| 205 | "sha":"ff6271a9d4570cd46f169091637a9d2e40ad5c2a","ref":"cli-coverage",\ | ||
| 206 | "created_at":"2026-08-28T04:42:54.000Z","finished_at":"2026-08-28T04:43:06.000Z"},\ | ||
| 207 | "exit_code":0} | ||
| 208 | """ | ||
| 209 | |||
| 210 | @Test func showsWhatTheBuildWasAndWhatItPrinted() async throws { | ||
| 211 | let (client, stub) = try makeClient() | ||
| 212 | stub.enqueue(.init(status: 200, json: showJSON, match: "argv=build&argv=show")) | ||
| 213 | stub.enqueue(.init(status: 200, json: | ||
| 214 | #"{"protocol_version":1,"output":"step 1 ok\n","exit_code":0}"#, | ||
| 215 | match: "argv=build&argv=log")) | ||
| 216 | let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 60) | ||
| 217 | |||
| 218 | await model.load() | ||
| 219 | |||
| 220 | let detail = try #require(model.state.value) | ||
| 221 | #expect(detail.build.status == "success") | ||
| 222 | #expect(detail.build.ref == "cli-coverage") | ||
| 223 | #expect(detail.build.finishedAt != nil) | ||
| 224 | #expect(detail.log == "step 1 ok\n") | ||
| 225 | } | ||
| 226 | |||
| 227 | @Test func aQueuedBuildWithNoLogYetStillShowsItsHeader() async throws { | ||
| 228 | let (client, stub) = try makeClient() | ||
| 229 | stub.enqueue(.init(status: 200, json: """ | ||
| 230 | {"protocol_version":1,"data":{"number":61,"job":"build","status":"queued",\ | ||
| 231 | "sha":"aa00000000","ref":"main","created_at":"2026-08-28T05:00:00.000Z"},\ | ||
| 232 | "exit_code":0} | ||
| 233 | """, match: "argv=build&argv=show")) | ||
| 234 | stub.enqueue(.init(status: 404, json: | ||
| 235 | #"{"protocol_version":1,"error":"no log","exit_code":3}"#, | ||
| 236 | match: "argv=build&argv=log")) | ||
| 237 | let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 61) | ||
| 238 | |||
| 239 | await model.load() | ||
| 240 | |||
| 241 | // A missing log must not take the whole screen down. | ||
| 242 | let detail = try #require(model.state.value) | ||
| 243 | #expect(detail.build.status == "queued") | ||
| 244 | #expect(detail.log.isEmpty) | ||
| 245 | } | ||
| 246 | } | ||
gitbayTests/RepoManagementTests.swift −1
| @@ -240,7 +240,6 @@ struct BuildTriggerTests { | |||
| 240 | stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list")) | 240 | stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list")) |
| 241 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") | 241 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") |
| 242 | await model.load() | 242 | await model.load() |
| 243 | #expect(model.latestJob == "ci") | ||
| 244 | 243 | ||
| 245 | stub.enqueue(.init(status: 200, json: | 244 | stub.enqueue(.init(status: 200, json: |
| 246 | #"{"protocol_version":1,"data":{"build":4,"job":"ci","sha":"aa"},"exit_code":0}"#, | 245 | #"{"protocol_version":1,"data":{"build":4,"job":"ci","sha":"aa"},"exit_code":0}"#, |
gitbayUITests/LiveSmokeUITests.swift +54 −14
| @@ -295,7 +295,10 @@ extension LiveSmokeUITests { | |||
| 295 | XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove") | 295 | XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove") |
| 296 | 296 | ||
| 297 | let resolved = app.switches["Require threads resolved"].firstMatch | 297 | let resolved = app.switches["Require threads resolved"].firstMatch |
| 298 | XCTAssertTrue(resolved.waitForExistence(timeout: 5)) | 298 | // The merge-requirements section sits below the fold, and a List |
| 299 | // does not build rows it has not shown, so it must be scrolled | ||
| 300 | // into existence before it can be queried. | ||
| 301 | XCTAssertTrue(scrollTo(resolved), "merge requirements section not reachable") | ||
| 299 | // SwiftUI exposes the row as a switch that wraps the real | 302 | // SwiftUI exposes the row as a switch that wraps the real |
| 300 | // control; tap the innermost switch when there is one, else the | 303 | // control; tap the innermost switch when there is one, else the |
| 301 | // right edge of the row. | 304 | // right edge of the row. |
| @@ -314,20 +317,21 @@ extension LiveSmokeUITests { | |||
| 314 | 317 | ||
| 315 | back() // settings -> repo | 318 | back() // settings -> repo |
| 316 | 319 | ||
| 317 | // --- build trigger error path (no .gitbay job config here) --- | 320 | // --- nothing to trigger without a job file --- |
| 321 | // This repo has no .gitbay/ci.yml, so `build jobs` returns none | ||
| 322 | // and the control is gated rather than failing after a guess. | ||
| 318 | app.staticTexts["Builds"].firstMatch.tap() | 323 | app.staticTexts["Builds"].firstMatch.tap() |
| 319 | app.descendants(matching: .any).matching(identifier: "build-trigger-button") | 324 | let trigger = app.descendants(matching: .any) |
| 320 | .firstMatch.tap() | 325 | .matching(identifier: "build-trigger-button").firstMatch |
| 321 | let jobField = app.textFields.firstMatch | 326 | XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing") |
| 322 | XCTAssertTrue(jobField.waitForExistence(timeout: 5), "trigger alert missing") | 327 | // Assert on behaviour, not on isEnabled: a disabled SwiftUI Menu |
| 323 | focusAndType(jobField, "ci") | 328 | // still reports itself enabled to XCUITest. |
| 324 | app.buttons["Trigger"].firstMatch.tap() | 329 | trigger.tap() |
| 325 | // "no job X" for an unknown job, "has no .gitbay/ci.yml" when the | 330 | let anyJob = app.buttons.containing( |
| 326 | // repo has no CI config at all. | 331 | NSPredicate(format: "label CONTAINS 'on push' OR label CONTAINS 'schedule '")) |
| 327 | let refusal = app.staticTexts | 332 | .firstMatch |
| 328 | .containing(NSPredicate(format: "label CONTAINS 'no job' OR label CONTAINS 'no .gitbay'")).firstMatch | 333 | XCTAssertFalse(anyJob.waitForExistence(timeout: 3), |
| 329 | XCTAssertTrue(refusal.waitForExistence(timeout: 15), | 334 | "a repo with no job file offered a job to trigger") |
| 330 | "trigger refusal not surfaced") | ||
| 331 | } | 335 | } |
| 332 | 336 | ||
| 333 | private func waitForValue(_ element: XCUIElement, _ value: String, | 337 | private func waitForValue(_ element: XCUIElement, _ value: String, |
| @@ -834,3 +838,39 @@ extension LiveSmokeUITests { | |||
| 834 | "org markup leaked into the rendered page") | 838 | "org markup leaked into the rendered page") |
| 835 | } | 839 | } |
| 836 | } | 840 | } |
| 841 | |||
| 842 | |||
| 843 | extension LiveSmokeUITests { | ||
| 844 | |||
| 845 | /// Builds on a repo that has a job file: the picker offers what the | ||
| 846 | /// server names, and the detail screen says more than the log. | ||
| 847 | func testBuildJobsAndDetail() throws { | ||
| 848 | openRepo("krz/gitbay") | ||
| 849 | app.staticTexts["Builds"].firstMatch.tap() | ||
| 850 | |||
| 851 | // The picker lists jobs from `build jobs` — no name to type. | ||
| 852 | let trigger = app.descendants(matching: .any) | ||
| 853 | .matching(identifier: "build-trigger-button").firstMatch | ||
| 854 | XCTAssertTrue(trigger.waitForExistence(timeout: 20), "trigger control missing") | ||
| 855 | XCTAssertTrue(trigger.isEnabled, "trigger is disabled where a job file exists") | ||
| 856 | trigger.tap() | ||
| 857 | let job = app.buttons | ||
| 858 | .containing(NSPredicate(format: "label CONTAINS 'build' AND label CONTAINS 'on push'")) | ||
| 859 | .firstMatch | ||
| 860 | XCTAssertTrue(job.waitForExistence(timeout: 10), | ||
| 861 | "job menu did not list the job with why it runs") | ||
| 862 | // Dismiss without queueing a real build. | ||
| 863 | app.tap() | ||
| 864 | |||
| 865 | // The detail screen carries status, ref and timing, not just log. | ||
| 866 | let firstBuild = app.cells.firstMatch | ||
| 867 | XCTAssertTrue(firstBuild.waitForExistence(timeout: 20), "no builds listed") | ||
| 868 | firstBuild.tap() | ||
| 869 | for label in ["success", "queued"] where app.staticTexts[label].firstMatch.exists { | ||
| 870 | XCTAssertTrue(true) | ||
| 871 | } | ||
| 872 | XCTAssertTrue(app.staticTexts | ||
| 873 | .containing(NSPredicate(format: "label BEGINSWITH 'queued '")).firstMatch | ||
| 874 | .waitForExistence(timeout: 20), "build detail shows no timing") | ||
| 875 | } | ||
| 876 | } | ||