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

merged merged by cmc on 2026-08-28 00:53 UTC · krz/gitbay-ios:org-readmes-inline-threads into main

10 files changed, +515 −73

Layout: unified · split

gitbay/MRs/UnifiedDiff.swift +23
@@ -49,12 +49,35 @@ nonisolated struct UnifiedDiff: Sendable, Hashable {
49 let text: String 49 let text: String
50 50
51 var id: String { "\(oldNumber ?? 0):\(newNumber ?? 0):\(text)" } 51 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 }
52 } 64 }
53 65
54 /// Total across files, for the summary row. 66 /// Total across files, for the summary row.
55 var additions: Int { files.reduce(0) { $0 + $1.additions } } 67 var additions: Int { files.reduce(0) { $0 + $1.additions } }
56 var deletions: Int { files.reduce(0) { $0 + $1.deletions } } 68 var deletions: Int { files.reduce(0) { $0 + $1.deletions } }
57 69
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
58 // MARK: - Parsing 81 // MARK: - Parsing
59 82
60 static func parse(_ text: String) -> UnifiedDiff { 83 static func parse(_ text: String) -> UnifiedDiff {
gitbay/Repos/RepoDetailViewModel.swift +2
@@ -10,6 +10,7 @@ final class RepoDetailViewModel {
10 private(set) var state: LoadState<RepoDetail> = .loading 10 private(set) var state: LoadState<RepoDetail> = .loading
11 /// README markdown, when the root tree has one. Absent is normal. 11 /// README markdown, when the root tree has one. Absent is normal.
12 private(set) var readme: String? 12 private(set) var readme: String?
13 private(set) var readmeName: String?
13 /// 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 —
14 /// pin state only exists in the dashboard aggregate. 15 /// pin state only exists in the dashboard aggregate.
15 private(set) var isPinned: Bool? 16 private(set) var isPinned: Bool?
@@ -83,6 +84,7 @@ final class RepoDetailViewModel {
83 guard let file = try? await client.read( 84 guard let file = try? await client.read(
84 ["repo", "cat", path, candidate.name], as: FileContent.self 85 ["repo", "cat", path, candidate.name], as: FileContent.self
85 ), !file.binary, let content = file.content else { return } 86 ), !file.binary, let content = file.content else { return }
87 readmeName = candidate.name
86 readme = content 88 readme = content
87 } 89 }
88} 90}
gitbay/Repos/RepoModels.swift +14
@@ -38,6 +38,20 @@ nonisolated struct RepoDetail: Decodable, Sendable, Hashable {
38 var isArchived: Bool { archived ?? false } 38 var isArchived: Bool { archived ?? false }
39} 39}
40 40
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
41/// `repo tree` — one directory listing. 55/// `repo tree` — one directory listing.
42nonisolated struct TreeListing: Decodable, Sendable, Hashable { 56nonisolated struct TreeListing: Decodable, Sendable, Hashable {
43 let path: String 57 let path: String
gitbay/Views/MRs/DiffView.swift +30 −20
@@ -4,50 +4,52 @@ import SwiftUI
4/// text so the screen is reachable by value from anywhere. 4/// text so the screen is reachable by value from anywhere.
5struct DiffView: View { 5struct DiffView: View {
6 6
7 private let client: GitbayClient 7 @State private var model: MRDetailViewModel
8 private let repo: String
9 private let number: Int64
10 @State private var state: LoadState<UnifiedDiff> = .loading
11 8
12 init(client: GitbayClient, repo: String, number: Int64) { 9 init(client: GitbayClient, repo: String, number: Int64) {
13 self.client = client 10 _model = State(initialValue: MRDetailViewModel(
14 self.repo = repo 11 client: client, repoPath: repo, number: number
15 self.number = number 12 ))
16 } 13 }
17 14
18 var body: some View { 15 var body: some View {
19 ZStack { 16 ZStack {
20 Color.clear 17 Color.clear
21 if let diff = state.value, !diff.files.isEmpty { 18 if let diff = model.diff, !diff.files.isEmpty {
22 List { 19 List {
23 ForEach(diff.files) { file in 20 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 }
25 } 32 }
26 } 33 }
27 .listStyle(.plain) 34 .listStyle(.plain)
28 } else if case .loaded = state { 35 } else if model.state.value != nil {
29 ContentUnavailableView { 36 ContentUnavailableView {
30 Label("No changes", systemImage: "plus.forwardslash.minus") 37 Label("No changes", systemImage: "plus.forwardslash.minus")
31 } 38 }
32 } 39 }
33 } 40 }
34 .overlay { LoadStateOverlay(state: state) } 41 .overlay { LoadStateOverlay(state: model.state) }
35 .navigationTitle("Diff") 42 .navigationTitle("Diff")
36 .navigationBarTitleDisplayMode(.inline) 43 .navigationBarTitleDisplayMode(.inline)
37 .task { 44 .task { await model.load() }
38 do { 45 .refreshable { await model.load() }
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 }
45 } 46 }
46} 47}
47 48
48private struct FileDiffSection: View { 49private struct FileDiffSection: View {
49 50
50 let file: UnifiedDiff.File 51 let file: UnifiedDiff.File
52 let model: MRDetailViewModel
51 @State private var collapsed = false 53 @State private var collapsed = false
52 54
53 var body: some View { 55 var body: some View {
@@ -59,7 +61,7 @@ private struct FileDiffSection: View {
59 .foregroundStyle(.secondary) 61 .foregroundStyle(.secondary)
60 } else { 62 } else {
61 ForEach(file.hunks) { hunk in 63 ForEach(file.hunks) { hunk in
62 HunkView(hunk: hunk) 64 HunkView(file: file, hunk: hunk, model: model)
63 } 65 }
64 } 66 }
65 } 67 }
@@ -93,7 +95,9 @@ private struct FileDiffSection: View {
93 95
94private struct HunkView: View { 96private struct HunkView: View {
95 97
98 let file: UnifiedDiff.File
96 let hunk: UnifiedDiff.Hunk 99 let hunk: UnifiedDiff.Hunk
100 let model: MRDetailViewModel
97 101
98 var body: some View { 102 var body: some View {
99 ScrollView(.horizontal) { 103 ScrollView(.horizontal) {
@@ -106,6 +110,12 @@ private struct HunkView: View {
106 .background(Color.gbFillSubtle) 110 .background(Color.gbFillSubtle)
107 ForEach(hunk.lines) { line in 111 ForEach(hunk.lines) { line in
108 LineView(line: line) 112 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 }
109 } 119 }
110 } 120 }
111 } 121 }
gitbay/Views/MRs/MRView.swift +2 −2
@@ -194,7 +194,7 @@ struct MRView: View {
194 private var threadsSection: some View { 194 private var threadsSection: some View {
195 Section("Threads — \(model.unresolvedCount) unresolved") { 195 Section("Threads — \(model.unresolvedCount) unresolved") {
196 ForEach(model.threads) { thread in 196 ForEach(model.threads) { thread in
197 ThreadView(thread: thread, model: model) 197 ReviewThreadView(thread: thread, model: model)
198 } 198 }
199 } 199 }
200 } 200 }
@@ -299,7 +299,7 @@ struct MRView: View {
299} 299}
300 300
301/// One review thread: anchor, comments, reply, resolve. 301/// One review thread: anchor, comments, reply, resolve.
302private struct ThreadView: View { 302struct ReviewThreadView: View {
303 303
304 let thread: ReviewThread 304 let thread: ReviewThread
305 let model: MRDetailViewModel 305 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 {
56 56
57 if let readme = model.readme { 57 if let readme = model.readme {
58 Section("README") { 58 Section("README") {
59 MarkdownView(markdown: readme) 59 ReadmeView(name: model.readmeName ?? "README.md", content: readme)
60 .padding(.vertical, 4) 60 .padding(.vertical, 4)
61 } 61 }
62 } 62 }
gitbayTests/MRViewModelTests.swift +64
@@ -277,3 +277,67 @@ struct MRDetailViewModelTests {
277 #expect(body["stdin"] as? String == "because 5xx is transient") 277 #expect(body["stdin"] as? String == "because 5xx is transient")
278 } 278 }
279} 279}
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 {
83 let source = app.descendants(matching: .any) 83 let source = app.descendants(matching: .any)
84 .matching(identifier: "mr-source").firstMatch 84 .matching(identifier: "mr-source").firstMatch
85 XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open") 85 XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open")
86 source.tap() 86 focusAndType(source, "ui-smoke")
87 source.typeText("ui-smoke")
88 87
89 let target = app.descendants(matching: .any) 88 let target = app.descendants(matching: .any)
90 .matching(identifier: "mr-target").firstMatch 89 .matching(identifier: "mr-target").firstMatch
@@ -93,8 +92,7 @@ final class LiveSmokeUITests: XCTestCase {
93 92
94 let mrTitle = app.descendants(matching: .any) 93 let mrTitle = app.descendants(matching: .any)
95 .matching(identifier: "mr-title").firstMatch 94 .matching(identifier: "mr-title").firstMatch
96 mrTitle.tap() 95 focusAndType(mrTitle, "UI smoke: mr create from the app")
97 mrTitle.typeText("UI smoke: mr create from the app")
98 96
99 app.descendants(matching: .any).matching(identifier: "mr-submit") 97 app.descendants(matching: .any).matching(identifier: "mr-submit")
100 .firstMatch.tap() 98 .firstMatch.tap()
@@ -121,24 +119,63 @@ final class LiveSmokeUITests: XCTestCase {
121 // MARK: - Helpers 119 // MARK: - Helpers
122 120
123 private func openRepo(_ path: String) { 121 private func openRepo(_ path: String) {
124 let tab = app.buttons["Repositories"].firstMatch 122 selectTab("Repositories")
125 XCTAssertTrue(tab.waitForExistence(timeout: 10)) 123
126 tab.tap() 124 // `.searchable` keeps the field tucked above the list until it is
125 // scrolled into view.
127 let search = app.searchFields.firstMatch 126 let search = app.searchFields.firstMatch
128 XCTAssertTrue(search.waitForExistence(timeout: 10)) 127 if !search.waitForExistence(timeout: 5) {
129 search.tap() 128 app.swipeDown()
130 search.typeText(path) 129 XCTAssertTrue(search.waitForExistence(timeout: 10), "search field never appeared")
130 }
131 focusAndType(search, path)
132
131 let row = app.staticTexts[path].firstMatch 133 let row = app.staticTexts[path].firstMatch
132 XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list") 134 XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
133 row.tap() 135 row.tap()
134 // Repo screen is loaded once its links render. 136 // 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")
136 } 139 }
137 140
138 private func back() { 141 private func back() {
139 app.navigationBars.buttons.firstMatch.tap() 142 app.navigationBars.buttons.firstMatch.tap()
140 } 143 }
141 144
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
142 private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool { 179 private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
143 let predicate = NSPredicate(format: "exists == false") 180 let predicate = NSPredicate(format: "exists == false")
144 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) 181 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
@@ -156,16 +193,13 @@ extension LiveSmokeUITests {
156 // --- repo create first: it ends in the list's search state, 193 // --- repo create first: it ends in the list's search state,
157 // which the next step reuses. (Scratch repo; the runner deletes 194 // which the next step reuses. (Scratch repo; the runner deletes
158 // it over SSH afterwards — deletion is CLI-only by design.) 195 // it over SSH afterwards — deletion is CLI-only by design.)
159 let tab = app.buttons["Repositories"].firstMatch 196 selectTab("Repositories")
160 XCTAssertTrue(tab.waitForExistence(timeout: 10))
161 tab.tap()
162 app.descendants(matching: .any).matching(identifier: "repo-create-button") 197 app.descendants(matching: .any).matching(identifier: "repo-create-button")
163 .firstMatch.tap() 198 .firstMatch.tap()
164 let pathField = app.descendants(matching: .any) 199 let pathField = app.descendants(matching: .any)
165 .matching(identifier: "repo-create-path").firstMatch 200 .matching(identifier: "repo-create-path").firstMatch
166 XCTAssertTrue(pathField.waitForExistence(timeout: 5)) 201 XCTAssertTrue(pathField.waitForExistence(timeout: 5))
167 pathField.tap() 202 focusAndType(pathField, "ui-smoke")
168 pathField.typeText("ui-smoke")
169 app.switches.firstMatch.tap() // Private on 203 app.switches.firstMatch.tap() // Private on
170 app.descendants(matching: .any).matching(identifier: "repo-create-submit") 204 app.descendants(matching: .any).matching(identifier: "repo-create-submit")
171 .firstMatch.tap() 205 .firstMatch.tap()
@@ -175,16 +209,15 @@ extension LiveSmokeUITests {
175 "create sheet did not dismiss") 209 "create sheet did not dismiss")
176 let search = app.searchFields.firstMatch 210 let search = app.searchFields.firstMatch
177 XCTAssertTrue(search.waitForExistence(timeout: 10)) 211 XCTAssertTrue(search.waitForExistence(timeout: 10))
178 search.tap() 212 focusAndType(search, "ui-smoke")
179 search.typeText("ui-smoke")
180 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch 213 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
181 .waitForExistence(timeout: 15), "created repo not in the list") 214 .waitForExistence(timeout: 15), "created repo not in the list")
182 215
183 // --- pin / unpin round-trip on krz/gitbay-ios --- 216 // --- pin / unpin round-trip on krz/gitbay-ios ---
184 // Reuse the open search to get there. 217 // Reuse the open search to get there.
185 let clear = search.buttons.firstMatch 218 let clear = search.buttons.firstMatch
186 if clear.exists { clear.tap() } else { search.tap() } 219 if clear.exists { clear.tap() }
187 search.typeText("krz/gitbay-ios") 220 focusAndType(search, "krz/gitbay-ios")
188 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch 221 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
189 XCTAssertTrue(repoRow.waitForExistence(timeout: 15)) 222 XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
190 repoRow.tap() 223 repoRow.tap()
@@ -207,8 +240,7 @@ extension LiveSmokeUITests {
207 let addTopic = app.descendants(matching: .any) 240 let addTopic = app.descendants(matching: .any)
208 .matching(identifier: "settings-add-topic").firstMatch 241 .matching(identifier: "settings-add-topic").firstMatch
209 XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load") 242 XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
210 addTopic.tap() 243 focusAndType(addTopic, "ios")
211 addTopic.typeText("ios")
212 app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit") 244 app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit")
213 .firstMatch.tap() 245 .firstMatch.tap()
214 let chip = app.staticTexts["ios"].firstMatch 246 let chip = app.staticTexts["ios"].firstMatch
@@ -243,8 +275,7 @@ extension LiveSmokeUITests {
243 .firstMatch.tap() 275 .firstMatch.tap()
244 let jobField = app.textFields.firstMatch 276 let jobField = app.textFields.firstMatch
245 XCTAssertTrue(jobField.waitForExistence(timeout: 5), "trigger alert missing") 277 XCTAssertTrue(jobField.waitForExistence(timeout: 5), "trigger alert missing")
246 jobField.tap() 278 focusAndType(jobField, "ci")
247 jobField.typeText("ci")
248 app.buttons["Trigger"].firstMatch.tap() 279 app.buttons["Trigger"].firstMatch.tap()
249 // "no job X" for an unknown job, "has no .gitbay/ci.yml" when the 280 // "no job X" for an unknown job, "has no .gitbay/ci.yml" when the
250 // repo has no CI config at all. 281 // repo has no CI config at all.
@@ -268,9 +299,7 @@ extension LiveSmokeUITests {
268 /// profiles. No cleanup needed. 299 /// profiles. No cleanup needed.
269 func testDiscoveryFlows() throws { 300 func testDiscoveryFlows() throws {
270 // --- feed renders events and navigates --- 301 // --- feed renders events and navigates ---
271 let feedTab = app.buttons["Feed"].firstMatch 302 selectTab("Feed")
272 XCTAssertTrue(feedTab.waitForExistence(timeout: 10))
273 feedTab.tap()
274 let firstEvent = app.cells.firstMatch 303 let firstEvent = app.cells.firstMatch
275 XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events") 304 XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events")
276 firstEvent.tap() 305 firstEvent.tap()
@@ -281,12 +310,10 @@ extension LiveSmokeUITests {
281 310
282 // --- server-side search: "astronomy" is only a topic, invisible 311 // --- server-side search: "astronomy" is only a topic, invisible
283 // to the client-side path/description filter --- 312 // to the client-side path/description filter ---
284 let repoTab = app.buttons["Repositories"].firstMatch 313 selectTab("Repositories")
285 repoTab.tap()
286 let search = app.searchFields.firstMatch 314 let search = app.searchFields.firstMatch
287 XCTAssertTrue(search.waitForExistence(timeout: 10)) 315 XCTAssertTrue(search.waitForExistence(timeout: 10))
288 search.tap() 316 focusAndType(search, "astronomy")
289 search.typeText("astronomy")
290 let hit = app.staticTexts["krz/space-wiki"].firstMatch 317 let hit = app.staticTexts["krz/space-wiki"].firstMatch
291 XCTAssertTrue(hit.waitForExistence(timeout: 15), 318 XCTAssertTrue(hit.waitForExistence(timeout: 15),
292 "server-side topic search found nothing") 319 "server-side topic search found nothing")
@@ -306,8 +333,7 @@ extension LiveSmokeUITests {
306 app.staticTexts["Search in Files"].firstMatch.tap() 333 app.staticTexts["Search in Files"].firstMatch.tap()
307 let grepField = app.searchFields.firstMatch 334 let grepField = app.searchFields.firstMatch
308 XCTAssertTrue(grepField.waitForExistence(timeout: 10)) 335 XCTAssertTrue(grepField.waitForExistence(timeout: 10))
309 grepField.tap() 336 focusAndType(grepField, "space")
310 grepField.typeText("space")
311 app.keyboards.buttons["search"].firstMatch.tap() 337 app.keyboards.buttons["search"].firstMatch.tap()
312 let match = app.cells.firstMatch 338 let match = app.cells.firstMatch
313 XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches") 339 XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches")
@@ -355,8 +381,7 @@ extension LiveSmokeUITests {
355 let tag = app.descendants(matching: .any) 381 let tag = app.descendants(matching: .any)
356 .matching(identifier: "release-tag").firstMatch 382 .matching(identifier: "release-tag").firstMatch
357 XCTAssertTrue(tag.waitForExistence(timeout: 5)) 383 XCTAssertTrue(tag.waitForExistence(timeout: 5))
358 tag.tap() 384 focusAndType(tag, "v9.9.9")
359 tag.typeText("v9.9.9")
360 app.descendants(matching: .any).matching(identifier: "release-submit") 385 app.descendants(matching: .any).matching(identifier: "release-submit")
361 .firstMatch.tap() 386 .firstMatch.tap()
362 XCTAssertTrue(app.staticTexts 387 XCTAssertTrue(app.staticTexts
@@ -390,8 +415,7 @@ extension LiveSmokeUITests {
390 let paste = app.descendants(matching: .any) 415 let paste = app.descendants(matching: .any)
391 .matching(identifier: "key-paste-text").firstMatch 416 .matching(identifier: "key-paste-text").firstMatch
392 XCTAssertTrue(paste.waitForExistence(timeout: 5)) 417 XCTAssertTrue(paste.waitForExistence(timeout: 5))
393 paste.tap() 418 focusAndType(paste, "not a key")
394 paste.typeText("not a key")
395 app.descendants(matching: .any).matching(identifier: "key-paste-submit") 419 app.descendants(matching: .any).matching(identifier: "key-paste-submit")
396 .firstMatch.tap() 420 .firstMatch.tap()
397 XCTAssertTrue(app.staticTexts 421 XCTAssertTrue(app.staticTexts
@@ -403,13 +427,7 @@ extension LiveSmokeUITests {
403 let code = app.descendants(matching: .any) 427 let code = app.descendants(matching: .any)
404 .matching(identifier: "email-code").firstMatch 428 .matching(identifier: "email-code").firstMatch
405 XCTAssertTrue(code.waitForExistence(timeout: 5)) 429 XCTAssertTrue(code.waitForExistence(timeout: 5))
406 code.tap() 430 focusAndType(code, "000000")
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 }
413 let verify = app.descendants(matching: .any) 431 let verify = app.descendants(matching: .any)
414 .matching(identifier: "email-verify").firstMatch 432 .matching(identifier: "email-verify").firstMatch
415 XCTAssertTrue(verify.waitForExistence(timeout: 5)) 433 XCTAssertTrue(verify.waitForExistence(timeout: 5))
@@ -444,8 +462,7 @@ extension LiveSmokeUITests {
444 let teamField = app.descendants(matching: .any) 462 let teamField = app.descendants(matching: .any)
445 .matching(identifier: "org-new-team").firstMatch 463 .matching(identifier: "org-new-team").firstMatch
446 XCTAssertTrue(teamField.waitForExistence(timeout: 5)) 464 XCTAssertTrue(teamField.waitForExistence(timeout: 5))
447 teamField.tap() 465 focusAndType(teamField, "ui-smoke")
448 teamField.typeText("ui-smoke")
449 app.descendants(matching: .any).matching(identifier: "org-new-team-submit") 466 app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
450 .firstMatch.tap() 467 .firstMatch.tap()
451 let teamRow = app.staticTexts["ui-smoke"].firstMatch 468 let teamRow = app.staticTexts["ui-smoke"].firstMatch
@@ -455,8 +472,7 @@ extension LiveSmokeUITests {
455 teamRow.tap() 472 teamRow.tap()
456 let repoField = app.textFields["org/repo"].firstMatch 473 let repoField = app.textFields["org/repo"].firstMatch
457 XCTAssertTrue(repoField.waitForExistence(timeout: 10)) 474 XCTAssertTrue(repoField.waitForExistence(timeout: 10))
458 repoField.tap() 475 focusAndType(repoField, "krz/gitbay-ios")
459 repoField.typeText("krz/gitbay-ios")
460 app.buttons["Grant"].firstMatch.tap() 476 app.buttons["Grant"].firstMatch.tap()
461 let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch 477 let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
462 XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed") 478 XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
@@ -520,8 +536,7 @@ extension LiveSmokeUITests {
520 XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided") 536 XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
521 return 537 return
522 } 538 }
523 tokenField.tap() 539 focusAndType(tokenField, token + "\n")
524 tokenField.typeText(token + "\n")
525 XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch 540 XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
526 .waitForExistence(timeout: 20), "sign-in did not land") 541 .waitForExistence(timeout: 20), "sign-in did not land")
527 } 542 }