a native ios client for gitbay

client ios swift

https://gitbay.org

readmes as org, review threads anchored in the diff !13

merged cmc wants to merge krz/gitbay-ios:org-readmes-inline-threads into main

10 files changed, +515 −73

gitbay/MRs/UnifiedDiff.swift +23
@@ -49,12 +49,35 @@ nonisolated struct UnifiedDiff: Sendable, Hashable {
4949 let text: String
5050
5151 var id: String { "\(oldNumber ?? 0):\(newNumber ?? 0):\(text)" }
52
53 /// Whether a review thread hangs off this line. `side` picks
54 /// which numbering the thread was recorded against; a stale
55 /// thread anchors nowhere, because the head it referenced is
56 /// gone.
57 func anchors(_ thread: ReviewThread, in file: File) -> Bool {
58 guard !thread.stale, thread.path == file.displayPath else { return false }
59 return switch thread.side {
60 case "old": oldNumber == Int(thread.line)
61 default: newNumber == Int(thread.line)
62 }
63 }
5264 }
5365
5466 /// Total across files, for the summary row.
5567 var additions: Int { files.reduce(0) { $0 + $1.additions } }
5668 var deletions: Int { files.reduce(0) { $0 + $1.deletions } }
5769
70 /// Whether this diff still shows the line a thread was left on. A
71 /// thread the diff has moved past renders in its own section rather
72 /// than vanishing.
73 func anchors(_ thread: ReviewThread) -> Bool {
74 files.contains { file in
75 file.hunks.contains { hunk in
76 hunk.lines.contains { $0.anchors(thread, in: file) }
77 }
78 }
79 }
80
5881 // MARK: - Parsing
5982
6083 static func parse(_ text: String) -> UnifiedDiff {
gitbay/Repos/RepoDetailViewModel.swift +2
@@ -10,6 +10,7 @@ final class RepoDetailViewModel {
1010 private(set) var state: LoadState<RepoDetail> = .loading
1111 /// README markdown, when the root tree has one. Absent is normal.
1212 private(set) var readme: String?
13 private(set) var readmeName: String?
1314 /// Whether this repo is on the account's dashboard. nil until known
1415 /// pin state only exists in the dashboard aggregate.
1516 private(set) var isPinned: Bool?
@@ -83,6 +84,7 @@ final class RepoDetailViewModel {
8384 guard let file = try? await client.read(
8485 ["repo", "cat", path, candidate.name], as: FileContent.self
8586 ), !file.binary, let content = file.content else { return }
87 readmeName = candidate.name
8688 readme = content
8789 }
8890 }
gitbay/Repos/RepoModels.swift +14
@@ -38,6 +38,20 @@ nonisolated struct RepoDetail: Decodable, Sendable, Hashable {
3838 var isArchived: Bool { archived ?? false }
3939 }
4040
41/// The shape a `repo refs <owner/name>` command would return. No such
42/// command exists yet krz/gitbay#45 proposes it, and until it lands
43/// branch names are typed, not picked. Unused on purpose.
44nonisolated struct RepoRefs: Decodable, Sendable, Hashable {
45 let branches: [RepoRef]
46 let tags: [RepoRef]
47}
48
49nonisolated struct RepoRef: Decodable, Sendable, Hashable, Identifiable {
50 let name: String
51 let sha: String
52 var id: String { name }
53}
54
4155 /// `repo tree` one directory listing.
4256 nonisolated struct TreeListing: Decodable, Sendable, Hashable {
4357 let path: String
gitbay/Views/MRs/DiffView.swift +30 −20
@@ -4,50 +4,52 @@ import SwiftUI
44 /// text so the screen is reachable by value from anywhere.
55 struct DiffView: View {
66
7 private let client: GitbayClient
8 private let repo: String
9 private let number: Int64
10 @State private var state: LoadState<UnifiedDiff> = .loading
7 @State private var model: MRDetailViewModel
118
129 init(client: GitbayClient, repo: String, number: Int64) {
13 self.client = client
14 self.repo = repo
15 self.number = number
10 _model = State(initialValue: MRDetailViewModel(
11 client: client, repoPath: repo, number: number
12 ))
1613 }
1714
1815 var body: some View {
1916 ZStack {
2017 Color.clear
21 if let diff = state.value, !diff.files.isEmpty {
18 if let diff = model.diff, !diff.files.isEmpty {
2219 List {
2320 ForEach(diff.files) { file in
24 FileDiffSection(file: file)
21 FileDiffSection(file: file, model: model)
22 }
23 let detached = model.threads.filter { thread in
24 thread.stale || !diff.anchors(thread)
25 }
26 if !detached.isEmpty {
27 Section("Threads on earlier revisions") {
28 ForEach(detached) { thread in
29 ReviewThreadView(thread: thread, model: model)
30 }
31 }
2532 }
2633 }
2734 .listStyle(.plain)
28 } else if case .loaded = state {
35 } else if model.state.value != nil {
2936 ContentUnavailableView {
3037 Label("No changes", systemImage: "plus.forwardslash.minus")
3138 }
3239 }
3340 }
34 .overlay { LoadStateOverlay(state: state) }
41 .overlay { LoadStateOverlay(state: model.state) }
3542 .navigationTitle("Diff")
3643 .navigationBarTitleDisplayMode(.inline)
37 .task {
38 do {
39 let text = try await client.readText(["mr", "diff", repo, String(number)])
40 state = .loaded(UnifiedDiff.parse(text))
41 } catch {
42 state = .from(error)
43 }
44 }
44 .task { await model.load() }
45 .refreshable { await model.load() }
4546 }
4647 }
4748
4849 private struct FileDiffSection: View {
4950
5051 let file: UnifiedDiff.File
52 let model: MRDetailViewModel
5153 @State private var collapsed = false
5254
5355 var body: some View {
@@ -59,7 +61,7 @@ private struct FileDiffSection: View {
5961 .foregroundStyle(.secondary)
6062 } else {
6163 ForEach(file.hunks) { hunk in
62 HunkView(hunk: hunk)
64 HunkView(file: file, hunk: hunk, model: model)
6365 }
6466 }
6567 }
@@ -93,7 +95,9 @@ private struct FileDiffSection: View {
9395
9496 private struct HunkView: View {
9597
98 let file: UnifiedDiff.File
9699 let hunk: UnifiedDiff.Hunk
100 let model: MRDetailViewModel
97101
98102 var body: some View {
99103 ScrollView(.horizontal) {
@@ -106,6 +110,12 @@ private struct HunkView: View {
106110 .background(Color.gbFillSubtle)
107111 ForEach(hunk.lines) { line in
108112 LineView(line: line)
113 ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in
114 ReviewThreadView(thread: thread, model: model)
115 .padding(.vertical, 6)
116 .padding(.horizontal, 8)
117 .background(Color.gbFillSubtle)
118 }
109119 }
110120 }
111121 }
gitbay/Views/MRs/MRView.swift +2 −2
@@ -194,7 +194,7 @@ struct MRView: View {
194194 private var threadsSection: some View {
195195 Section("Threads — \(model.unresolvedCount) unresolved") {
196196 ForEach(model.threads) { thread in
197 ThreadView(thread: thread, model: model)
197 ReviewThreadView(thread: thread, model: model)
198198 }
199199 }
200200 }
@@ -299,7 +299,7 @@ struct MRView: View {
299299 }
300300
301301 /// One review thread: anchor, comments, reply, resolve.
302private struct ThreadView: View {
302struct ReviewThreadView: View {
303303
304304 let thread: ReviewThread
305305 let model: MRDetailViewModel
gitbay/Views/Repos/ReadmeView.swift added +216
@@ -0,0 +1,216 @@
1import Foundation
2import SwiftUI
3
4/// README rendering follows the file's format. Org is parsed as Org;
5/// everything else keeps the established Markdown renderer.
6struct ReadmeView: View {
7 let name: String
8 let content: String
9
10 /// Format follows the file name, never a guess at the content.
11 var isOrg: Bool { name.lowercased().hasSuffix(".org") }
12
13 var body: some View {
14 if isOrg {
15 OrgDocumentView(document: OrgDocument.parse(content))
16 } else {
17 MarkdownView(markdown: content)
18 }
19 }
20}
21
22nonisolated struct OrgDocument: Sendable, Hashable {
23 let blocks: [OrgBlock]
24
25 static func parse(_ source: String) -> OrgDocument {
26 var result: [OrgBlock] = []
27 var lines = source.components(separatedBy: "\n")[...]
28
29 while let raw = lines.first {
30 let line = raw.trimmingCharacters(in: .whitespaces)
31 if line.isEmpty {
32 lines = lines.dropFirst()
33 continue
34 }
35 let lower = line.lowercased()
36 if lower.hasPrefix("#+title:") {
37 result.append(.heading(level: 1, text: String(line.dropFirst(8)).trimmingCharacters(in: .whitespaces)))
38 lines = lines.dropFirst()
39 continue
40 }
41 if lower.hasPrefix("#+begin_src") || lower.hasPrefix("#+begin_example") {
42 let language = lower.hasPrefix("#+begin_src")
43 ? String(line.dropFirst(11)).trimmingCharacters(in: .whitespaces)
44 : ""
45 lines = lines.dropFirst()
46 var body: [String] = []
47 while let next = lines.first,
48 !next.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("#+end_") {
49 body.append(next)
50 lines = lines.dropFirst()
51 }
52 if !lines.isEmpty { lines = lines.dropFirst() }
53 result.append(.code(language: language, text: body.joined(separator: "\n")))
54 continue
55 }
56 if lower == "#+begin_quote" {
57 lines = lines.dropFirst()
58 var body: [String] = []
59 while let next = lines.first,
60 next.trimmingCharacters(in: .whitespaces).lowercased() != "#+end_quote" {
61 body.append(next.trimmingCharacters(in: .whitespaces))
62 lines = lines.dropFirst()
63 }
64 if !lines.isEmpty { lines = lines.dropFirst() }
65 result.append(.quote(body.joined(separator: " ")))
66 continue
67 }
68 if line.first == "*" {
69 let stars = line.prefix(while: { $0 == "*" }).count
70 if line.dropFirst(stars).first == " " {
71 result.append(.heading(
72 level: min(stars, 4),
73 text: String(line.dropFirst(stars)).trimmingCharacters(in: .whitespaces)
74 ))
75 lines = lines.dropFirst()
76 continue
77 }
78 }
79 if line.hasPrefix("- ") || line.hasPrefix("+ ") {
80 var items: [String] = []
81 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
82 next.hasPrefix("- ") || next.hasPrefix("+ ") {
83 items.append(String(next.dropFirst(2)))
84 lines = lines.dropFirst()
85 }
86 result.append(.bullet(items))
87 continue
88 }
89 if line.range(of: #"^\d+[.)] "#, options: .regularExpression) != nil {
90 var items: [String] = []
91 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
92 let range = next.range(of: #"^\d+[.)] "#, options: .regularExpression) {
93 items.append(String(next[range.upperBound...]))
94 lines = lines.dropFirst()
95 }
96 result.append(.ordered(items))
97 continue
98 }
99 if lower.hasPrefix("#+") {
100 lines = lines.dropFirst() // document metadata, not prose
101 continue
102 }
103
104 var paragraph: [String] = []
105 while let next = lines.first {
106 let trimmed = next.trimmingCharacters(in: .whitespaces)
107 let nextLower = trimmed.lowercased()
108 if trimmed.isEmpty || trimmed.hasPrefix("* ") || trimmed.hasPrefix("- ")
109 || trimmed.hasPrefix("+ ") || nextLower.hasPrefix("#+") {
110 break
111 }
112 paragraph.append(trimmed)
113 lines = lines.dropFirst()
114 }
115 result.append(.paragraph(paragraph.joined(separator: " ")))
116 }
117 return OrgDocument(blocks: result)
118 }
119}
120
121nonisolated enum OrgBlock: Sendable, Hashable {
122 case heading(level: Int, text: String)
123 case code(language: String, text: String)
124 case quote(String)
125 case bullet([String])
126 case ordered([String])
127 case paragraph(String)
128}
129
130private struct OrgDocumentView: View {
131 let document: OrgDocument
132
133 var body: some View {
134 VStack(alignment: .leading, spacing: 12) {
135 ForEach(Array(document.blocks.enumerated()), id: \.offset) { _, block in
136 blockView(block)
137 }
138 }
139 }
140
141 @ViewBuilder
142 private func blockView(_ block: OrgBlock) -> some View {
143 switch block {
144 case .heading(let level, let text):
145 inline(text)
146 .font(headingFont(level))
147 .padding(.top, level <= 2 ? 8 : 4)
148 case .code(let language, let text):
149 VStack(alignment: .leading, spacing: 4) {
150 if !language.isEmpty {
151 Text(language)
152 .font(.gbMono(.caption2))
153 .foregroundStyle(.secondary)
154 }
155 ScrollView(.horizontal) {
156 Text(text)
157 .font(.gbMono(.caption))
158 .padding(10)
159 }
160 }
161 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
162 case .quote(let text):
163 HStack(spacing: 10) {
164 RoundedRectangle(cornerRadius: 2).fill(.tertiary).frame(width: 3)
165 inline(text).foregroundStyle(.secondary)
166 }
167 .fixedSize(horizontal: false, vertical: true)
168 case .bullet(let items):
169 list(items, ordered: false)
170 case .ordered(let items):
171 list(items, ordered: true)
172 case .paragraph(let text):
173 inline(text)
174 }
175 }
176
177 private func list(_ items: [String], ordered: Bool) -> some View {
178 VStack(alignment: .leading, spacing: 4) {
179 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
180 HStack(alignment: .firstTextBaseline, spacing: 8) {
181 Text(ordered ? "\(index + 1)." : "")
182 inline(item)
183 }
184 }
185 }
186 }
187
188 private func inline(_ source: String) -> Text {
189 var markdown = source
190 markdown = replacing(markdown, pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#, template: "[$2]($1)")
191 markdown = replacing(markdown, pattern: #"\[\[([^\]]+)\]\]"#, template: "<$1>")
192 markdown = replacing(markdown, pattern: #"=([^=]+)="#, template: "`$1`")
193 if let attributed = try? AttributedString(
194 markdown: markdown,
195 options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
196 ) {
197 return Text(attributed)
198 }
199 return Text(source)
200 }
201
202 private func replacing(_ source: String, pattern: String, template: String) -> String {
203 guard let expression = try? NSRegularExpression(pattern: pattern) else { return source }
204 let range = NSRange(source.startIndex..., in: source)
205 return expression.stringByReplacingMatches(in: source, range: range, withTemplate: template)
206 }
207
208 private func headingFont(_ level: Int) -> Font {
209 switch level {
210 case 1: .gbSans(.title2).bold()
211 case 2: .gbSans(.title3).bold()
212 case 3: .gbSans(.headline)
213 default: .gbSans(.subheadline).bold()
214 }
215 }
216}
gitbay/Views/Repos/RepoView.swift +1 −1
@@ -56,7 +56,7 @@ struct RepoView: View {
5656
5757 if let readme = model.readme {
5858 Section("README") {
59 MarkdownView(markdown: readme)
59 ReadmeView(name: model.readmeName ?? "README.md", content: readme)
6060 .padding(.vertical, 4)
6161 }
6262 }
gitbayTests/MRViewModelTests.swift +64
@@ -277,3 +277,67 @@ struct MRDetailViewModelTests {
277277 #expect(body["stdin"] as? String == "because 5xx is transient")
278278 }
279279 }
280
281/// Where a review thread hangs in the diff. Threads the diff has moved
282/// past must not vanish they render in their own section.
283struct ThreadAnchoringTests {
284
285 private func thread(
286 path: String, line: Int64, side: String = "new", stale: Bool = false
287 ) throws -> ReviewThread {
288 let json = """
289 {"id":1,"path":"\(path)","side":"\(side)","line":\(line),"stale":\(stale),\
290 "comments":[{"id":1,"author":"krz","body":"why?",\
291 "created_at":"2026-08-20T10:30:00.000Z"}]}
292 """
293 let decoder = JSONDecoder()
294 decoder.dateDecodingStrategy = .iso8601
295 return try decoder.decode(ReviewThread.self, from: Data(json.utf8))
296 }
297
298 private let diff = UnifiedDiff.parse("""
299 diff --git a/main.go b/main.go
300 --- a/main.go
301 +++ b/main.go
302 @@ -1,4 +1,5 @@
303 package main
304 -import "fmt"
305 +import (
306 +\t"fmt"
307 +)
308 """)
309
310 @Test func aThreadAnchorsToItsLineOnTheNewSide() throws {
311 // "+import (" is new line 2.
312 let subject = try thread(path: "main.go", line: 2)
313 #expect(diff.anchors(subject))
314
315 let file = try #require(diff.files.first)
316 let line = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 })
317 #expect(line.anchors(subject, in: file))
318 }
319
320 @Test func anOldSideThreadAnchorsToTheDeletedLine() throws {
321 // `-import "fmt"` is old line 2 and has no new number.
322 let subject = try thread(path: "main.go", line: 2, side: "old")
323 #expect(diff.anchors(subject))
324
325 let file = try #require(diff.files.first)
326 let deletion = try #require(file.hunks.first?.lines.first { $0.kind == .deletion })
327 #expect(deletion.anchors(subject, in: file))
328 // The same line number on the other side is a different anchor.
329 let addition = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 })
330 #expect(!addition.anchors(subject, in: file))
331 }
332
333 @Test func aStaleThreadAnchorsNowhere() throws {
334 // Its head is gone, so the line numbers cannot be trusted.
335 let subject = try thread(path: "main.go", line: 2, stale: true)
336 #expect(!diff.anchors(subject))
337 }
338
339 @Test func aThreadOnAnotherFileOrLineDoesNotAnchor() throws {
340 #expect(!diff.anchors(try thread(path: "other.go", line: 2)))
341 #expect(!diff.anchors(try thread(path: "main.go", line: 999)))
342 }
343}
gitbayTests/ReadmeOrgTests.swift added +98
@@ -0,0 +1,98 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5/// README.org rendered as Org, not Markdown (krz/gitbay-ios#4). The
6/// markdown block parser turned `#+title:` into a heading and left
7/// `[[link]]` raw.
8struct OrgDocumentTests {
9
10 @Test func titleAndStarHeadingsBecomeHeadings() {
11 let doc = OrgDocument.parse("""
12 #+title: orgo
13 #+author: krz
14
15 * Install
16 ** From source
17 """)
18
19 #expect(doc.blocks == [
20 .heading(level: 1, text: "orgo"),
21 .heading(level: 1, text: "Install"),
22 .heading(level: 2, text: "From source"),
23 ])
24 }
25
26 @Test func metadataOtherThanTitleIsDropped() {
27 let doc = OrgDocument.parse("#+options: toc:nil\n#+startup: showall\n\nProse.")
28 #expect(doc.blocks == [.paragraph("Prose.")])
29 }
30
31 @Test func sourceBlocksKeepTheirLanguageAndBody() {
32 let doc = OrgDocument.parse("""
33 #+begin_src sh
34 gitbay repo list
35 gitbay mr create
36 #+end_src
37 """)
38 #expect(doc.blocks == [
39 .code(language: "sh", text: "gitbay repo list\ngitbay mr create"),
40 ])
41 }
42
43 @Test func examplesAndQuotesAreTheirOwnBlocks() {
44 let doc = OrgDocument.parse("""
45 #+begin_example
46 $ gitbay whoami
47 #+end_example
48
49 #+begin_quote
50 SSH is the API.
51 #+end_quote
52 """)
53 #expect(doc.blocks == [
54 .code(language: "", text: "$ gitbay whoami"),
55 .quote("SSH is the API."),
56 ])
57 }
58
59 @Test func bothListFormsParse() {
60 let doc = OrgDocument.parse("""
61 - one
62 + two
63
64 1. first
65 2) second
66 """)
67 #expect(doc.blocks == [
68 .bullet(["one", "two"]),
69 .ordered(["first", "second"]),
70 ])
71 }
72
73 @Test func aBoldStarLineIsNotAHeading() {
74 // "*bold*" has no space after the star, so it is prose.
75 let doc = OrgDocument.parse("*bold* opening line")
76 #expect(doc.blocks == [.paragraph("*bold* opening line")])
77 }
78
79 @Test func paragraphsRunUntilABlankLineOrBlockStart() {
80 let doc = OrgDocument.parse("""
81 One sentence
82 continued here.
83 * Heading
84 """)
85 #expect(doc.blocks == [
86 .paragraph("One sentence continued here."),
87 .heading(level: 1, text: "Heading"),
88 ])
89 }
90
91 @Test func readmeViewPicksTheRendererByExtension() {
92 // Only the name decides; content is not sniffed.
93 #expect(ReadmeView(name: "README.org", content: "* x").isOrg)
94 #expect(ReadmeView(name: "readme.ORG", content: "* x").isOrg)
95 #expect(!ReadmeView(name: "README.md", content: "# x").isOrg)
96 #expect(!ReadmeView(name: "README", content: "x").isOrg)
97 }
98}
gitbayUITests/LiveSmokeUITests.swift +65 −50
@@ -83,8 +83,7 @@ final class LiveSmokeUITests: XCTestCase {
8383 let source = app.descendants(matching: .any)
8484 .matching(identifier: "mr-source").firstMatch
8585 XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open")
86 source.tap()
87 source.typeText("ui-smoke")
86 focusAndType(source, "ui-smoke")
8887
8988 let target = app.descendants(matching: .any)
9089 .matching(identifier: "mr-target").firstMatch
@@ -93,8 +92,7 @@ final class LiveSmokeUITests: XCTestCase {
9392
9493 let mrTitle = app.descendants(matching: .any)
9594 .matching(identifier: "mr-title").firstMatch
96 mrTitle.tap()
97 mrTitle.typeText("UI smoke: mr create from the app")
95 focusAndType(mrTitle, "UI smoke: mr create from the app")
9896
9997 app.descendants(matching: .any).matching(identifier: "mr-submit")
10098 .firstMatch.tap()
@@ -121,24 +119,63 @@ final class LiveSmokeUITests: XCTestCase {
121119 // MARK: - Helpers
122120
123121 private func openRepo(_ path: String) {
124 let tab = app.buttons["Repositories"].firstMatch
125 XCTAssertTrue(tab.waitForExistence(timeout: 10))
126 tab.tap()
122 selectTab("Repositories")
123
124 // `.searchable` keeps the field tucked above the list until it is
125 // scrolled into view.
127126 let search = app.searchFields.firstMatch
128 XCTAssertTrue(search.waitForExistence(timeout: 10))
129 search.tap()
130 search.typeText(path)
127 if !search.waitForExistence(timeout: 5) {
128 app.swipeDown()
129 XCTAssertTrue(search.waitForExistence(timeout: 10), "search field never appeared")
130 }
131 focusAndType(search, path)
132
131133 let row = app.staticTexts[path].firstMatch
132134 XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
133135 row.tap()
134136 // Repo screen is loaded once its links render.
135 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
137 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10),
138 "repo screen did not open")
136139 }
137140
138141 private func back() {
139142 app.navigationBars.buttons.firstMatch.tap()
140143 }
141144
145 /// Switch tabs and wait until that tab is actually front. A tap
146 /// dispatched before the app is interactive which happens right
147 /// after launch when there is no sign-in to slow things down is
148 /// swallowed silently, so this taps until the tab reports selected.
149 func selectTab(_ name: String,
150 file: StaticString = #filePath, line: UInt = #line) {
151 let tab = app.tabBars.buttons[name].firstMatch
152 XCTAssertTrue(tab.waitForExistence(timeout: 15),
153 "\(name) tab missing", file: file, line: line)
154 // The app is interactive once its own chrome is hittable.
155 XCTAssertTrue(tab.isHittable || tab.waitForExistence(timeout: 5),
156 "\(name) tab never became hittable", file: file, line: line)
157
158 for _ in 0..<4 {
159 if tab.isSelected, app.navigationBars[name].firstMatch.exists { return }
160 tab.tap()
161 if app.navigationBars[name].firstMatch.waitForExistence(timeout: 5) { return }
162 }
163 XCTFail("\(name) tab did not come to front", file: file, line: line)
164 }
165
166 /// Tap a field and type into it, surviving the focus race: a tap can
167 /// land before the field is ready, and the keystrokes go nowhere.
168 func focusAndType(_ element: XCUIElement, _ text: String,
169 file: StaticString = #filePath, line: UInt = #line) {
170 element.tap()
171 if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
172 element.tap()
173 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
174 "keyboard never appeared for \(element)", file: file, line: line)
175 }
176 element.typeText(text)
177 }
178
142179 private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
143180 let predicate = NSPredicate(format: "exists == false")
144181 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
@@ -156,16 +193,13 @@ extension LiveSmokeUITests {
156193 // --- repo create first: it ends in the list's search state,
157194 // which the next step reuses. (Scratch repo; the runner deletes
158195 // it over SSH afterwards deletion is CLI-only by design.)
159 let tab = app.buttons["Repositories"].firstMatch
160 XCTAssertTrue(tab.waitForExistence(timeout: 10))
161 tab.tap()
196 selectTab("Repositories")
162197 app.descendants(matching: .any).matching(identifier: "repo-create-button")
163198 .firstMatch.tap()
164199 let pathField = app.descendants(matching: .any)
165200 .matching(identifier: "repo-create-path").firstMatch
166201 XCTAssertTrue(pathField.waitForExistence(timeout: 5))
167 pathField.tap()
168 pathField.typeText("ui-smoke")
202 focusAndType(pathField, "ui-smoke")
169203 app.switches.firstMatch.tap() // Private on
170204 app.descendants(matching: .any).matching(identifier: "repo-create-submit")
171205 .firstMatch.tap()
@@ -175,16 +209,15 @@ extension LiveSmokeUITests {
175209 "create sheet did not dismiss")
176210 let search = app.searchFields.firstMatch
177211 XCTAssertTrue(search.waitForExistence(timeout: 10))
178 search.tap()
179 search.typeText("ui-smoke")
212 focusAndType(search, "ui-smoke")
180213 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
181214 .waitForExistence(timeout: 15), "created repo not in the list")
182215
183216 // --- pin / unpin round-trip on krz/gitbay-ios ---
184217 // Reuse the open search to get there.
185218 let clear = search.buttons.firstMatch
186 if clear.exists { clear.tap() } else { search.tap() }
187 search.typeText("krz/gitbay-ios")
219 if clear.exists { clear.tap() }
220 focusAndType(search, "krz/gitbay-ios")
188221 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
189222 XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
190223 repoRow.tap()
@@ -207,8 +240,7 @@ extension LiveSmokeUITests {
207240 let addTopic = app.descendants(matching: .any)
208241 .matching(identifier: "settings-add-topic").firstMatch
209242 XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
210 addTopic.tap()
211 addTopic.typeText("ios")
243 focusAndType(addTopic, "ios")
212244 app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit")
213245 .firstMatch.tap()
214246 let chip = app.staticTexts["ios"].firstMatch
@@ -243,8 +275,7 @@ extension LiveSmokeUITests {
243275 .firstMatch.tap()
244276 let jobField = app.textFields.firstMatch
245277 XCTAssertTrue(jobField.waitForExistence(timeout: 5), "trigger alert missing")
246 jobField.tap()
247 jobField.typeText("ci")
278 focusAndType(jobField, "ci")
248279 app.buttons["Trigger"].firstMatch.tap()
249280 // "no job X" for an unknown job, "has no .gitbay/ci.yml" when the
250281 // repo has no CI config at all.
@@ -268,9 +299,7 @@ extension LiveSmokeUITests {
268299 /// profiles. No cleanup needed.
269300 func testDiscoveryFlows() throws {
270301 // --- feed renders events and navigates ---
271 let feedTab = app.buttons["Feed"].firstMatch
272 XCTAssertTrue(feedTab.waitForExistence(timeout: 10))
273 feedTab.tap()
302 selectTab("Feed")
274303 let firstEvent = app.cells.firstMatch
275304 XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events")
276305 firstEvent.tap()
@@ -281,12 +310,10 @@ extension LiveSmokeUITests {
281310
282311 // --- server-side search: "astronomy" is only a topic, invisible
283312 // to the client-side path/description filter ---
284 let repoTab = app.buttons["Repositories"].firstMatch
285 repoTab.tap()
313 selectTab("Repositories")
286314 let search = app.searchFields.firstMatch
287315 XCTAssertTrue(search.waitForExistence(timeout: 10))
288 search.tap()
289 search.typeText("astronomy")
316 focusAndType(search, "astronomy")
290317 let hit = app.staticTexts["krz/space-wiki"].firstMatch
291318 XCTAssertTrue(hit.waitForExistence(timeout: 15),
292319 "server-side topic search found nothing")
@@ -306,8 +333,7 @@ extension LiveSmokeUITests {
306333 app.staticTexts["Search in Files"].firstMatch.tap()
307334 let grepField = app.searchFields.firstMatch
308335 XCTAssertTrue(grepField.waitForExistence(timeout: 10))
309 grepField.tap()
310 grepField.typeText("space")
336 focusAndType(grepField, "space")
311337 app.keyboards.buttons["search"].firstMatch.tap()
312338 let match = app.cells.firstMatch
313339 XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches")
@@ -355,8 +381,7 @@ extension LiveSmokeUITests {
355381 let tag = app.descendants(matching: .any)
356382 .matching(identifier: "release-tag").firstMatch
357383 XCTAssertTrue(tag.waitForExistence(timeout: 5))
358 tag.tap()
359 tag.typeText("v9.9.9")
384 focusAndType(tag, "v9.9.9")
360385 app.descendants(matching: .any).matching(identifier: "release-submit")
361386 .firstMatch.tap()
362387 XCTAssertTrue(app.staticTexts
@@ -390,8 +415,7 @@ extension LiveSmokeUITests {
390415 let paste = app.descendants(matching: .any)
391416 .matching(identifier: "key-paste-text").firstMatch
392417 XCTAssertTrue(paste.waitForExistence(timeout: 5))
393 paste.tap()
394 paste.typeText("not a key")
418 focusAndType(paste, "not a key")
395419 app.descendants(matching: .any).matching(identifier: "key-paste-submit")
396420 .firstMatch.tap()
397421 XCTAssertTrue(app.staticTexts
@@ -403,13 +427,7 @@ extension LiveSmokeUITests {
403427 let code = app.descendants(matching: .any)
404428 .matching(identifier: "email-code").firstMatch
405429 XCTAssertTrue(code.waitForExistence(timeout: 5))
406 code.tap()
407 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
408 code.typeText("000000")
409 if (code.value as? String)?.contains("000000") != true {
410 // The first keystrokes can race focus; type once more.
411 code.typeText("000000")
412 }
430 focusAndType(code, "000000")
413431 let verify = app.descendants(matching: .any)
414432 .matching(identifier: "email-verify").firstMatch
415433 XCTAssertTrue(verify.waitForExistence(timeout: 5))
@@ -444,8 +462,7 @@ extension LiveSmokeUITests {
444462 let teamField = app.descendants(matching: .any)
445463 .matching(identifier: "org-new-team").firstMatch
446464 XCTAssertTrue(teamField.waitForExistence(timeout: 5))
447 teamField.tap()
448 teamField.typeText("ui-smoke")
465 focusAndType(teamField, "ui-smoke")
449466 app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
450467 .firstMatch.tap()
451468 let teamRow = app.staticTexts["ui-smoke"].firstMatch
@@ -455,8 +472,7 @@ extension LiveSmokeUITests {
455472 teamRow.tap()
456473 let repoField = app.textFields["org/repo"].firstMatch
457474 XCTAssertTrue(repoField.waitForExistence(timeout: 10))
458 repoField.tap()
459 repoField.typeText("krz/gitbay-ios")
475 focusAndType(repoField, "krz/gitbay-ios")
460476 app.buttons["Grant"].firstMatch.tap()
461477 let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
462478 XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
@@ -520,8 +536,7 @@ extension LiveSmokeUITests {
520536 XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
521537 return
522538 }
523 tokenField.tap()
524 tokenField.typeText(token + "\n")
539 focusAndType(tokenField, token + "\n")
525540 XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
526541 .waitForExistence(timeout: 20), "sign-in did not land")
527542 }