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