Commit 813408e5ca

813408e5cabc5d427fe6000ec4a021d934615f4e

parent: 113f65e4fb

Unsigned

cmc <hello@cleberg.net> · 2026-08-28 00:44 UTC

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

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 }