gitbayUITests/LiveSmokeUITests.swift
1375 lines · 66565 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 // The watch preference (v1.19.0) flips the same way.
864 let watch = app.switches["Watch writable repositories"].firstMatch
865 XCTAssertTrue(scrollTo(watch), "watch toggle not reachable")
866 let innerWatch = watch.switches.firstMatch
867 let flipWatch: () -> Void = {
868 if innerWatch.exists && innerWatch != watch {
869 innerWatch.tap()
870 } else {
871 watch.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
872 }
873 }
874 let watchWasOn = (watch.value as? String) == "1"
875 flipWatch()
876 XCTAssertTrue(waitForValue(watch, watchWasOn ? "0" : "1", timeout: 10), "watch toggle did not persist")
877 flipWatch()
878 XCTAssertTrue(waitForValue(watch, watchWasOn ? "1" : "0", timeout: 10), "watch toggle did not restore")
879
880 // A bogus verification code is refused, not swallowed.
881 let code = app.descendants(matching: .any)
882 .matching(identifier: "email-code").firstMatch
883 // Email sits below the keys on a List, which does not build rows
884 // it has not shown.
885 XCTAssertTrue(scrollTo(code), "email section not reachable")
886 // The addresses list above the fields, the primary one marked.
887 XCTAssertTrue(app.staticTexts["primary"].firstMatch.exists, "primary address not marked")
888 focusAndType(code, "000000")
889 let verify = app.descendants(matching: .any)
890 .matching(identifier: "email-verify").firstMatch
891 XCTAssertTrue(verify.waitForExistence(timeout: 5))
892 XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed")
893 verify.tap()
894 XCTAssertTrue(app.staticTexts
895 .containing(NSPredicate(format: "label CONTAINS 'invalid, expired'")).firstMatch
896 .waitForExistence(timeout: 15), "bad-code refusal not surfaced")
897 }
898}
899
900extension LiveSmokeUITests {
901
902 /// Orgs: members render, and a team lives a full life — created,
903 /// granted a repo, the grant revoked, the team deleted. Everything
904 /// this test makes, it removes.
905 func testOrgFlows() throws {
906 openAccountScreen()
907
908 let orgRow = app.staticTexts["krz"].firstMatch
909 XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
910 orgRow.tap()
911
912 // Members render with roles.
913 XCTAssertTrue(app.staticTexts["cmc"].firstMatch
914 .waitForExistence(timeout: 15), "org members missing")
915
916 // Create a team.
917 let teamField = app.descendants(matching: .any)
918 .matching(identifier: "org-new-team").firstMatch
919 XCTAssertTrue(teamField.waitForExistence(timeout: 5))
920 focusAndType(teamField, "ui-smoke")
921 app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
922 .firstMatch.tap()
923 let teamRow = app.staticTexts["ui-smoke"].firstMatch
924 XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed")
925
926 // Grant it a repo, then revoke.
927 teamRow.tap()
928 let repoField = app.textFields["org/repo"].firstMatch
929 XCTAssertTrue(repoField.waitForExistence(timeout: 10))
930 focusAndType(repoField, "krz/gitbay-ios")
931 app.buttons["Grant"].firstMatch.tap()
932 let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
933 XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
934 grantRow.swipeLeft()
935 app.buttons["Revoke"].firstMatch.tap()
936 // The confirmation dialog's destructive Revoke.
937 let confirmRevoke = app.buttons["Revoke"].firstMatch
938 XCTAssertTrue(confirmRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
939 confirmRevoke.tap()
940 XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
941
942 back()
943
944 // Delete the team, through its confirmation.
945 let row = app.staticTexts["ui-smoke"].firstMatch
946 XCTAssertTrue(row.waitForExistence(timeout: 10))
947 row.swipeLeft()
948 app.buttons["Delete"].firstMatch.tap()
949 // The confirmation dialog's destructive Delete.
950 let confirm = app.buttons["Delete"].firstMatch
951 XCTAssertTrue(confirm.waitForExistence(timeout: 5))
952 confirm.tap()
953 XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
954 }
955}
956
957extension LiveSmokeUITests {
958
959 /// The pre-merge screenshot checkpoint: walks the dense screens and
960 /// attaches captures. Run per device/appearance; export the
961 /// attachments from the xcresult. Signs in first when the device has
962 /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided.
963 func testScreenshotCheckpoint() throws {
964 signInIfNeeded()
965
966 snap("dashboard")
967
968 openRepo("krz/gitbay")
969 snap("repo")
970
971 app.staticTexts["Merge Requests"].firstMatch.tap()
972 let all = app.segmentedControls.buttons["All"].firstMatch
973 XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
974 all.tap()
975 // A real MR row carries its !number; the picker row does not.
976 let mrNumber = app.staticTexts
977 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
978 XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All")
979 snap("mr-list")
980
981 mrNumber.tap()
982 // The detail opens at its header; the diff row sits below the
983 // fold and a List does not build rows it has not shown.
984 let diffRow = app.staticTexts["Diff"].firstMatch
985 XCTAssertTrue(scrollTo(diffRow), "MR detail has no diff row")
986 snap("mr-detail")
987
988 diffRow.tap()
989 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
990 snap("diff")
991 }
992
993 private func signInIfNeeded() {
994 // Signed in already: the tab bar is up and there is no token field.
995 if app.tabBars.firstMatch.waitForExistence(timeout: 10),
996 !app.secureTextFields.firstMatch.exists {
997 return
998 }
999 let tokenField = app.secureTextFields.firstMatch
1000 guard tokenField.waitForExistence(timeout: 5) else { return }
1001 guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else {
1002 XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
1003 return
1004 }
1005 focusAndType(tokenField, token + "\n")
1006 XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
1007 .waitForExistence(timeout: 20), "sign-in did not land")
1008 dismissSavePasswordPrompt()
1009 }
1010
1011 /// iOS offers to save whatever went into a SecureField, and the sheet
1012 /// sits over the app until answered — over the dashboard, in one set
1013 /// of store screenshots. It belongs to springboard, not to the app.
1014 private func dismissSavePasswordPrompt() {
1015 let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
1016 let notNow = springboard.buttons["Not Now"].firstMatch
1017 if notNow.waitForExistence(timeout: 8) {
1018 notNow.tap()
1019 _ = waitForDisappearance(notNow, timeout: 10)
1020 return
1021 }
1022 let inApp = app.buttons["Not Now"].firstMatch
1023 if inApp.waitForExistence(timeout: 3) {
1024 inApp.tap()
1025 _ = waitForDisappearance(inApp, timeout: 10)
1026 return
1027 }
1028 // No prompt is fine: iOS only offers once per credential.
1029 }
1030
1031 /// Capture a screen, but not while it is still loading. These go to
1032 /// the App Store, and a spinner shipped as a screenshot once already.
1033 private func snap(_ name: String, file: StaticString = #filePath, line: UInt = #line) {
1034 let spinner = app.activityIndicators.firstMatch
1035 if spinner.exists {
1036 XCTAssertTrue(waitForDisappearance(spinner, timeout: 30),
1037 "\(name) still loading after 30s", file: file, line: line)
1038 }
1039 // A list that has loaded has rows; an empty state has text. Either
1040 // way something must be on screen besides chrome.
1041 let content = app.cells.firstMatch
1042 _ = content.waitForExistence(timeout: 10)
1043
1044 let attachment = XCTAttachment(screenshot: app.screenshot())
1045 attachment.name = name
1046 attachment.lifetime = .keepAlways
1047 add(attachment)
1048 }
1049}
1050
1051extension LiveSmokeUITests {
1052
1053 /// Blame and file editing — the two capabilities that were web-only
1054 /// until krz/gitbay#42 and #43 made them commands. Blame is read-only
1055 /// on a real repo; the edit writes to a scratch repo the runner
1056 /// creates and deletes over SSH.
1057 func testBlameAndEditFlows() throws {
1058 // --- blame on a real file ---
1059 openRepo("krz/gitbay")
1060 app.staticTexts["Files"].firstMatch.tap()
1061 let goMod = app.staticTexts["go.mod"].firstMatch
1062 XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
1063 goMod.tap()
1064 XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu")
1065 .firstMatch.waitForExistence(timeout: 15), "file screen did not open")
1066 app.descendants(matching: .any).matching(identifier: "file-actions-menu")
1067 .firstMatch.tap()
1068 app.buttons["Blame"].firstMatch.tap()
1069 // Attribution shows the commit subject beside the lines.
1070 XCTAssertTrue(app.staticTexts
1071 .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch
1072 .waitForExistence(timeout: 20), "blame rendered no attribution")
1073
1074 // --- edit on the scratch repo ---
1075 // Relaunch rather than reuse the list: `.searchable` keeps the
1076 // previous query, and clearing it from the harness is unreliable.
1077 app.terminate()
1078 app.launch()
1079 openRepo("cmc/ui-smoke-edit")
1080 app.staticTexts["Files"].firstMatch.tap()
1081 let notes = app.staticTexts["notes.txt"].firstMatch
1082 XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
1083 notes.tap()
1084
1085 let menu = app.descendants(matching: .any)
1086 .matching(identifier: "file-actions-menu").firstMatch
1087 XCTAssertTrue(menu.waitForExistence(timeout: 15))
1088 menu.tap()
1089 let edit = app.buttons
1090 .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch
1091 XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing")
1092 edit.tap()
1093
1094 let content = app.descendants(matching: .any)
1095 .matching(identifier: "file-edit-content").firstMatch
1096 XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open")
1097 focusAndType(content, " edited from the app")
1098
1099 let message = app.descendants(matching: .any)
1100 .matching(identifier: "file-edit-message").firstMatch
1101 focusAndType(message, "edit from ios")
1102
1103 app.descendants(matching: .any).matching(identifier: "file-edit-commit")
1104 .firstMatch.tap()
1105 XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss")
1106 // The reloaded file shows the committed text.
1107 XCTAssertTrue(app.staticTexts
1108 .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch
1109 .waitForExistence(timeout: 20), "edit not reflected after commit")
1110 }
1111}
1112
1113
1114extension LiveSmokeUITests {
1115
1116 /// The navigation and profile changes: identity lives in one menu,
1117 /// the dashboard can create a repository, a profile is a real
1118 /// profile, and a log entry opens its commit.
1119 func testProfileAndNavigationFlows() throws {
1120 // --- the dashboard offers creation ---
1121 selectTab("Dashboard")
1122 let create = app.descendants(matching: .any)
1123 .matching(identifier: "dashboard-create-button").firstMatch
1124 XCTAssertTrue(create.waitForExistence(timeout: 15), "dashboard + missing")
1125 create.tap()
1126 let pathField = app.descendants(matching: .any)
1127 .matching(identifier: "repo-create-path").firstMatch
1128 XCTAssertTrue(pathField.waitForExistence(timeout: 10),
1129 "dashboard + did not open the create sheet")
1130 app.buttons["Cancel"].firstMatch.tap()
1131
1132 // --- identity is its own tab; the account menu rides with it ---
1133 selectTab("My Profile")
1134 let menu = app.descendants(matching: .any)
1135 .matching(identifier: "account-menu").firstMatch
1136 XCTAssertTrue(menu.waitForExistence(timeout: 10),
1137 "account menu missing from My Profile")
1138 menu.tap()
1139 XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
1140 "keys not in the account menu")
1141 app.tap() // dismiss the menu; the profile is already on screen
1142
1143 // --- a profile is a profile: description, links, orgs, graph, repos ---
1144 XCTAssertTrue(app.staticTexts
1145 .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
1146 .waitForExistence(timeout: 20), "profile description missing")
1147 XCTAssertTrue(header("Organizations").exists,
1148 "org memberships missing")
1149 XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing")
1150
1151 // --- an org admin can edit the org's profile ---
1152 app.staticTexts["krz"].firstMatch.tap()
1153 XCTAssertTrue(app.descendants(matching: .any)
1154 .matching(identifier: "profile-edit-button").firstMatch
1155 .waitForExistence(timeout: 15), "org profile offers no edit to its admin")
1156 back()
1157
1158 // The graph and the repositories sit below the orgs and links,
1159 // and a List does not build rows it has not shown.
1160 XCTAssertTrue(scrollTo(app.staticTexts
1161 .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch),
1162 "activity graph missing")
1163 XCTAssertTrue(scrollTo(app.staticTexts
1164 .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch),
1165 "repositories missing")
1166
1167 // --- a log entry opens its commit ---
1168 app.terminate()
1169 app.launch()
1170 openRepo("krz/gitbay")
1171 app.staticTexts["History"].firstMatch.tap()
1172 let firstCommit = app.cells.firstMatch
1173 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
1174 firstCommit.tap()
1175 // The commit screen IS the patch: no navigating away to find it.
1176 // A hunk header (@@) only appears in a rendered diff, and sits
1177 // below the message and the file list.
1178 let hunk = app.staticTexts
1179 .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch
1180 _ = hunk.waitForExistence(timeout: 20)
1181 XCTAssertTrue(scrollTo(hunk, swipes: 10),
1182 "commit screen did not render the patch inline")
1183 }
1184}
1185
1186extension LiveSmokeUITests {
1187
1188 /// The three gaps that needed no server work: branches and tags,
1189 /// milestones, and an MR's milestone. Read-only except the MR
1190 /// milestone, which is set and cleared back.
1191 func testRefsAndMilestoneFlows() throws {
1192 openRepo("krz/gitbay")
1193
1194 // --- branches and tags, and browsing at a ref ---
1195 app.staticTexts["Branches & Tags"].firstMatch.tap()
1196 let main = app.staticTexts["main"].firstMatch
1197 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1198 XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
1199 "the default branch is not marked")
1200 XCTAssertTrue(header("Tags").exists, "tags section missing")
1201 main.tap()
1202 // Tapping a ref browses the repository there.
1203 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1204 "browsing at a ref showed nothing")
1205 back()
1206 back()
1207
1208 // --- milestones with progress ---
1209 app.staticTexts["Milestones"].firstMatch.tap()
1210 XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
1211 "milestones did not load")
1212 app.segmentedControls.buttons["All"].firstMatch.tap()
1213 XCTAssertTrue(app.staticTexts
1214 .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch
1215 .waitForExistence(timeout: 20), "milestone progress missing")
1216 back()
1217
1218 // --- an MR's milestone reads back, and can be cleared ---
1219 app.staticTexts["Merge Requests"].firstMatch.tap()
1220 app.segmentedControls.buttons["All"].firstMatch.tap()
1221 let mrNumber = app.staticTexts
1222 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
1223 XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs")
1224 mrNumber.tap()
1225 let milestone = app.descendants(matching: .any)
1226 .matching(identifier: "mr-milestone-menu").firstMatch
1227 // The nav bar is the "opened" signal; everything else on this
1228 // screen can be below the fold.
1229 XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20),
1230 "MR detail did not open")
1231 XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing")
1232 milestone.tap()
1233 // The picker offers None plus the open milestones.
1234 XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10),
1235 "milestone picker did not open")
1236 app.buttons["None"].firstMatch.tap()
1237 }
1238}
1239
1240extension LiveSmokeUITests {
1241
1242 /// Explore and history at a ref — the two capabilities krz/gitbay!101
1243 /// made reachable. Read-only.
1244 func testExploreAndRefLogFlows() throws {
1245 // --- explore lists what the instance hosts ---
1246 selectTab("Explore")
1247 let firstRepo = app.cells.firstMatch
1248 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1249 // Public repos this account does not own are reachable here.
1250 XCTAssertTrue(app.staticTexts
1251 .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
1252 .waitForExistence(timeout: 10), "explore is not the public listing")
1253 firstRepo.tap()
1254 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1255 "explore row did not open its repo")
1256
1257 // --- history at a ref ---
1258 app.terminate()
1259 app.launch()
1260 openRepo("krz/gitbay")
1261 // Browse a ref, then ask for its history — the move the tree page
1262 // offers on the web.
1263 app.staticTexts["Branches & Tags"].firstMatch.tap()
1264 let main = app.staticTexts["main"].firstMatch
1265 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1266 main.tap()
1267 let history = app.descendants(matching: .any)
1268 .matching(identifier: "tree-history-button").firstMatch
1269 XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree")
1270 history.tap()
1271 XCTAssertTrue(app.navigationBars
1272 .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch
1273 .waitForExistence(timeout: 20), "ref history did not open")
1274 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1275 "ref history rendered no commits")
1276 }
1277}
1278
1279extension LiveSmokeUITests {
1280
1281 /// The wiki — the last capability that was browser-only until
1282 /// krz/gitbay#48. Read-only; editing is a push, on every surface.
1283 func testWikiFlows() throws {
1284 openRepo("krz/gitbay")
1285 app.staticTexts["Wiki"].firstMatch.tap()
1286
1287 // The landing page leads and is marked.
1288 let home = app.staticTexts["Home"].firstMatch
1289 XCTAssertTrue(home.waitForExistence(timeout: 20), "wiki pages did not load")
1290 XCTAssertTrue(app.staticTexts["home"].firstMatch.exists,
1291 "the landing page is not marked")
1292
1293 // Parity.org is an org page, so this also proves the org renderer
1294 // runs on wiki content and not just READMEs.
1295 let parity = app.staticTexts["Parity"].firstMatch
1296 XCTAssertTrue(parity.exists, "Parity page missing from the listing")
1297 parity.tap()
1298 XCTAssertTrue(app.staticTexts
1299 .containing(NSPredicate(format: "label CONTAINS 'surface'")).firstMatch
1300 .waitForExistence(timeout: 20), "wiki page rendered no prose")
1301 // Org headings render as headings, not as raw #+title:.
1302 XCTAssertFalse(app.staticTexts
1303 .containing(NSPredicate(format: "label CONTAINS '#+title'")).firstMatch.exists,
1304 "org markup leaked into the rendered page")
1305 }
1306}
1307
1308
1309extension LiveSmokeUITests {
1310
1311 /// Builds on a repo that has a job file: the picker offers what the
1312 /// server names, and the detail screen says more than the log.
1313 func testBuildJobsAndDetail() throws {
1314 openRepo("krz/gitbay")
1315 app.staticTexts["Builds"].firstMatch.tap()
1316
1317 // The picker lists jobs from `build jobs` — no name to type.
1318 let trigger = app.descendants(matching: .any)
1319 .matching(identifier: "build-trigger-button").firstMatch
1320 XCTAssertTrue(trigger.waitForExistence(timeout: 20), "trigger control missing")
1321 XCTAssertTrue(trigger.isEnabled, "trigger is disabled where a job file exists")
1322 trigger.tap()
1323 let job = app.buttons
1324 .containing(NSPredicate(format: "label CONTAINS 'build' AND label CONTAINS 'on push'"))
1325 .firstMatch
1326 XCTAssertTrue(job.waitForExistence(timeout: 10),
1327 "job menu did not list the job with why it runs")
1328 // Dismiss without queueing a real build.
1329 app.tap()
1330
1331 // The detail screen carries status, ref and timing, not just log.
1332 let firstBuild = app.cells.firstMatch
1333 XCTAssertTrue(firstBuild.waitForExistence(timeout: 20), "no builds listed")
1334 firstBuild.tap()
1335 for label in ["success", "queued"] where app.staticTexts[label].firstMatch.exists {
1336 XCTAssertTrue(true)
1337 }
1338 XCTAssertTrue(app.staticTexts
1339 .containing(NSPredicate(format: "label BEGINSWITH 'queued '")).firstMatch
1340 .waitForExistence(timeout: 20), "build detail shows no timing")
1341 }
1342}
1343
1344extension LiveSmokeUITests {
1345
1346 /// Store screenshots on iPad. It navigates by tab and through
1347 /// Explore rather than reusing the iPhone checkpoint: that one drives
1348 /// the repo list's `.searchable`, which iPadOS lays out differently,
1349 /// and chasing those differences is the iPad pass this release
1350 /// deferred. Tabs and a list row work on both.
1351 func testIPadScreenshotCheckpoint() throws {
1352 signInIfNeeded()
1353 snap("dashboard")
1354
1355 selectTab("Explore")
1356 snap("explore")
1357
1358 // First public repo in the list — no search field involved.
1359 let firstRepo = app.cells.firstMatch
1360 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1361 firstRepo.tap()
1362 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1363 "repo screen did not open")
1364 snap("repo")
1365
1366 selectTab("Feed")
1367 snap("feed")
1368
1369 selectTab("My Profile")
1370 XCTAssertTrue(app.staticTexts
1371 .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
1372 .waitForExistence(timeout: 20), "profile did not load")
1373 snap("profile")
1374 }
1375}