builds: a detail screen and a job picker !20
11 files changed, +332 −117
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 | 7 | final class BuildListViewModel { |
| 8 | 8 | |
| 9 | 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 | 13 | private(set) var actionError: String? |
| 11 | 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. | |
| 33 | var latestJob: String? { | |
| 34 | state.value?.first?.job | |
| 35 | /// `build jobs <owner/name>` — what the picker offers. A repo | |
| 36 | /// without a CI config answers "not found"; that means no jobs, not | |
| 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 | 42 | /// `build trigger <owner/name> <job>` — queue a job now. |
gitbay/Builds/BuildModels.swift +41
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import Foundation |
| 2 | import SwiftUI | |
| 2 | 3 | |
| 3 | 4 | /// One row of `build list`. |
| 4 | 5 | nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable { |
| @@ -19,3 +20,43 @@ nonisolated struct Build: Decodable, Sendable, Hashable, Identifiable { | ||
| 19 | 20 | var id: Int64 { number } |
| 20 | 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 | 154 | switch route { |
| 155 | 155 | case .list(let repo): |
| 156 | 156 | BuildListView(client: client, repo: repo) |
| 157 | case .log(let repo, let number): | |
| 158 | BuildLogView(client: client, repo: repo, number: number) | |
| 157 | case .detail(let repo, let 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 | 3 | struct BuildListView: View { |
| 4 | 4 | |
| 5 | 5 | @State private var model: BuildListViewModel |
| 6 | @State private var triggering = false | |
| 7 | @State private var jobName = "" | |
| 8 | 6 | |
| 9 | 7 | init(client: GitbayClient, repo: String) { |
| 10 | 8 | _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo)) |
| @@ -18,7 +16,7 @@ struct BuildListView: View { | ||
| 18 | 16 | } |
| 19 | 17 | } |
| 20 | 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 | 20 | BuildRow(build: build) |
| 23 | 21 | } |
| 24 | 22 | } |
| @@ -28,29 +26,29 @@ struct BuildListView: View { | ||
| 28 | 26 | .navigationBarTitleDisplayMode(.inline) |
| 29 | 27 | .toolbar { |
| 30 | 28 | ToolbarItem(placement: .topBarTrailing) { |
| 31 | Button { | |
| 32 | jobName = model.latestJob ?? "" | |
| 33 | triggering = true | |
| 29 | // A menu of jobs, like the web's picker — not a name to | |
| 30 | // type. Nothing to trigger without a job file. | |
| 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 | 37 | } label: { |
| 35 | 38 | Image(systemName: "play.circle") |
| 36 | 39 | } |
| 37 | .disabled(model.working) | |
| 40 | .disabled(model.working || model.jobs.isEmpty) | |
| 38 | 41 | .accessibilityIdentifier("build-trigger-button") |
| 39 | 42 | } |
| 40 | 43 | } |
| 41 | .alert("Trigger a build", isPresented: $triggering) { | |
| 42 | TextField("Job name", text: $jobName) | |
| 43 | .autocorrectionDisabled() | |
| 44 | .textInputAutocapitalization(.never) | |
| 45 | Button("Trigger") { | |
| 46 | Task { await model.trigger(job: jobName.trimmingCharacters(in: .whitespaces)) } | |
| 47 | } | |
| 48 | Button("Cancel", role: .cancel) {} | |
| 49 | } message: { | |
| 50 | Text("Runs the named job from .gitbay/ at the default branch head.") | |
| 44 | .task { | |
| 45 | await model.load() | |
| 46 | await model.loadJobs() | |
| 47 | } | |
| 48 | .refreshable { | |
| 49 | await model.load() | |
| 50 | await model.loadJobs() | |
| 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 | 58 | var body: some View { |
| 61 | 59 | HStack(spacing: 10) { |
| 62 | Image(systemName: icon) | |
| 63 | .foregroundStyle(color) | |
| 60 | Image(systemName: build.statusIcon) | |
| 61 | .foregroundStyle(build.statusColor) | |
| 64 | 62 | VStack(alignment: .leading, spacing: 2) { |
| 65 | 63 | Text("#\(build.number) \(build.job)") |
| 66 | 64 | .font(.gbSans(.subheadline).weight(.medium)) |
| @@ -76,7 +74,7 @@ private struct BuildRow: View { | ||
| 76 | 74 | VStack(alignment: .trailing, spacing: 2) { |
| 77 | 75 | Text(build.status) |
| 78 | 76 | .font(.gbSans(.caption).weight(.medium)) |
| 79 | .foregroundStyle(color) | |
| 77 | .foregroundStyle(build.statusColor) | |
| 80 | 78 | Text(build.createdAt, format: .relative(presentation: .named)) |
| 81 | 79 | .font(.gbSans(.caption)) |
| 82 | 80 | .foregroundStyle(.secondary) |
| @@ -85,27 +83,9 @@ private struct BuildRow: View { | ||
| 85 | 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 | 88 | nonisolated enum BuildRoute: Hashable { |
| 109 | 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 | 48 | case .issue(let repo, let number): |
| 49 | 49 | NavigationLink(value: IssueRoute.issue(repo: repo, number: number)) { FeedRow(event: event) } |
| 50 | 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 | 52 | case .release(let repo, let tag): |
| 53 | 53 | NavigationLink(value: ReleaseRoute.release(repo: repo, tag: tag)) { FeedRow(event: event) } |
| 54 | 54 | case nil: |
gitbayTests/IssueBuildViewModelTests.swift +84
| @@ -160,3 +160,87 @@ struct BuildListViewModelTests { | ||
| 160 | 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 | 240 | stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list")) |
| 241 | 241 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") |
| 242 | 242 | await model.load() |
| 243 | #expect(model.latestJob == "ci") | |
| 244 | 243 | |
| 245 | 244 | stub.enqueue(.init(status: 200, json: |
| 246 | 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 | 295 | XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove") |
| 296 | 296 | |
| 297 | 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 | 302 | // SwiftUI exposes the row as a switch that wraps the real |
| 300 | 303 | // control; tap the innermost switch when there is one, else the |
| 301 | 304 | // right edge of the row. |
| @@ -314,20 +317,21 @@ extension LiveSmokeUITests { | ||
| 314 | 317 | |
| 315 | 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 | 323 | app.staticTexts["Builds"].firstMatch.tap() |
| 319 | app.descendants(matching: .any).matching(identifier: "build-trigger-button") | |
| 320 | .firstMatch.tap() | |
| 321 | let jobField = app.textFields.firstMatch | |
| 322 | XCTAssertTrue(jobField.waitForExistence(timeout: 5), "trigger alert missing") | |
| 323 | focusAndType(jobField, "ci") | |
| 324 | app.buttons["Trigger"].firstMatch.tap() | |
| 325 | // "no job X" for an unknown job, "has no .gitbay/ci.yml" when the | |
| 326 | // repo has no CI config at all. | |
| 327 | let refusal = app.staticTexts | |
| 328 | .containing(NSPredicate(format: "label CONTAINS 'no job' OR label CONTAINS 'no .gitbay'")).firstMatch | |
| 329 | XCTAssertTrue(refusal.waitForExistence(timeout: 15), | |
| 330 | "trigger refusal not surfaced") | |
| 324 | let trigger = app.descendants(matching: .any) | |
| 325 | .matching(identifier: "build-trigger-button").firstMatch | |
| 326 | XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing") | |
| 327 | // Assert on behaviour, not on isEnabled: a disabled SwiftUI Menu | |
| 328 | // still reports itself enabled to XCUITest. | |
| 329 | trigger.tap() | |
| 330 | let anyJob = app.buttons.containing( | |
| 331 | NSPredicate(format: "label CONTAINS 'on push' OR label CONTAINS 'schedule '")) | |
| 332 | .firstMatch | |
| 333 | XCTAssertFalse(anyJob.waitForExistence(timeout: 3), | |
| 334 | "a repo with no job file offered a job to trigger") | |
| 331 | 335 | } |
| 332 | 336 | |
| 333 | 337 | private func waitForValue(_ element: XCUIElement, _ value: String, |
| @@ -834,3 +838,39 @@ extension LiveSmokeUITests { | ||
| 834 | 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 | } | |