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