a native ios client for gitbay

client ios swift

https://gitbay.org

gitbayUITests/LiveSmokeUITests.swift

ui-smoke
gitbay-ios/gitbayUITests/LiveSmokeUITests.swift history · blame · raw

917 lines · 42887 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        if ProcessInfo.processInfo.environment["GITBAY_UITEST_DARK"] == "1" {
 37            app.launchArguments.append("-gb-dark")
 38        }
 39        app.launch()
 40        if name.contains("testRepoManagementFlows") {
 41            deleteScratchRepo()
 42        }
 43    }
 44
 45    override func tearDownWithError() throws {
 46        if name.contains("testRepoManagementFlows") {
 47            deleteScratchRepo()
 48        }
 49    }
 50
 51    /// `repo delete` over the JSON API  the app has no delete screen
 52    /// (deletion is SSH-only by design), so cleanup cannot go through
 53    /// the UI. A repo that is not there answers exit 3, which is the
 54    /// outcome we want anyway.
 55    private func deleteScratchRepo() {
 56        guard let token = ProcessInfo.processInfo
 57            .environment["GITBAY_UITEST_TOKEN"], !token.isEmpty,
 58            let url = URL(string: "https://gitbay.org/api/v1/cmd")
 59        else { return }
 60
 61        var request = URLRequest(url: url)
 62        request.httpMethod = "POST"
 63        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
 64        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 65        request.httpBody = try? JSONSerialization.data(withJSONObject: [
 66            "argv": ["repo", "delete", Self.scratchRepo, "--yes"],
 67        ])
 68
 69        let done = DispatchSemaphore(value: 0)
 70        URLSession.shared.dataTask(with: request) { _, _, _ in done.signal() }.resume()
 71        _ = done.wait(timeout: .now() + 15)
 72    }
 73
 74    /// Everything in one ordered pass: milestone assign, edit-save,
 75    /// MR create, MR close. One test so the flows share navigation and
 76    /// the writes happen exactly once.
 77    func testCreationAndEditingFlows() throws {
 78        openRepo("krz/gitbay-ios")
 79
 80        // --- Issue #4: milestone picker assigns v1.0.0 ---
 81        app.staticTexts["Issues"].firstMatch.tap()
 82        let issueRow = app.staticTexts
 83            .containing(NSPredicate(format: "label CONTAINS 'README.org'")).firstMatch
 84        XCTAssertTrue(issueRow.waitForExistence(timeout: 10), "issue #4 not in the list")
 85        issueRow.tap()
 86
 87        let milestoneMenu = app.descendants(matching: .any)
 88            .matching(identifier: "milestone-menu").firstMatch
 89        XCTAssertTrue(milestoneMenu.waitForExistence(timeout: 10))
 90        milestoneMenu.tap()
 91        let milestoneChoice = app.buttons
 92            .containing(NSPredicate(format: "label BEGINSWITH 'v1.0.0'")).firstMatch
 93        XCTAssertTrue(milestoneChoice.waitForExistence(timeout: 5), "milestone menu did not open")
 94        milestoneChoice.tap()
 95        // The reload renders the assigned milestone in the header row.
 96        XCTAssertTrue(app.staticTexts["v1.0.0"].firstMatch
 97            .waitForExistence(timeout: 10), "milestone not shown after assign")
 98
 99        // --- Issue #4: edit sheet saves (content unchanged) ---
100        app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
101            .firstMatch.tap()
102        let edit = app.buttons["Edit"].firstMatch
103        XCTAssertTrue(edit.waitForExistence(timeout: 5))
104        edit.tap()
105        let title = app.descendants(matching: .any)
106            .matching(identifier: "compose-title").firstMatch
107        XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
108        XCTAssertTrue((title.value as? String)?.contains("README.org") == true,
109                      "edit sheet did not prefill")
110        app.descendants(matching: .any).matching(identifier: "compose-submit")
111            .firstMatch.tap()
112        // Sheet dismissal proves the save round-tripped without error.
113        XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
114
115        back() // to issues list
116        back() // to repo screen
117
118        // --- MR: create from ui-smoke, then close it ---
119        app.staticTexts["Merge Requests"].firstMatch.tap()
120        app.descendants(matching: .any).matching(identifier: "mr-create-button")
121            .firstMatch.tap()
122
123        let source = app.descendants(matching: .any)
124            .matching(identifier: "mr-source").firstMatch
125        XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open")
126        focusAndType(source, "ui-smoke")
127
128        let target = app.descendants(matching: .any)
129            .matching(identifier: "mr-target").firstMatch
130        // The default branch prefilled while the sheet loaded.
131        XCTAssertEqual(target.value as? String, "main", "target did not prefill")
132
133        let mrTitle = app.descendants(matching: .any)
134            .matching(identifier: "mr-title").firstMatch
135        focusAndType(mrTitle, "UI smoke: mr create from the app")
136
137        app.descendants(matching: .any).matching(identifier: "mr-submit")
138            .firstMatch.tap()
139
140        let createdRow = app.staticTexts
141            .containing(NSPredicate(format: "label CONTAINS 'UI smoke'")).firstMatch
142        XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list")
143        createdRow.tap()
144
145        app.descendants(matching: .any).matching(identifier: "mr-actions-menu")
146            .firstMatch.tap()
147        let close = app.buttons["Close"].firstMatch
148        XCTAssertTrue(close.waitForExistence(timeout: 5))
149        close.tap()
150        // The confirmation dialog's destructive Close.
151        let confirm = app.buttons["Close"].firstMatch
152        XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing")
153        confirm.tap()
154
155        XCTAssertTrue(app.staticTexts["closed"].firstMatch
156            .waitForExistence(timeout: 15), "MR did not show closed after close")
157    }
158
159    // MARK: - Helpers
160
161    private func openRepo(_ path: String) {
162        selectTab("Repositories")
163
164        // `.searchable` keeps the field tucked above the list until it is
165        // scrolled into view.
166        let search = app.searchFields.firstMatch
167        if !search.waitForExistence(timeout: 5) {
168            app.swipeDown()
169            XCTAssertTrue(search.waitForExistence(timeout: 10), "search field never appeared")
170        }
171        focusAndType(search, path)
172
173        let row = app.staticTexts[path].firstMatch
174        XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
175        row.tap()
176        // Repo screen is loaded once its links render.
177        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10),
178                      "repo screen did not open")
179    }
180
181    private func back() {
182        app.navigationBars.buttons.firstMatch.tap()
183    }
184
185    /// Keys, PGP and email live behind the account menu, which is on the
186    /// My Profile tab and nowhere else.
187    func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
188        selectTab("My Profile")
189        let menu = app.descendants(matching: .any)
190            .matching(identifier: "account-menu").firstMatch
191        XCTAssertTrue(menu.waitForExistence(timeout: 15),
192                      "account menu missing", file: file, line: line)
193        menu.tap()
194        let keys = app.buttons["Keys & Email"].firstMatch
195        XCTAssertTrue(keys.waitForExistence(timeout: 5),
196                      "Keys & Email missing from the account menu", file: file, line: line)
197        keys.tap()
198    }
199
200    /// Scroll a list until an element is in the hierarchy. Offscreen
201    /// rows do not exist to XCUITest, so waitForExistence alone fails on
202    /// anything below the fold.
203    @discardableResult
204    func scrollTo(_ element: XCUIElement, swipes: Int = 6) -> Bool {
205        for _ in 0..<swipes {
206            if element.exists { return true }
207            app.swipeUp()
208        }
209        return element.exists
210    }
211
212    /// Switch tabs and wait until that tab is actually front. A tap
213    /// dispatched before the app is interactive  which happens right
214    /// after launch when there is no sign-in to slow things down  is
215    /// swallowed silently, so this taps until the tab reports selected.
216    func selectTab(_ name: String,
217                   file: StaticString = #filePath, line: UInt = #line) {
218        let tab = app.tabBars.buttons[name].firstMatch
219        XCTAssertTrue(tab.waitForExistence(timeout: 15),
220                      "\(name) tab missing", file: file, line: line)
221        // The app is interactive once its own chrome is hittable.
222        XCTAssertTrue(tab.isHittable || tab.waitForExistence(timeout: 5),
223                      "\(name) tab never became hittable", file: file, line: line)
224
225        for _ in 0..<4 {
226            if tab.isSelected, app.navigationBars[name].firstMatch.exists { return }
227            tab.tap()
228            if app.navigationBars[name].firstMatch.waitForExistence(timeout: 5) { return }
229        }
230        XCTFail("\(name) tab did not come to front", file: file, line: line)
231    }
232
233    /// Empty a text field before typing. Search fields keep their query
234    /// across navigation, and the clear button is not reliably
235    /// addressable, so this deletes character by character.
236    func clearAndType(_ element: XCUIElement, _ text: String,
237                      file: StaticString = #filePath, line: UInt = #line) {
238        element.tap()
239        if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
240            element.tap()
241            XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
242                          "keyboard never appeared", file: file, line: line)
243        }
244        if let existing = element.value as? String,
245           existing != element.placeholderValue, !existing.isEmpty {
246            element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue,
247                                    count: existing.count))
248        }
249        element.typeText(text)
250    }
251
252    /// Tap a field and type into it, surviving the focus race: a tap can
253    /// land before the field is ready, and the keystrokes go nowhere.
254    func focusAndType(_ element: XCUIElement, _ text: String,
255                      file: StaticString = #filePath, line: UInt = #line) {
256        element.tap()
257        if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
258            element.tap()
259            XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
260                          "keyboard never appeared for \(element)", file: file, line: line)
261        }
262        element.typeText(text)
263    }
264
265    private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
266        let predicate = NSPredicate(format: "exists == false")
267        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
268        return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
269    }
270}
271
272extension LiveSmokeUITests {
273
274    /// Repo management, kept reversible: create a scratch repo (deleted
275    /// by the runner afterwards, deletion is CLI-only by design), pin and
276    /// unpin, a topic round-trip, a merge-rule toggle round-trip, and the
277    /// build-trigger error path on a repo with no job config.
278    func testRepoManagementFlows() throws {
279        // --- repo create first: it ends in the list's search state,
280        // which the next step reuses. (Scratch repo; the runner deletes
281        // it over SSH afterwards  deletion is CLI-only by design.)
282        selectTab("Repositories")
283        app.descendants(matching: .any).matching(identifier: "repo-create-button")
284            .firstMatch.tap()
285        let pathField = app.descendants(matching: .any)
286            .matching(identifier: "repo-create-path").firstMatch
287        XCTAssertTrue(pathField.waitForExistence(timeout: 5))
288        focusAndType(pathField, "ui-smoke")
289        app.switches.firstMatch.tap() // Private on
290        app.descendants(matching: .any).matching(identifier: "repo-create-submit")
291            .firstMatch.tap()
292        // The sheet dismissing proves the create round-tripped; rows are
293        // lazy, so find the new repo through the filter.
294        XCTAssertTrue(waitForDisappearance(pathField, timeout: 15),
295                      "create sheet did not dismiss")
296        let search = app.searchFields.firstMatch
297        XCTAssertTrue(search.waitForExistence(timeout: 10))
298        focusAndType(search, "ui-smoke")
299        XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
300            .waitForExistence(timeout: 15), "created repo not in the list")
301
302        // --- pin / unpin round-trip on krz/gitbay-ios ---
303        // Reuse the open search to get there.
304        let clear = search.buttons.firstMatch
305        if clear.exists { clear.tap() }
306        focusAndType(search, "krz/gitbay-ios")
307        let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
308        XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
309        repoRow.tap()
310        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
311        let menu = app.descendants(matching: .any)
312            .matching(identifier: "repo-actions-menu").firstMatch
313        XCTAssertTrue(menu.waitForExistence(timeout: 10))
314        menu.tap()
315        let pin = app.buttons["Pin"].firstMatch
316        XCTAssertTrue(pin.waitForExistence(timeout: 5), "Pin action missing")
317        pin.tap()
318        // State refreshed from the dashboard: the menu now offers Unpin.
319        menu.tap()
320        let unpin = app.buttons["Unpin"].firstMatch
321        XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take")
322        unpin.tap()
323
324        // --- settings: topic and merge-rule round-trips ---
325        app.staticTexts["Settings"].firstMatch.tap()
326        let addTopic = app.descendants(matching: .any)
327            .matching(identifier: "settings-add-topic").firstMatch
328        XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
329        focusAndType(addTopic, "ios")
330        app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit")
331            .firstMatch.tap()
332        let chip = app.staticTexts["ios"].firstMatch
333        XCTAssertTrue(chip.waitForExistence(timeout: 10), "topic did not appear")
334        // Remove it again: the chip's own x button is the next button.
335        app.scrollViews.buttons.firstMatch.tap()
336        XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
337
338        let resolved = app.switches["Require threads resolved"].firstMatch
339        // The merge-requirements section sits below the fold, and a List
340        // does not build rows it has not shown, so it must be scrolled
341        // into existence before it can be queried.
342        XCTAssertTrue(scrollTo(resolved), "merge requirements section not reachable")
343        // SwiftUI exposes the row as a switch that wraps the real
344        // control; tap the innermost switch when there is one, else the
345        // right edge of the row.
346        let inner = resolved.switches.firstMatch
347        let control: () -> Void = {
348            if inner.exists && inner != resolved {
349                inner.tap()
350            } else {
351                resolved.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
352            }
353        }
354        control()
355        XCTAssertTrue(waitForValue(resolved, "1", timeout: 10), "toggle did not persist on")
356        control()
357        XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off")
358
359        back() // settings -> repo
360
361        // --- nothing to trigger without a job file ---
362        // This repo has no .gitbay/ci.yml, so `build jobs` returns none
363        // and the control is gated rather than failing after a guess.
364        app.staticTexts["Builds"].firstMatch.tap()
365        let trigger = app.descendants(matching: .any)
366            .matching(identifier: "build-trigger-button").firstMatch
367        XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing")
368        // Assert on behaviour, not on isEnabled: a disabled SwiftUI Menu
369        // still reports itself enabled to XCUITest.
370        trigger.tap()
371        let anyJob = app.buttons.containing(
372            NSPredicate(format: "label CONTAINS 'on push' OR label CONTAINS 'schedule '"))
373            .firstMatch
374        XCTAssertFalse(anyJob.waitForExistence(timeout: 3),
375                       "a repo with no job file offered a job to trigger")
376    }
377
378    private func waitForValue(_ element: XCUIElement, _ value: String,
379                              timeout: TimeInterval) -> Bool {
380        let predicate = NSPredicate(format: "value == %@", value)
381        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
382        return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
383    }
384}
385
386extension LiveSmokeUITests {
387
388    /// Discovery is read-only: feed, server-side repo search, grep, and
389    /// profiles. No cleanup needed.
390    func testDiscoveryFlows() throws {
391        // --- feed renders events and navigates ---
392        selectTab("Feed")
393        let firstEvent = app.cells.firstMatch
394        XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events")
395        firstEvent.tap()
396        // Wherever the event led, it left the feed root behind.
397        XCTAssertTrue(app.navigationBars.buttons.firstMatch
398            .waitForExistence(timeout: 10), "feed row did not navigate")
399        back()
400
401        // --- server-side search: "astronomy" is only a topic, invisible
402        // to the client-side path/description filter ---
403        selectTab("Repositories")
404        let search = app.searchFields.firstMatch
405        XCTAssertTrue(search.waitForExistence(timeout: 10))
406        focusAndType(search, "astronomy")
407        let hit = app.staticTexts["krz/space-wiki"].firstMatch
408        XCTAssertTrue(hit.waitForExistence(timeout: 15),
409                      "server-side topic search found nothing")
410        hit.tap()
411        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
412
413        // --- owner profile from the repo screen ---
414        // The row's label merges; match it at any element type.
415        app.descendants(matching: .any)["krz"].firstMatch.tap()
416        XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch
417            .waitForExistence(timeout: 15), "profile did not load")
418        XCTAssertTrue(app.staticTexts["Repositories"].firstMatch.exists,
419                      "profile repos missing")
420        back()
421
422        // --- grep inside the repo, last: its search UI owns the screen ---
423        app.staticTexts["Search in Files"].firstMatch.tap()
424        let grepField = app.searchFields.firstMatch
425        XCTAssertTrue(grepField.waitForExistence(timeout: 10))
426        focusAndType(grepField, "space")
427        app.keyboards.buttons["search"].firstMatch.tap()
428        let match = app.cells.firstMatch
429        XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches")
430    }
431}
432
433extension LiveSmokeUITests {
434
435    /// Releases: list and detail on a real release, an edit round-trip
436    /// that saves the prefilled content (a no-op write), and the
437    /// missing-tag refusal on create. Nothing changes state.
438    func testReleaseFlows() throws {
439        openRepo("krz/gitbay")
440        app.staticTexts["Releases"].firstMatch.tap()
441
442        let row = app.staticTexts
443            .containing(NSPredicate(format: "label CONTAINS 'v1.0.0'")).firstMatch
444        XCTAssertTrue(row.waitForExistence(timeout: 15), "release list empty")
445        row.tap()
446
447        // Notes render and assets carry sizes.
448        XCTAssertTrue(app.staticTexts
449            .containing(NSPredicate(format: "label CONTAINS 'SHA256SUMS'")).firstMatch
450            .waitForExistence(timeout: 15), "assets missing")
451
452        // Edit sheet prefills; saving unchanged content round-trips.
453        app.descendants(matching: .any).matching(identifier: "release-edit-button")
454            .firstMatch.tap()
455        let title = app.descendants(matching: .any)
456            .matching(identifier: "compose-title").firstMatch
457        XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
458        XCTAssertTrue((title.value as? String)?.contains("v1.0.0") == true,
459                      "edit sheet did not prefill")
460        app.descendants(matching: .any).matching(identifier: "compose-submit")
461            .firstMatch.tap()
462        XCTAssertTrue(waitForDisappearance(title, timeout: 15),
463                      "release edit did not dismiss")
464
465        back()
466
467        // Create with a tag that does not exist: the server's refusal is
468        // the UI contract.
469        app.descendants(matching: .any).matching(identifier: "release-create-button")
470            .firstMatch.tap()
471        let tag = app.descendants(matching: .any)
472            .matching(identifier: "release-tag").firstMatch
473        XCTAssertTrue(tag.waitForExistence(timeout: 5))
474        focusAndType(tag, "v9.9.9")
475        app.descendants(matching: .any).matching(identifier: "release-submit")
476            .firstMatch.tap()
477        XCTAssertTrue(app.staticTexts
478            .containing(NSPredicate(format: "label CONTAINS 'push the tag first'")).firstMatch
479            .waitForExistence(timeout: 15), "missing-tag refusal not surfaced")
480        app.buttons["Cancel"].firstMatch.tap()
481    }
482}
483
484extension LiveSmokeUITests {
485
486    /// Account keys and email. Read-only plus refusal paths  no key is
487    /// added or removed, no mail is sent.
488    func testAccountFlows() throws {
489        // Dashboard toolbar -> account screen.
490        openAccountScreen()
491
492        // Real keys render: SSH fingerprints and the PGP key's UID email.
493        XCTAssertTrue(app.staticTexts
494            .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
495            .waitForExistence(timeout: 15), "SSH keys missing")
496        XCTAssertTrue(app.staticTexts["hello@cleberg.net"].firstMatch
497            .waitForExistence(timeout: 10), "PGP key UID missing")
498
499        // Pasting garbage as an SSH key surfaces the server's validation.
500        app.descendants(matching: .any).matching(identifier: "add-ssh-key")
501            .firstMatch.tap()
502        let paste = app.descendants(matching: .any)
503            .matching(identifier: "key-paste-text").firstMatch
504        XCTAssertTrue(paste.waitForExistence(timeout: 5))
505        focusAndType(paste, "not a key")
506        app.descendants(matching: .any).matching(identifier: "key-paste-submit")
507            .firstMatch.tap()
508        XCTAssertTrue(app.staticTexts
509            .containing(NSPredicate(format: "label CONTAINS 'not a valid public key'")).firstMatch
510            .waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
511        app.buttons["Cancel"].firstMatch.tap()
512
513        // A bogus verification code is refused, not swallowed.
514        let code = app.descendants(matching: .any)
515            .matching(identifier: "email-code").firstMatch
516        // Email sits below the keys on a List, which does not build rows
517        // it has not shown.
518        XCTAssertTrue(scrollTo(code), "email section not reachable")
519        focusAndType(code, "000000")
520        let verify = app.descendants(matching: .any)
521            .matching(identifier: "email-verify").firstMatch
522        XCTAssertTrue(verify.waitForExistence(timeout: 5))
523        XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed")
524        verify.tap()
525        XCTAssertTrue(app.staticTexts
526            .containing(NSPredicate(format: "label CONTAINS 'invalid, expired'")).firstMatch
527            .waitForExistence(timeout: 15), "bad-code refusal not surfaced")
528    }
529}
530
531extension LiveSmokeUITests {
532
533    /// Orgs: members render, and a team lives a full life  created,
534    /// granted a repo, the grant revoked, the team deleted. Everything
535    /// this test makes, it removes.
536    func testOrgFlows() throws {
537        openAccountScreen()
538
539        let orgRow = app.staticTexts["krz"].firstMatch
540        XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
541        orgRow.tap()
542
543        // Members render with roles.
544        XCTAssertTrue(app.staticTexts["cmc"].firstMatch
545            .waitForExistence(timeout: 15), "org members missing")
546
547        // Create a team.
548        let teamField = app.descendants(matching: .any)
549            .matching(identifier: "org-new-team").firstMatch
550        XCTAssertTrue(teamField.waitForExistence(timeout: 5))
551        focusAndType(teamField, "ui-smoke")
552        app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
553            .firstMatch.tap()
554        let teamRow = app.staticTexts["ui-smoke"].firstMatch
555        XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed")
556
557        // Grant it a repo, then revoke.
558        teamRow.tap()
559        let repoField = app.textFields["org/repo"].firstMatch
560        XCTAssertTrue(repoField.waitForExistence(timeout: 10))
561        focusAndType(repoField, "krz/gitbay-ios")
562        app.buttons["Grant"].firstMatch.tap()
563        let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
564        XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
565        grantRow.swipeLeft()
566        app.buttons["Revoke"].firstMatch.tap()
567        XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
568
569        back()
570
571        // Delete the team, through its confirmation.
572        let row = app.staticTexts["ui-smoke"].firstMatch
573        XCTAssertTrue(row.waitForExistence(timeout: 10))
574        row.swipeLeft()
575        app.buttons["Delete"].firstMatch.tap()
576        // The confirmation dialog's destructive Delete.
577        let confirm = app.buttons["Delete"].firstMatch
578        XCTAssertTrue(confirm.waitForExistence(timeout: 5))
579        confirm.tap()
580        XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
581    }
582}
583
584extension LiveSmokeUITests {
585
586    /// The pre-merge screenshot checkpoint: walks the dense screens and
587    /// attaches captures. Run per device/appearance; export the
588    /// attachments from the xcresult. Signs in first when the device has
589    /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided.
590    func testScreenshotCheckpoint() throws {
591        signInIfNeeded()
592
593        snap("dashboard")
594
595        openRepo("krz/gitbay")
596        snap("repo")
597
598        app.staticTexts["Merge Requests"].firstMatch.tap()
599        let all = app.segmentedControls.buttons["All"].firstMatch
600        XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
601        all.tap()
602        // A real MR row carries its !number; the picker row does not.
603        let mrNumber = app.staticTexts
604            .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
605        XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All")
606        snap("mr-list")
607
608        mrNumber.tap()
609        XCTAssertTrue(app.staticTexts["Diff"].firstMatch.waitForExistence(timeout: 15),
610                      "MR detail did not open")
611        snap("mr-detail")
612
613        app.staticTexts["Diff"].firstMatch.tap()
614        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
615        snap("diff")
616    }
617
618    private func signInIfNeeded() {
619        let tokenField = app.secureTextFields.firstMatch
620        guard tokenField.waitForExistence(timeout: 5) else { return }
621        guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else {
622            XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
623            return
624        }
625        focusAndType(tokenField, token + "\n")
626        XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
627            .waitForExistence(timeout: 20), "sign-in did not land")
628    }
629
630    private func snap(_ name: String) {
631        let attachment = XCTAttachment(screenshot: app.screenshot())
632        attachment.name = name
633        attachment.lifetime = .keepAlways
634        add(attachment)
635    }
636}
637
638extension LiveSmokeUITests {
639
640    /// Blame and file editing  the two capabilities that were web-only
641    /// until krz/gitbay#42 and #43 made them commands. Blame is read-only
642    /// on a real repo; the edit writes to a scratch repo the runner
643    /// creates and deletes over SSH.
644    func testBlameAndEditFlows() throws {
645        // --- blame on a real file ---
646        openRepo("krz/gitbay")
647        app.staticTexts["Files"].firstMatch.tap()
648        let goMod = app.staticTexts["go.mod"].firstMatch
649        XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
650        goMod.tap()
651        XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu")
652            .firstMatch.waitForExistence(timeout: 15), "file screen did not open")
653        app.descendants(matching: .any).matching(identifier: "file-actions-menu")
654            .firstMatch.tap()
655        app.buttons["Blame"].firstMatch.tap()
656        // Attribution shows the commit subject beside the lines.
657        XCTAssertTrue(app.staticTexts
658            .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch
659            .waitForExistence(timeout: 20), "blame rendered no attribution")
660
661        // --- edit on the scratch repo ---
662        // Relaunch rather than reuse the list: `.searchable` keeps the
663        // previous query, and clearing it from the harness is unreliable.
664        app.terminate()
665        app.launch()
666        openRepo("cmc/ui-smoke-edit")
667        app.staticTexts["Files"].firstMatch.tap()
668        let notes = app.staticTexts["notes.txt"].firstMatch
669        XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
670        notes.tap()
671
672        let menu = app.descendants(matching: .any)
673            .matching(identifier: "file-actions-menu").firstMatch
674        XCTAssertTrue(menu.waitForExistence(timeout: 15))
675        menu.tap()
676        let edit = app.buttons
677            .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch
678        XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing")
679        edit.tap()
680
681        let content = app.descendants(matching: .any)
682            .matching(identifier: "file-edit-content").firstMatch
683        XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open")
684        focusAndType(content, " edited from the app")
685
686        let message = app.descendants(matching: .any)
687            .matching(identifier: "file-edit-message").firstMatch
688        focusAndType(message, "edit from ios")
689
690        app.descendants(matching: .any).matching(identifier: "file-edit-commit")
691            .firstMatch.tap()
692        XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss")
693        // The reloaded file shows the committed text.
694        XCTAssertTrue(app.staticTexts
695            .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch
696            .waitForExistence(timeout: 20), "edit not reflected after commit")
697    }
698}
699
700
701extension LiveSmokeUITests {
702
703    /// The navigation and profile changes: identity lives in one menu,
704    /// the dashboard can create a repository, a profile is a real
705    /// profile, and a log entry opens its commit.
706    func testProfileAndNavigationFlows() throws {
707        // --- the dashboard offers creation ---
708        selectTab("Dashboard")
709        let create = app.descendants(matching: .any)
710            .matching(identifier: "dashboard-create-button").firstMatch
711        XCTAssertTrue(create.waitForExistence(timeout: 15), "dashboard + missing")
712        create.tap()
713        let pathField = app.descendants(matching: .any)
714            .matching(identifier: "repo-create-path").firstMatch
715        XCTAssertTrue(pathField.waitForExistence(timeout: 10),
716                      "dashboard + did not open the create sheet")
717        app.buttons["Cancel"].firstMatch.tap()
718
719        // --- identity is its own tab; the account menu rides with it ---
720        selectTab("My Profile")
721        let menu = app.descendants(matching: .any)
722            .matching(identifier: "account-menu").firstMatch
723        XCTAssertTrue(menu.waitForExistence(timeout: 10),
724                      "account menu missing from My Profile")
725        menu.tap()
726        XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
727                      "keys not in the account menu")
728        app.tap() // dismiss the menu; the profile is already on screen
729
730        // --- a profile is a profile: description, links, orgs, graph, repos ---
731        XCTAssertTrue(app.staticTexts
732            .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
733            .waitForExistence(timeout: 20), "profile description missing")
734        XCTAssertTrue(app.staticTexts["Organizations"].firstMatch.exists,
735                      "org memberships missing")
736        XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing")
737        XCTAssertTrue(app.staticTexts
738            .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch
739            .exists, "activity graph missing")
740        XCTAssertTrue(app.staticTexts
741            .containing(NSPredicate(format: "label BEGINSWITH 'Repositories'")).firstMatch
742            .exists, "repositories missing")
743
744        // --- a log entry opens its commit ---
745        app.terminate()
746        app.launch()
747        openRepo("krz/gitbay")
748        app.staticTexts["History"].firstMatch.tap()
749        let firstCommit = app.cells.firstMatch
750        XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
751        firstCommit.tap()
752        // The commit screen IS the patch: no navigating away to find it.
753        // A hunk header (@@) only appears in a rendered diff.
754        XCTAssertTrue(app.staticTexts
755            .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch
756            .waitForExistence(timeout: 20),
757            "commit screen did not render the patch inline")
758    }
759}
760
761extension LiveSmokeUITests {
762
763    /// The three gaps that needed no server work: branches and tags,
764    /// milestones, and an MR's milestone. Read-only except the MR
765    /// milestone, which is set and cleared back.
766    func testRefsAndMilestoneFlows() throws {
767        openRepo("krz/gitbay")
768
769        // --- branches and tags, and browsing at a ref ---
770        app.staticTexts["Branches & Tags"].firstMatch.tap()
771        let main = app.staticTexts["main"].firstMatch
772        XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
773        XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
774                      "the default branch is not marked")
775        XCTAssertTrue(app.staticTexts["Tags"].firstMatch.exists, "tags section missing")
776        main.tap()
777        // Tapping a ref browses the repository there.
778        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
779                      "browsing at a ref showed nothing")
780        back()
781        back()
782
783        // --- milestones with progress ---
784        app.staticTexts["Milestones"].firstMatch.tap()
785        XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
786                      "milestones did not load")
787        app.segmentedControls.buttons["All"].firstMatch.tap()
788        XCTAssertTrue(app.staticTexts
789            .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch
790            .waitForExistence(timeout: 20), "milestone progress missing")
791        back()
792
793        // --- an MR's milestone reads back, and can be cleared ---
794        app.staticTexts["Merge Requests"].firstMatch.tap()
795        app.segmentedControls.buttons["All"].firstMatch.tap()
796        let mrNumber = app.staticTexts
797            .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
798        XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs")
799        mrNumber.tap()
800        let milestone = app.descendants(matching: .any)
801            .matching(identifier: "mr-milestone-menu").firstMatch
802        // The nav bar is the "opened" signal; everything else on this
803        // screen can be below the fold.
804        XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20),
805                      "MR detail did not open")
806        XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing")
807        milestone.tap()
808        // The picker offers None plus the open milestones.
809        XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10),
810                      "milestone picker did not open")
811        app.buttons["None"].firstMatch.tap()
812    }
813}
814
815extension LiveSmokeUITests {
816
817    /// Explore and history at a ref  the two capabilities krz/gitbay!101
818    /// made reachable. Read-only.
819    func testExploreAndRefLogFlows() throws {
820        // --- explore lists what the instance hosts ---
821        selectTab("Explore")
822        let firstRepo = app.cells.firstMatch
823        XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
824        // Public repos this account does not own are reachable here.
825        XCTAssertTrue(app.staticTexts
826            .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
827            .waitForExistence(timeout: 10), "explore is not the public listing")
828        firstRepo.tap()
829        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
830                      "explore row did not open its repo")
831
832        // --- history at a ref ---
833        app.terminate()
834        app.launch()
835        openRepo("krz/gitbay")
836        // Browse a ref, then ask for its history  the move the tree page
837        // offers on the web.
838        app.staticTexts["Branches & Tags"].firstMatch.tap()
839        let main = app.staticTexts["main"].firstMatch
840        XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
841        main.tap()
842        let history = app.descendants(matching: .any)
843            .matching(identifier: "tree-history-button").firstMatch
844        XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree")
845        history.tap()
846        XCTAssertTrue(app.navigationBars
847            .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch
848            .waitForExistence(timeout: 20), "ref history did not open")
849        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
850                      "ref history rendered no commits")
851    }
852}
853
854extension LiveSmokeUITests {
855
856    /// The wiki  the last capability that was browser-only until
857    /// krz/gitbay#48. Read-only; editing is a push, on every surface.
858    func testWikiFlows() throws {
859        openRepo("krz/gitbay")
860        app.staticTexts["Wiki"].firstMatch.tap()
861
862        // The landing page leads and is marked.
863        let home = app.staticTexts["Home"].firstMatch
864        XCTAssertTrue(home.waitForExistence(timeout: 20), "wiki pages did not load")
865        XCTAssertTrue(app.staticTexts["home"].firstMatch.exists,
866                      "the landing page is not marked")
867
868        // Parity.org is an org page, so this also proves the org renderer
869        // runs on wiki content and not just READMEs.
870        let parity = app.staticTexts["Parity"].firstMatch
871        XCTAssertTrue(parity.exists, "Parity page missing from the listing")
872        parity.tap()
873        XCTAssertTrue(app.staticTexts
874            .containing(NSPredicate(format: "label CONTAINS 'surface'")).firstMatch
875            .waitForExistence(timeout: 20), "wiki page rendered no prose")
876        // Org headings render as headings, not as raw #+title:.
877        XCTAssertFalse(app.staticTexts
878            .containing(NSPredicate(format: "label CONTAINS '#+title'")).firstMatch.exists,
879                       "org markup leaked into the rendered page")
880    }
881}
882
883
884extension LiveSmokeUITests {
885
886    /// Builds on a repo that has a job file: the picker offers what the
887    /// server names, and the detail screen says more than the log.
888    func testBuildJobsAndDetail() throws {
889        openRepo("krz/gitbay")
890        app.staticTexts["Builds"].firstMatch.tap()
891
892        // The picker lists jobs from `build jobs`  no name to type.
893        let trigger = app.descendants(matching: .any)
894            .matching(identifier: "build-trigger-button").firstMatch
895        XCTAssertTrue(trigger.waitForExistence(timeout: 20), "trigger control missing")
896        XCTAssertTrue(trigger.isEnabled, "trigger is disabled where a job file exists")
897        trigger.tap()
898        let job = app.buttons
899            .containing(NSPredicate(format: "label CONTAINS 'build' AND label CONTAINS 'on push'"))
900            .firstMatch
901        XCTAssertTrue(job.waitForExistence(timeout: 10),
902                      "job menu did not list the job with why it runs")
903        // Dismiss without queueing a real build.
904        app.tap()
905
906        // The detail screen carries status, ref and timing, not just log.
907        let firstBuild = app.cells.firstMatch
908        XCTAssertTrue(firstBuild.waitForExistence(timeout: 20), "no builds listed")
909        firstBuild.tap()
910        for label in ["success", "queued"] where app.staticTexts[label].firstMatch.exists {
911            XCTAssertTrue(true)
912        }
913        XCTAssertTrue(app.staticTexts
914            .containing(NSPredicate(format: "label BEGINSWITH 'queued '")).firstMatch
915            .waitForExistence(timeout: 20), "build detail shows no timing")
916    }
917}