gitbayUITests/LiveSmokeUITests.swift

786da630a1d52c71d0dda0c36e6491f7136b033b
gitbay-ios/gitbayUITests/LiveSmokeUITests.swift history · blame · raw

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