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