gitbayUITests/LiveSmokeUITests.swift

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

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