gitbayUITests/LiveSmokeUITests.swift

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

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