a native ios client for gitbay

client ios swift

https://gitbay.org

Commit d2243f1bfc

d2243f1bfc2d3598a70200d15aa20cd1221115c2

parent: 5c8121c009

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-28T00:44:51Z

readmes as org, review threads anchored in the diff

README.org renders as Org rather than through the Markdown block
parser, which turned #+title: into a heading and left [[link]] raw.
Headings (#+title and stars), source and example blocks, quotes, both
list forms, and inline =code=/[[link][text]] all parse; a bold *word*
opening a line stays prose. Format follows the file name, never a
guess at the content.

Review threads now render against the diff line they were left on,
with replies and resolve in place. Threads the diff has moved past —
stale, or anchored to a line no longer shown — collect in their own
section rather than vanishing. The anchoring rule moved out of the
view into UnifiedDiff, where it is domain logic and testable: a thread
matches on path plus the side's numbering, and a stale thread anchors
nowhere because the head it referenced is gone. ThreadView became
ReviewThreadView, shared by the MR screen and the diff.

Two UI-test helpers, both encoding failures this caught: selectTab
taps until the tab reports front (a tap dispatched before the app is
interactive is swallowed silently — it only passed on runs where
signing in happened to add the delay), and focusAndType waits for the
keyboard before typing (a tap can land before the field is ready and
the keystrokes go nowhere). Every typing site routes through it.

RepoRefs models the shape krz/gitbay#45 proposes; no such command
exists, so it stays unused and says so.

150 unit tests. Screenshot checkpoint green on iPhone 17e, 17 Pro and
17 Pro Max, light and dark.

Closes #4
Ref #11
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 }