gitbayUITests/LiveSmokeUITests.swift

v1.1.0
gitbay-ios/gitbayUITests/LiveSmokeUITests.swift history · blame · raw

1358 lines · 65722 bytes

   1import XCTest
   2
   3/// Live smoke flows against gitbay.org, driven by accessibility — the
   4/// coordinate-free verification the simulator's input pipeline cannot be
   5/// trusted to do (krz/gitbay-ios#5).
   6///
   7/// These WRITE to the live instance (an MR is opened and closed, a
   8/// milestone assigned), so they only run when explicitly asked:
   9///
  10///     TEST_RUNNER_GITBAY_UITEST_LIVE=1 xcodebuild test \
  11///       -only-testing:gitbayUITests ...
  12///
  13/// They assume the simulator is already signed in as an account that can
  14/// write to krz/gitbay-ios, and that branch `ui-smoke` exists.
  15final class LiveSmokeUITests: XCTestCase {
  16
  17    private var app: XCUIApplication!
  18
  19    /// The scratch repo testRepoManagementFlows creates. A run that dies
  20    /// before deleting it leaves it behind, and then every later run
  21    /// fails at "create sheet did not dismiss" — the create is refused
  22    /// because the name is taken, which reads like a UI bug and is not.
  23    /// Removed before and after, so neither a crashed run nor this one
  24    /// can strand it.
  25    private static let scratchRepo = "cmc/ui-smoke"
  26
  27    override func setUpWithError() throws {
  28        try XCTSkipUnless(
  29            ProcessInfo.processInfo.environment["GITBAY_UITEST_LIVE"] == "1",
  30            "live UI smoke is opt-in; set TEST_RUNNER_GITBAY_UITEST_LIVE=1"
  31        )
  32        continueAfterFailure = false
  33        app = XCUIApplication()
  34        // xcodebuild runs tests on simulator clones, so the base
  35        // device's appearance never applies; force it per run.
  36        switch ProcessInfo.processInfo.environment["GITBAY_UITEST_DARK"] {
  37        case "1": app.launchArguments.append("-gb-dark")
  38        case "0": app.launchArguments.append("-gb-light")
  39        default: break // follow the device, as a real launch does
  40        }
  41        app.launch()
  42        // A fresh device — a clone on another runtime, say — starts on
  43        // the token screen; the flows assume a signed-in app.
  44        signInIfNeeded()
  45        if name.contains("testRepoManagementFlows") {
  46            deleteScratchRepo()
  47        }
  48        if name.contains("testCreationAndEditingFlows") {
  49            ensureFixtureIssue()
  50            try requireFixtureBranch()
  51        }
  52        if name.contains("testBlameAndEditFlows") {
  53            try requireFixtureRepo()
  54        }
  55    }
  56
  57    override func tearDownWithError() throws {
  58        if name.contains("testRepoManagementFlows") {
  59            deleteScratchRepo()
  60        }
  61    }
  62
  63    /// `repo delete` over the JSON API — the app has no delete screen
  64    /// (deletion is SSH-only by design), so cleanup cannot go through
  65    /// the UI. A repo that is not there answers exit 3, which is the
  66    /// outcome we want anyway.
  67    private func deleteScratchRepo() {
  68        _ = runCommand(["repo", "delete", Self.scratchRepo, "--yes"])
  69        // The rename round-trip's halfway name, in case a run died there.
  70        _ = runCommand(["repo", "delete", Self.scratchRepo + "-2", "--yes"])
  71    }
  72
  73    /// The issue the editing flow works on. It is a fixture, not a real
  74    /// issue: pinning the test to whatever issue happened to be open put
  75    /// it at the mercy of the project moving on, and it duly broke when
  76    /// that issue was closed (krz/gitbay-ios#8).
  77    static let fixtureIssueTitle = "ui-smoke fixture: do not close"
  78
  79    /// Guarantee the fixture issue exists and is open. It is reused
  80    /// rather than recreated, so runs do not pile up closed issues.
  81    private func ensureFixtureIssue() {
  82        guard let data = readCommand(
  83            ["issue", "list", Self.fixtureRepo, "--state", "open"]),
  84            let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
  85        else { return }
  86
  87        let issues = envelope["data"] as? [[String: Any]] ?? []
  88        if issues.contains(where: { $0["title"] as? String == Self.fixtureIssueTitle }) {
  89            return
  90        }
  91        _ = runCommand(
  92            ["issue", "create", Self.fixtureRepo, "--title", Self.fixtureIssueTitle, "--file", "-"],
  93            stdin: "Created by the live smoke suite. It exercises the milestone "
  94                 + "picker and the edit sheet, and is reused every run."
  95        )
  96    }
  97
  98    static let fixtureRepo = "krz/gitbay-ios"
  99
 100    /// The MR flow needs a source branch carrying a commit main does not
 101    /// have. Nothing in the app or the API can create a branch — that is
 102    /// a push — so this only checks, and says so plainly rather than
 103    /// letting the test fail later at "created MR not in the list".
 104    private func requireFixtureBranch() throws {
 105        guard let data = readCommand(["repo", "refs", Self.fixtureRepo]),
 106              let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
 107              let refs = envelope["data"] as? [String: Any],
 108              let branches = refs["branches"] as? [[String: Any]]
 109        else { return } // no token, or the instance is unreachable
 110        let names = branches.compactMap { $0["name"] as? String }
 111        try XCTSkipUnless(names.contains("ui-smoke"), """
 112            krz/gitbay-ios has no ui-smoke branch. The MR flow opens a \
 113            merge request from it; recreate it with a commit main does \
 114            not have (see .gitbay/ui-smoke.md on that branch).
 115            """)
 116    }
 117
 118    /// POST /api/v1/cmd. Writes the suite needs but the app cannot make.
 119    @discardableResult
 120    private func runCommand(_ argv: [String], stdin: String? = nil) -> Data? {
 121        var body: [String: Any] = ["argv": argv]
 122        if let stdin { body["stdin"] = stdin }
 123        return call(method: "POST", path: "/api/v1/cmd", body: body)
 124    }
 125
 126    /// GET /api/v1/read, for the reads that decide what a test sets up.
 127    private func readCommand(_ argv: [String]) -> Data? {
 128        let query = argv
 129            .map { "argv=" + ($0.addingPercentEncoding(
 130                withAllowedCharacters: .alphanumerics) ?? $0) }
 131            .joined(separator: "&")
 132        return call(method: "GET", path: "/api/v1/read?" + query, body: nil)
 133    }
 134
 135    private func call(method: String, path: String, body: [String: Any]?) -> Data? {
 136        guard let token = ProcessInfo.processInfo
 137            .environment["GITBAY_UITEST_TOKEN"], !token.isEmpty,
 138            let url = URL(string: "https://gitbay.org" + path)
 139        else { return nil }
 140
 141        var request = URLRequest(url: url)
 142        request.httpMethod = method
 143        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
 144        if let body {
 145            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 146            request.httpBody = try? JSONSerialization.data(withJSONObject: body)
 147        }
 148
 149        var result: Data?
 150        let done = DispatchSemaphore(value: 0)
 151        URLSession.shared.dataTask(with: request) { data, _, _ in
 152            result = data
 153            done.signal()
 154        }.resume()
 155        _ = done.wait(timeout: .now() + 15)
 156        return result
 157    }
 158
 159    /// Everything in one ordered pass: milestone assign, edit-save,
 160    /// MR create, MR close. One test so the flows share navigation and
 161    /// the writes happen exactly once.
 162    func testCreationAndEditingFlows() throws {
 163        openRepo("krz/gitbay-ios")
 164
 165        // --- the fixture issue: milestone picker assigns v1.0.0 ---
 166        app.staticTexts["Issues"].firstMatch.tap()
 167        let issueRow = app.staticTexts
 168            .containing(NSPredicate(format: "label CONTAINS 'ui-smoke fixture'")).firstMatch
 169        XCTAssertTrue(issueRow.waitForExistence(timeout: 10),
 170                      "fixture issue not in the list; setUp should have created it")
 171        issueRow.tap()
 172
 173        let milestoneMenu = app.descendants(matching: .any)
 174            .matching(identifier: "milestone-menu").firstMatch
 175        XCTAssertTrue(milestoneMenu.waitForExistence(timeout: 10))
 176        milestoneMenu.tap()
 177        let milestoneChoice = app.buttons
 178            .containing(NSPredicate(format: "label BEGINSWITH 'v1.0.0'")).firstMatch
 179        XCTAssertTrue(milestoneChoice.waitForExistence(timeout: 5), "milestone menu did not open")
 180        milestoneChoice.tap()
 181        // The reload renders the assigned milestone in the header row.
 182        XCTAssertTrue(app.staticTexts["v1.0.0"].firstMatch
 183            .waitForExistence(timeout: 10), "milestone not shown after assign")
 184
 185        // --- the fixture issue: edit sheet saves (content unchanged) ---
 186        app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
 187            .firstMatch.tap()
 188        let edit = app.buttons["Edit"].firstMatch
 189        XCTAssertTrue(edit.waitForExistence(timeout: 5))
 190        edit.tap()
 191        let title = app.descendants(matching: .any)
 192            .matching(identifier: "compose-title").firstMatch
 193        XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
 194        XCTAssertTrue((title.value as? String)?.contains("ui-smoke fixture") == true,
 195                      "edit sheet did not prefill")
 196        // Save the body as org, reopen, and the picker must start on Org:
 197        // the sheet has to read the stored format, not its own default.
 198        let format = app.segmentedControls.matching(identifier: "compose-format").firstMatch
 199        XCTAssertTrue(format.waitForExistence(timeout: 5), "format picker missing")
 200        XCTAssertTrue(format.buttons["Markdown"].isSelected, "fixture did not open on Markdown")
 201        format.buttons["Org"].tap()
 202        app.descendants(matching: .any).matching(identifier: "compose-submit")
 203            .firstMatch.tap()
 204        // Sheet dismissal proves the save round-tripped without error.
 205        XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
 206
 207        app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
 208            .firstMatch.tap()
 209        XCTAssertTrue(edit.waitForExistence(timeout: 5))
 210        edit.tap()
 211        XCTAssertTrue(format.waitForExistence(timeout: 5), "edit sheet did not reopen")
 212        XCTAssertTrue(format.buttons["Org"].isSelected, "edit sheet did not open on the stored format")
 213        // Restore the fixture to markdown.
 214        format.buttons["Markdown"].tap()
 215        app.descendants(matching: .any).matching(identifier: "compose-submit")
 216            .firstMatch.tap()
 217        XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
 218
 219        back() // to issues list
 220        back() // to repo screen
 221
 222        // --- MR: create from ui-smoke, then close it ---
 223        app.staticTexts["Merge Requests"].firstMatch.tap()
 224        app.descendants(matching: .any).matching(identifier: "mr-create-button")
 225            .firstMatch.tap()
 226
 227        let source = app.descendants(matching: .any)
 228            .matching(identifier: "mr-source").firstMatch
 229        XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open")
 230        focusAndType(source, "ui-smoke")
 231
 232        let target = app.descendants(matching: .any)
 233            .matching(identifier: "mr-target").firstMatch
 234        // The default branch prefilled while the sheet loaded.
 235        XCTAssertEqual(target.value as? String, "main", "target did not prefill")
 236
 237        let mrTitle = app.descendants(matching: .any)
 238            .matching(identifier: "mr-title").firstMatch
 239        focusAndType(mrTitle, "UI smoke: mr create from the app")
 240
 241        app.descendants(matching: .any).matching(identifier: "mr-submit")
 242            .firstMatch.tap()
 243
 244        let createdRow = app.staticTexts
 245            .containing(NSPredicate(format: "label CONTAINS 'UI smoke'")).firstMatch
 246        XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list")
 247        createdRow.tap()
 248
 249        // The gates are shown before any merge is tried.
 250        let gates = header("Gates")
 251        XCTAssertTrue(scrollTo(gates, swipes: 8), "gates section missing")
 252
 253        // A fresh MR has one revision; the section lists it.
 254        let revisions = header("Revisions")
 255        XCTAssertTrue(scrollTo(revisions, swipes: 8), "revisions section missing")
 256        XCTAssertTrue(app.staticTexts["v1"].firstMatch.exists, "first revision not listed")
 257
 258        app.descendants(matching: .any).matching(identifier: "mr-actions-menu")
 259            .firstMatch.tap()
 260        let close = app.buttons["Close"].firstMatch
 261        XCTAssertTrue(close.waitForExistence(timeout: 5))
 262        close.tap()
 263        // The confirmation dialog's destructive Close.
 264        let confirm = app.buttons["Close"].firstMatch
 265        XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing")
 266        confirm.tap()
 267
 268        XCTAssertTrue(app.staticTexts["closed"].firstMatch
 269            .waitForExistence(timeout: 15), "MR did not show closed after close")
 270    }
 271
 272    // MARK: - Helpers
 273
 274    private func openRepo(_ path: String) {
 275        selectTab("Repositories")
 276
 277        // `.searchable` keeps the field tucked above the list until it is
 278        // scrolled into view.
 279        let search = app.searchFields.firstMatch
 280        if !search.waitForExistence(timeout: 5) {
 281            app.swipeDown()
 282            XCTAssertTrue(search.waitForExistence(timeout: 10), "search field never appeared")
 283        }
 284        focusAndType(search, path)
 285
 286        let row = app.staticTexts[path].firstMatch
 287        XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
 288        row.tap()
 289        // Repo screen is loaded once its links render.
 290        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10),
 291                      "repo screen did not open")
 292    }
 293
 294    private func back() {
 295        app.navigationBars.buttons.firstMatch.tap()
 296    }
 297
 298    /// Keys, PGP and email live behind the account menu, which is on the
 299    /// My Profile tab and nowhere else.
 300    func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
 301        selectTab("My Profile")
 302        let menu = app.descendants(matching: .any)
 303            .matching(identifier: "account-menu").firstMatch
 304        XCTAssertTrue(menu.waitForExistence(timeout: 15),
 305                      "account menu missing", file: file, line: line)
 306        menu.tap()
 307        let keys = app.buttons["Keys & Email"].firstMatch
 308        XCTAssertTrue(keys.waitForExistence(timeout: 5),
 309                      "Keys & Email missing from the account menu", file: file, line: line)
 310        keys.tap()
 311    }
 312
 313    /// The edit flow saves a change to a real file. Seeding a
 314    /// repository's first commit is a push, which no test can make, so
 315    /// this only checks — and says why, rather than failing later at
 316    /// "cmc/ui-smoke-edit not in the repo list".
 317    private func requireFixtureRepo() throws {
 318        guard let data = readCommand(["repo", "show", "cmc/ui-smoke-edit"]),
 319              let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
 320        else { return } // no token, or the instance is unreachable
 321        try XCTSkipUnless(envelope["data"] != nil, """
 322            cmc/ui-smoke-edit is missing. The edit flow saves a change to \
 323            notes.txt in it; recreate the repo with that file (see its \
 324            README) and run again.
 325            """)
 326    }
 327
 328    /// Scroll a list until an element is in the hierarchy. Offscreen
 329    /// rows do not exist to XCUITest, so waitForExistence alone fails on
 330    /// anything below the fold.
 331    @discardableResult
 332    /// A list section header. iOS 18 renders these uppercased and iOS 26
 333    /// as written, so the match ignores case.
 334    func header(_ text: String) -> XCUIElement {
 335        app.staticTexts.matching(NSPredicate(format: "label ==[c] %@", text)).firstMatch
 336    }
 337
 338    func scrollTo(_ element: XCUIElement, swipes: Int = 6) -> Bool {
 339        for _ in 0..<swipes {
 340            if element.exists { return true }
 341            app.swipeUp()
 342        }
 343        return element.exists
 344    }
 345
 346    /// Switch tabs and wait until that tab is actually front. A tap
 347    /// dispatched before the app is interactive — which happens right
 348    /// after launch when there is no sign-in to slow things down — is
 349    /// swallowed silently, so this taps until the tab reports selected.
 350    func selectTab(_ name: String,
 351                   file: StaticString = #filePath, line: UInt = #line) {
 352        // iPadOS renders a TabView as a sidebar or top bar rather than a
 353        // bottom tab bar, so the control is not under app.tabBars there.
 354        var tab = app.tabBars.buttons[name].firstMatch
 355        if !tab.waitForExistence(timeout: 5) {
 356            tab = app.buttons[name].firstMatch
 357        }
 358        XCTAssertTrue(tab.waitForExistence(timeout: 15),
 359                      "\(name) tab missing", file: file, line: line)
 360        // The app is interactive once its own chrome is hittable.
 361        XCTAssertTrue(tab.isHittable || tab.waitForExistence(timeout: 5),
 362                      "\(name) tab never became hittable", file: file, line: line)
 363
 364        for _ in 0..<4 {
 365            if tab.isSelected, app.navigationBars[name].firstMatch.exists { return }
 366            tab.tap()
 367            if app.navigationBars[name].firstMatch.waitForExistence(timeout: 5) { return }
 368        }
 369        XCTFail("\(name) tab did not come to front", file: file, line: line)
 370    }
 371
 372    /// Empty a text field before typing. Search fields keep their query
 373    /// across navigation, and the clear button is not reliably
 374    /// addressable, so this deletes character by character.
 375    func clearAndType(_ element: XCUIElement, _ text: String,
 376                      file: StaticString = #filePath, line: UInt = #line) {
 377        element.tap()
 378        if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
 379            element.tap()
 380            XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
 381                          "keyboard never appeared", file: file, line: line)
 382        }
 383        if let existing = element.value as? String,
 384           existing != element.placeholderValue, !existing.isEmpty {
 385            element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue,
 386                                    count: existing.count))
 387        }
 388        element.typeText(text)
 389    }
 390
 391    /// Tap a field and type into it, surviving the focus race: a tap can
 392    /// land before the field is ready, and the keystrokes go nowhere.
 393    func focusAndType(_ element: XCUIElement, _ text: String,
 394                      file: StaticString = #filePath, line: UInt = #line) {
 395        element.tap()
 396        if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
 397            element.tap()
 398            XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
 399                          "keyboard never appeared for \(element)", file: file, line: line)
 400        }
 401        element.typeText(text)
 402    }
 403
 404    private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
 405        let predicate = NSPredicate(format: "exists == false")
 406        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
 407        return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
 408    }
 409}
 410
 411extension LiveSmokeUITests {
 412
 413    /// Repo management, kept reversible: create a scratch repo (deleted
 414    /// by the runner afterwards, deletion is CLI-only by design), pin and
 415    /// unpin, a topic round-trip, a merge-rule toggle round-trip, and the
 416    /// build-trigger error path on a repo with no job config.
 417    func testRepoManagementFlows() throws {
 418        // --- repo create first: it ends in the list's search state,
 419        // which the next step reuses. (Scratch repo; the runner deletes
 420        // it over SSH afterwards — deletion is CLI-only by design.)
 421        selectTab("Repositories")
 422        app.descendants(matching: .any).matching(identifier: "repo-create-button")
 423            .firstMatch.tap()
 424        let newRepo = app.buttons["New Repository"].firstMatch
 425        XCTAssertTrue(newRepo.waitForExistence(timeout: 5), "create menu did not open")
 426        newRepo.tap()
 427        let pathField = app.descendants(matching: .any)
 428            .matching(identifier: "repo-create-path").firstMatch
 429        XCTAssertTrue(pathField.waitForExistence(timeout: 5))
 430        focusAndType(pathField, "ui-smoke")
 431        app.switches.firstMatch.tap() // Private on
 432        app.descendants(matching: .any).matching(identifier: "repo-create-submit")
 433            .firstMatch.tap()
 434        // The sheet dismissing proves the create round-tripped; rows are
 435        // lazy, so find the new repo through the filter.
 436        XCTAssertTrue(waitForDisappearance(pathField, timeout: 15),
 437                      "create sheet did not dismiss")
 438        let createSearch = app.searchFields.firstMatch
 439        XCTAssertTrue(createSearch.waitForExistence(timeout: 10))
 440        focusAndType(createSearch, "ui-smoke")
 441        XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
 442            .waitForExistence(timeout: 15), "created repo not in the list")
 443
 444        // --- access: grant a user on the scratch repo, then revoke ---
 445        app.staticTexts["cmc/ui-smoke"].firstMatch.tap()
 446        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
 447        app.staticTexts["Settings"].firstMatch.tap()
 448        let grantUser = app.descendants(matching: .any)
 449            .matching(identifier: "settings-grant-user").firstMatch
 450        XCTAssertTrue(scrollTo(grantUser, swipes: 8), "access section not reachable")
 451        focusAndType(grantUser, "apple-review")
 452        app.descendants(matching: .any).matching(identifier: "settings-grant-submit")
 453            .firstMatch.tap()
 454        let grantRow = app.staticTexts["apple-review"].firstMatch
 455        XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
 456        grantRow.swipeLeft()
 457        app.buttons["Revoke"].firstMatch.tap()
 458        let confirmGrantRevoke = app.buttons["Revoke"].firstMatch
 459        XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
 460        confirmGrantRevoke.tap()
 461        XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
 462
 463        // --- webhooks: add one, see it listed, remove it ---
 464        let webhooksLink = app.descendants(matching: .any)
 465            .matching(identifier: "settings-webhooks-link").firstMatch
 466        XCTAssertTrue(scrollTo(webhooksLink, swipes: 8), "webhooks link not reachable")
 467        webhooksLink.tap()
 468        let addHook = app.descendants(matching: .any)
 469            .matching(identifier: "webhook-add-button").firstMatch
 470        XCTAssertTrue(addHook.waitForExistence(timeout: 10), "webhooks did not load")
 471        addHook.tap()
 472        let hookURL = app.descendants(matching: .any)
 473            .matching(identifier: "webhook-url").firstMatch
 474        XCTAssertTrue(hookURL.waitForExistence(timeout: 5), "webhook sheet did not open")
 475        focusAndType(hookURL, "https://example.com/ui-smoke")
 476        app.descendants(matching: .any).matching(identifier: "webhook-submit")
 477            .firstMatch.tap()
 478        let hookRow = app.staticTexts["https://example.com/ui-smoke"].firstMatch
 479        XCTAssertTrue(hookRow.waitForExistence(timeout: 15), "webhook not listed")
 480        hookRow.swipeLeft()
 481        app.buttons["Remove"].firstMatch.tap()
 482        let confirmHookRemove = app.buttons["Remove"].firstMatch
 483        XCTAssertTrue(confirmHookRemove.waitForExistence(timeout: 5), "webhook remove confirmation missing")
 484        confirmHookRemove.tap()
 485        XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed")
 486        back() // webhooks -> settings
 487
 488        // --- rename the scratch repo there and back ---
 489        // Each rename pushes the renamed repository; the round-trip ends
 490        // on cmc/ui-smoke again, several screens deep.
 491        var firstRename = true
 492        let rename: (String, String) -> Void = { newName, expectedPath in
 493            if firstRename {
 494                firstRename = false // already on the settings screen
 495            } else {
 496                // Fresh repo screen: scroll to the top so no row hides
 497                // under the navigation bar, then reach Settings.
 498                self.app.swipeDown()
 499                let settingsLink = self.app.descendants(matching: .any).matching(identifier: "repo-settings-link").firstMatch
 500                XCTAssertTrue(self.scrollTo(settingsLink), "settings link not reachable")
 501                settingsLink.tap()
 502            }
 503            let renameButton = self.app.descendants(matching: .any)
 504                .matching(identifier: "settings-rename-button").firstMatch
 505            XCTAssertTrue(self.scrollTo(renameButton, swipes: 10), "rename button not reachable")
 506            renameButton.tap()
 507            let field = self.app.textFields["New name"].firstMatch
 508            XCTAssertTrue(field.waitForExistence(timeout: 5), "rename alert did not open")
 509            field.tap()
 510            // Prefilled with the current name; clear it before typing.
 511            field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) + newName)
 512            self.app.buttons["Rename"].firstMatch.tap()
 513            // The settings screen offers the renamed repository at the
 514            // top; the list is scrolled to its end, so go back up first.
 515            let renamedLink = self.app.descendants(matching: .any)
 516                .matching(identifier: "settings-renamed-link").firstMatch
 517            sleep(2)
 518            for _ in 0..<6 where !renamedLink.exists { self.app.swipeDown() }
 519            XCTAssertTrue(renamedLink.waitForExistence(timeout: 10), "rename did not land")
 520            renamedLink.tap()
 521            XCTAssertTrue(self.app.staticTexts[expectedPath].firstMatch.waitForExistence(timeout: 15),
 522                          "renamed repo did not open as \(expectedPath)")
 523        }
 524        rename("ui-smoke-2", "cmc/ui-smoke-2")
 525        rename("ui-smoke", "cmc/ui-smoke")
 526
 527        // --- pin / unpin round-trip on krz/gitbay-ios ---
 528        // Start the list fresh; the rename left the stack several deep.
 529        app.terminate()
 530        app.launch()
 531        selectTab("Repositories")
 532        let search = app.searchFields.firstMatch
 533        XCTAssertTrue(search.waitForExistence(timeout: 10))
 534        focusAndType(search, "krz/gitbay-ios")
 535        let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
 536        XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
 537        repoRow.tap()
 538        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
 539        let menu = app.descendants(matching: .any)
 540            .matching(identifier: "repo-actions-menu").firstMatch
 541        XCTAssertTrue(menu.waitForExistence(timeout: 10))
 542        menu.tap()
 543        let pin = app.buttons["Pin"].firstMatch
 544        XCTAssertTrue(pin.waitForExistence(timeout: 5), "Pin action missing")
 545        pin.tap()
 546        // State refreshed from the dashboard: the menu now offers Unpin.
 547        menu.tap()
 548        let unpin = app.buttons["Unpin"].firstMatch
 549        XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take")
 550        unpin.tap()
 551
 552        // Bookmark it, find it in the bookmarks list, then unbookmark.
 553        // A run that died between the two leaves it bookmarked; undo
 554        // that first so the step starts from the same state every time.
 555        menu.tap()
 556        let leftover = app.buttons["Unbookmark"].firstMatch
 557        if leftover.waitForExistence(timeout: 3) {
 558            leftover.tap()
 559            XCTAssertTrue(waitForDisappearance(leftover, timeout: 10))
 560            menu.tap()
 561        }
 562        let bookmark = app.buttons["Bookmark"].firstMatch
 563        XCTAssertTrue(bookmark.waitForExistence(timeout: 5), "Bookmark action missing")
 564        bookmark.tap()
 565        // An active search hides the list's toolbar, so start the list
 566        // fresh: relaunching lands on a list with the bookmarks button.
 567        app.terminate()
 568        app.launch()
 569        selectTab("Repositories")
 570        let bookmarksButton = app.descendants(matching: .any)
 571            .matching(identifier: "repo-bookmarks-button").firstMatch
 572        XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 15), "bookmarks button missing")
 573        bookmarksButton.tap()
 574        let bookmarkedRow = app.staticTexts["krz/gitbay-ios"].firstMatch
 575        XCTAssertTrue(bookmarkedRow.waitForExistence(timeout: 15),
 576                      "bookmarked repo not in the bookmarks list")
 577        // Open it from the bookmarks list and undo the bookmark there.
 578        bookmarkedRow.tap()
 579        XCTAssertTrue(menu.waitForExistence(timeout: 10))
 580        menu.tap()
 581        let unbookmark = app.buttons["Unbookmark"].firstMatch
 582        XCTAssertTrue(unbookmark.waitForExistence(timeout: 10), "bookmark did not take")
 583        unbookmark.tap()
 584
 585        // --- settings: topic and merge-rule round-trips ---
 586        // The links section can sit below the README fold, and the row's
 587        // label is not always exposed as a static text.
 588        let settingsLink = app.descendants(matching: .any)
 589            .matching(identifier: "repo-settings-link").firstMatch
 590        XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable")
 591        settingsLink.tap()
 592        let addTopic = app.descendants(matching: .any)
 593            .matching(identifier: "settings-add-topic").firstMatch
 594        XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
 595        focusAndType(addTopic, "ios")
 596        app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit")
 597            .firstMatch.tap()
 598        let chip = app.staticTexts["ios"].firstMatch
 599        XCTAssertTrue(chip.waitForExistence(timeout: 10), "topic did not appear")
 600        // Remove it again through the chip's own x button — by name, since
 601        // the repository carries other topics — and it asks first.
 602        app.descendants(matching: .any).matching(identifier: "settings-remove-topic-ios")
 603            .firstMatch.tap()
 604        let confirmRemove = app.buttons["Remove"].firstMatch
 605        XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "topic remove confirmation missing")
 606        confirmRemove.tap()
 607        XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
 608
 609        // Merge-requests-only sits with the protected branches.
 610        let mergeOnly = app.switches["Merge requests only"].firstMatch
 611        XCTAssertTrue(scrollTo(mergeOnly), "merge-only toggle not reachable")
 612        let innerMergeOnly = mergeOnly.switches.firstMatch
 613        let flipMergeOnly: () -> Void = {
 614            if innerMergeOnly.exists && innerMergeOnly != mergeOnly {
 615                innerMergeOnly.tap()
 616            } else {
 617                mergeOnly.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
 618            }
 619        }
 620        flipMergeOnly()
 621        XCTAssertTrue(waitForValue(mergeOnly, "1", timeout: 10), "merge-only toggle did not persist on")
 622        flipMergeOnly()
 623        XCTAssertTrue(waitForValue(mergeOnly, "0", timeout: 10), "merge-only toggle did not persist off")
 624
 625        // A tag glob is protected, listed, and unprotected.
 626        let tagGlob = app.descendants(matching: .any)
 627            .matching(identifier: "settings-protect-tag").firstMatch
 628        XCTAssertTrue(scrollTo(tagGlob), "protected tags section not reachable")
 629        focusAndType(tagGlob, "ui-smoke-*")
 630        app.descendants(matching: .any).matching(identifier: "settings-protect-tag-submit")
 631            .firstMatch.tap()
 632        let globRow = app.staticTexts["ui-smoke-*"].firstMatch
 633        XCTAssertTrue(globRow.waitForExistence(timeout: 10), "tag glob not listed")
 634        // By identifier: the protected branch above has an Unprotect too.
 635        app.descendants(matching: .any).matching(identifier: "settings-unprotect-tag-ui-smoke-*")
 636            .firstMatch.tap()
 637        XCTAssertTrue(waitForDisappearance(globRow, timeout: 10), "tag glob not unprotected")
 638
 639        // Merge rules sit below the protected branches and tags.
 640        let resolved = app.switches["Require threads resolved"].firstMatch
 641        // The merge-requirements section sits below the fold, and a List
 642        // does not build rows it has not shown, so it must be scrolled
 643        // into existence before it can be queried.
 644        XCTAssertTrue(scrollTo(resolved), "merge requirements section not reachable")
 645        // SwiftUI exposes the row as a switch that wraps the real
 646        // control; tap the innermost switch when there is one, else the
 647        // right edge of the row.
 648        let inner = resolved.switches.firstMatch
 649        let control: () -> Void = {
 650            if inner.exists && inner != resolved {
 651                inner.tap()
 652            } else {
 653                resolved.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
 654            }
 655        }
 656        control()
 657        XCTAssertTrue(waitForValue(resolved, "1", timeout: 10), "toggle did not persist on")
 658        control()
 659        XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off")
 660
 661        // The code-owner rule is one more toggle in the same section.
 662        let codeowners = app.switches["Require code owner review"].firstMatch
 663        XCTAssertTrue(scrollTo(codeowners), "code owner toggle not reachable")
 664        let innerCodeowners = codeowners.switches.firstMatch
 665        let flipCodeowners: () -> Void = {
 666            if innerCodeowners.exists && innerCodeowners != codeowners {
 667                innerCodeowners.tap()
 668            } else {
 669                codeowners.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
 670            }
 671        }
 672        flipCodeowners()
 673        XCTAssertTrue(waitForValue(codeowners, "1", timeout: 10), "code owner toggle did not persist on")
 674        flipCodeowners()
 675        XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off")
 676
 677
 678        back() // settings -> repo
 679        // The repo screen keeps the scroll that reached Settings, which
 680        // can leave Builds under the navigation bar.
 681        app.swipeDown()
 682
 683        // --- nothing to trigger without a job file ---
 684        // This repo has no .gitbay/ci.yml, so `build jobs` returns none
 685        // and the control is gated rather than failing after a guess.
 686        app.staticTexts["Builds"].firstMatch.tap()
 687        let trigger = app.descendants(matching: .any)
 688            .matching(identifier: "build-trigger-button").firstMatch
 689        XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing")
 690        // Assert on behaviour, not on isEnabled: a disabled SwiftUI Menu
 691        // still reports itself enabled to XCUITest.
 692        trigger.tap()
 693        let anyJob = app.buttons.containing(
 694            NSPredicate(format: "label CONTAINS 'on push' OR label CONTAINS 'schedule '"))
 695            .firstMatch
 696        XCTAssertFalse(anyJob.waitForExistence(timeout: 3),
 697                       "a repo with no job file offered a job to trigger")
 698
 699    }
 700
 701    private func waitForValue(_ element: XCUIElement, _ value: String,
 702                              timeout: TimeInterval) -> Bool {
 703        let predicate = NSPredicate(format: "value == %@", value)
 704        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
 705        return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
 706    }
 707}
 708
 709extension LiveSmokeUITests {
 710
 711    /// Discovery is read-only: feed, server-side repo search, grep, and
 712    /// profiles. No cleanup needed.
 713    func testDiscoveryFlows() throws {
 714        // --- feed renders events and navigates ---
 715        selectTab("Feed")
 716        let firstEvent = app.cells.firstMatch
 717        XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events")
 718        firstEvent.tap()
 719        // Wherever the event led, it left the feed root behind.
 720        XCTAssertTrue(app.navigationBars.buttons.firstMatch
 721            .waitForExistence(timeout: 10), "feed row did not navigate")
 722        back()
 723
 724        // --- server-side search: "astronomy" is only a topic, invisible
 725        // to the client-side path/description filter ---
 726        selectTab("Repositories")
 727        let search = app.searchFields.firstMatch
 728        XCTAssertTrue(search.waitForExistence(timeout: 10))
 729        focusAndType(search, "astronomy")
 730        let hit = app.staticTexts["krz/space-wiki"].firstMatch
 731        XCTAssertTrue(hit.waitForExistence(timeout: 15),
 732                      "server-side topic search found nothing")
 733        hit.tap()
 734        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
 735
 736        // --- owner profile from the repo screen ---
 737        // The row's label merges; match it at any element type. It is
 738        // the last row of the links section, below the fold.
 739        let ownerLink = app.descendants(matching: .any)["krz"].firstMatch
 740        XCTAssertTrue(scrollTo(ownerLink), "owner link not reachable")
 741        ownerLink.tap()
 742        XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch
 743            .waitForExistence(timeout: 15), "profile did not load")
 744        XCTAssertTrue(app.staticTexts
 745            .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch
 746            .waitForExistence(timeout: 10), "profile repos missing")
 747        back()
 748
 749        // --- grep inside the repo, last: its search UI owns the screen ---
 750        app.staticTexts["Search in Files"].firstMatch.tap()
 751        let grepField = app.searchFields.firstMatch
 752        XCTAssertTrue(grepField.waitForExistence(timeout: 10))
 753        focusAndType(grepField, "space")
 754        app.keyboards.buttons["search"].firstMatch.tap()
 755        let match = app.cells.firstMatch
 756        XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches")
 757    }
 758}
 759
 760extension LiveSmokeUITests {
 761
 762    /// Releases: list and detail on a real release, an edit round-trip
 763    /// that saves the prefilled content (a no-op write), and the
 764    /// missing-tag refusal on create. Nothing changes state.
 765    func testReleaseFlows() throws {
 766        openRepo("krz/gitbay")
 767        app.staticTexts["Releases"].firstMatch.tap()
 768
 769        // Newest first, and the list has grown past the fold, so the
 770        // first row is whichever release is current.
 771        let row = app.cells.firstMatch
 772        XCTAssertTrue(row.waitForExistence(timeout: 15), "release list empty")
 773        row.tap()
 774
 775        // Notes render and assets carry sizes. The assets sit under the
 776        // notes, which can run past the fold.
 777        XCTAssertTrue(app.descendants(matching: .any)
 778            .matching(identifier: "release-edit-button").firstMatch
 779            .waitForExistence(timeout: 15), "release did not open")
 780        XCTAssertTrue(scrollTo(app.staticTexts
 781            .containing(NSPredicate(format: "label CONTAINS 'SHA256SUMS'")).firstMatch, swipes: 10),
 782            "assets missing")
 783
 784        // Edit sheet prefills; saving unchanged content round-trips.
 785        app.descendants(matching: .any).matching(identifier: "release-edit-button")
 786            .firstMatch.tap()
 787        let title = app.descendants(matching: .any)
 788            .matching(identifier: "compose-title").firstMatch
 789        XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
 790        XCTAssertFalse((title.value as? String ?? "").isEmpty,
 791                       "edit sheet did not prefill")
 792        app.descendants(matching: .any).matching(identifier: "compose-submit")
 793            .firstMatch.tap()
 794        XCTAssertTrue(waitForDisappearance(title, timeout: 15),
 795                      "release edit did not dismiss")
 796
 797        back()
 798
 799        // Create with a tag that does not exist: the server's refusal is
 800        // the UI contract.
 801        app.descendants(matching: .any).matching(identifier: "release-create-button")
 802            .firstMatch.tap()
 803        let tag = app.descendants(matching: .any)
 804            .matching(identifier: "release-tag").firstMatch
 805        XCTAssertTrue(tag.waitForExistence(timeout: 5))
 806        focusAndType(tag, "v9.9.9")
 807        app.descendants(matching: .any).matching(identifier: "release-submit")
 808            .firstMatch.tap()
 809        XCTAssertTrue(app.staticTexts
 810            .containing(NSPredicate(format: "label CONTAINS 'push the tag first'")).firstMatch
 811            .waitForExistence(timeout: 15), "missing-tag refusal not surfaced")
 812        app.buttons["Cancel"].firstMatch.tap()
 813    }
 814}
 815
 816extension LiveSmokeUITests {
 817
 818    /// Account keys and email. Read-only plus refusal paths — no key is
 819    /// added or removed, no mail is sent.
 820    func testAccountFlows() throws {
 821        // Dashboard toolbar -> account screen.
 822        openAccountScreen()
 823
 824        // Real keys render: SSH fingerprints and the PGP key's UID email.
 825        XCTAssertTrue(app.staticTexts
 826            .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
 827            .waitForExistence(timeout: 15), "SSH keys missing")
 828        XCTAssertTrue(app.staticTexts["hello@cleberg.net"].firstMatch
 829            .waitForExistence(timeout: 10), "PGP key UID missing")
 830
 831        // Pasting garbage as an SSH key surfaces the server's validation.
 832        app.descendants(matching: .any).matching(identifier: "add-ssh-key")
 833            .firstMatch.tap()
 834        let paste = app.descendants(matching: .any)
 835            .matching(identifier: "key-paste-text").firstMatch
 836        XCTAssertTrue(paste.waitForExistence(timeout: 5))
 837        focusAndType(paste, "not a key")
 838        app.descendants(matching: .any).matching(identifier: "key-paste-submit")
 839            .firstMatch.tap()
 840        XCTAssertTrue(app.staticTexts
 841            .containing(NSPredicate(format: "label CONTAINS 'not a valid public key'")).firstMatch
 842            .waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
 843        app.buttons["Cancel"].firstMatch.tap()
 844
 845        // Activity mail flips off and back on; the value comes back from
 846        // the server each time.
 847        let mail = app.switches["Activity by mail"].firstMatch
 848        XCTAssertTrue(scrollTo(mail), "mail toggle not reachable")
 849        let innerMail = mail.switches.firstMatch
 850        let flipMail: () -> Void = {
 851            if innerMail.exists && innerMail != mail {
 852                innerMail.tap()
 853            } else {
 854                mail.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
 855            }
 856        }
 857        let mailWasOn = (mail.value as? String) == "1"
 858        flipMail()
 859        XCTAssertTrue(waitForValue(mail, mailWasOn ? "0" : "1", timeout: 10), "mail toggle did not persist")
 860        flipMail()
 861        XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore")
 862
 863        // A bogus verification code is refused, not swallowed.
 864        let code = app.descendants(matching: .any)
 865            .matching(identifier: "email-code").firstMatch
 866        // Email sits below the keys on a List, which does not build rows
 867        // it has not shown.
 868        XCTAssertTrue(scrollTo(code), "email section not reachable")
 869        // The addresses list above the fields, the primary one marked.
 870        XCTAssertTrue(app.staticTexts["primary"].firstMatch.exists, "primary address not marked")
 871        focusAndType(code, "000000")
 872        let verify = app.descendants(matching: .any)
 873            .matching(identifier: "email-verify").firstMatch
 874        XCTAssertTrue(verify.waitForExistence(timeout: 5))
 875        XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed")
 876        verify.tap()
 877        XCTAssertTrue(app.staticTexts
 878            .containing(NSPredicate(format: "label CONTAINS 'invalid, expired'")).firstMatch
 879            .waitForExistence(timeout: 15), "bad-code refusal not surfaced")
 880    }
 881}
 882
 883extension LiveSmokeUITests {
 884
 885    /// Orgs: members render, and a team lives a full life — created,
 886    /// granted a repo, the grant revoked, the team deleted. Everything
 887    /// this test makes, it removes.
 888    func testOrgFlows() throws {
 889        openAccountScreen()
 890
 891        let orgRow = app.staticTexts["krz"].firstMatch
 892        XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
 893        orgRow.tap()
 894
 895        // Members render with roles.
 896        XCTAssertTrue(app.staticTexts["cmc"].firstMatch
 897            .waitForExistence(timeout: 15), "org members missing")
 898
 899        // Create a team.
 900        let teamField = app.descendants(matching: .any)
 901            .matching(identifier: "org-new-team").firstMatch
 902        XCTAssertTrue(teamField.waitForExistence(timeout: 5))
 903        focusAndType(teamField, "ui-smoke")
 904        app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
 905            .firstMatch.tap()
 906        let teamRow = app.staticTexts["ui-smoke"].firstMatch
 907        XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed")
 908
 909        // Grant it a repo, then revoke.
 910        teamRow.tap()
 911        let repoField = app.textFields["org/repo"].firstMatch
 912        XCTAssertTrue(repoField.waitForExistence(timeout: 10))
 913        focusAndType(repoField, "krz/gitbay-ios")
 914        app.buttons["Grant"].firstMatch.tap()
 915        let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
 916        XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
 917        grantRow.swipeLeft()
 918        app.buttons["Revoke"].firstMatch.tap()
 919        // The confirmation dialog's destructive Revoke.
 920        let confirmRevoke = app.buttons["Revoke"].firstMatch
 921        XCTAssertTrue(confirmRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
 922        confirmRevoke.tap()
 923        XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
 924
 925        back()
 926
 927        // Delete the team, through its confirmation.
 928        let row = app.staticTexts["ui-smoke"].firstMatch
 929        XCTAssertTrue(row.waitForExistence(timeout: 10))
 930        row.swipeLeft()
 931        app.buttons["Delete"].firstMatch.tap()
 932        // The confirmation dialog's destructive Delete.
 933        let confirm = app.buttons["Delete"].firstMatch
 934        XCTAssertTrue(confirm.waitForExistence(timeout: 5))
 935        confirm.tap()
 936        XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
 937    }
 938}
 939
 940extension LiveSmokeUITests {
 941
 942    /// The pre-merge screenshot checkpoint: walks the dense screens and
 943    /// attaches captures. Run per device/appearance; export the
 944    /// attachments from the xcresult. Signs in first when the device has
 945    /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided.
 946    func testScreenshotCheckpoint() throws {
 947        signInIfNeeded()
 948
 949        snap("dashboard")
 950
 951        openRepo("krz/gitbay")
 952        snap("repo")
 953
 954        app.staticTexts["Merge Requests"].firstMatch.tap()
 955        let all = app.segmentedControls.buttons["All"].firstMatch
 956        XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
 957        all.tap()
 958        // A real MR row carries its !number; the picker row does not.
 959        let mrNumber = app.staticTexts
 960            .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
 961        XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All")
 962        snap("mr-list")
 963
 964        mrNumber.tap()
 965        // The detail opens at its header; the diff row sits below the
 966        // fold and a List does not build rows it has not shown.
 967        let diffRow = app.staticTexts["Diff"].firstMatch
 968        XCTAssertTrue(scrollTo(diffRow), "MR detail has no diff row")
 969        snap("mr-detail")
 970
 971        diffRow.tap()
 972        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
 973        snap("diff")
 974    }
 975
 976    private func signInIfNeeded() {
 977        // Signed in already: the tab bar is up and there is no token field.
 978        if app.tabBars.firstMatch.waitForExistence(timeout: 10),
 979           !app.secureTextFields.firstMatch.exists {
 980            return
 981        }
 982        let tokenField = app.secureTextFields.firstMatch
 983        guard tokenField.waitForExistence(timeout: 5) else { return }
 984        guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else {
 985            XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
 986            return
 987        }
 988        focusAndType(tokenField, token + "\n")
 989        XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
 990            .waitForExistence(timeout: 20), "sign-in did not land")
 991        dismissSavePasswordPrompt()
 992    }
 993
 994    /// iOS offers to save whatever went into a SecureField, and the sheet
 995    /// sits over the app until answered — over the dashboard, in one set
 996    /// of store screenshots. It belongs to springboard, not to the app.
 997    private func dismissSavePasswordPrompt() {
 998        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
 999        let notNow = springboard.buttons["Not Now"].firstMatch
1000        if notNow.waitForExistence(timeout: 8) {
1001            notNow.tap()
1002            _ = waitForDisappearance(notNow, timeout: 10)
1003            return
1004        }
1005        let inApp = app.buttons["Not Now"].firstMatch
1006        if inApp.waitForExistence(timeout: 3) {
1007            inApp.tap()
1008            _ = waitForDisappearance(inApp, timeout: 10)
1009            return
1010        }
1011        // No prompt is fine: iOS only offers once per credential.
1012    }
1013
1014    /// Capture a screen, but not while it is still loading. These go to
1015    /// the App Store, and a spinner shipped as a screenshot once already.
1016    private func snap(_ name: String, file: StaticString = #filePath, line: UInt = #line) {
1017        let spinner = app.activityIndicators.firstMatch
1018        if spinner.exists {
1019            XCTAssertTrue(waitForDisappearance(spinner, timeout: 30),
1020                          "\(name) still loading after 30s", file: file, line: line)
1021        }
1022        // A list that has loaded has rows; an empty state has text. Either
1023        // way something must be on screen besides chrome.
1024        let content = app.cells.firstMatch
1025        _ = content.waitForExistence(timeout: 10)
1026
1027        let attachment = XCTAttachment(screenshot: app.screenshot())
1028        attachment.name = name
1029        attachment.lifetime = .keepAlways
1030        add(attachment)
1031    }
1032}
1033
1034extension LiveSmokeUITests {
1035
1036    /// Blame and file editing — the two capabilities that were web-only
1037    /// until krz/gitbay#42 and #43 made them commands. Blame is read-only
1038    /// on a real repo; the edit writes to a scratch repo the runner
1039    /// creates and deletes over SSH.
1040    func testBlameAndEditFlows() throws {
1041        // --- blame on a real file ---
1042        openRepo("krz/gitbay")
1043        app.staticTexts["Files"].firstMatch.tap()
1044        let goMod = app.staticTexts["go.mod"].firstMatch
1045        XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
1046        goMod.tap()
1047        XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu")
1048            .firstMatch.waitForExistence(timeout: 15), "file screen did not open")
1049        app.descendants(matching: .any).matching(identifier: "file-actions-menu")
1050            .firstMatch.tap()
1051        app.buttons["Blame"].firstMatch.tap()
1052        // Attribution shows the commit subject beside the lines.
1053        XCTAssertTrue(app.staticTexts
1054            .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch
1055            .waitForExistence(timeout: 20), "blame rendered no attribution")
1056
1057        // --- edit on the scratch repo ---
1058        // Relaunch rather than reuse the list: `.searchable` keeps the
1059        // previous query, and clearing it from the harness is unreliable.
1060        app.terminate()
1061        app.launch()
1062        openRepo("cmc/ui-smoke-edit")
1063        app.staticTexts["Files"].firstMatch.tap()
1064        let notes = app.staticTexts["notes.txt"].firstMatch
1065        XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
1066        notes.tap()
1067
1068        let menu = app.descendants(matching: .any)
1069            .matching(identifier: "file-actions-menu").firstMatch
1070        XCTAssertTrue(menu.waitForExistence(timeout: 15))
1071        menu.tap()
1072        let edit = app.buttons
1073            .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch
1074        XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing")
1075        edit.tap()
1076
1077        let content = app.descendants(matching: .any)
1078            .matching(identifier: "file-edit-content").firstMatch
1079        XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open")
1080        focusAndType(content, " edited from the app")
1081
1082        let message = app.descendants(matching: .any)
1083            .matching(identifier: "file-edit-message").firstMatch
1084        focusAndType(message, "edit from ios")
1085
1086        app.descendants(matching: .any).matching(identifier: "file-edit-commit")
1087            .firstMatch.tap()
1088        XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss")
1089        // The reloaded file shows the committed text.
1090        XCTAssertTrue(app.staticTexts
1091            .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch
1092            .waitForExistence(timeout: 20), "edit not reflected after commit")
1093    }
1094}
1095
1096
1097extension LiveSmokeUITests {
1098
1099    /// The navigation and profile changes: identity lives in one menu,
1100    /// the dashboard can create a repository, a profile is a real
1101    /// profile, and a log entry opens its commit.
1102    func testProfileAndNavigationFlows() throws {
1103        // --- the dashboard offers creation ---
1104        selectTab("Dashboard")
1105        let create = app.descendants(matching: .any)
1106            .matching(identifier: "dashboard-create-button").firstMatch
1107        XCTAssertTrue(create.waitForExistence(timeout: 15), "dashboard + missing")
1108        create.tap()
1109        let pathField = app.descendants(matching: .any)
1110            .matching(identifier: "repo-create-path").firstMatch
1111        XCTAssertTrue(pathField.waitForExistence(timeout: 10),
1112                      "dashboard + did not open the create sheet")
1113        app.buttons["Cancel"].firstMatch.tap()
1114
1115        // --- identity is its own tab; the account menu rides with it ---
1116        selectTab("My Profile")
1117        let menu = app.descendants(matching: .any)
1118            .matching(identifier: "account-menu").firstMatch
1119        XCTAssertTrue(menu.waitForExistence(timeout: 10),
1120                      "account menu missing from My Profile")
1121        menu.tap()
1122        XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
1123                      "keys not in the account menu")
1124        app.tap() // dismiss the menu; the profile is already on screen
1125
1126        // --- a profile is a profile: description, links, orgs, graph, repos ---
1127        XCTAssertTrue(app.staticTexts
1128            .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
1129            .waitForExistence(timeout: 20), "profile description missing")
1130        XCTAssertTrue(header("Organizations").exists,
1131                      "org memberships missing")
1132        XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing")
1133
1134        // --- an org admin can edit the org's profile ---
1135        app.staticTexts["krz"].firstMatch.tap()
1136        XCTAssertTrue(app.descendants(matching: .any)
1137            .matching(identifier: "profile-edit-button").firstMatch
1138            .waitForExistence(timeout: 15), "org profile offers no edit to its admin")
1139        back()
1140
1141        // The graph and the repositories sit below the orgs and links,
1142        // and a List does not build rows it has not shown.
1143        XCTAssertTrue(scrollTo(app.staticTexts
1144            .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch),
1145            "activity graph missing")
1146        XCTAssertTrue(scrollTo(app.staticTexts
1147            .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch),
1148            "repositories missing")
1149
1150        // --- a log entry opens its commit ---
1151        app.terminate()
1152        app.launch()
1153        openRepo("krz/gitbay")
1154        app.staticTexts["History"].firstMatch.tap()
1155        let firstCommit = app.cells.firstMatch
1156        XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
1157        firstCommit.tap()
1158        // The commit screen IS the patch: no navigating away to find it.
1159        // A hunk header (@@) only appears in a rendered diff, and sits
1160        // below the message and the file list.
1161        let hunk = app.staticTexts
1162            .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch
1163        _ = hunk.waitForExistence(timeout: 20)
1164        XCTAssertTrue(scrollTo(hunk, swipes: 10),
1165                      "commit screen did not render the patch inline")
1166    }
1167}
1168
1169extension LiveSmokeUITests {
1170
1171    /// The three gaps that needed no server work: branches and tags,
1172    /// milestones, and an MR's milestone. Read-only except the MR
1173    /// milestone, which is set and cleared back.
1174    func testRefsAndMilestoneFlows() throws {
1175        openRepo("krz/gitbay")
1176
1177        // --- branches and tags, and browsing at a ref ---
1178        app.staticTexts["Branches & Tags"].firstMatch.tap()
1179        let main = app.staticTexts["main"].firstMatch
1180        XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1181        XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
1182                      "the default branch is not marked")
1183        XCTAssertTrue(header("Tags").exists, "tags section missing")
1184        main.tap()
1185        // Tapping a ref browses the repository there.
1186        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1187                      "browsing at a ref showed nothing")
1188        back()
1189        back()
1190
1191        // --- milestones with progress ---
1192        app.staticTexts["Milestones"].firstMatch.tap()
1193        XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
1194                      "milestones did not load")
1195        app.segmentedControls.buttons["All"].firstMatch.tap()
1196        XCTAssertTrue(app.staticTexts
1197            .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch
1198            .waitForExistence(timeout: 20), "milestone progress missing")
1199        back()
1200
1201        // --- an MR's milestone reads back, and can be cleared ---
1202        app.staticTexts["Merge Requests"].firstMatch.tap()
1203        app.segmentedControls.buttons["All"].firstMatch.tap()
1204        let mrNumber = app.staticTexts
1205            .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
1206        XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs")
1207        mrNumber.tap()
1208        let milestone = app.descendants(matching: .any)
1209            .matching(identifier: "mr-milestone-menu").firstMatch
1210        // The nav bar is the "opened" signal; everything else on this
1211        // screen can be below the fold.
1212        XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20),
1213                      "MR detail did not open")
1214        XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing")
1215        milestone.tap()
1216        // The picker offers None plus the open milestones.
1217        XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10),
1218                      "milestone picker did not open")
1219        app.buttons["None"].firstMatch.tap()
1220    }
1221}
1222
1223extension LiveSmokeUITests {
1224
1225    /// Explore and history at a ref — the two capabilities krz/gitbay!101
1226    /// made reachable. Read-only.
1227    func testExploreAndRefLogFlows() throws {
1228        // --- explore lists what the instance hosts ---
1229        selectTab("Explore")
1230        let firstRepo = app.cells.firstMatch
1231        XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1232        // Public repos this account does not own are reachable here.
1233        XCTAssertTrue(app.staticTexts
1234            .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
1235            .waitForExistence(timeout: 10), "explore is not the public listing")
1236        firstRepo.tap()
1237        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1238                      "explore row did not open its repo")
1239
1240        // --- history at a ref ---
1241        app.terminate()
1242        app.launch()
1243        openRepo("krz/gitbay")
1244        // Browse a ref, then ask for its history — the move the tree page
1245        // offers on the web.
1246        app.staticTexts["Branches & Tags"].firstMatch.tap()
1247        let main = app.staticTexts["main"].firstMatch
1248        XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1249        main.tap()
1250        let history = app.descendants(matching: .any)
1251            .matching(identifier: "tree-history-button").firstMatch
1252        XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree")
1253        history.tap()
1254        XCTAssertTrue(app.navigationBars
1255            .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch
1256            .waitForExistence(timeout: 20), "ref history did not open")
1257        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1258                      "ref history rendered no commits")
1259    }
1260}
1261
1262extension LiveSmokeUITests {
1263
1264    /// The wiki — the last capability that was browser-only until
1265    /// krz/gitbay#48. Read-only; editing is a push, on every surface.
1266    func testWikiFlows() throws {
1267        openRepo("krz/gitbay")
1268        app.staticTexts["Wiki"].firstMatch.tap()
1269
1270        // The landing page leads and is marked.
1271        let home = app.staticTexts["Home"].firstMatch
1272        XCTAssertTrue(home.waitForExistence(timeout: 20), "wiki pages did not load")
1273        XCTAssertTrue(app.staticTexts["home"].firstMatch.exists,
1274                      "the landing page is not marked")
1275
1276        // Parity.org is an org page, so this also proves the org renderer
1277        // runs on wiki content and not just READMEs.
1278        let parity = app.staticTexts["Parity"].firstMatch
1279        XCTAssertTrue(parity.exists, "Parity page missing from the listing")
1280        parity.tap()
1281        XCTAssertTrue(app.staticTexts
1282            .containing(NSPredicate(format: "label CONTAINS 'surface'")).firstMatch
1283            .waitForExistence(timeout: 20), "wiki page rendered no prose")
1284        // Org headings render as headings, not as raw #+title:.
1285        XCTAssertFalse(app.staticTexts
1286            .containing(NSPredicate(format: "label CONTAINS '#+title'")).firstMatch.exists,
1287                       "org markup leaked into the rendered page")
1288    }
1289}
1290
1291
1292extension LiveSmokeUITests {
1293
1294    /// Builds on a repo that has a job file: the picker offers what the
1295    /// server names, and the detail screen says more than the log.
1296    func testBuildJobsAndDetail() throws {
1297        openRepo("krz/gitbay")
1298        app.staticTexts["Builds"].firstMatch.tap()
1299
1300        // The picker lists jobs from `build jobs` — no name to type.
1301        let trigger = app.descendants(matching: .any)
1302            .matching(identifier: "build-trigger-button").firstMatch
1303        XCTAssertTrue(trigger.waitForExistence(timeout: 20), "trigger control missing")
1304        XCTAssertTrue(trigger.isEnabled, "trigger is disabled where a job file exists")
1305        trigger.tap()
1306        let job = app.buttons
1307            .containing(NSPredicate(format: "label CONTAINS 'build' AND label CONTAINS 'on push'"))
1308            .firstMatch
1309        XCTAssertTrue(job.waitForExistence(timeout: 10),
1310                      "job menu did not list the job with why it runs")
1311        // Dismiss without queueing a real build.
1312        app.tap()
1313
1314        // The detail screen carries status, ref and timing, not just log.
1315        let firstBuild = app.cells.firstMatch
1316        XCTAssertTrue(firstBuild.waitForExistence(timeout: 20), "no builds listed")
1317        firstBuild.tap()
1318        for label in ["success", "queued"] where app.staticTexts[label].firstMatch.exists {
1319            XCTAssertTrue(true)
1320        }
1321        XCTAssertTrue(app.staticTexts
1322            .containing(NSPredicate(format: "label BEGINSWITH 'queued '")).firstMatch
1323            .waitForExistence(timeout: 20), "build detail shows no timing")
1324    }
1325}
1326
1327extension LiveSmokeUITests {
1328
1329    /// Store screenshots on iPad. It navigates by tab and through
1330    /// Explore rather than reusing the iPhone checkpoint: that one drives
1331    /// the repo list's `.searchable`, which iPadOS lays out differently,
1332    /// and chasing those differences is the iPad pass this release
1333    /// deferred. Tabs and a list row work on both.
1334    func testIPadScreenshotCheckpoint() throws {
1335        signInIfNeeded()
1336        snap("dashboard")
1337
1338        selectTab("Explore")
1339        snap("explore")
1340
1341        // First public repo in the list — no search field involved.
1342        let firstRepo = app.cells.firstMatch
1343        XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1344        firstRepo.tap()
1345        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1346                      "repo screen did not open")
1347        snap("repo")
1348
1349        selectTab("Feed")
1350        snap("feed")
1351
1352        selectTab("My Profile")
1353        XCTAssertTrue(app.staticTexts
1354            .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
1355            .waitForExistence(timeout: 20), "profile did not load")
1356        snap("profile")
1357    }
1358}