Commit 8618aa6dc1

8618aa6dc178ef77bb962512caa85dc5f100ba58

parent: 41d60b2d2b

Unsigned

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

Repository page: grid for the main four, More section (!140)

- Files, Merge Requests, Issues, Builds in a 2x2 grid under the header. Counts come from one page (limit 50) of `mr list` / `issue list`, shown as "50+" when a next page exists; build status from `build list --limit 1`. `repo show` carries none of this, so these are three extra reads per load, run concurrently after the README.
- History, Branches & Tags, Milestones, Labels, Releases, Wiki, Search in Files under a collapsed More section below the README.
- Settings moves to the `…` menu (it had no gating before; still shown to everyone, keeps `repo-settings-link`).
- Owner is a link in the header (`repo-owner-link`) instead of its own row.
- LiveSmokeUITests: grid tiles found by `repo-link-*`, More links expand `repo-more` first, Settings opened through the menu, rename check reads the nav title.

Closes #16

Layout: unified · split

gitbay/Repos/RepoDetailViewModel.swift +24
@@ -14,6 +14,11 @@ final class RepoDetailViewModel {
14 /// Whether this repo is on the account's dashboard. nil until known — 14 /// Whether this repo is on the account's dashboard. nil until known —
15 /// pin state only exists in the dashboard aggregate. 15 /// pin state only exists in the dashboard aggregate.
16 private(set) var isPinned: Bool? 16 private(set) var isPinned: Bool?
17 /// Open merge request and issue counts from one page each, with a
18 /// trailing "+" when more pages exist. nil when the read failed.
19 private(set) var openMRs: String?
20 private(set) var openIssues: String?
21 private(set) var latestBuild: Build?
17 private(set) var actionError: String? 22 private(set) var actionError: String?
18 private(set) var working = false 23 private(set) var working = false
19 24
@@ -35,6 +40,25 @@ final class RepoDetailViewModel {
35 } 40 }
36 await loadPinned() 41 await loadPinned()
37 await loadReadme() 42 await loadReadme()
43 await loadSummary()
44 }
45
46 private nonisolated struct Numbered: Decodable, Sendable {
47 let number: Int64
48 }
49
50 private func loadSummary() async {
51 async let mrs = try? client.readPage(["mr", "list", path], of: Numbered.self, limit: 50)
52 async let issues = try? client.readPage(["issue", "list", path], of: Numbered.self, limit: 50)
53 async let builds = try? client.readPage(["build", "list", path], of: Build.self, limit: 1)
54 openMRs = Self.countLabel(await mrs)
55 openIssues = Self.countLabel(await issues)
56 latestBuild = await builds?.items.first
57 }
58
59 private static func countLabel(_ page: GitbayClient.Page<Numbered>?) -> String? {
60 guard let page else { return nil }
61 return "\(page.items.count)\(page.next == nil ? "" : "+")"
38 } 62 }
39 63
40 private func loadPinned() async { 64 private func loadPinned() async {
gitbay/Views/Repos/RepoView.swift +106 −41
@@ -20,6 +20,14 @@ struct RepoView: View {
20 @State private var confirmingFork = false 20 @State private var confirmingFork = false
21 @State private var forkName = "" 21 @State private var forkName = ""
22 @State private var forkDestination: RepoRoute? 22 @State private var forkDestination: RepoRoute?
23 @State private var showingMore = false
24 @State private var destination: Destination?
25
26 /// Pushed from buttons rather than NavigationLinks: several share a
27 /// list row, and a link there claims the whole row and adds a chevron.
28 private enum Destination: Hashable {
29 case files, mergeRequests, issues, builds, owner, settings
30 }
23 31
24 var body: some View { 32 var body: some View {
25 List { 33 List {
@@ -42,46 +50,20 @@ struct RepoView: View {
42 header(detail) 50 header(detail)
43 51
44 Section { 52 Section {
45 NavigationLink(value: RepoRoute.tree(repo: path, directory: "", ref: nil)) { 53 Grid(horizontalSpacing: 0, verticalSpacing: 0) {
46 Label("Files", systemImage: "folder") 54 GridRow {
47 } 55 tile("Files", systemImage: "folder", id: "files", to: .files)
48 NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) { 56 tile("Merge Requests", systemImage: "arrow.triangle.merge", id: "merge-requests",
49 Label("History", systemImage: "clock") 57 to: .mergeRequests, count: model.openMRs)
50 } 58 }
51 NavigationLink(value: RepoRoute.refs(repo: path)) { 59 GridRow {
52 Label("Branches & Tags", systemImage: "arrow.triangle.branch") 60 tile("Issues", systemImage: "smallcircle.filled.circle", id: "issues",
53 } 61 to: .issues, count: model.openIssues)
54 NavigationLink(value: MRRoute.list(repo: path)) { 62 tile("Builds", systemImage: "hammer", id: "builds",
55 Label("Merge Requests", systemImage: "arrow.triangle.merge") 63 to: .builds, build: model.latestBuild)
56 } 64 }
57 NavigationLink(value: IssueRoute.list(repo: path)) {
58 Label("Issues", systemImage: "smallcircle.filled.circle")
59 }
60 NavigationLink(value: RepoRoute.milestones(repo: path)) {
61 Label("Milestones", systemImage: "flag")
62 }
63 NavigationLink(value: RepoRoute.labels(repo: path)) {
64 Label("Labels", systemImage: "tag")
65 }
66 NavigationLink(value: BuildRoute.list(repo: path)) {
67 Label("Builds", systemImage: "hammer")
68 }
69 NavigationLink(value: ReleaseRoute.list(repo: path)) {
70 Label("Releases", systemImage: "shippingbox")
71 }
72 NavigationLink(value: RepoRoute.wiki(repo: path)) {
73 Label("Wiki", systemImage: "book")
74 }
75 NavigationLink(value: RepoRoute.grep(repo: path)) {
76 Label("Search in Files", systemImage: "text.magnifyingglass")
77 }
78 NavigationLink(value: RepoRoute.settings(repo: path)) {
79 Label("Settings", systemImage: "gearshape")
80 }
81 .accessibilityIdentifier("repo-settings-link")
82 NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) {
83 Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle")
84 } 65 }
66 .listRowInsets(EdgeInsets())
85 } 67 }
86 68
87 if let readme = model.readme { 69 if let readme = model.readme {
@@ -95,6 +77,33 @@ struct RepoView: View {
95 .padding(.vertical, 4) 77 .padding(.vertical, 4)
96 } 78 }
97 } 79 }
80
81 Section {
82 DisclosureGroup("More", isExpanded: $showingMore) {
83 NavigationLink(value: RepoRoute.log(repo: path, ref: nil, path: nil)) {
84 Label("History", systemImage: "clock")
85 }
86 NavigationLink(value: RepoRoute.refs(repo: path)) {
87 Label("Branches & Tags", systemImage: "arrow.triangle.branch")
88 }
89 NavigationLink(value: RepoRoute.milestones(repo: path)) {
90 Label("Milestones", systemImage: "flag")
91 }
92 NavigationLink(value: RepoRoute.labels(repo: path)) {
93 Label("Labels", systemImage: "tag")
94 }
95 NavigationLink(value: ReleaseRoute.list(repo: path)) {
96 Label("Releases", systemImage: "shippingbox")
97 }
98 NavigationLink(value: RepoRoute.wiki(repo: path)) {
99 Label("Wiki", systemImage: "book")
100 }
101 NavigationLink(value: RepoRoute.grep(repo: path)) {
102 Label("Search in Files", systemImage: "text.magnifyingglass")
103 }
104 }
105 .accessibilityIdentifier("repo-more")
106 }
98 } 107 }
99 } 108 }
100 .overlay { LoadStateOverlay(state: model.state) } 109 .overlay { LoadStateOverlay(state: model.state) }
@@ -108,6 +117,16 @@ struct RepoView: View {
108 RepoView(client: client, path: forkedPath) 117 RepoView(client: client, path: forkedPath)
109 } 118 }
110 } 119 }
120 .navigationDestination(item: $destination) { destination in
121 switch destination {
122 case .files: TreeView(client: client, repo: path, directory: "", ref: nil)
123 case .mergeRequests: MRListView(client: client, repo: path)
124 case .issues: IssueListView(client: client, repo: path)
125 case .builds: BuildListView(client: client, repo: path)
126 case .owner: ProfileView(client: client, name: owner)
127 case .settings: RepoSettingsView(client: client, repo: path)
128 }
129 }
111 .alert("Fork \(path)", isPresented: $confirmingFork) { 130 .alert("Fork \(path)", isPresented: $confirmingFork) {
112 TextField("New name (optional)", text: $forkName) 131 TextField("New name (optional)", text: $forkName)
113 .autocorrectionDisabled() 132 .autocorrectionDisabled()
@@ -213,6 +232,12 @@ struct RepoView: View {
213 Label("Fork", systemImage: "tuningfork") 232 Label("Fork", systemImage: "tuningfork")
214 } 233 }
215 Divider() 234 Divider()
235 Button {
236 destination = .settings
237 } label: {
238 Label("Settings", systemImage: "gearshape")
239 }
240 .accessibilityIdentifier("repo-settings-link")
216 Button(role: detail.isArchived ? nil : .destructive) { 241 Button(role: detail.isArchived ? nil : .destructive) {
217 confirmingArchive = true 242 confirmingArchive = true
218 } label: { 243 } label: {
@@ -238,8 +263,13 @@ struct RepoView: View {
238 Section { 263 Section {
239 VStack(alignment: .leading, spacing: 8) { 264 VStack(alignment: .leading, spacing: 8) {
240 HStack(spacing: 6) { 265 HStack(spacing: 6) {
241 Text(detail.path) 266 HStack(spacing: 0) {
242 .font(.gbSans(.headline)) 267 Button(owner) { destination = .owner }
268 .buttonStyle(.borderless)
269 .accessibilityIdentifier("repo-owner-link")
270 Text("/" + (detail.path.split(separator: "/").last.map(String.init) ?? ""))
271 }
272 .font(.gbSans(.headline))
243 if detail.visibility == "private" { 273 if detail.visibility == "private" {
244 Image(systemName: "lock.fill") 274 Image(systemName: "lock.fill")
245 .font(.gbSans(.caption)) 275 .font(.gbSans(.caption))
@@ -279,4 +309,39 @@ struct RepoView: View {
279 .padding(.vertical, 4) 309 .padding(.vertical, 4)
280 } 310 }
281 } 311 }
312
313 private var owner: String { String(path.split(separator: "/").first ?? "") }
314
315 private func tile(
316 _ title: String, systemImage: String, id: String, to target: Destination,
317 count: String? = nil, build: Build? = nil
318 ) -> some View {
319 Button { destination = target } label: {
320 HStack(spacing: 8) {
321 Image(systemName: systemImage)
322 .foregroundStyle(Color.gbAccent)
323 .frame(width: 24)
324 Text(title)
325 .lineLimit(1)
326 .minimumScaleFactor(0.8)
327 Spacer(minLength: 4)
328 if let count {
329 Text(count)
330 .font(.gbSans(.subheadline))
331 .foregroundStyle(.secondary)
332 }
333 if let build {
334 Image(systemName: build.statusIcon)
335 .foregroundStyle(build.statusColor)
336 .accessibilityLabel(build.status)
337 }
338 }
339 .padding(.horizontal, 14)
340 .padding(.vertical, 12)
341 .frame(maxWidth: .infinity, alignment: .leading)
342 .contentShape(Rectangle())
343 }
344 .buttonStyle(.plain)
345 .accessibilityIdentifier("repo-link-\(id)")
346 }
282} 347}
gitbayTests/RepoViewModelTests.swift +44 −1
@@ -126,7 +126,7 @@ struct RepoDetailViewModelTests {
126 #expect(model.readme == "# gitbay\n\na forge") 126 #expect(model.readme == "# gitbay\n\na forge")
127 #expect(model.isPinned == false) 127 #expect(model.isPinned == false)
128 // The README was fetched by name from the tree listing. 128 // The README was fetched by name from the tree listing.
129 let catRequest = try #require(stub.seen.last) 129 let catRequest = try #require(stub.seen.first { ($0.url.query() ?? "").contains("argv=cat") })
130 #expect(catRequest.url.query()?.contains("argv=README.md") == true) 130 #expect(catRequest.url.query()?.contains("argv=README.md") == true)
131 } 131 }
132 132
@@ -162,6 +162,49 @@ struct RepoDetailViewModelTests {
162 } 162 }
163 #expect(message == "no such repository krz/nope") 163 #expect(message == "no such repository krz/nope")
164 } 164 }
165
166 @Test func loadsOpenCountsAndTheLatestBuild() async throws {
167 let (client, stub) = try makeClient()
168 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
169 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
170 stub.enqueue(.init(status: 200, json: """
171 {"protocol_version":1,"data":{"items":[{"number":3},{"number":2}]},"exit_code":0}
172 """, match: "argv=mr&argv=list"))
173 stub.enqueue(.init(status: 200, json: """
174 {"protocol_version":1,"data":{"items":[{"number":9}],"next":"aXNzdWU6OQ"},"exit_code":0}
175 """, match: "argv=issue&argv=list"))
176 stub.enqueue(.init(status: 200, json: """
177 {"protocol_version":1,"data":{"items":[{"number":1761,"job":"test","status":"failure",\
178 "sha":"d0c63c43655f5d6f03e2b77ca1f2c07dd53a43a3","ref":"main",\
179 "created_at":"2026-09-29T17:12:08Z"}],"next":"YnVpbGQ6MTc2MQ"},"exit_code":0}
180 """, match: "argv=build&argv=list"))
181 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
182
183 await model.load()
184
185 #expect(model.openMRs == "2")
186 // A further page means the count is a floor.
187 #expect(model.openIssues == "1+")
188 #expect(model.latestBuild?.status == "failure")
189 let buildQuery = try #require(stub.seen.first {
190 ($0.url.query() ?? "").contains("argv=build&argv=list")
191 }?.url.query())
192 #expect(buildQuery.hasSuffix("argv=--limit&argv=1"))
193 }
194
195 @Test func missingCountsLeaveTheGridBare() async throws {
196 let (client, stub) = try makeClient()
197 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
198 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
199 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
200
201 await model.load()
202
203 #expect(model.state.value != nil)
204 #expect(model.openMRs == nil)
205 #expect(model.openIssues == nil)
206 #expect(model.latestBuild == nil)
207 }
165} 208}
166 209
167@MainActor 210@MainActor
gitbayUITests/LiveSmokeUITests.swift +64 −48
@@ -310,7 +310,7 @@ final class LiveSmokeUITests: XCTestCase {
310 openRepo(Self.fixtureRepo) 310 openRepo(Self.fixtureRepo)
311 311
312 // --- the fixture issue: milestone picker assigns the fixture --- 312 // --- the fixture issue: milestone picker assigns the fixture ---
313 app.staticTexts["Issues"].firstMatch.tap() 313 repoLink("issues").tap()
314 let issueRow = app.staticTexts 314 let issueRow = app.staticTexts
315 .containing(NSPredicate(format: "label CONTAINS 'ui-smoke fixture'")).firstMatch 315 .containing(NSPredicate(format: "label CONTAINS 'ui-smoke fixture'")).firstMatch
316 XCTAssertTrue(issueRow.waitForExistence(timeout: 10), 316 XCTAssertTrue(issueRow.waitForExistence(timeout: 10),
@@ -367,7 +367,7 @@ final class LiveSmokeUITests: XCTestCase {
367 back() // to repo screen 367 back() // to repo screen
368 368
369 // --- MR: create from ui-smoke, then close it --- 369 // --- MR: create from ui-smoke, then close it ---
370 app.staticTexts["Merge Requests"].firstMatch.tap() 370 repoLink("merge-requests").tap()
371 app.descendants(matching: .any).matching(identifier: "mr-create-button") 371 app.descendants(matching: .any).matching(identifier: "mr-create-button")
372 .firstMatch.tap() 372 .firstMatch.tap()
373 373
@@ -434,7 +434,7 @@ final class LiveSmokeUITests: XCTestCase {
434 XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list") 434 XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
435 row.tap() 435 row.tap()
436 // Repo screen is loaded once its links render. 436 // Repo screen is loaded once its links render.
437 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10), 437 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10),
438 "repo screen did not open") 438 "repo screen did not open")
439 } 439 }
440 440
@@ -442,6 +442,34 @@ final class LiveSmokeUITests: XCTestCase {
442 app.navigationBars.buttons.firstMatch.tap() 442 app.navigationBars.buttons.firstMatch.tap()
443 } 443 }
444 444
445 /// One of the four links in the grid at the top of the repo screen.
446 private func repoLink(_ id: String) -> XCUIElement {
447 app.descendants(matching: .any).matching(identifier: "repo-link-\(id)").firstMatch
448 }
449
450 /// A link in the repo screen's More section, below the README.
451 /// Expands the section when it is still collapsed.
452 private func moreLink(_ title: String) -> XCUIElement {
453 let link = app.staticTexts[title].firstMatch
454 if link.exists { return link }
455 let more = app.descendants(matching: .any).matching(identifier: "repo-more").firstMatch
456 XCTAssertTrue(scrollTo(more, swipes: 20), "More section not reachable")
457 more.tap()
458 XCTAssertTrue(scrollTo(link), "\(title) not under More")
459 return link
460 }
461
462 /// Settings is in the repo screen's actions menu.
463 private func openRepoSettings() {
464 let menu = app.descendants(matching: .any)
465 .matching(identifier: "repo-actions-menu").firstMatch
466 XCTAssertTrue(menu.waitForExistence(timeout: 10), "repo actions menu missing")
467 menu.tap()
468 let settings = app.buttons["Settings"].firstMatch
469 XCTAssertTrue(settings.waitForExistence(timeout: 5), "Settings missing from the actions menu")
470 settings.tap()
471 }
472
445 /// Keys, PGP and email live behind the account menu, which is on the 473 /// Keys, PGP and email live behind the account menu, which is on the
446 /// My Profile tab and nowhere else. 474 /// My Profile tab and nowhere else.
447 func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) { 475 func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
@@ -589,8 +617,8 @@ extension LiveSmokeUITests {
589 617
590 // --- access: grant a user on the scratch repo, then revoke --- 618 // --- access: grant a user on the scratch repo, then revoke ---
591 app.staticTexts[Self.scratchRepo].firstMatch.tap() 619 app.staticTexts[Self.scratchRepo].firstMatch.tap()
592 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10)) 620 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10))
593 app.staticTexts["Settings"].firstMatch.tap() 621 openRepoSettings()
594 let grantUser = app.descendants(matching: .any) 622 let grantUser = app.descendants(matching: .any)
595 .matching(identifier: "settings-grant-user").firstMatch 623 .matching(identifier: "settings-grant-user").firstMatch
596 XCTAssertTrue(scrollTo(grantUser, swipes: 8), "access section not reachable") 624 XCTAssertTrue(scrollTo(grantUser, swipes: 8), "access section not reachable")
@@ -639,12 +667,7 @@ extension LiveSmokeUITests {
639 if firstRename { 667 if firstRename {
640 firstRename = false // already on the settings screen 668 firstRename = false // already on the settings screen
641 } else { 669 } else {
642 // Fresh repo screen: scroll to the top so no row hides 670 self.openRepoSettings()
643 // under the navigation bar, then reach Settings.
644 self.app.swipeDown()
645 let settingsLink = self.app.descendants(matching: .any).matching(identifier: "repo-settings-link").firstMatch
646 XCTAssertTrue(self.scrollTo(settingsLink), "settings link not reachable")
647 settingsLink.tap()
648 } 671 }
649 let renameButton = self.app.descendants(matching: .any) 672 let renameButton = self.app.descendants(matching: .any)
650 .matching(identifier: "settings-rename-button").firstMatch 673 .matching(identifier: "settings-rename-button").firstMatch
@@ -664,8 +687,10 @@ extension LiveSmokeUITests {
664 for _ in 0..<6 where !renamedLink.exists { self.app.swipeDown() } 687 for _ in 0..<6 where !renamedLink.exists { self.app.swipeDown() }
665 XCTAssertTrue(renamedLink.waitForExistence(timeout: 10), "rename did not land") 688 XCTAssertTrue(renamedLink.waitForExistence(timeout: 10), "rename did not land")
666 renamedLink.tap() 689 renamedLink.tap()
667 XCTAssertTrue(self.app.staticTexts[expectedPath].firstMatch.waitForExistence(timeout: 15), 690 // The repo screen's title is the name alone; the header
668 "renamed repo did not open as \(expectedPath)") 691 // splits the path into the owner link and the rest.
692 XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch
693 .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)")
669 } 694 }
670 rename("ui-smoke-2", Self.scratchRepo + "-2") 695 rename("ui-smoke-2", Self.scratchRepo + "-2")
671 rename("ui-smoke", Self.scratchRepo) 696 rename("ui-smoke", Self.scratchRepo)
@@ -681,7 +706,7 @@ extension LiveSmokeUITests {
681 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch 706 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
682 XCTAssertTrue(repoRow.waitForExistence(timeout: 15)) 707 XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
683 repoRow.tap() 708 repoRow.tap()
684 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10)) 709 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10))
685 let menu = app.descendants(matching: .any) 710 let menu = app.descendants(matching: .any)
686 .matching(identifier: "repo-actions-menu").firstMatch 711 .matching(identifier: "repo-actions-menu").firstMatch
687 XCTAssertTrue(menu.waitForExistence(timeout: 10)) 712 XCTAssertTrue(menu.waitForExistence(timeout: 10))
@@ -729,12 +754,7 @@ extension LiveSmokeUITests {
729 unbookmark.tap() 754 unbookmark.tap()
730 755
731 // --- settings: topic and merge-rule round-trips --- 756 // --- settings: topic and merge-rule round-trips ---
732 // The links section can sit below the README fold, and the row's 757 openRepoSettings()
733 // label is not always exposed as a static text.
734 let settingsLink = app.descendants(matching: .any)
735 .matching(identifier: "repo-settings-link").firstMatch
736 XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable")
737 settingsLink.tap()
738 let addTopic = app.descendants(matching: .any) 758 let addTopic = app.descendants(matching: .any)
739 .matching(identifier: "settings-add-topic").firstMatch 759 .matching(identifier: "settings-add-topic").firstMatch
740 XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load") 760 XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
@@ -822,14 +842,11 @@ extension LiveSmokeUITests {
822 842
823 843
824 back() // settings -> repo 844 back() // settings -> repo
825 // The repo screen keeps the scroll that reached Settings, which
826 // can leave Builds under the navigation bar.
827 app.swipeDown()
828 845
829 // --- nothing to trigger without a job file --- 846 // --- nothing to trigger without a job file ---
830 // This repo has no .gitbay/ci.yml, so `build jobs` returns none 847 // This repo has no .gitbay/ci.yml, so `build jobs` returns none
831 // and the control is gated rather than failing after a guess. 848 // and the control is gated rather than failing after a guess.
832 app.staticTexts["Builds"].firstMatch.tap() 849 repoLink("builds").tap()
833 let trigger = app.descendants(matching: .any) 850 let trigger = app.descendants(matching: .any)
834 .matching(identifier: "build-trigger-button").firstMatch 851 .matching(identifier: "build-trigger-button").firstMatch
835 XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing") 852 XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing")
@@ -877,13 +894,12 @@ extension LiveSmokeUITests {
877 XCTAssertTrue(hit.waitForExistence(timeout: 15), 894 XCTAssertTrue(hit.waitForExistence(timeout: 15),
878 "server-side topic search found nothing") 895 "server-side topic search found nothing")
879 hit.tap() 896 hit.tap()
880 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10)) 897 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10))
881 898
882 // --- owner profile from the repo screen --- 899 // --- owner profile from the repo screen ---
883 // The row's label merges; match it at any element type. It is 900 let ownerLink = app.descendants(matching: .any)
884 // the last row of the links section, below the fold. 901 .matching(identifier: "repo-owner-link").firstMatch
885 let ownerLink = app.descendants(matching: .any)["krz"].firstMatch 902 XCTAssertTrue(ownerLink.waitForExistence(timeout: 10), "owner link missing")
886 XCTAssertTrue(scrollTo(ownerLink), "owner link not reachable")
887 ownerLink.tap() 903 ownerLink.tap()
888 XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch 904 XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch
889 .waitForExistence(timeout: 15), "profile did not load") 905 .waitForExistence(timeout: 15), "profile did not load")
@@ -893,7 +909,7 @@ extension LiveSmokeUITests {
893 back() 909 back()
894 910
895 // --- grep inside the repo, last: its search UI owns the screen --- 911 // --- grep inside the repo, last: its search UI owns the screen ---
896 app.staticTexts["Search in Files"].firstMatch.tap() 912 moreLink("Search in Files").tap()
897 let grepField = app.searchFields.firstMatch 913 let grepField = app.searchFields.firstMatch
898 XCTAssertTrue(grepField.waitForExistence(timeout: 10)) 914 XCTAssertTrue(grepField.waitForExistence(timeout: 10))
899 focusAndType(grepField, "space") 915 focusAndType(grepField, "space")
@@ -910,7 +926,7 @@ extension LiveSmokeUITests {
910 /// missing-tag refusal on create. Nothing changes state. 926 /// missing-tag refusal on create. Nothing changes state.
911 func testReleaseFlows() throws { 927 func testReleaseFlows() throws {
912 openRepo("krz/gitbay") 928 openRepo("krz/gitbay")
913 app.staticTexts["Releases"].firstMatch.tap() 929 moreLink("Releases").tap()
914 930
915 // Newest first, and the list has grown past the fold, so the 931 // Newest first, and the list has grown past the fold, so the
916 // first row is whichever release is current. 932 // first row is whichever release is current.
@@ -1119,7 +1135,7 @@ extension LiveSmokeUITests {
1119 openRepo("krz/gitbay") 1135 openRepo("krz/gitbay")
1120 snap("repo") 1136 snap("repo")
1121 1137
1122 app.staticTexts["Merge Requests"].firstMatch.tap() 1138 repoLink("merge-requests").tap()
1123 let all = app.segmentedControls.buttons["All"].firstMatch 1139 let all = app.segmentedControls.buttons["All"].firstMatch
1124 XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing") 1140 XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
1125 all.tap() 1141 all.tap()
@@ -1208,7 +1224,7 @@ extension LiveSmokeUITests {
1208 func testBlameAndEditFlows() throws { 1224 func testBlameAndEditFlows() throws {
1209 // --- blame on a real file --- 1225 // --- blame on a real file ---
1210 openRepo("krz/gitbay") 1226 openRepo("krz/gitbay")
1211 app.staticTexts["Files"].firstMatch.tap() 1227 repoLink("files").tap()
1212 let goMod = app.staticTexts["go.mod"].firstMatch 1228 let goMod = app.staticTexts["go.mod"].firstMatch
1213 XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree") 1229 XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
1214 goMod.tap() 1230 goMod.tap()
@@ -1228,7 +1244,7 @@ extension LiveSmokeUITests {
1228 app.terminate() 1244 app.terminate()
1229 app.launch() 1245 app.launch()
1230 openRepo(Self.fixtureRepo) 1246 openRepo(Self.fixtureRepo)
1231 app.staticTexts["Files"].firstMatch.tap() 1247 repoLink("files").tap()
1232 let notes = app.staticTexts["notes.txt"].firstMatch 1248 let notes = app.staticTexts["notes.txt"].firstMatch
1233 XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree") 1249 XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
1234 notes.tap() 1250 notes.tap()
@@ -1344,7 +1360,7 @@ extension LiveSmokeUITests {
1344 app.terminate() 1360 app.terminate()
1345 app.launch() 1361 app.launch()
1346 openRepo("krz/gitbay") 1362 openRepo("krz/gitbay")
1347 app.staticTexts["History"].firstMatch.tap() 1363 moreLink("History").tap()
1348 let firstCommit = app.cells.firstMatch 1364 let firstCommit = app.cells.firstMatch
1349 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty") 1365 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
1350 firstCommit.tap() 1366 firstCommit.tap()
@@ -1368,7 +1384,7 @@ extension LiveSmokeUITests {
1368 openRepo("krz/gitbay") 1384 openRepo("krz/gitbay")
1369 1385
1370 // --- branches and tags, and browsing at a ref --- 1386 // --- branches and tags, and browsing at a ref ---
1371 app.staticTexts["Branches & Tags"].firstMatch.tap() 1387 moreLink("Branches & Tags").tap()
1372 let main = app.staticTexts["main"].firstMatch 1388 let main = app.staticTexts["main"].firstMatch
1373 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load") 1389 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1374 XCTAssertTrue(app.staticTexts["default"].firstMatch.exists, 1390 XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
@@ -1382,7 +1398,7 @@ extension LiveSmokeUITests {
1382 back() 1398 back()
1383 1399
1384 // --- milestones with progress --- 1400 // --- milestones with progress ---
1385 app.staticTexts["Milestones"].firstMatch.tap() 1401 moreLink("Milestones").tap()
1386 XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15), 1402 XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
1387 "milestones did not load") 1403 "milestones did not load")
1388 app.segmentedControls.buttons["All"].firstMatch.tap() 1404 app.segmentedControls.buttons["All"].firstMatch.tap()
@@ -1392,7 +1408,7 @@ extension LiveSmokeUITests {
1392 back() 1408 back()
1393 1409
1394 // --- an MR's milestone reads back, and can be cleared --- 1410 // --- an MR's milestone reads back, and can be cleared ---
1395 app.staticTexts["Merge Requests"].firstMatch.tap() 1411 repoLink("merge-requests").tap()
1396 app.segmentedControls.buttons["All"].firstMatch.tap() 1412 app.segmentedControls.buttons["All"].firstMatch.tap()
1397 let mrNumber = app.staticTexts 1413 let mrNumber = app.staticTexts
1398 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch 1414 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
@@ -1427,7 +1443,7 @@ extension LiveSmokeUITests {
1427 .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch 1443 .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
1428 .waitForExistence(timeout: 10), "explore is not the public listing") 1444 .waitForExistence(timeout: 10), "explore is not the public listing")
1429 firstRepo.tap() 1445 firstRepo.tap()
1430 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20), 1446 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 20),
1431 "explore row did not open its repo") 1447 "explore row did not open its repo")
1432 1448
1433 // --- history at a ref --- 1449 // --- history at a ref ---
@@ -1436,7 +1452,7 @@ extension LiveSmokeUITests {
1436 openRepo("krz/gitbay") 1452 openRepo("krz/gitbay")
1437 // Browse a ref, then ask for its history — the move the tree page 1453 // Browse a ref, then ask for its history — the move the tree page
1438 // offers on the web. 1454 // offers on the web.
1439 app.staticTexts["Branches & Tags"].firstMatch.tap() 1455 moreLink("Branches & Tags").tap()
1440 let main = app.staticTexts["main"].firstMatch 1456 let main = app.staticTexts["main"].firstMatch
1441 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load") 1457 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1442 main.tap() 1458 main.tap()
@@ -1458,7 +1474,7 @@ extension LiveSmokeUITests {
1458 /// krz/gitbay#48. Read-only; editing is a push, on every surface. 1474 /// krz/gitbay#48. Read-only; editing is a push, on every surface.
1459 func testWikiFlows() throws { 1475 func testWikiFlows() throws {
1460 openRepo("krz/gitbay") 1476 openRepo("krz/gitbay")
1461 app.staticTexts["Wiki"].firstMatch.tap() 1477 moreLink("Wiki").tap()
1462 1478
1463 // The landing page leads and is marked. 1479 // The landing page leads and is marked.
1464 let home = app.staticTexts["Home"].firstMatch 1480 let home = app.staticTexts["Home"].firstMatch
@@ -1488,7 +1504,7 @@ extension LiveSmokeUITests {
1488 /// server names, and the detail screen says more than the log. 1504 /// server names, and the detail screen says more than the log.
1489 func testBuildJobsAndDetail() throws { 1505 func testBuildJobsAndDetail() throws {
1490 openRepo("krz/gitbay") 1506 openRepo("krz/gitbay")
1491 app.staticTexts["Builds"].firstMatch.tap() 1507 repoLink("builds").tap()
1492 1508
1493 // The picker lists jobs from `build jobs` — no name to type. 1509 // The picker lists jobs from `build jobs` — no name to type.
1494 let trigger = app.descendants(matching: .any) 1510 let trigger = app.descendants(matching: .any)
@@ -1535,7 +1551,7 @@ extension LiveSmokeUITests {
1535 let firstRepo = app.cells.firstMatch 1551 let firstRepo = app.cells.firstMatch
1536 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing") 1552 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1537 firstRepo.tap() 1553 firstRepo.tap()
1538 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20), 1554 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 20),
1539 "repo screen did not open") 1555 "repo screen did not open")
1540 snap("repo") 1556 snap("repo")
1541 1557
@@ -1782,7 +1798,7 @@ extension LiveSmokeUITests {
1782 /// filtered empty state. Clearing brings the list back. 1798 /// filtered empty state. Clearing brings the list back.
1783 func testBuildFilterFlow() throws { 1799 func testBuildFilterFlow() throws {
1784 openRepo("krz/gitbay") 1800 openRepo("krz/gitbay")
1785 app.staticTexts["Builds"].firstMatch.tap() 1801 repoLink("builds").tap()
1786 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed") 1802 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed")
1787 1803
1788 let filter = app.descendants(matching: .any) 1804 let filter = app.descendants(matching: .any)
@@ -1837,7 +1853,7 @@ extension LiveSmokeUITests {
1837 else { throw XCTSkip("could not read krz/gitbay's builds over the API") } 1853 else { throw XCTSkip("could not read krz/gitbay's builds over the API") }
1838 1854
1839 openRepo("krz/gitbay") 1855 openRepo("krz/gitbay")
1840 app.staticTexts["Builds"].firstMatch.tap() 1856 repoLink("builds").tap()
1841 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed") 1857 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed")
1842 let thirtyFirst = app.staticTexts 1858 let thirtyFirst = app.staticTexts
1843 .containing(NSPredicate(format: "label BEGINSWITH %@", "#\(number) ")).firstMatch 1859 .containing(NSPredicate(format: "label BEGINSWITH %@", "#\(number) ")).firstMatch
@@ -1850,7 +1866,7 @@ extension LiveSmokeUITests {
1850 /// links to it. 1866 /// links to it.
1851 func testCloseInFavourFlow() throws { 1867 func testCloseInFavourFlow() throws {
1852 openRepo(Self.fixtureRepo) 1868 openRepo(Self.fixtureRepo)
1853 app.staticTexts["Merge Requests"].firstMatch.tap() 1869 repoLink("merge-requests").tap()
1854 let superseded = app.staticTexts[Self.supersededTitle].firstMatch 1870 let superseded = app.staticTexts[Self.supersededTitle].firstMatch
1855 XCTAssertTrue(superseded.waitForExistence(timeout: 15), "fixture MR not listed") 1871 XCTAssertTrue(superseded.waitForExistence(timeout: 15), "fixture MR not listed")
1856 superseded.tap() 1872 superseded.tap()
@@ -1885,7 +1901,7 @@ extension LiveSmokeUITests {
1885 /// list filters to it, and removing it clears the chip. 1901 /// list filters to it, and removing it clears the chip.
1886 func testMRLabelFlow() throws { 1902 func testMRLabelFlow() throws {
1887 openRepo(Self.fixtureRepo) 1903 openRepo(Self.fixtureRepo)
1888 app.staticTexts["Merge Requests"].firstMatch.tap() 1904 repoLink("merge-requests").tap()
1889 let row = app.staticTexts[Self.labelledTitle].firstMatch 1905 let row = app.staticTexts[Self.labelledTitle].firstMatch
1890 XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed") 1906 XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed")
1891 row.tap() 1907 row.tap()
@@ -1923,7 +1939,7 @@ extension LiveSmokeUITests {
1923 /// the bold markers are gone and the word remains. Nothing is posted. 1939 /// the bold markers are gone and the word remains. Nothing is posted.
1924 func testMarkupPreviewFlow() throws { 1940 func testMarkupPreviewFlow() throws {
1925 openRepo(Self.fixtureRepo) 1941 openRepo(Self.fixtureRepo)
1926 app.staticTexts["Issues"].firstMatch.tap() 1942 repoLink("issues").tap()
1927 let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch 1943 let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch
1928 XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed") 1944 XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed")
1929 issue.tap() 1945 issue.tap()