gitbayUITests/LiveSmokeUITests.swift
1255 lines · 60013 bytes
1import XCTest
2
3/// Live smoke flows against gitbay.org, driven by accessibility — the
4/// coordinate-free verification the simulator's input pipeline cannot be
5/// trusted to do (krz/gitbay-ios#5).
6///
7/// These WRITE to the live instance (an MR is opened and closed, a
8/// milestone assigned), so they only run when explicitly asked:
9///
10/// TEST_RUNNER_GITBAY_UITEST_LIVE=1 xcodebuild test \
11/// -only-testing:gitbayUITests ...
12///
13/// They assume the simulator is already signed in as an account that can
14/// write to krz/gitbay-ios, and that branch `ui-smoke` exists.
15final class LiveSmokeUITests: XCTestCase {
16
17 private var app: XCUIApplication!
18
19 /// The scratch repo testRepoManagementFlows creates. A run that dies
20 /// before deleting it leaves it behind, and then every later run
21 /// fails at "create sheet did not dismiss" — the create is refused
22 /// because the name is taken, which reads like a UI bug and is not.
23 /// Removed before and after, so neither a crashed run nor this one
24 /// can strand it.
25 private static let scratchRepo = "cmc/ui-smoke"
26
27 override func setUpWithError() throws {
28 try XCTSkipUnless(
29 ProcessInfo.processInfo.environment["GITBAY_UITEST_LIVE"] == "1",
30 "live UI smoke is opt-in; set TEST_RUNNER_GITBAY_UITEST_LIVE=1"
31 )
32 continueAfterFailure = false
33 app = XCUIApplication()
34 // xcodebuild runs tests on simulator clones, so the base
35 // device's appearance never applies; force it per run.
36 switch ProcessInfo.processInfo.environment["GITBAY_UITEST_DARK"] {
37 case "1": app.launchArguments.append("-gb-dark")
38 case "0": app.launchArguments.append("-gb-light")
39 default: break // follow the device, as a real launch does
40 }
41 app.launch()
42 // A fresh device — a clone on another runtime, say — starts on
43 // the token screen; the flows assume a signed-in app.
44 signInIfNeeded()
45 if name.contains("testRepoManagementFlows") {
46 deleteScratchRepo()
47 }
48 if name.contains("testCreationAndEditingFlows") {
49 ensureFixtureIssue()
50 try requireFixtureBranch()
51 }
52 if name.contains("testBlameAndEditFlows") {
53 try requireFixtureRepo()
54 }
55 }
56
57 override func tearDownWithError() throws {
58 if name.contains("testRepoManagementFlows") {
59 deleteScratchRepo()
60 }
61 }
62
63 /// `repo delete` over the JSON API — the app has no delete screen
64 /// (deletion is SSH-only by design), so cleanup cannot go through
65 /// the UI. A repo that is not there answers exit 3, which is the
66 /// outcome we want anyway.
67 private func deleteScratchRepo() {
68 _ = runCommand(["repo", "delete", Self.scratchRepo, "--yes"])
69 }
70
71 /// The issue the editing flow works on. It is a fixture, not a real
72 /// issue: pinning the test to whatever issue happened to be open put
73 /// it at the mercy of the project moving on, and it duly broke when
74 /// that issue was closed (krz/gitbay-ios#8).
75 static let fixtureIssueTitle = "ui-smoke fixture: do not close"
76
77 /// Guarantee the fixture issue exists and is open. It is reused
78 /// rather than recreated, so runs do not pile up closed issues.
79 private func ensureFixtureIssue() {
80 guard let data = readCommand(
81 ["issue", "list", Self.fixtureRepo, "--state", "open"]),
82 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
83 else { return }
84
85 let issues = envelope["data"] as? [[String: Any]] ?? []
86 if issues.contains(where: { $0["title"] as? String == Self.fixtureIssueTitle }) {
87 return
88 }
89 _ = runCommand(
90 ["issue", "create", Self.fixtureRepo, "--title", Self.fixtureIssueTitle, "--file", "-"],
91 stdin: "Created by the live smoke suite. It exercises the milestone "
92 + "picker and the edit sheet, and is reused every run."
93 )
94 }
95
96 static let fixtureRepo = "krz/gitbay-ios"
97
98 /// The MR flow needs a source branch carrying a commit main does not
99 /// have. Nothing in the app or the API can create a branch — that is
100 /// a push — so this only checks, and says so plainly rather than
101 /// letting the test fail later at "created MR not in the list".
102 private func requireFixtureBranch() throws {
103 guard let data = readCommand(["repo", "refs", Self.fixtureRepo]),
104 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
105 let refs = envelope["data"] as? [String: Any],
106 let branches = refs["branches"] as? [[String: Any]]
107 else { return } // no token, or the instance is unreachable
108 let names = branches.compactMap { $0["name"] as? String }
109 try XCTSkipUnless(names.contains("ui-smoke"), """
110 krz/gitbay-ios has no ui-smoke branch. The MR flow opens a \
111 merge request from it; recreate it with a commit main does \
112 not have (see .gitbay/ui-smoke.md on that branch).
113 """)
114 }
115
116 /// POST /api/v1/cmd. Writes the suite needs but the app cannot make.
117 @discardableResult
118 private func runCommand(_ argv: [String], stdin: String? = nil) -> Data? {
119 var body: [String: Any] = ["argv": argv]
120 if let stdin { body["stdin"] = stdin }
121 return call(method: "POST", path: "/api/v1/cmd", body: body)
122 }
123
124 /// GET /api/v1/read, for the reads that decide what a test sets up.
125 private func readCommand(_ argv: [String]) -> Data? {
126 let query = argv
127 .map { "argv=" + ($0.addingPercentEncoding(
128 withAllowedCharacters: .alphanumerics) ?? $0) }
129 .joined(separator: "&")
130 return call(method: "GET", path: "/api/v1/read?" + query, body: nil)
131 }
132
133 private func call(method: String, path: String, body: [String: Any]?) -> Data? {
134 guard let token = ProcessInfo.processInfo
135 .environment["GITBAY_UITEST_TOKEN"], !token.isEmpty,
136 let url = URL(string: "https://gitbay.org" + path)
137 else { return nil }
138
139 var request = URLRequest(url: url)
140 request.httpMethod = method
141 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
142 if let body {
143 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
144 request.httpBody = try? JSONSerialization.data(withJSONObject: body)
145 }
146
147 var result: Data?
148 let done = DispatchSemaphore(value: 0)
149 URLSession.shared.dataTask(with: request) { data, _, _ in
150 result = data
151 done.signal()
152 }.resume()
153 _ = done.wait(timeout: .now() + 15)
154 return result
155 }
156
157 /// Everything in one ordered pass: milestone assign, edit-save,
158 /// MR create, MR close. One test so the flows share navigation and
159 /// the writes happen exactly once.
160 func testCreationAndEditingFlows() throws {
161 openRepo("krz/gitbay-ios")
162
163 // --- the fixture issue: milestone picker assigns v1.0.0 ---
164 app.staticTexts["Issues"].firstMatch.tap()
165 let issueRow = app.staticTexts
166 .containing(NSPredicate(format: "label CONTAINS 'ui-smoke fixture'")).firstMatch
167 XCTAssertTrue(issueRow.waitForExistence(timeout: 10),
168 "fixture issue not in the list; setUp should have created it")
169 issueRow.tap()
170
171 let milestoneMenu = app.descendants(matching: .any)
172 .matching(identifier: "milestone-menu").firstMatch
173 XCTAssertTrue(milestoneMenu.waitForExistence(timeout: 10))
174 milestoneMenu.tap()
175 let milestoneChoice = app.buttons
176 .containing(NSPredicate(format: "label BEGINSWITH 'v1.0.0'")).firstMatch
177 XCTAssertTrue(milestoneChoice.waitForExistence(timeout: 5), "milestone menu did not open")
178 milestoneChoice.tap()
179 // The reload renders the assigned milestone in the header row.
180 XCTAssertTrue(app.staticTexts["v1.0.0"].firstMatch
181 .waitForExistence(timeout: 10), "milestone not shown after assign")
182
183 // --- the fixture issue: edit sheet saves (content unchanged) ---
184 app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
185 .firstMatch.tap()
186 let edit = app.buttons["Edit"].firstMatch
187 XCTAssertTrue(edit.waitForExistence(timeout: 5))
188 edit.tap()
189 let title = app.descendants(matching: .any)
190 .matching(identifier: "compose-title").firstMatch
191 XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
192 XCTAssertTrue((title.value as? String)?.contains("ui-smoke fixture") == true,
193 "edit sheet did not prefill")
194 // Save the body as org, reopen, and the picker must start on Org:
195 // the sheet has to read the stored format, not its own default.
196 let format = app.segmentedControls.matching(identifier: "compose-format").firstMatch
197 XCTAssertTrue(format.waitForExistence(timeout: 5), "format picker missing")
198 XCTAssertTrue(format.buttons["Markdown"].isSelected, "fixture did not open on Markdown")
199 format.buttons["Org"].tap()
200 app.descendants(matching: .any).matching(identifier: "compose-submit")
201 .firstMatch.tap()
202 // Sheet dismissal proves the save round-tripped without error.
203 XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
204
205 app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
206 .firstMatch.tap()
207 XCTAssertTrue(edit.waitForExistence(timeout: 5))
208 edit.tap()
209 XCTAssertTrue(format.waitForExistence(timeout: 5), "edit sheet did not reopen")
210 XCTAssertTrue(format.buttons["Org"].isSelected, "edit sheet did not open on the stored format")
211 // Restore the fixture to markdown.
212 format.buttons["Markdown"].tap()
213 app.descendants(matching: .any).matching(identifier: "compose-submit")
214 .firstMatch.tap()
215 XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
216
217 back() // to issues list
218 back() // to repo screen
219
220 // --- MR: create from ui-smoke, then close it ---
221 app.staticTexts["Merge Requests"].firstMatch.tap()
222 app.descendants(matching: .any).matching(identifier: "mr-create-button")
223 .firstMatch.tap()
224
225 let source = app.descendants(matching: .any)
226 .matching(identifier: "mr-source").firstMatch
227 XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open")
228 focusAndType(source, "ui-smoke")
229
230 let target = app.descendants(matching: .any)
231 .matching(identifier: "mr-target").firstMatch
232 // The default branch prefilled while the sheet loaded.
233 XCTAssertEqual(target.value as? String, "main", "target did not prefill")
234
235 let mrTitle = app.descendants(matching: .any)
236 .matching(identifier: "mr-title").firstMatch
237 focusAndType(mrTitle, "UI smoke: mr create from the app")
238
239 app.descendants(matching: .any).matching(identifier: "mr-submit")
240 .firstMatch.tap()
241
242 let createdRow = app.staticTexts
243 .containing(NSPredicate(format: "label CONTAINS 'UI smoke'")).firstMatch
244 XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list")
245 createdRow.tap()
246
247 // A fresh MR has one revision; the section lists it.
248 let revisions = header("Revisions")
249 XCTAssertTrue(scrollTo(revisions, swipes: 8), "revisions section missing")
250 XCTAssertTrue(app.staticTexts["v1"].firstMatch.exists, "first revision not listed")
251
252 app.descendants(matching: .any).matching(identifier: "mr-actions-menu")
253 .firstMatch.tap()
254 let close = app.buttons["Close"].firstMatch
255 XCTAssertTrue(close.waitForExistence(timeout: 5))
256 close.tap()
257 // The confirmation dialog's destructive Close.
258 let confirm = app.buttons["Close"].firstMatch
259 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing")
260 confirm.tap()
261
262 XCTAssertTrue(app.staticTexts["closed"].firstMatch
263 .waitForExistence(timeout: 15), "MR did not show closed after close")
264 }
265
266 // MARK: - Helpers
267
268 private func openRepo(_ path: String) {
269 selectTab("Repositories")
270
271 // `.searchable` keeps the field tucked above the list until it is
272 // scrolled into view.
273 let search = app.searchFields.firstMatch
274 if !search.waitForExistence(timeout: 5) {
275 app.swipeDown()
276 XCTAssertTrue(search.waitForExistence(timeout: 10), "search field never appeared")
277 }
278 focusAndType(search, path)
279
280 let row = app.staticTexts[path].firstMatch
281 XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
282 row.tap()
283 // Repo screen is loaded once its links render.
284 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10),
285 "repo screen did not open")
286 }
287
288 private func back() {
289 app.navigationBars.buttons.firstMatch.tap()
290 }
291
292 /// Keys, PGP and email live behind the account menu, which is on the
293 /// My Profile tab and nowhere else.
294 func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
295 selectTab("My Profile")
296 let menu = app.descendants(matching: .any)
297 .matching(identifier: "account-menu").firstMatch
298 XCTAssertTrue(menu.waitForExistence(timeout: 15),
299 "account menu missing", file: file, line: line)
300 menu.tap()
301 let keys = app.buttons["Keys & Email"].firstMatch
302 XCTAssertTrue(keys.waitForExistence(timeout: 5),
303 "Keys & Email missing from the account menu", file: file, line: line)
304 keys.tap()
305 }
306
307 /// The edit flow saves a change to a real file. Seeding a
308 /// repository's first commit is a push, which no test can make, so
309 /// this only checks — and says why, rather than failing later at
310 /// "cmc/ui-smoke-edit not in the repo list".
311 private func requireFixtureRepo() throws {
312 guard let data = readCommand(["repo", "show", "cmc/ui-smoke-edit"]),
313 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
314 else { return } // no token, or the instance is unreachable
315 try XCTSkipUnless(envelope["data"] != nil, """
316 cmc/ui-smoke-edit is missing. The edit flow saves a change to \
317 notes.txt in it; recreate the repo with that file (see its \
318 README) and run again.
319 """)
320 }
321
322 /// Scroll a list until an element is in the hierarchy. Offscreen
323 /// rows do not exist to XCUITest, so waitForExistence alone fails on
324 /// anything below the fold.
325 @discardableResult
326 /// A list section header. iOS 18 renders these uppercased and iOS 26
327 /// as written, so the match ignores case.
328 func header(_ text: String) -> XCUIElement {
329 app.staticTexts.matching(NSPredicate(format: "label ==[c] %@", text)).firstMatch
330 }
331
332 func scrollTo(_ element: XCUIElement, swipes: Int = 6) -> Bool {
333 for _ in 0..<swipes {
334 if element.exists { return true }
335 app.swipeUp()
336 }
337 return element.exists
338 }
339
340 /// Switch tabs and wait until that tab is actually front. A tap
341 /// dispatched before the app is interactive — which happens right
342 /// after launch when there is no sign-in to slow things down — is
343 /// swallowed silently, so this taps until the tab reports selected.
344 func selectTab(_ name: String,
345 file: StaticString = #filePath, line: UInt = #line) {
346 // iPadOS renders a TabView as a sidebar or top bar rather than a
347 // bottom tab bar, so the control is not under app.tabBars there.
348 var tab = app.tabBars.buttons[name].firstMatch
349 if !tab.waitForExistence(timeout: 5) {
350 tab = app.buttons[name].firstMatch
351 }
352 XCTAssertTrue(tab.waitForExistence(timeout: 15),
353 "\(name) tab missing", file: file, line: line)
354 // The app is interactive once its own chrome is hittable.
355 XCTAssertTrue(tab.isHittable || tab.waitForExistence(timeout: 5),
356 "\(name) tab never became hittable", file: file, line: line)
357
358 for _ in 0..<4 {
359 if tab.isSelected, app.navigationBars[name].firstMatch.exists { return }
360 tab.tap()
361 if app.navigationBars[name].firstMatch.waitForExistence(timeout: 5) { return }
362 }
363 XCTFail("\(name) tab did not come to front", file: file, line: line)
364 }
365
366 /// Empty a text field before typing. Search fields keep their query
367 /// across navigation, and the clear button is not reliably
368 /// addressable, so this deletes character by character.
369 func clearAndType(_ element: XCUIElement, _ text: String,
370 file: StaticString = #filePath, line: UInt = #line) {
371 element.tap()
372 if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
373 element.tap()
374 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
375 "keyboard never appeared", file: file, line: line)
376 }
377 if let existing = element.value as? String,
378 existing != element.placeholderValue, !existing.isEmpty {
379 element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue,
380 count: existing.count))
381 }
382 element.typeText(text)
383 }
384
385 /// Tap a field and type into it, surviving the focus race: a tap can
386 /// land before the field is ready, and the keystrokes go nowhere.
387 func focusAndType(_ element: XCUIElement, _ text: String,
388 file: StaticString = #filePath, line: UInt = #line) {
389 element.tap()
390 if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
391 element.tap()
392 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
393 "keyboard never appeared for \(element)", file: file, line: line)
394 }
395 element.typeText(text)
396 }
397
398 private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
399 let predicate = NSPredicate(format: "exists == false")
400 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
401 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
402 }
403}
404
405extension LiveSmokeUITests {
406
407 /// Repo management, kept reversible: create a scratch repo (deleted
408 /// by the runner afterwards, deletion is CLI-only by design), pin and
409 /// unpin, a topic round-trip, a merge-rule toggle round-trip, and the
410 /// build-trigger error path on a repo with no job config.
411 func testRepoManagementFlows() throws {
412 // --- repo create first: it ends in the list's search state,
413 // which the next step reuses. (Scratch repo; the runner deletes
414 // it over SSH afterwards — deletion is CLI-only by design.)
415 selectTab("Repositories")
416 app.descendants(matching: .any).matching(identifier: "repo-create-button")
417 .firstMatch.tap()
418 let newRepo = app.buttons["New Repository"].firstMatch
419 XCTAssertTrue(newRepo.waitForExistence(timeout: 5), "create menu did not open")
420 newRepo.tap()
421 let pathField = app.descendants(matching: .any)
422 .matching(identifier: "repo-create-path").firstMatch
423 XCTAssertTrue(pathField.waitForExistence(timeout: 5))
424 focusAndType(pathField, "ui-smoke")
425 app.switches.firstMatch.tap() // Private on
426 app.descendants(matching: .any).matching(identifier: "repo-create-submit")
427 .firstMatch.tap()
428 // The sheet dismissing proves the create round-tripped; rows are
429 // lazy, so find the new repo through the filter.
430 XCTAssertTrue(waitForDisappearance(pathField, timeout: 15),
431 "create sheet did not dismiss")
432 let search = app.searchFields.firstMatch
433 XCTAssertTrue(search.waitForExistence(timeout: 10))
434 focusAndType(search, "ui-smoke")
435 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
436 .waitForExistence(timeout: 15), "created repo not in the list")
437
438 // --- access: grant a user on the scratch repo, then revoke ---
439 app.staticTexts["cmc/ui-smoke"].firstMatch.tap()
440 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
441 app.staticTexts["Settings"].firstMatch.tap()
442 let grantUser = app.descendants(matching: .any)
443 .matching(identifier: "settings-grant-user").firstMatch
444 XCTAssertTrue(scrollTo(grantUser, swipes: 8), "access section not reachable")
445 focusAndType(grantUser, "apple-review")
446 app.descendants(matching: .any).matching(identifier: "settings-grant-submit")
447 .firstMatch.tap()
448 let grantRow = app.staticTexts["apple-review"].firstMatch
449 XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
450 grantRow.swipeLeft()
451 app.buttons["Revoke"].firstMatch.tap()
452 let confirmGrantRevoke = app.buttons["Revoke"].firstMatch
453 XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
454 confirmGrantRevoke.tap()
455 XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
456
457 // --- webhooks: add one, see it listed, remove it ---
458 let webhooksLink = app.descendants(matching: .any)
459 .matching(identifier: "settings-webhooks-link").firstMatch
460 XCTAssertTrue(scrollTo(webhooksLink, swipes: 8), "webhooks link not reachable")
461 webhooksLink.tap()
462 let addHook = app.descendants(matching: .any)
463 .matching(identifier: "webhook-add-button").firstMatch
464 XCTAssertTrue(addHook.waitForExistence(timeout: 10), "webhooks did not load")
465 addHook.tap()
466 let hookURL = app.descendants(matching: .any)
467 .matching(identifier: "webhook-url").firstMatch
468 XCTAssertTrue(hookURL.waitForExistence(timeout: 5), "webhook sheet did not open")
469 focusAndType(hookURL, "https://example.com/ui-smoke")
470 app.descendants(matching: .any).matching(identifier: "webhook-submit")
471 .firstMatch.tap()
472 let hookRow = app.staticTexts["https://example.com/ui-smoke"].firstMatch
473 XCTAssertTrue(hookRow.waitForExistence(timeout: 15), "webhook not listed")
474 hookRow.swipeLeft()
475 app.buttons["Remove"].firstMatch.tap()
476 let confirmHookRemove = app.buttons["Remove"].firstMatch
477 XCTAssertTrue(confirmHookRemove.waitForExistence(timeout: 5), "webhook remove confirmation missing")
478 confirmHookRemove.tap()
479 XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed")
480 back() // webhooks -> settings
481 back() // settings -> repo
482 back() // repo -> list
483
484 // --- pin / unpin round-trip on krz/gitbay-ios ---
485 // Reuse the open search to get there.
486 let clear = search.buttons.firstMatch
487 if clear.exists { clear.tap() }
488 focusAndType(search, "krz/gitbay-ios")
489 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
490 XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
491 repoRow.tap()
492 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
493 let menu = app.descendants(matching: .any)
494 .matching(identifier: "repo-actions-menu").firstMatch
495 XCTAssertTrue(menu.waitForExistence(timeout: 10))
496 menu.tap()
497 let pin = app.buttons["Pin"].firstMatch
498 XCTAssertTrue(pin.waitForExistence(timeout: 5), "Pin action missing")
499 pin.tap()
500 // State refreshed from the dashboard: the menu now offers Unpin.
501 menu.tap()
502 let unpin = app.buttons["Unpin"].firstMatch
503 XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take")
504 unpin.tap()
505
506 // Bookmark it, find it in the bookmarks list, then unbookmark.
507 // A run that died between the two leaves it bookmarked; undo
508 // that first so the step starts from the same state every time.
509 menu.tap()
510 let leftover = app.buttons["Unbookmark"].firstMatch
511 if leftover.waitForExistence(timeout: 3) {
512 leftover.tap()
513 XCTAssertTrue(waitForDisappearance(leftover, timeout: 10))
514 menu.tap()
515 }
516 let bookmark = app.buttons["Bookmark"].firstMatch
517 XCTAssertTrue(bookmark.waitForExistence(timeout: 5), "Bookmark action missing")
518 bookmark.tap()
519 // An active search hides the list's toolbar, so start the list
520 // fresh: relaunching lands on a list with the bookmarks button.
521 app.terminate()
522 app.launch()
523 selectTab("Repositories")
524 let bookmarksButton = app.descendants(matching: .any)
525 .matching(identifier: "repo-bookmarks-button").firstMatch
526 XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 15), "bookmarks button missing")
527 bookmarksButton.tap()
528 let bookmarkedRow = app.staticTexts["krz/gitbay-ios"].firstMatch
529 XCTAssertTrue(bookmarkedRow.waitForExistence(timeout: 15),
530 "bookmarked repo not in the bookmarks list")
531 // Open it from the bookmarks list and undo the bookmark there.
532 bookmarkedRow.tap()
533 XCTAssertTrue(menu.waitForExistence(timeout: 10))
534 menu.tap()
535 let unbookmark = app.buttons["Unbookmark"].firstMatch
536 XCTAssertTrue(unbookmark.waitForExistence(timeout: 10), "bookmark did not take")
537 unbookmark.tap()
538
539 // --- settings: topic and merge-rule round-trips ---
540 // The links section can sit below the README fold, and the row's
541 // label is not always exposed as a static text.
542 let settingsLink = app.descendants(matching: .any)["Settings"].firstMatch
543 XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable")
544 settingsLink.tap()
545 let addTopic = app.descendants(matching: .any)
546 .matching(identifier: "settings-add-topic").firstMatch
547 XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
548 focusAndType(addTopic, "ios")
549 app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit")
550 .firstMatch.tap()
551 let chip = app.staticTexts["ios"].firstMatch
552 XCTAssertTrue(chip.waitForExistence(timeout: 10), "topic did not appear")
553 // Remove it again through the chip's own x button — by name, since
554 // the repository carries other topics — and it asks first.
555 app.descendants(matching: .any).matching(identifier: "settings-remove-topic-ios")
556 .firstMatch.tap()
557 let confirmRemove = app.buttons["Remove"].firstMatch
558 XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "topic remove confirmation missing")
559 confirmRemove.tap()
560 XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
561
562 let resolved = app.switches["Require threads resolved"].firstMatch
563 // The merge-requirements section sits below the fold, and a List
564 // does not build rows it has not shown, so it must be scrolled
565 // into existence before it can be queried.
566 XCTAssertTrue(scrollTo(resolved), "merge requirements section not reachable")
567 // SwiftUI exposes the row as a switch that wraps the real
568 // control; tap the innermost switch when there is one, else the
569 // right edge of the row.
570 let inner = resolved.switches.firstMatch
571 let control: () -> Void = {
572 if inner.exists && inner != resolved {
573 inner.tap()
574 } else {
575 resolved.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
576 }
577 }
578 control()
579 XCTAssertTrue(waitForValue(resolved, "1", timeout: 10), "toggle did not persist on")
580 control()
581 XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off")
582
583 // The code-owner rule is one more toggle in the same section.
584 let codeowners = app.switches["Require code owner review"].firstMatch
585 XCTAssertTrue(scrollTo(codeowners), "code owner toggle not reachable")
586 let innerCodeowners = codeowners.switches.firstMatch
587 let flipCodeowners: () -> Void = {
588 if innerCodeowners.exists && innerCodeowners != codeowners {
589 innerCodeowners.tap()
590 } else {
591 codeowners.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
592 }
593 }
594 flipCodeowners()
595 XCTAssertTrue(waitForValue(codeowners, "1", timeout: 10), "code owner toggle did not persist on")
596 flipCodeowners()
597 XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off")
598
599 back() // settings -> repo
600
601 // --- nothing to trigger without a job file ---
602 // This repo has no .gitbay/ci.yml, so `build jobs` returns none
603 // and the control is gated rather than failing after a guess.
604 app.staticTexts["Builds"].firstMatch.tap()
605 let trigger = app.descendants(matching: .any)
606 .matching(identifier: "build-trigger-button").firstMatch
607 XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing")
608 // Assert on behaviour, not on isEnabled: a disabled SwiftUI Menu
609 // still reports itself enabled to XCUITest.
610 trigger.tap()
611 let anyJob = app.buttons.containing(
612 NSPredicate(format: "label CONTAINS 'on push' OR label CONTAINS 'schedule '"))
613 .firstMatch
614 XCTAssertFalse(anyJob.waitForExistence(timeout: 3),
615 "a repo with no job file offered a job to trigger")
616 }
617
618 private func waitForValue(_ element: XCUIElement, _ value: String,
619 timeout: TimeInterval) -> Bool {
620 let predicate = NSPredicate(format: "value == %@", value)
621 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
622 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
623 }
624}
625
626extension LiveSmokeUITests {
627
628 /// Discovery is read-only: feed, server-side repo search, grep, and
629 /// profiles. No cleanup needed.
630 func testDiscoveryFlows() throws {
631 // --- feed renders events and navigates ---
632 selectTab("Feed")
633 let firstEvent = app.cells.firstMatch
634 XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events")
635 firstEvent.tap()
636 // Wherever the event led, it left the feed root behind.
637 XCTAssertTrue(app.navigationBars.buttons.firstMatch
638 .waitForExistence(timeout: 10), "feed row did not navigate")
639 back()
640
641 // --- server-side search: "astronomy" is only a topic, invisible
642 // to the client-side path/description filter ---
643 selectTab("Repositories")
644 let search = app.searchFields.firstMatch
645 XCTAssertTrue(search.waitForExistence(timeout: 10))
646 focusAndType(search, "astronomy")
647 let hit = app.staticTexts["krz/space-wiki"].firstMatch
648 XCTAssertTrue(hit.waitForExistence(timeout: 15),
649 "server-side topic search found nothing")
650 hit.tap()
651 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
652
653 // --- owner profile from the repo screen ---
654 // The row's label merges; match it at any element type. It is
655 // the last row of the links section, below the fold.
656 let ownerLink = app.descendants(matching: .any)["krz"].firstMatch
657 XCTAssertTrue(scrollTo(ownerLink), "owner link not reachable")
658 ownerLink.tap()
659 XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch
660 .waitForExistence(timeout: 15), "profile did not load")
661 XCTAssertTrue(app.staticTexts
662 .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch
663 .waitForExistence(timeout: 10), "profile repos missing")
664 back()
665
666 // --- grep inside the repo, last: its search UI owns the screen ---
667 app.staticTexts["Search in Files"].firstMatch.tap()
668 let grepField = app.searchFields.firstMatch
669 XCTAssertTrue(grepField.waitForExistence(timeout: 10))
670 focusAndType(grepField, "space")
671 app.keyboards.buttons["search"].firstMatch.tap()
672 let match = app.cells.firstMatch
673 XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches")
674 }
675}
676
677extension LiveSmokeUITests {
678
679 /// Releases: list and detail on a real release, an edit round-trip
680 /// that saves the prefilled content (a no-op write), and the
681 /// missing-tag refusal on create. Nothing changes state.
682 func testReleaseFlows() throws {
683 openRepo("krz/gitbay")
684 app.staticTexts["Releases"].firstMatch.tap()
685
686 // Newest first, and the list has grown past the fold, so the
687 // first row is whichever release is current.
688 let row = app.cells.firstMatch
689 XCTAssertTrue(row.waitForExistence(timeout: 15), "release list empty")
690 row.tap()
691
692 // Notes render and assets carry sizes. The assets sit under the
693 // notes, which can run past the fold.
694 XCTAssertTrue(app.descendants(matching: .any)
695 .matching(identifier: "release-edit-button").firstMatch
696 .waitForExistence(timeout: 15), "release did not open")
697 XCTAssertTrue(scrollTo(app.staticTexts
698 .containing(NSPredicate(format: "label CONTAINS 'SHA256SUMS'")).firstMatch, swipes: 10),
699 "assets missing")
700
701 // Edit sheet prefills; saving unchanged content round-trips.
702 app.descendants(matching: .any).matching(identifier: "release-edit-button")
703 .firstMatch.tap()
704 let title = app.descendants(matching: .any)
705 .matching(identifier: "compose-title").firstMatch
706 XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
707 XCTAssertFalse((title.value as? String ?? "").isEmpty,
708 "edit sheet did not prefill")
709 app.descendants(matching: .any).matching(identifier: "compose-submit")
710 .firstMatch.tap()
711 XCTAssertTrue(waitForDisappearance(title, timeout: 15),
712 "release edit did not dismiss")
713
714 back()
715
716 // Create with a tag that does not exist: the server's refusal is
717 // the UI contract.
718 app.descendants(matching: .any).matching(identifier: "release-create-button")
719 .firstMatch.tap()
720 let tag = app.descendants(matching: .any)
721 .matching(identifier: "release-tag").firstMatch
722 XCTAssertTrue(tag.waitForExistence(timeout: 5))
723 focusAndType(tag, "v9.9.9")
724 app.descendants(matching: .any).matching(identifier: "release-submit")
725 .firstMatch.tap()
726 XCTAssertTrue(app.staticTexts
727 .containing(NSPredicate(format: "label CONTAINS 'push the tag first'")).firstMatch
728 .waitForExistence(timeout: 15), "missing-tag refusal not surfaced")
729 app.buttons["Cancel"].firstMatch.tap()
730 }
731}
732
733extension LiveSmokeUITests {
734
735 /// Account keys and email. Read-only plus refusal paths — no key is
736 /// added or removed, no mail is sent.
737 func testAccountFlows() throws {
738 // Dashboard toolbar -> account screen.
739 openAccountScreen()
740
741 // Real keys render: SSH fingerprints and the PGP key's UID email.
742 XCTAssertTrue(app.staticTexts
743 .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
744 .waitForExistence(timeout: 15), "SSH keys missing")
745 XCTAssertTrue(app.staticTexts["hello@cleberg.net"].firstMatch
746 .waitForExistence(timeout: 10), "PGP key UID missing")
747
748 // Pasting garbage as an SSH key surfaces the server's validation.
749 app.descendants(matching: .any).matching(identifier: "add-ssh-key")
750 .firstMatch.tap()
751 let paste = app.descendants(matching: .any)
752 .matching(identifier: "key-paste-text").firstMatch
753 XCTAssertTrue(paste.waitForExistence(timeout: 5))
754 focusAndType(paste, "not a key")
755 app.descendants(matching: .any).matching(identifier: "key-paste-submit")
756 .firstMatch.tap()
757 XCTAssertTrue(app.staticTexts
758 .containing(NSPredicate(format: "label CONTAINS 'not a valid public key'")).firstMatch
759 .waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
760 app.buttons["Cancel"].firstMatch.tap()
761
762 // A bogus verification code is refused, not swallowed.
763 let code = app.descendants(matching: .any)
764 .matching(identifier: "email-code").firstMatch
765 // Email sits below the keys on a List, which does not build rows
766 // it has not shown.
767 XCTAssertTrue(scrollTo(code), "email section not reachable")
768 focusAndType(code, "000000")
769 let verify = app.descendants(matching: .any)
770 .matching(identifier: "email-verify").firstMatch
771 XCTAssertTrue(verify.waitForExistence(timeout: 5))
772 XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed")
773 verify.tap()
774 XCTAssertTrue(app.staticTexts
775 .containing(NSPredicate(format: "label CONTAINS 'invalid, expired'")).firstMatch
776 .waitForExistence(timeout: 15), "bad-code refusal not surfaced")
777 }
778}
779
780extension LiveSmokeUITests {
781
782 /// Orgs: members render, and a team lives a full life — created,
783 /// granted a repo, the grant revoked, the team deleted. Everything
784 /// this test makes, it removes.
785 func testOrgFlows() throws {
786 openAccountScreen()
787
788 let orgRow = app.staticTexts["krz"].firstMatch
789 XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
790 orgRow.tap()
791
792 // Members render with roles.
793 XCTAssertTrue(app.staticTexts["cmc"].firstMatch
794 .waitForExistence(timeout: 15), "org members missing")
795
796 // Create a team.
797 let teamField = app.descendants(matching: .any)
798 .matching(identifier: "org-new-team").firstMatch
799 XCTAssertTrue(teamField.waitForExistence(timeout: 5))
800 focusAndType(teamField, "ui-smoke")
801 app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
802 .firstMatch.tap()
803 let teamRow = app.staticTexts["ui-smoke"].firstMatch
804 XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed")
805
806 // Grant it a repo, then revoke.
807 teamRow.tap()
808 let repoField = app.textFields["org/repo"].firstMatch
809 XCTAssertTrue(repoField.waitForExistence(timeout: 10))
810 focusAndType(repoField, "krz/gitbay-ios")
811 app.buttons["Grant"].firstMatch.tap()
812 let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
813 XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
814 grantRow.swipeLeft()
815 app.buttons["Revoke"].firstMatch.tap()
816 // The confirmation dialog's destructive Revoke.
817 let confirmRevoke = app.buttons["Revoke"].firstMatch
818 XCTAssertTrue(confirmRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
819 confirmRevoke.tap()
820 XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
821
822 back()
823
824 // Delete the team, through its confirmation.
825 let row = app.staticTexts["ui-smoke"].firstMatch
826 XCTAssertTrue(row.waitForExistence(timeout: 10))
827 row.swipeLeft()
828 app.buttons["Delete"].firstMatch.tap()
829 // The confirmation dialog's destructive Delete.
830 let confirm = app.buttons["Delete"].firstMatch
831 XCTAssertTrue(confirm.waitForExistence(timeout: 5))
832 confirm.tap()
833 XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
834 }
835}
836
837extension LiveSmokeUITests {
838
839 /// The pre-merge screenshot checkpoint: walks the dense screens and
840 /// attaches captures. Run per device/appearance; export the
841 /// attachments from the xcresult. Signs in first when the device has
842 /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided.
843 func testScreenshotCheckpoint() throws {
844 signInIfNeeded()
845
846 snap("dashboard")
847
848 openRepo("krz/gitbay")
849 snap("repo")
850
851 app.staticTexts["Merge Requests"].firstMatch.tap()
852 let all = app.segmentedControls.buttons["All"].firstMatch
853 XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
854 all.tap()
855 // A real MR row carries its !number; the picker row does not.
856 let mrNumber = app.staticTexts
857 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
858 XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All")
859 snap("mr-list")
860
861 mrNumber.tap()
862 // The detail opens at its header; the diff row sits below the
863 // fold and a List does not build rows it has not shown.
864 let diffRow = app.staticTexts["Diff"].firstMatch
865 XCTAssertTrue(scrollTo(diffRow), "MR detail has no diff row")
866 snap("mr-detail")
867
868 diffRow.tap()
869 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
870 snap("diff")
871 }
872
873 private func signInIfNeeded() {
874 // Signed in already: the tab bar is up and there is no token field.
875 if app.tabBars.firstMatch.waitForExistence(timeout: 10),
876 !app.secureTextFields.firstMatch.exists {
877 return
878 }
879 let tokenField = app.secureTextFields.firstMatch
880 guard tokenField.waitForExistence(timeout: 5) else { return }
881 guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else {
882 XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
883 return
884 }
885 focusAndType(tokenField, token + "\n")
886 XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
887 .waitForExistence(timeout: 20), "sign-in did not land")
888 dismissSavePasswordPrompt()
889 }
890
891 /// iOS offers to save whatever went into a SecureField, and the sheet
892 /// sits over the app until answered — over the dashboard, in one set
893 /// of store screenshots. It belongs to springboard, not to the app.
894 private func dismissSavePasswordPrompt() {
895 let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
896 let notNow = springboard.buttons["Not Now"].firstMatch
897 if notNow.waitForExistence(timeout: 8) {
898 notNow.tap()
899 _ = waitForDisappearance(notNow, timeout: 10)
900 return
901 }
902 let inApp = app.buttons["Not Now"].firstMatch
903 if inApp.waitForExistence(timeout: 3) {
904 inApp.tap()
905 _ = waitForDisappearance(inApp, timeout: 10)
906 return
907 }
908 // No prompt is fine: iOS only offers once per credential.
909 }
910
911 /// Capture a screen, but not while it is still loading. These go to
912 /// the App Store, and a spinner shipped as a screenshot once already.
913 private func snap(_ name: String, file: StaticString = #filePath, line: UInt = #line) {
914 let spinner = app.activityIndicators.firstMatch
915 if spinner.exists {
916 XCTAssertTrue(waitForDisappearance(spinner, timeout: 30),
917 "\(name) still loading after 30s", file: file, line: line)
918 }
919 // A list that has loaded has rows; an empty state has text. Either
920 // way something must be on screen besides chrome.
921 let content = app.cells.firstMatch
922 _ = content.waitForExistence(timeout: 10)
923
924 let attachment = XCTAttachment(screenshot: app.screenshot())
925 attachment.name = name
926 attachment.lifetime = .keepAlways
927 add(attachment)
928 }
929}
930
931extension LiveSmokeUITests {
932
933 /// Blame and file editing — the two capabilities that were web-only
934 /// until krz/gitbay#42 and #43 made them commands. Blame is read-only
935 /// on a real repo; the edit writes to a scratch repo the runner
936 /// creates and deletes over SSH.
937 func testBlameAndEditFlows() throws {
938 // --- blame on a real file ---
939 openRepo("krz/gitbay")
940 app.staticTexts["Files"].firstMatch.tap()
941 let goMod = app.staticTexts["go.mod"].firstMatch
942 XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
943 goMod.tap()
944 XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu")
945 .firstMatch.waitForExistence(timeout: 15), "file screen did not open")
946 app.descendants(matching: .any).matching(identifier: "file-actions-menu")
947 .firstMatch.tap()
948 app.buttons["Blame"].firstMatch.tap()
949 // Attribution shows the commit subject beside the lines.
950 XCTAssertTrue(app.staticTexts
951 .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch
952 .waitForExistence(timeout: 20), "blame rendered no attribution")
953
954 // --- edit on the scratch repo ---
955 // Relaunch rather than reuse the list: `.searchable` keeps the
956 // previous query, and clearing it from the harness is unreliable.
957 app.terminate()
958 app.launch()
959 openRepo("cmc/ui-smoke-edit")
960 app.staticTexts["Files"].firstMatch.tap()
961 let notes = app.staticTexts["notes.txt"].firstMatch
962 XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
963 notes.tap()
964
965 let menu = app.descendants(matching: .any)
966 .matching(identifier: "file-actions-menu").firstMatch
967 XCTAssertTrue(menu.waitForExistence(timeout: 15))
968 menu.tap()
969 let edit = app.buttons
970 .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch
971 XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing")
972 edit.tap()
973
974 let content = app.descendants(matching: .any)
975 .matching(identifier: "file-edit-content").firstMatch
976 XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open")
977 focusAndType(content, " edited from the app")
978
979 let message = app.descendants(matching: .any)
980 .matching(identifier: "file-edit-message").firstMatch
981 focusAndType(message, "edit from ios")
982
983 app.descendants(matching: .any).matching(identifier: "file-edit-commit")
984 .firstMatch.tap()
985 XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss")
986 // The reloaded file shows the committed text.
987 XCTAssertTrue(app.staticTexts
988 .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch
989 .waitForExistence(timeout: 20), "edit not reflected after commit")
990 }
991}
992
993
994extension LiveSmokeUITests {
995
996 /// The navigation and profile changes: identity lives in one menu,
997 /// the dashboard can create a repository, a profile is a real
998 /// profile, and a log entry opens its commit.
999 func testProfileAndNavigationFlows() throws {
1000 // --- the dashboard offers creation ---
1001 selectTab("Dashboard")
1002 let create = app.descendants(matching: .any)
1003 .matching(identifier: "dashboard-create-button").firstMatch
1004 XCTAssertTrue(create.waitForExistence(timeout: 15), "dashboard + missing")
1005 create.tap()
1006 let pathField = app.descendants(matching: .any)
1007 .matching(identifier: "repo-create-path").firstMatch
1008 XCTAssertTrue(pathField.waitForExistence(timeout: 10),
1009 "dashboard + did not open the create sheet")
1010 app.buttons["Cancel"].firstMatch.tap()
1011
1012 // --- identity is its own tab; the account menu rides with it ---
1013 selectTab("My Profile")
1014 let menu = app.descendants(matching: .any)
1015 .matching(identifier: "account-menu").firstMatch
1016 XCTAssertTrue(menu.waitForExistence(timeout: 10),
1017 "account menu missing from My Profile")
1018 menu.tap()
1019 XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
1020 "keys not in the account menu")
1021 app.tap() // dismiss the menu; the profile is already on screen
1022
1023 // --- a profile is a profile: description, links, orgs, graph, repos ---
1024 XCTAssertTrue(app.staticTexts
1025 .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
1026 .waitForExistence(timeout: 20), "profile description missing")
1027 XCTAssertTrue(header("Organizations").exists,
1028 "org memberships missing")
1029 XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing")
1030
1031 // --- an org admin can edit the org's profile ---
1032 app.staticTexts["krz"].firstMatch.tap()
1033 XCTAssertTrue(app.descendants(matching: .any)
1034 .matching(identifier: "profile-edit-button").firstMatch
1035 .waitForExistence(timeout: 15), "org profile offers no edit to its admin")
1036 back()
1037
1038 // The graph and the repositories sit below the orgs and links,
1039 // and a List does not build rows it has not shown.
1040 XCTAssertTrue(scrollTo(app.staticTexts
1041 .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch),
1042 "activity graph missing")
1043 XCTAssertTrue(scrollTo(app.staticTexts
1044 .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch),
1045 "repositories missing")
1046
1047 // --- a log entry opens its commit ---
1048 app.terminate()
1049 app.launch()
1050 openRepo("krz/gitbay")
1051 app.staticTexts["History"].firstMatch.tap()
1052 let firstCommit = app.cells.firstMatch
1053 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
1054 firstCommit.tap()
1055 // The commit screen IS the patch: no navigating away to find it.
1056 // A hunk header (@@) only appears in a rendered diff, and sits
1057 // below the message and the file list.
1058 let hunk = app.staticTexts
1059 .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch
1060 _ = hunk.waitForExistence(timeout: 20)
1061 XCTAssertTrue(scrollTo(hunk, swipes: 10),
1062 "commit screen did not render the patch inline")
1063 }
1064}
1065
1066extension LiveSmokeUITests {
1067
1068 /// The three gaps that needed no server work: branches and tags,
1069 /// milestones, and an MR's milestone. Read-only except the MR
1070 /// milestone, which is set and cleared back.
1071 func testRefsAndMilestoneFlows() throws {
1072 openRepo("krz/gitbay")
1073
1074 // --- branches and tags, and browsing at a ref ---
1075 app.staticTexts["Branches & Tags"].firstMatch.tap()
1076 let main = app.staticTexts["main"].firstMatch
1077 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1078 XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
1079 "the default branch is not marked")
1080 XCTAssertTrue(header("Tags").exists, "tags section missing")
1081 main.tap()
1082 // Tapping a ref browses the repository there.
1083 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1084 "browsing at a ref showed nothing")
1085 back()
1086 back()
1087
1088 // --- milestones with progress ---
1089 app.staticTexts["Milestones"].firstMatch.tap()
1090 XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
1091 "milestones did not load")
1092 app.segmentedControls.buttons["All"].firstMatch.tap()
1093 XCTAssertTrue(app.staticTexts
1094 .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch
1095 .waitForExistence(timeout: 20), "milestone progress missing")
1096 back()
1097
1098 // --- an MR's milestone reads back, and can be cleared ---
1099 app.staticTexts["Merge Requests"].firstMatch.tap()
1100 app.segmentedControls.buttons["All"].firstMatch.tap()
1101 let mrNumber = app.staticTexts
1102 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
1103 XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs")
1104 mrNumber.tap()
1105 let milestone = app.descendants(matching: .any)
1106 .matching(identifier: "mr-milestone-menu").firstMatch
1107 // The nav bar is the "opened" signal; everything else on this
1108 // screen can be below the fold.
1109 XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20),
1110 "MR detail did not open")
1111 XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing")
1112 milestone.tap()
1113 // The picker offers None plus the open milestones.
1114 XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10),
1115 "milestone picker did not open")
1116 app.buttons["None"].firstMatch.tap()
1117 }
1118}
1119
1120extension LiveSmokeUITests {
1121
1122 /// Explore and history at a ref — the two capabilities krz/gitbay!101
1123 /// made reachable. Read-only.
1124 func testExploreAndRefLogFlows() throws {
1125 // --- explore lists what the instance hosts ---
1126 selectTab("Explore")
1127 let firstRepo = app.cells.firstMatch
1128 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1129 // Public repos this account does not own are reachable here.
1130 XCTAssertTrue(app.staticTexts
1131 .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
1132 .waitForExistence(timeout: 10), "explore is not the public listing")
1133 firstRepo.tap()
1134 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1135 "explore row did not open its repo")
1136
1137 // --- history at a ref ---
1138 app.terminate()
1139 app.launch()
1140 openRepo("krz/gitbay")
1141 // Browse a ref, then ask for its history — the move the tree page
1142 // offers on the web.
1143 app.staticTexts["Branches & Tags"].firstMatch.tap()
1144 let main = app.staticTexts["main"].firstMatch
1145 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1146 main.tap()
1147 let history = app.descendants(matching: .any)
1148 .matching(identifier: "tree-history-button").firstMatch
1149 XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree")
1150 history.tap()
1151 XCTAssertTrue(app.navigationBars
1152 .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch
1153 .waitForExistence(timeout: 20), "ref history did not open")
1154 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1155 "ref history rendered no commits")
1156 }
1157}
1158
1159extension LiveSmokeUITests {
1160
1161 /// The wiki — the last capability that was browser-only until
1162 /// krz/gitbay#48. Read-only; editing is a push, on every surface.
1163 func testWikiFlows() throws {
1164 openRepo("krz/gitbay")
1165 app.staticTexts["Wiki"].firstMatch.tap()
1166
1167 // The landing page leads and is marked.
1168 let home = app.staticTexts["Home"].firstMatch
1169 XCTAssertTrue(home.waitForExistence(timeout: 20), "wiki pages did not load")
1170 XCTAssertTrue(app.staticTexts["home"].firstMatch.exists,
1171 "the landing page is not marked")
1172
1173 // Parity.org is an org page, so this also proves the org renderer
1174 // runs on wiki content and not just READMEs.
1175 let parity = app.staticTexts["Parity"].firstMatch
1176 XCTAssertTrue(parity.exists, "Parity page missing from the listing")
1177 parity.tap()
1178 XCTAssertTrue(app.staticTexts
1179 .containing(NSPredicate(format: "label CONTAINS 'surface'")).firstMatch
1180 .waitForExistence(timeout: 20), "wiki page rendered no prose")
1181 // Org headings render as headings, not as raw #+title:.
1182 XCTAssertFalse(app.staticTexts
1183 .containing(NSPredicate(format: "label CONTAINS '#+title'")).firstMatch.exists,
1184 "org markup leaked into the rendered page")
1185 }
1186}
1187
1188
1189extension LiveSmokeUITests {
1190
1191 /// Builds on a repo that has a job file: the picker offers what the
1192 /// server names, and the detail screen says more than the log.
1193 func testBuildJobsAndDetail() throws {
1194 openRepo("krz/gitbay")
1195 app.staticTexts["Builds"].firstMatch.tap()
1196
1197 // The picker lists jobs from `build jobs` — no name to type.
1198 let trigger = app.descendants(matching: .any)
1199 .matching(identifier: "build-trigger-button").firstMatch
1200 XCTAssertTrue(trigger.waitForExistence(timeout: 20), "trigger control missing")
1201 XCTAssertTrue(trigger.isEnabled, "trigger is disabled where a job file exists")
1202 trigger.tap()
1203 let job = app.buttons
1204 .containing(NSPredicate(format: "label CONTAINS 'build' AND label CONTAINS 'on push'"))
1205 .firstMatch
1206 XCTAssertTrue(job.waitForExistence(timeout: 10),
1207 "job menu did not list the job with why it runs")
1208 // Dismiss without queueing a real build.
1209 app.tap()
1210
1211 // The detail screen carries status, ref and timing, not just log.
1212 let firstBuild = app.cells.firstMatch
1213 XCTAssertTrue(firstBuild.waitForExistence(timeout: 20), "no builds listed")
1214 firstBuild.tap()
1215 for label in ["success", "queued"] where app.staticTexts[label].firstMatch.exists {
1216 XCTAssertTrue(true)
1217 }
1218 XCTAssertTrue(app.staticTexts
1219 .containing(NSPredicate(format: "label BEGINSWITH 'queued '")).firstMatch
1220 .waitForExistence(timeout: 20), "build detail shows no timing")
1221 }
1222}
1223
1224extension LiveSmokeUITests {
1225
1226 /// Store screenshots on iPad. It navigates by tab and through
1227 /// Explore rather than reusing the iPhone checkpoint: that one drives
1228 /// the repo list's `.searchable`, which iPadOS lays out differently,
1229 /// and chasing those differences is the iPad pass this release
1230 /// deferred. Tabs and a list row work on both.
1231 func testIPadScreenshotCheckpoint() throws {
1232 signInIfNeeded()
1233 snap("dashboard")
1234
1235 selectTab("Explore")
1236 snap("explore")
1237
1238 // First public repo in the list — no search field involved.
1239 let firstRepo = app.cells.firstMatch
1240 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1241 firstRepo.tap()
1242 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1243 "repo screen did not open")
1244 snap("repo")
1245
1246 selectTab("Feed")
1247 snap("feed")
1248
1249 selectTab("My Profile")
1250 XCTAssertTrue(app.staticTexts
1251 .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
1252 .waitForExistence(timeout: 20), "profile did not load")
1253 snap("profile")
1254 }
1255}