gitbayUITests/LiveSmokeUITests.swift

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

1375 lines · 66565 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        // The watch preference (v1.19.0) flips the same way.
 864        let watch = app.switches["Watch writable repositories"].firstMatch
 865        XCTAssertTrue(scrollTo(watch), "watch toggle not reachable")
 866        let innerWatch = watch.switches.firstMatch
 867        let flipWatch: () -> Void = {
 868            if innerWatch.exists && innerWatch != watch {
 869                innerWatch.tap()
 870            } else {
 871                watch.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
 872            }
 873        }
 874        let watchWasOn = (watch.value as? String) == "1"
 875        flipWatch()
 876        XCTAssertTrue(waitForValue(watch, watchWasOn ? "0" : "1", timeout: 10), "watch toggle did not persist")
 877        flipWatch()
 878        XCTAssertTrue(waitForValue(watch, watchWasOn ? "1" : "0", timeout: 10), "watch toggle did not restore")
 879
 880        // A bogus verification code is refused, not swallowed.
 881        let code = app.descendants(matching: .any)
 882            .matching(identifier: "email-code").firstMatch
 883        // Email sits below the keys on a List, which does not build rows
 884        // it has not shown.
 885        XCTAssertTrue(scrollTo(code), "email section not reachable")
 886        // The addresses list above the fields, the primary one marked.
 887        XCTAssertTrue(app.staticTexts["primary"].firstMatch.exists, "primary address not marked")
 888        focusAndType(code, "000000")
 889        let verify = app.descendants(matching: .any)
 890            .matching(identifier: "email-verify").firstMatch
 891        XCTAssertTrue(verify.waitForExistence(timeout: 5))
 892        XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed")
 893        verify.tap()
 894        XCTAssertTrue(app.staticTexts
 895            .containing(NSPredicate(format: "label CONTAINS 'invalid, expired'")).firstMatch
 896            .waitForExistence(timeout: 15), "bad-code refusal not surfaced")
 897    }
 898}
 899
 900extension LiveSmokeUITests {
 901
 902    /// Orgs: members render, and a team lives a full life — created,
 903    /// granted a repo, the grant revoked, the team deleted. Everything
 904    /// this test makes, it removes.
 905    func testOrgFlows() throws {
 906        openAccountScreen()
 907
 908        let orgRow = app.staticTexts["krz"].firstMatch
 909        XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
 910        orgRow.tap()
 911
 912        // Members render with roles.
 913        XCTAssertTrue(app.staticTexts["cmc"].firstMatch
 914            .waitForExistence(timeout: 15), "org members missing")
 915
 916        // Create a team.
 917        let teamField = app.descendants(matching: .any)
 918            .matching(identifier: "org-new-team").firstMatch
 919        XCTAssertTrue(teamField.waitForExistence(timeout: 5))
 920        focusAndType(teamField, "ui-smoke")
 921        app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
 922            .firstMatch.tap()
 923        let teamRow = app.staticTexts["ui-smoke"].firstMatch
 924        XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed")
 925
 926        // Grant it a repo, then revoke.
 927        teamRow.tap()
 928        let repoField = app.textFields["org/repo"].firstMatch
 929        XCTAssertTrue(repoField.waitForExistence(timeout: 10))
 930        focusAndType(repoField, "krz/gitbay-ios")
 931        app.buttons["Grant"].firstMatch.tap()
 932        let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
 933        XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
 934        grantRow.swipeLeft()
 935        app.buttons["Revoke"].firstMatch.tap()
 936        // The confirmation dialog's destructive Revoke.
 937        let confirmRevoke = app.buttons["Revoke"].firstMatch
 938        XCTAssertTrue(confirmRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
 939        confirmRevoke.tap()
 940        XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
 941
 942        back()
 943
 944        // Delete the team, through its confirmation.
 945        let row = app.staticTexts["ui-smoke"].firstMatch
 946        XCTAssertTrue(row.waitForExistence(timeout: 10))
 947        row.swipeLeft()
 948        app.buttons["Delete"].firstMatch.tap()
 949        // The confirmation dialog's destructive Delete.
 950        let confirm = app.buttons["Delete"].firstMatch
 951        XCTAssertTrue(confirm.waitForExistence(timeout: 5))
 952        confirm.tap()
 953        XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
 954    }
 955}
 956
 957extension LiveSmokeUITests {
 958
 959    /// The pre-merge screenshot checkpoint: walks the dense screens and
 960    /// attaches captures. Run per device/appearance; export the
 961    /// attachments from the xcresult. Signs in first when the device has
 962    /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided.
 963    func testScreenshotCheckpoint() throws {
 964        signInIfNeeded()
 965
 966        snap("dashboard")
 967
 968        openRepo("krz/gitbay")
 969        snap("repo")
 970
 971        app.staticTexts["Merge Requests"].firstMatch.tap()
 972        let all = app.segmentedControls.buttons["All"].firstMatch
 973        XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
 974        all.tap()
 975        // A real MR row carries its !number; the picker row does not.
 976        let mrNumber = app.staticTexts
 977            .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
 978        XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All")
 979        snap("mr-list")
 980
 981        mrNumber.tap()
 982        // The detail opens at its header; the diff row sits below the
 983        // fold and a List does not build rows it has not shown.
 984        let diffRow = app.staticTexts["Diff"].firstMatch
 985        XCTAssertTrue(scrollTo(diffRow), "MR detail has no diff row")
 986        snap("mr-detail")
 987
 988        diffRow.tap()
 989        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
 990        snap("diff")
 991    }
 992
 993    private func signInIfNeeded() {
 994        // Signed in already: the tab bar is up and there is no token field.
 995        if app.tabBars.firstMatch.waitForExistence(timeout: 10),
 996           !app.secureTextFields.firstMatch.exists {
 997            return
 998        }
 999        let tokenField = app.secureTextFields.firstMatch
1000        guard tokenField.waitForExistence(timeout: 5) else { return }
1001        guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else {
1002            XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
1003            return
1004        }
1005        focusAndType(tokenField, token + "\n")
1006        XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
1007            .waitForExistence(timeout: 20), "sign-in did not land")
1008        dismissSavePasswordPrompt()
1009    }
1010
1011    /// iOS offers to save whatever went into a SecureField, and the sheet
1012    /// sits over the app until answered — over the dashboard, in one set
1013    /// of store screenshots. It belongs to springboard, not to the app.
1014    private func dismissSavePasswordPrompt() {
1015        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
1016        let notNow = springboard.buttons["Not Now"].firstMatch
1017        if notNow.waitForExistence(timeout: 8) {
1018            notNow.tap()
1019            _ = waitForDisappearance(notNow, timeout: 10)
1020            return
1021        }
1022        let inApp = app.buttons["Not Now"].firstMatch
1023        if inApp.waitForExistence(timeout: 3) {
1024            inApp.tap()
1025            _ = waitForDisappearance(inApp, timeout: 10)
1026            return
1027        }
1028        // No prompt is fine: iOS only offers once per credential.
1029    }
1030
1031    /// Capture a screen, but not while it is still loading. These go to
1032    /// the App Store, and a spinner shipped as a screenshot once already.
1033    private func snap(_ name: String, file: StaticString = #filePath, line: UInt = #line) {
1034        let spinner = app.activityIndicators.firstMatch
1035        if spinner.exists {
1036            XCTAssertTrue(waitForDisappearance(spinner, timeout: 30),
1037                          "\(name) still loading after 30s", file: file, line: line)
1038        }
1039        // A list that has loaded has rows; an empty state has text. Either
1040        // way something must be on screen besides chrome.
1041        let content = app.cells.firstMatch
1042        _ = content.waitForExistence(timeout: 10)
1043
1044        let attachment = XCTAttachment(screenshot: app.screenshot())
1045        attachment.name = name
1046        attachment.lifetime = .keepAlways
1047        add(attachment)
1048    }
1049}
1050
1051extension LiveSmokeUITests {
1052
1053    /// Blame and file editing — the two capabilities that were web-only
1054    /// until krz/gitbay#42 and #43 made them commands. Blame is read-only
1055    /// on a real repo; the edit writes to a scratch repo the runner
1056    /// creates and deletes over SSH.
1057    func testBlameAndEditFlows() throws {
1058        // --- blame on a real file ---
1059        openRepo("krz/gitbay")
1060        app.staticTexts["Files"].firstMatch.tap()
1061        let goMod = app.staticTexts["go.mod"].firstMatch
1062        XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
1063        goMod.tap()
1064        XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu")
1065            .firstMatch.waitForExistence(timeout: 15), "file screen did not open")
1066        app.descendants(matching: .any).matching(identifier: "file-actions-menu")
1067            .firstMatch.tap()
1068        app.buttons["Blame"].firstMatch.tap()
1069        // Attribution shows the commit subject beside the lines.
1070        XCTAssertTrue(app.staticTexts
1071            .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch
1072            .waitForExistence(timeout: 20), "blame rendered no attribution")
1073
1074        // --- edit on the scratch repo ---
1075        // Relaunch rather than reuse the list: `.searchable` keeps the
1076        // previous query, and clearing it from the harness is unreliable.
1077        app.terminate()
1078        app.launch()
1079        openRepo("cmc/ui-smoke-edit")
1080        app.staticTexts["Files"].firstMatch.tap()
1081        let notes = app.staticTexts["notes.txt"].firstMatch
1082        XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
1083        notes.tap()
1084
1085        let menu = app.descendants(matching: .any)
1086            .matching(identifier: "file-actions-menu").firstMatch
1087        XCTAssertTrue(menu.waitForExistence(timeout: 15))
1088        menu.tap()
1089        let edit = app.buttons
1090            .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch
1091        XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing")
1092        edit.tap()
1093
1094        let content = app.descendants(matching: .any)
1095            .matching(identifier: "file-edit-content").firstMatch
1096        XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open")
1097        focusAndType(content, " edited from the app")
1098
1099        let message = app.descendants(matching: .any)
1100            .matching(identifier: "file-edit-message").firstMatch
1101        focusAndType(message, "edit from ios")
1102
1103        app.descendants(matching: .any).matching(identifier: "file-edit-commit")
1104            .firstMatch.tap()
1105        XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss")
1106        // The reloaded file shows the committed text.
1107        XCTAssertTrue(app.staticTexts
1108            .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch
1109            .waitForExistence(timeout: 20), "edit not reflected after commit")
1110    }
1111}
1112
1113
1114extension LiveSmokeUITests {
1115
1116    /// The navigation and profile changes: identity lives in one menu,
1117    /// the dashboard can create a repository, a profile is a real
1118    /// profile, and a log entry opens its commit.
1119    func testProfileAndNavigationFlows() throws {
1120        // --- the dashboard offers creation ---
1121        selectTab("Dashboard")
1122        let create = app.descendants(matching: .any)
1123            .matching(identifier: "dashboard-create-button").firstMatch
1124        XCTAssertTrue(create.waitForExistence(timeout: 15), "dashboard + missing")
1125        create.tap()
1126        let pathField = app.descendants(matching: .any)
1127            .matching(identifier: "repo-create-path").firstMatch
1128        XCTAssertTrue(pathField.waitForExistence(timeout: 10),
1129                      "dashboard + did not open the create sheet")
1130        app.buttons["Cancel"].firstMatch.tap()
1131
1132        // --- identity is its own tab; the account menu rides with it ---
1133        selectTab("My Profile")
1134        let menu = app.descendants(matching: .any)
1135            .matching(identifier: "account-menu").firstMatch
1136        XCTAssertTrue(menu.waitForExistence(timeout: 10),
1137                      "account menu missing from My Profile")
1138        menu.tap()
1139        XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
1140                      "keys not in the account menu")
1141        app.tap() // dismiss the menu; the profile is already on screen
1142
1143        // --- a profile is a profile: description, links, orgs, graph, repos ---
1144        XCTAssertTrue(app.staticTexts
1145            .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
1146            .waitForExistence(timeout: 20), "profile description missing")
1147        XCTAssertTrue(header("Organizations").exists,
1148                      "org memberships missing")
1149        XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing")
1150
1151        // --- an org admin can edit the org's profile ---
1152        app.staticTexts["krz"].firstMatch.tap()
1153        XCTAssertTrue(app.descendants(matching: .any)
1154            .matching(identifier: "profile-edit-button").firstMatch
1155            .waitForExistence(timeout: 15), "org profile offers no edit to its admin")
1156        back()
1157
1158        // The graph and the repositories sit below the orgs and links,
1159        // and a List does not build rows it has not shown.
1160        XCTAssertTrue(scrollTo(app.staticTexts
1161            .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch),
1162            "activity graph missing")
1163        XCTAssertTrue(scrollTo(app.staticTexts
1164            .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch),
1165            "repositories missing")
1166
1167        // --- a log entry opens its commit ---
1168        app.terminate()
1169        app.launch()
1170        openRepo("krz/gitbay")
1171        app.staticTexts["History"].firstMatch.tap()
1172        let firstCommit = app.cells.firstMatch
1173        XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
1174        firstCommit.tap()
1175        // The commit screen IS the patch: no navigating away to find it.
1176        // A hunk header (@@) only appears in a rendered diff, and sits
1177        // below the message and the file list.
1178        let hunk = app.staticTexts
1179            .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch
1180        _ = hunk.waitForExistence(timeout: 20)
1181        XCTAssertTrue(scrollTo(hunk, swipes: 10),
1182                      "commit screen did not render the patch inline")
1183    }
1184}
1185
1186extension LiveSmokeUITests {
1187
1188    /// The three gaps that needed no server work: branches and tags,
1189    /// milestones, and an MR's milestone. Read-only except the MR
1190    /// milestone, which is set and cleared back.
1191    func testRefsAndMilestoneFlows() throws {
1192        openRepo("krz/gitbay")
1193
1194        // --- branches and tags, and browsing at a ref ---
1195        app.staticTexts["Branches & Tags"].firstMatch.tap()
1196        let main = app.staticTexts["main"].firstMatch
1197        XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1198        XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
1199                      "the default branch is not marked")
1200        XCTAssertTrue(header("Tags").exists, "tags section missing")
1201        main.tap()
1202        // Tapping a ref browses the repository there.
1203        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1204                      "browsing at a ref showed nothing")
1205        back()
1206        back()
1207
1208        // --- milestones with progress ---
1209        app.staticTexts["Milestones"].firstMatch.tap()
1210        XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
1211                      "milestones did not load")
1212        app.segmentedControls.buttons["All"].firstMatch.tap()
1213        XCTAssertTrue(app.staticTexts
1214            .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch
1215            .waitForExistence(timeout: 20), "milestone progress missing")
1216        back()
1217
1218        // --- an MR's milestone reads back, and can be cleared ---
1219        app.staticTexts["Merge Requests"].firstMatch.tap()
1220        app.segmentedControls.buttons["All"].firstMatch.tap()
1221        let mrNumber = app.staticTexts
1222            .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
1223        XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs")
1224        mrNumber.tap()
1225        let milestone = app.descendants(matching: .any)
1226            .matching(identifier: "mr-milestone-menu").firstMatch
1227        // The nav bar is the "opened" signal; everything else on this
1228        // screen can be below the fold.
1229        XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20),
1230                      "MR detail did not open")
1231        XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing")
1232        milestone.tap()
1233        // The picker offers None plus the open milestones.
1234        XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10),
1235                      "milestone picker did not open")
1236        app.buttons["None"].firstMatch.tap()
1237    }
1238}
1239
1240extension LiveSmokeUITests {
1241
1242    /// Explore and history at a ref — the two capabilities krz/gitbay!101
1243    /// made reachable. Read-only.
1244    func testExploreAndRefLogFlows() throws {
1245        // --- explore lists what the instance hosts ---
1246        selectTab("Explore")
1247        let firstRepo = app.cells.firstMatch
1248        XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1249        // Public repos this account does not own are reachable here.
1250        XCTAssertTrue(app.staticTexts
1251            .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
1252            .waitForExistence(timeout: 10), "explore is not the public listing")
1253        firstRepo.tap()
1254        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1255                      "explore row did not open its repo")
1256
1257        // --- history at a ref ---
1258        app.terminate()
1259        app.launch()
1260        openRepo("krz/gitbay")
1261        // Browse a ref, then ask for its history — the move the tree page
1262        // offers on the web.
1263        app.staticTexts["Branches & Tags"].firstMatch.tap()
1264        let main = app.staticTexts["main"].firstMatch
1265        XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1266        main.tap()
1267        let history = app.descendants(matching: .any)
1268            .matching(identifier: "tree-history-button").firstMatch
1269        XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree")
1270        history.tap()
1271        XCTAssertTrue(app.navigationBars
1272            .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch
1273            .waitForExistence(timeout: 20), "ref history did not open")
1274        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1275                      "ref history rendered no commits")
1276    }
1277}
1278
1279extension LiveSmokeUITests {
1280
1281    /// The wiki — the last capability that was browser-only until
1282    /// krz/gitbay#48. Read-only; editing is a push, on every surface.
1283    func testWikiFlows() throws {
1284        openRepo("krz/gitbay")
1285        app.staticTexts["Wiki"].firstMatch.tap()
1286
1287        // The landing page leads and is marked.
1288        let home = app.staticTexts["Home"].firstMatch
1289        XCTAssertTrue(home.waitForExistence(timeout: 20), "wiki pages did not load")
1290        XCTAssertTrue(app.staticTexts["home"].firstMatch.exists,
1291                      "the landing page is not marked")
1292
1293        // Parity.org is an org page, so this also proves the org renderer
1294        // runs on wiki content and not just READMEs.
1295        let parity = app.staticTexts["Parity"].firstMatch
1296        XCTAssertTrue(parity.exists, "Parity page missing from the listing")
1297        parity.tap()
1298        XCTAssertTrue(app.staticTexts
1299            .containing(NSPredicate(format: "label CONTAINS 'surface'")).firstMatch
1300            .waitForExistence(timeout: 20), "wiki page rendered no prose")
1301        // Org headings render as headings, not as raw #+title:.
1302        XCTAssertFalse(app.staticTexts
1303            .containing(NSPredicate(format: "label CONTAINS '#+title'")).firstMatch.exists,
1304                       "org markup leaked into the rendered page")
1305    }
1306}
1307
1308
1309extension LiveSmokeUITests {
1310
1311    /// Builds on a repo that has a job file: the picker offers what the
1312    /// server names, and the detail screen says more than the log.
1313    func testBuildJobsAndDetail() throws {
1314        openRepo("krz/gitbay")
1315        app.staticTexts["Builds"].firstMatch.tap()
1316
1317        // The picker lists jobs from `build jobs` — no name to type.
1318        let trigger = app.descendants(matching: .any)
1319            .matching(identifier: "build-trigger-button").firstMatch
1320        XCTAssertTrue(trigger.waitForExistence(timeout: 20), "trigger control missing")
1321        XCTAssertTrue(trigger.isEnabled, "trigger is disabled where a job file exists")
1322        trigger.tap()
1323        let job = app.buttons
1324            .containing(NSPredicate(format: "label CONTAINS 'build' AND label CONTAINS 'on push'"))
1325            .firstMatch
1326        XCTAssertTrue(job.waitForExistence(timeout: 10),
1327                      "job menu did not list the job with why it runs")
1328        // Dismiss without queueing a real build.
1329        app.tap()
1330
1331        // The detail screen carries status, ref and timing, not just log.
1332        let firstBuild = app.cells.firstMatch
1333        XCTAssertTrue(firstBuild.waitForExistence(timeout: 20), "no builds listed")
1334        firstBuild.tap()
1335        for label in ["success", "queued"] where app.staticTexts[label].firstMatch.exists {
1336            XCTAssertTrue(true)
1337        }
1338        XCTAssertTrue(app.staticTexts
1339            .containing(NSPredicate(format: "label BEGINSWITH 'queued '")).firstMatch
1340            .waitForExistence(timeout: 20), "build detail shows no timing")
1341    }
1342}
1343
1344extension LiveSmokeUITests {
1345
1346    /// Store screenshots on iPad. It navigates by tab and through
1347    /// Explore rather than reusing the iPhone checkpoint: that one drives
1348    /// the repo list's `.searchable`, which iPadOS lays out differently,
1349    /// and chasing those differences is the iPad pass this release
1350    /// deferred. Tabs and a list row work on both.
1351    func testIPadScreenshotCheckpoint() throws {
1352        signInIfNeeded()
1353        snap("dashboard")
1354
1355        selectTab("Explore")
1356        snap("explore")
1357
1358        // First public repo in the list — no search field involved.
1359        let firstRepo = app.cells.firstMatch
1360        XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1361        firstRepo.tap()
1362        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1363                      "repo screen did not open")
1364        snap("repo")
1365
1366        selectTab("Feed")
1367        snap("feed")
1368
1369        selectTab("My Profile")
1370        XCTAssertTrue(app.staticTexts
1371            .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
1372            .waitForExistence(timeout: 20), "profile did not load")
1373        snap("profile")
1374    }
1375}