a native ios client for gitbay

client ios swift

https://gitbay.org

gitbayUITests/LiveSmokeUITests.swift

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

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