gitbayUITests/LiveSmokeUITests.swift

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

1913 lines · 95112 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 run as the `ios-smoke` account, a krz admin that owns the
  14/// scratch and fixture repositories (smoke-account.sh provisions it).
  15/// Everything they write goes to those, never to krz/gitbay-ios. A
  16/// signed-out device is signed in at setUp from
  17/// TEST_RUNNER_GITBAY_UITEST_TOKEN.
  18final class LiveSmokeUITests: XCTestCase {
  19
  20    private var app: XCUIApplication!
  21
  22    /// The scratch repo testRepoManagementFlows creates. A run that dies
  23    /// before deleting it leaves it behind, and then every later run
  24    /// fails at "create sheet did not dismiss" — the create is refused
  25    /// because the name is taken, which reads like a UI bug and is not.
  26    /// Removed before and after, so neither a crashed run nor this one
  27    /// can strand it.
  28    private static let scratchRepo = "\(smokeUser)/ui-smoke"
  29
  30    /// The account the suite runs as. smoke-account.sh creates it with
  31    /// the profile description, PGP key and fixture repository the
  32    /// assertions below read back.
  33    static let smokeUser = "ios-smoke"
  34    static let smokeProfileDescription = "Live UI smoke account for gitbay-ios"
  35    static let smokePGPEmail = "ios-smoke+pgp@gitbay.org"
  36
  37    /// The repository the write flows work in: the fixture issue and
  38    /// merge requests, and the file testBlameAndEditFlows edits.
  39    /// smoke-account.sh seeds it, with `main` and `ui-smoke` divergent.
  40    static let fixtureRepo = "\(smokeUser)/ui-smoke-edit"
  41
  42    override func setUpWithError() throws {
  43        try XCTSkipUnless(
  44            ProcessInfo.processInfo.environment["GITBAY_UITEST_LIVE"] == "1",
  45            "live UI smoke is opt-in; set TEST_RUNNER_GITBAY_UITEST_LIVE=1"
  46        )
  47        continueAfterFailure = false
  48        app = XCUIApplication()
  49        // xcodebuild runs tests on simulator clones, so the base
  50        // device's appearance never applies; force it per run.
  51        switch ProcessInfo.processInfo.environment["GITBAY_UITEST_DARK"] {
  52        case "1": app.launchArguments.append("-gb-dark")
  53        case "0": app.launchArguments.append("-gb-light")
  54        default: break // follow the device, as a real launch does
  55        }
  56        app.launch()
  57        // A fresh device — a clone on another runtime, say — starts on
  58        // the token screen; the flows assume a signed-in app.
  59        signInIfNeeded()
  60        if name.contains("testRepoManagementFlows") {
  61            deleteScratchRepo()
  62        }
  63        if name.contains("testCreationAndEditingFlows") {
  64            try requireFixtureRepo()
  65            ensureFixtureMilestone()
  66            ensureFixtureIssue()
  67            try requireFixtureBranch()
  68        }
  69        if name.contains("testBlameAndEditFlows") {
  70            try requireFixtureRepo()
  71        }
  72        if name.contains("testSnippetFlows") {
  73            deleteSmokeSnippets()
  74        }
  75        if name.contains("testOrgLabelAndMilestoneFlows") {
  76            ensureOrgFixtures()
  77        }
  78        if name.contains("testCloseInFavourFlow") {
  79            try requireFixtureRepo()
  80            try requireFixtureBranch()
  81            closeSmokeMergeRequests()
  82            try openSupersedeFixtures()
  83        }
  84        if name.contains("testMRLabelFlow") {
  85            try requireFixtureRepo()
  86            try requireFixtureBranch()
  87            closeSmokeMergeRequests()
  88            _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main",
  89                            "--title", Self.labelledTitle])
  90            _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "main", "--target", "ui-smoke",
  91                            "--title", Self.unlabelledTitle])
  92            let open = openSmokeMergeRequests().map { $0.title }
  93            try XCTSkipUnless(open.contains(Self.labelledTitle) && open.contains(Self.unlabelledTitle),
  94                              "could not open both label fixture merge requests on \(Self.fixtureRepo)")
  95        }
  96    }
  97
  98    override func tearDownWithError() throws {
  99        if name.contains("testRepoManagementFlows") {
 100            deleteScratchRepo()
 101        }
 102        if name.contains("testSnippetFlows") {
 103            deleteSmokeSnippets()
 104        }
 105        if name.contains("testCloseInFavourFlow") {
 106            closeSmokeMergeRequests()
 107        }
 108        if name.contains("testMRLabelFlow") {
 109            closeSmokeMergeRequests()
 110            _ = runCommand(["label", "remove", Self.fixtureRepo, Self.smokeMRLabel])
 111        }
 112    }
 113
 114    // MARK: - Fixtures for the v1.2.0 flows
 115
 116    static let smokeSnippetDescription = "UI smoke snippet"
 117
 118    /// Remove every snippet the flow made, before and after, so a run
 119    /// that died mid-way cannot strand one or hit the per-account cap.
 120    private func deleteSmokeSnippets() {
 121        guard let data = readCommand(["snippet", "list"]),
 122              let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
 123              let rows = envelope["data"] as? [[String: Any]] else { return }
 124        for row in rows where row["description"] as? String == Self.smokeSnippetDescription {
 125            if let id = row["id"] as? String {
 126                _ = runCommand(["snippet", "delete", id])
 127            }
 128        }
 129    }
 130
 131    static let smokeOrg = "krz"
 132    static let smokeOrgLabel = "ui-smoke-label"
 133    static let smokeOrgMilestone = "ui-smoke milestone"
 134
 135    /// The org milestone is a fixture, reused and left closed: the
 136    /// server has no milestone delete, so creating one per run would
 137    /// pile them up. The label is removed by the flow itself; a stale
 138    /// one is removed here first.
 139    private func ensureOrgFixtures() {
 140        _ = runCommand(["org", "label", "remove", Self.smokeOrg, Self.smokeOrgLabel])
 141        guard let data = readCommand(["org", "milestone", "list", Self.smokeOrg, "--state", "all"]),
 142              let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
 143        else { return }
 144        let rows = envelope["data"] as? [[String: Any]] ?? []
 145        if let row = rows.first(where: { $0["title"] as? String == Self.smokeOrgMilestone }) {
 146            if row["state"] as? String == "open" {
 147                _ = runCommand(["org", "milestone", "close", Self.smokeOrg, Self.smokeOrgMilestone])
 148            }
 149        } else {
 150            _ = runCommand(["org", "milestone", "create", Self.smokeOrg, Self.smokeOrgMilestone])
 151            _ = runCommand(["org", "milestone", "close", Self.smokeOrg, Self.smokeOrgMilestone])
 152        }
 153    }
 154
 155    static let supersededTitle = "UI smoke: superseded"
 156    static let successorTitle = "UI smoke: successor"
 157    static let labelledTitle = "UI smoke: labels"
 158    static let unlabelledTitle = "UI smoke: unlabelled"
 159    static let smokeMRLabel = "ui-smoke-mr-label"
 160
 161    /// Close in favour needs two open merge requests. The fixture branch
 162    /// gives one (ui-smoke -> main); main has commits ui-smoke lacks, so
 163    /// the reverse (main -> ui-smoke) is the other. Both are the API's
 164    /// to open; the flow closes the first from the app and tearDown
 165    /// closes whatever is left.
 166    private func openSupersedeFixtures() throws {
 167        _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main",
 168                        "--title", Self.supersededTitle])
 169        _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "main", "--target", "ui-smoke",
 170                        "--title", Self.successorTitle])
 171        let open = openSmokeMergeRequests().map { $0.title }
 172        try XCTSkipUnless(open.contains(Self.supersededTitle) && open.contains(Self.successorTitle), """
 173            could not open both fixture merge requests on \(Self.fixtureRepo) \
 174            (have \(open)); the ui-smoke branch may be level with main.
 175            """)
 176    }
 177
 178    private func openSmokeMergeRequests() -> [(number: Int, title: String)] {
 179        guard let data = readCommand(["mr", "list", Self.fixtureRepo, "--state", "open"]),
 180              let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
 181              let rows = envelope["data"] as? [[String: Any]] else { return [] }
 182        return rows.compactMap { row in
 183            guard let number = row["number"] as? Int, let title = row["title"] as? String,
 184                  title.hasPrefix("UI smoke:") else { return nil }
 185            return (number, title)
 186        }
 187    }
 188
 189    private func closeSmokeMergeRequests() {
 190        for mr in openSmokeMergeRequests() {
 191            _ = runCommand(["mr", "close", Self.fixtureRepo, String(mr.number)])
 192        }
 193    }
 194
 195    /// `repo delete` over the JSON API — the app has no delete screen
 196    /// (deletion is SSH-only by design), so cleanup cannot go through
 197    /// the UI. A repo that is not there answers exit 3, which is the
 198    /// outcome we want anyway.
 199    private func deleteScratchRepo() {
 200        _ = runCommand(["repo", "delete", Self.scratchRepo, "--yes"])
 201        // The rename round-trip's halfway name, in case a run died there.
 202        _ = runCommand(["repo", "delete", Self.scratchRepo + "-2", "--yes"])
 203    }
 204
 205    /// The milestone the editing flow assigns. Created rather than
 206    /// required: unlike a branch, the API can make one.
 207    static let fixtureMilestone = "v1.0.0"
 208
 209    private func ensureFixtureMilestone() {
 210        guard let data = readCommand(["milestone", "list", Self.fixtureRepo, "--state", "all"]),
 211              let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
 212        else { return }
 213        let rows = envelope["data"] as? [[String: Any]] ?? []
 214        if rows.contains(where: { $0["title"] as? String == Self.fixtureMilestone }) { return }
 215        _ = runCommand(["milestone", "create", Self.fixtureRepo, Self.fixtureMilestone])
 216    }
 217
 218    /// The issue the editing flow works on. It is a fixture, not a real
 219    /// issue: pinning the test to whatever issue happened to be open put
 220    /// it at the mercy of the project moving on, and it duly broke when
 221    /// that issue was closed (krz/gitbay-ios#8).
 222    static let fixtureIssueTitle = "ui-smoke fixture: do not close"
 223
 224    /// Guarantee the fixture issue exists and is open. It is reused
 225    /// rather than recreated, so runs do not pile up closed issues.
 226    private func ensureFixtureIssue() {
 227        guard let data = readCommand(
 228            ["issue", "list", Self.fixtureRepo, "--state", "open"]),
 229            let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
 230        else { return }
 231
 232        let issues = envelope["data"] as? [[String: Any]] ?? []
 233        if issues.contains(where: { $0["title"] as? String == Self.fixtureIssueTitle }) {
 234            return
 235        }
 236        _ = runCommand(
 237            ["issue", "create", Self.fixtureRepo, "--title", Self.fixtureIssueTitle, "--file", "-"],
 238            stdin: "Created by the live smoke suite. It exercises the milestone "
 239                 + "picker and the edit sheet, and is reused every run."
 240        )
 241    }
 242
 243    /// The MR flow needs a source branch carrying a commit main does not
 244    /// have. Nothing in the app or the API can create a branch — that is
 245    /// a push — so this only checks, and says so plainly rather than
 246    /// letting the test fail later at "created MR not in the list".
 247    private func requireFixtureBranch() throws {
 248        guard let data = readCommand(["repo", "refs", Self.fixtureRepo]),
 249              let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
 250              let refs = envelope["data"] as? [String: Any],
 251              let branches = refs["branches"] as? [[String: Any]]
 252        else { return } // no token, or the instance is unreachable
 253        let names = branches.compactMap { $0["name"] as? String }
 254        try XCTSkipUnless(names.contains("ui-smoke"), """
 255            \(Self.fixtureRepo) has no ui-smoke branch. The MR flows open \
 256            one merge request each way between it and main, so each side \
 257            needs a commit the other lacks; smoke-account.sh seeds both.
 258            """)
 259    }
 260
 261    /// POST /api/v1/cmd. Writes the suite needs but the app cannot make.
 262    @discardableResult
 263    private func runCommand(_ argv: [String], stdin: String? = nil) -> Data? {
 264        var body: [String: Any] = ["argv": argv]
 265        if let stdin { body["stdin"] = stdin }
 266        return call(method: "POST", path: "/api/v1/cmd", body: body)
 267    }
 268
 269    /// GET /api/v1/read, for the reads that decide what a test sets up.
 270    private func readCommand(_ argv: [String]) -> Data? {
 271        let query = argv
 272            .map { "argv=" + ($0.addingPercentEncoding(
 273                withAllowedCharacters: .alphanumerics) ?? $0) }
 274            .joined(separator: "&")
 275        return call(method: "GET", path: "/api/v1/read?" + query, body: nil)
 276    }
 277
 278    private func call(method: String, path: String, body: [String: Any]?) -> Data? {
 279        guard let token = ProcessInfo.processInfo
 280            .environment["GITBAY_UITEST_TOKEN"], !token.isEmpty,
 281            let url = URL(string: "https://gitbay.org" + path)
 282        else { return nil }
 283
 284        var request = URLRequest(url: url)
 285        request.httpMethod = method
 286        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
 287        if let body {
 288            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 289            request.httpBody = try? JSONSerialization.data(withJSONObject: body)
 290        }
 291
 292        var result: Data?
 293        let done = DispatchSemaphore(value: 0)
 294        URLSession.shared.dataTask(with: request) { data, _, _ in
 295            result = data
 296            done.signal()
 297        }.resume()
 298        _ = done.wait(timeout: .now() + 15)
 299        return result
 300    }
 301
 302    /// Everything in one ordered pass: milestone assign, edit-save,
 303    /// MR create, MR close. One test so the flows share navigation and
 304    /// the writes happen exactly once.
 305    func testCreationAndEditingFlows() throws {
 306        openRepo(Self.fixtureRepo)
 307
 308        // --- the fixture issue: milestone picker assigns the fixture ---
 309        app.staticTexts["Issues"].firstMatch.tap()
 310        let issueRow = app.staticTexts
 311            .containing(NSPredicate(format: "label CONTAINS 'ui-smoke fixture'")).firstMatch
 312        XCTAssertTrue(issueRow.waitForExistence(timeout: 10),
 313                      "fixture issue not in the list; setUp should have created it")
 314        issueRow.tap()
 315
 316        let milestoneMenu = app.descendants(matching: .any)
 317            .matching(identifier: "milestone-menu").firstMatch
 318        XCTAssertTrue(milestoneMenu.waitForExistence(timeout: 10))
 319        milestoneMenu.tap()
 320        let milestoneChoice = app.buttons
 321            .containing(NSPredicate(format: "label BEGINSWITH %@", Self.fixtureMilestone)).firstMatch
 322        XCTAssertTrue(milestoneChoice.waitForExistence(timeout: 5), "milestone menu did not open")
 323        milestoneChoice.tap()
 324        // The reload renders the assigned milestone in the header row.
 325        XCTAssertTrue(app.staticTexts[Self.fixtureMilestone].firstMatch
 326            .waitForExistence(timeout: 10), "milestone not shown after assign")
 327
 328        // --- the fixture issue: edit sheet saves (content unchanged) ---
 329        app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
 330            .firstMatch.tap()
 331        let edit = app.buttons["Edit"].firstMatch
 332        XCTAssertTrue(edit.waitForExistence(timeout: 5))
 333        edit.tap()
 334        let title = app.descendants(matching: .any)
 335            .matching(identifier: "compose-title").firstMatch
 336        XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
 337        XCTAssertTrue((title.value as? String)?.contains("ui-smoke fixture") == true,
 338                      "edit sheet did not prefill")
 339        // Save the body as org, reopen, and the picker must start on Org:
 340        // the sheet has to read the stored format, not its own default.
 341        let format = app.segmentedControls.matching(identifier: "compose-format").firstMatch
 342        XCTAssertTrue(format.waitForExistence(timeout: 5), "format picker missing")
 343        XCTAssertTrue(format.buttons["Markdown"].isSelected, "fixture did not open on Markdown")
 344        format.buttons["Org"].tap()
 345        app.descendants(matching: .any).matching(identifier: "compose-submit")
 346            .firstMatch.tap()
 347        // Sheet dismissal proves the save round-tripped without error.
 348        XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
 349
 350        app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
 351            .firstMatch.tap()
 352        XCTAssertTrue(edit.waitForExistence(timeout: 5))
 353        edit.tap()
 354        XCTAssertTrue(format.waitForExistence(timeout: 5), "edit sheet did not reopen")
 355        XCTAssertTrue(format.buttons["Org"].isSelected, "edit sheet did not open on the stored format")
 356        // Restore the fixture to markdown.
 357        format.buttons["Markdown"].tap()
 358        app.descendants(matching: .any).matching(identifier: "compose-submit")
 359            .firstMatch.tap()
 360        XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
 361
 362        back() // to issues list
 363        back() // to repo screen
 364
 365        // --- MR: create from ui-smoke, then close it ---
 366        app.staticTexts["Merge Requests"].firstMatch.tap()
 367        app.descendants(matching: .any).matching(identifier: "mr-create-button")
 368            .firstMatch.tap()
 369
 370        let source = app.descendants(matching: .any)
 371            .matching(identifier: "mr-source").firstMatch
 372        XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open")
 373        focusAndType(source, "ui-smoke")
 374
 375        let target = app.descendants(matching: .any)
 376            .matching(identifier: "mr-target").firstMatch
 377        // The default branch prefilled while the sheet loaded.
 378        XCTAssertEqual(target.value as? String, "main", "target did not prefill")
 379
 380        let mrTitle = app.descendants(matching: .any)
 381            .matching(identifier: "mr-title").firstMatch
 382        focusAndType(mrTitle, "UI smoke: mr create from the app")
 383
 384        app.descendants(matching: .any).matching(identifier: "mr-submit")
 385            .firstMatch.tap()
 386
 387        let createdRow = app.staticTexts
 388            .containing(NSPredicate(format: "label CONTAINS 'UI smoke'")).firstMatch
 389        XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list")
 390        createdRow.tap()
 391
 392        // The gates are shown before any merge is tried.
 393        let gates = header("Gates")
 394        XCTAssertTrue(scrollTo(gates, swipes: 8), "gates section missing")
 395
 396        // A fresh MR has one revision; the section lists it.
 397        let revisions = header("Revisions")
 398        XCTAssertTrue(scrollTo(revisions, swipes: 8), "revisions section missing")
 399        XCTAssertTrue(app.staticTexts["v1"].firstMatch.exists, "first revision not listed")
 400
 401        app.descendants(matching: .any).matching(identifier: "mr-actions-menu")
 402            .firstMatch.tap()
 403        let close = app.buttons["Close"].firstMatch
 404        XCTAssertTrue(close.waitForExistence(timeout: 5))
 405        close.tap()
 406        // The confirmation dialog's destructive Close.
 407        let confirm = app.buttons["Close"].firstMatch
 408        XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing")
 409        confirm.tap()
 410
 411        XCTAssertTrue(app.staticTexts["closed"].firstMatch
 412            .waitForExistence(timeout: 15), "MR did not show closed after close")
 413    }
 414
 415    // MARK: - Helpers
 416
 417    private func openRepo(_ path: String) {
 418        selectTab("Repositories")
 419
 420        // `.searchable` keeps the field tucked above the list until it is
 421        // scrolled into view.
 422        let search = app.searchFields.firstMatch
 423        if !search.waitForExistence(timeout: 5) {
 424            app.swipeDown()
 425            XCTAssertTrue(search.waitForExistence(timeout: 10), "search field never appeared")
 426        }
 427        focusAndType(search, path)
 428
 429        let row = app.staticTexts[path].firstMatch
 430        XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
 431        row.tap()
 432        // Repo screen is loaded once its links render.
 433        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10),
 434                      "repo screen did not open")
 435    }
 436
 437    private func back() {
 438        app.navigationBars.buttons.firstMatch.tap()
 439    }
 440
 441    /// Keys, PGP and email live behind the account menu, which is on the
 442    /// My Profile tab and nowhere else.
 443    func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
 444        selectTab("My Profile")
 445        let menu = app.descendants(matching: .any)
 446            .matching(identifier: "account-menu").firstMatch
 447        XCTAssertTrue(menu.waitForExistence(timeout: 15),
 448                      "account menu missing", file: file, line: line)
 449        menu.tap()
 450        let keys = app.buttons["Keys & Email"].firstMatch
 451        XCTAssertTrue(keys.waitForExistence(timeout: 5),
 452                      "Keys & Email missing from the account menu", file: file, line: line)
 453        keys.tap()
 454    }
 455
 456    /// Seeding a repository's first commit is a push, which no test can
 457    /// make, so this only checks — and says why, rather than failing
 458    /// later at "ios-smoke/ui-smoke-edit not in the repo list".
 459    private func requireFixtureRepo() throws {
 460        guard let data = readCommand(["repo", "show", Self.fixtureRepo]),
 461              let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
 462        else { return } // no token, or the instance is unreachable
 463        try XCTSkipUnless(envelope["data"] != nil, """
 464            \(Self.fixtureRepo) is missing. The write flows work in it; \
 465            gitbayUITests/smoke-account.sh seeds it (or push it by hand, \
 466            see that script) and run again.
 467            """)
 468    }
 469
 470    /// Scroll a list until an element is in the hierarchy. Offscreen
 471    /// rows do not exist to XCUITest, so waitForExistence alone fails on
 472    /// anything below the fold.
 473    @discardableResult
 474    /// A list section header. iOS 18 renders these uppercased and iOS 26
 475    /// as written, so the match ignores case.
 476    func header(_ text: String) -> XCUIElement {
 477        app.staticTexts.matching(NSPredicate(format: "label ==[c] %@", text)).firstMatch
 478    }
 479
 480    func scrollTo(_ element: XCUIElement, swipes: Int = 6) -> Bool {
 481        for _ in 0..<swipes {
 482            if element.exists { return true }
 483            app.swipeUp()
 484        }
 485        return element.exists
 486    }
 487
 488    /// Switch tabs and wait until that tab is actually front. A tap
 489    /// dispatched before the app is interactive — which happens right
 490    /// after launch when there is no sign-in to slow things down — is
 491    /// swallowed silently, so this taps until the tab reports selected.
 492    func selectTab(_ name: String,
 493                   file: StaticString = #filePath, line: UInt = #line) {
 494        // iPadOS renders a TabView as a sidebar or top bar rather than a
 495        // bottom tab bar, so the control is not under app.tabBars there.
 496        var tab = app.tabBars.buttons[name].firstMatch
 497        if !tab.waitForExistence(timeout: 5) {
 498            tab = app.buttons[name].firstMatch
 499        }
 500        XCTAssertTrue(tab.waitForExistence(timeout: 15),
 501                      "\(name) tab missing", file: file, line: line)
 502        // The app is interactive once its own chrome is hittable.
 503        XCTAssertTrue(tab.isHittable || tab.waitForExistence(timeout: 5),
 504                      "\(name) tab never became hittable", file: file, line: line)
 505
 506        for _ in 0..<4 {
 507            if tab.isSelected, app.navigationBars[name].firstMatch.exists { return }
 508            tab.tap()
 509            if app.navigationBars[name].firstMatch.waitForExistence(timeout: 5) { return }
 510        }
 511        XCTFail("\(name) tab did not come to front", file: file, line: line)
 512    }
 513
 514    /// Empty a text field before typing. Search fields keep their query
 515    /// across navigation, and the clear button is not reliably
 516    /// addressable, so this deletes character by character.
 517    func clearAndType(_ element: XCUIElement, _ text: String,
 518                      file: StaticString = #filePath, line: UInt = #line) {
 519        element.tap()
 520        if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
 521            element.tap()
 522            XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
 523                          "keyboard never appeared", file: file, line: line)
 524        }
 525        if let existing = element.value as? String,
 526           existing != element.placeholderValue, !existing.isEmpty {
 527            element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue,
 528                                    count: existing.count))
 529        }
 530        element.typeText(text)
 531    }
 532
 533    /// Tap a field and type into it, surviving the focus race: a tap can
 534    /// land before the field is ready, and the keystrokes go nowhere.
 535    func focusAndType(_ element: XCUIElement, _ text: String,
 536                      file: StaticString = #filePath, line: UInt = #line) {
 537        element.tap()
 538        if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
 539            element.tap()
 540            XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
 541                          "keyboard never appeared for \(element)", file: file, line: line)
 542        }
 543        element.typeText(text)
 544    }
 545
 546    private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
 547        let predicate = NSPredicate(format: "exists == false")
 548        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
 549        return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
 550    }
 551}
 552
 553extension LiveSmokeUITests {
 554
 555    /// Repo management, kept reversible: create a scratch repo (deleted
 556    /// by the runner afterwards, deletion is CLI-only by design), pin and
 557    /// unpin, a topic round-trip, a merge-rule toggle round-trip, and the
 558    /// build-trigger error path on a repo with no job config.
 559    func testRepoManagementFlows() throws {
 560        // --- repo create first: it ends in the list's search state,
 561        // which the next step reuses. (Scratch repo; the runner deletes
 562        // it over SSH afterwards — deletion is CLI-only by design.)
 563        selectTab("Repositories")
 564        app.descendants(matching: .any).matching(identifier: "repo-create-button")
 565            .firstMatch.tap()
 566        let newRepo = app.buttons["New Repository"].firstMatch
 567        XCTAssertTrue(newRepo.waitForExistence(timeout: 5), "create menu did not open")
 568        newRepo.tap()
 569        let pathField = app.descendants(matching: .any)
 570            .matching(identifier: "repo-create-path").firstMatch
 571        XCTAssertTrue(pathField.waitForExistence(timeout: 5))
 572        focusAndType(pathField, "ui-smoke")
 573        app.switches.firstMatch.tap() // Private on
 574        app.descendants(matching: .any).matching(identifier: "repo-create-submit")
 575            .firstMatch.tap()
 576        // The sheet dismissing proves the create round-tripped; rows are
 577        // lazy, so find the new repo through the filter.
 578        XCTAssertTrue(waitForDisappearance(pathField, timeout: 15),
 579                      "create sheet did not dismiss")
 580        let createSearch = app.searchFields.firstMatch
 581        XCTAssertTrue(createSearch.waitForExistence(timeout: 10))
 582        focusAndType(createSearch, "ui-smoke")
 583        XCTAssertTrue(app.staticTexts[Self.scratchRepo].firstMatch
 584            .waitForExistence(timeout: 15), "created repo not in the list")
 585
 586        // --- access: grant a user on the scratch repo, then revoke ---
 587        app.staticTexts[Self.scratchRepo].firstMatch.tap()
 588        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
 589        app.staticTexts["Settings"].firstMatch.tap()
 590        let grantUser = app.descendants(matching: .any)
 591            .matching(identifier: "settings-grant-user").firstMatch
 592        XCTAssertTrue(scrollTo(grantUser, swipes: 8), "access section not reachable")
 593        focusAndType(grantUser, "apple-review")
 594        app.descendants(matching: .any).matching(identifier: "settings-grant-submit")
 595            .firstMatch.tap()
 596        let grantRow = app.staticTexts["apple-review"].firstMatch
 597        XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
 598        grantRow.swipeLeft()
 599        app.buttons["Revoke"].firstMatch.tap()
 600        let confirmGrantRevoke = app.buttons["Revoke"].firstMatch
 601        XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
 602        confirmGrantRevoke.tap()
 603        XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
 604
 605        // --- webhooks: add one, see it listed, remove it ---
 606        let webhooksLink = app.descendants(matching: .any)
 607            .matching(identifier: "settings-webhooks-link").firstMatch
 608        XCTAssertTrue(scrollTo(webhooksLink, swipes: 8), "webhooks link not reachable")
 609        webhooksLink.tap()
 610        let addHook = app.descendants(matching: .any)
 611            .matching(identifier: "webhook-add-button").firstMatch
 612        XCTAssertTrue(addHook.waitForExistence(timeout: 10), "webhooks did not load")
 613        addHook.tap()
 614        let hookURL = app.descendants(matching: .any)
 615            .matching(identifier: "webhook-url").firstMatch
 616        XCTAssertTrue(hookURL.waitForExistence(timeout: 5), "webhook sheet did not open")
 617        focusAndType(hookURL, "https://example.com/ui-smoke")
 618        app.descendants(matching: .any).matching(identifier: "webhook-submit")
 619            .firstMatch.tap()
 620        let hookRow = app.staticTexts["https://example.com/ui-smoke"].firstMatch
 621        XCTAssertTrue(hookRow.waitForExistence(timeout: 15), "webhook not listed")
 622        hookRow.swipeLeft()
 623        app.buttons["Remove"].firstMatch.tap()
 624        let confirmHookRemove = app.buttons["Remove"].firstMatch
 625        XCTAssertTrue(confirmHookRemove.waitForExistence(timeout: 5), "webhook remove confirmation missing")
 626        confirmHookRemove.tap()
 627        XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed")
 628        back() // webhooks -> settings
 629
 630        // --- rename the scratch repo there and back ---
 631        // Each rename pushes the renamed repository; the round-trip ends
 632        // on the scratch repo again, several screens deep.
 633        var firstRename = true
 634        let rename: (String, String) -> Void = { newName, expectedPath in
 635            if firstRename {
 636                firstRename = false // already on the settings screen
 637            } else {
 638                // Fresh repo screen: scroll to the top so no row hides
 639                // under the navigation bar, then reach Settings.
 640                self.app.swipeDown()
 641                let settingsLink = self.app.descendants(matching: .any).matching(identifier: "repo-settings-link").firstMatch
 642                XCTAssertTrue(self.scrollTo(settingsLink), "settings link not reachable")
 643                settingsLink.tap()
 644            }
 645            let renameButton = self.app.descendants(matching: .any)
 646                .matching(identifier: "settings-rename-button").firstMatch
 647            XCTAssertTrue(self.scrollTo(renameButton, swipes: 10), "rename button not reachable")
 648            renameButton.tap()
 649            let field = self.app.textFields["New name"].firstMatch
 650            XCTAssertTrue(field.waitForExistence(timeout: 5), "rename alert did not open")
 651            field.tap()
 652            // Prefilled with the current name; clear it before typing.
 653            field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) + newName)
 654            self.app.buttons["Rename"].firstMatch.tap()
 655            // The settings screen offers the renamed repository at the
 656            // top; the list is scrolled to its end, so go back up first.
 657            let renamedLink = self.app.descendants(matching: .any)
 658                .matching(identifier: "settings-renamed-link").firstMatch
 659            sleep(2)
 660            for _ in 0..<6 where !renamedLink.exists { self.app.swipeDown() }
 661            XCTAssertTrue(renamedLink.waitForExistence(timeout: 10), "rename did not land")
 662            renamedLink.tap()
 663            XCTAssertTrue(self.app.staticTexts[expectedPath].firstMatch.waitForExistence(timeout: 15),
 664                          "renamed repo did not open as \(expectedPath)")
 665        }
 666        rename("ui-smoke-2", Self.scratchRepo + "-2")
 667        rename("ui-smoke", Self.scratchRepo)
 668
 669        // --- pin / unpin round-trip on krz/gitbay-ios ---
 670        // Start the list fresh; the rename left the stack several deep.
 671        app.terminate()
 672        app.launch()
 673        selectTab("Repositories")
 674        let search = app.searchFields.firstMatch
 675        XCTAssertTrue(search.waitForExistence(timeout: 10))
 676        focusAndType(search, "krz/gitbay-ios")
 677        let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
 678        XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
 679        repoRow.tap()
 680        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
 681        let menu = app.descendants(matching: .any)
 682            .matching(identifier: "repo-actions-menu").firstMatch
 683        XCTAssertTrue(menu.waitForExistence(timeout: 10))
 684        menu.tap()
 685        let pin = app.buttons["Pin"].firstMatch
 686        XCTAssertTrue(pin.waitForExistence(timeout: 5), "Pin action missing")
 687        pin.tap()
 688        // State refreshed from the dashboard: the menu now offers Unpin.
 689        menu.tap()
 690        let unpin = app.buttons["Unpin"].firstMatch
 691        XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take")
 692        unpin.tap()
 693
 694        // Bookmark it, find it in the bookmarks list, then unbookmark.
 695        // A run that died between the two leaves it bookmarked; undo
 696        // that first so the step starts from the same state every time.
 697        menu.tap()
 698        let leftover = app.buttons["Unbookmark"].firstMatch
 699        if leftover.waitForExistence(timeout: 3) {
 700            leftover.tap()
 701            XCTAssertTrue(waitForDisappearance(leftover, timeout: 10))
 702            menu.tap()
 703        }
 704        let bookmark = app.buttons["Bookmark"].firstMatch
 705        XCTAssertTrue(bookmark.waitForExistence(timeout: 5), "Bookmark action missing")
 706        bookmark.tap()
 707        // An active search hides the list's toolbar, so start the list
 708        // fresh: relaunching lands on a list with the bookmarks button.
 709        app.terminate()
 710        app.launch()
 711        selectTab("Repositories")
 712        let bookmarksButton = app.descendants(matching: .any)
 713            .matching(identifier: "repo-bookmarks-button").firstMatch
 714        XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 15), "bookmarks button missing")
 715        bookmarksButton.tap()
 716        let bookmarkedRow = app.staticTexts["krz/gitbay-ios"].firstMatch
 717        XCTAssertTrue(bookmarkedRow.waitForExistence(timeout: 15),
 718                      "bookmarked repo not in the bookmarks list")
 719        // Open it from the bookmarks list and undo the bookmark there.
 720        bookmarkedRow.tap()
 721        XCTAssertTrue(menu.waitForExistence(timeout: 10))
 722        menu.tap()
 723        let unbookmark = app.buttons["Unbookmark"].firstMatch
 724        XCTAssertTrue(unbookmark.waitForExistence(timeout: 10), "bookmark did not take")
 725        unbookmark.tap()
 726
 727        // --- settings: topic and merge-rule round-trips ---
 728        // The links section can sit below the README fold, and the row's
 729        // label is not always exposed as a static text.
 730        let settingsLink = app.descendants(matching: .any)
 731            .matching(identifier: "repo-settings-link").firstMatch
 732        XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable")
 733        settingsLink.tap()
 734        let addTopic = app.descendants(matching: .any)
 735            .matching(identifier: "settings-add-topic").firstMatch
 736        XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
 737        focusAndType(addTopic, "ios")
 738        app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit")
 739            .firstMatch.tap()
 740        let chip = app.staticTexts["ios"].firstMatch
 741        XCTAssertTrue(chip.waitForExistence(timeout: 10), "topic did not appear")
 742        // Remove it again through the chip's own x button — by name, since
 743        // the repository carries other topics — and it asks first.
 744        app.descendants(matching: .any).matching(identifier: "settings-remove-topic-ios")
 745            .firstMatch.tap()
 746        let confirmRemove = app.buttons["Remove"].firstMatch
 747        XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "topic remove confirmation missing")
 748        confirmRemove.tap()
 749        XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
 750
 751        // Merge-requests-only sits with the protected branches.
 752        let mergeOnly = app.switches["Merge requests only"].firstMatch
 753        XCTAssertTrue(scrollTo(mergeOnly), "merge-only toggle not reachable")
 754        let innerMergeOnly = mergeOnly.switches.firstMatch
 755        let flipMergeOnly: () -> Void = {
 756            if innerMergeOnly.exists && innerMergeOnly != mergeOnly {
 757                innerMergeOnly.tap()
 758            } else {
 759                mergeOnly.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
 760            }
 761        }
 762        flipMergeOnly()
 763        XCTAssertTrue(waitForValue(mergeOnly, "1", timeout: 10), "merge-only toggle did not persist on")
 764        flipMergeOnly()
 765        XCTAssertTrue(waitForValue(mergeOnly, "0", timeout: 10), "merge-only toggle did not persist off")
 766
 767        // A tag glob is protected, listed, and unprotected.
 768        let tagGlob = app.descendants(matching: .any)
 769            .matching(identifier: "settings-protect-tag").firstMatch
 770        XCTAssertTrue(scrollTo(tagGlob), "protected tags section not reachable")
 771        focusAndType(tagGlob, "ui-smoke-*")
 772        app.descendants(matching: .any).matching(identifier: "settings-protect-tag-submit")
 773            .firstMatch.tap()
 774        let globRow = app.staticTexts["ui-smoke-*"].firstMatch
 775        XCTAssertTrue(globRow.waitForExistence(timeout: 10), "tag glob not listed")
 776        // By identifier: the protected branch above has an Unprotect too.
 777        app.descendants(matching: .any).matching(identifier: "settings-unprotect-tag-ui-smoke-*")
 778            .firstMatch.tap()
 779        XCTAssertTrue(waitForDisappearance(globRow, timeout: 10), "tag glob not unprotected")
 780
 781        // Merge rules sit below the protected branches and tags.
 782        let resolved = app.switches["Require threads resolved"].firstMatch
 783        // The merge-requirements section sits below the fold, and a List
 784        // does not build rows it has not shown, so it must be scrolled
 785        // into existence before it can be queried.
 786        XCTAssertTrue(scrollTo(resolved), "merge requirements section not reachable")
 787        // SwiftUI exposes the row as a switch that wraps the real
 788        // control; tap the innermost switch when there is one, else the
 789        // right edge of the row.
 790        let inner = resolved.switches.firstMatch
 791        let control: () -> Void = {
 792            if inner.exists && inner != resolved {
 793                inner.tap()
 794            } else {
 795                resolved.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
 796            }
 797        }
 798        control()
 799        XCTAssertTrue(waitForValue(resolved, "1", timeout: 10), "toggle did not persist on")
 800        control()
 801        XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off")
 802
 803        // The code-owner rule is one more toggle in the same section.
 804        let codeowners = app.switches["Require code owner review"].firstMatch
 805        XCTAssertTrue(scrollTo(codeowners), "code owner toggle not reachable")
 806        let innerCodeowners = codeowners.switches.firstMatch
 807        let flipCodeowners: () -> Void = {
 808            if innerCodeowners.exists && innerCodeowners != codeowners {
 809                innerCodeowners.tap()
 810            } else {
 811                codeowners.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
 812            }
 813        }
 814        flipCodeowners()
 815        XCTAssertTrue(waitForValue(codeowners, "1", timeout: 10), "code owner toggle did not persist on")
 816        flipCodeowners()
 817        XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off")
 818
 819
 820        back() // settings -> repo
 821        // The repo screen keeps the scroll that reached Settings, which
 822        // can leave Builds under the navigation bar.
 823        app.swipeDown()
 824
 825        // --- nothing to trigger without a job file ---
 826        // This repo has no .gitbay/ci.yml, so `build jobs` returns none
 827        // and the control is gated rather than failing after a guess.
 828        app.staticTexts["Builds"].firstMatch.tap()
 829        let trigger = app.descendants(matching: .any)
 830            .matching(identifier: "build-trigger-button").firstMatch
 831        XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing")
 832        // Assert on behaviour, not on isEnabled: a disabled SwiftUI Menu
 833        // still reports itself enabled to XCUITest.
 834        trigger.tap()
 835        let anyJob = app.buttons.containing(
 836            NSPredicate(format: "label CONTAINS 'on push' OR label CONTAINS 'schedule '"))
 837            .firstMatch
 838        XCTAssertFalse(anyJob.waitForExistence(timeout: 3),
 839                       "a repo with no job file offered a job to trigger")
 840
 841    }
 842
 843    private func waitForValue(_ element: XCUIElement, _ value: String,
 844                              timeout: TimeInterval) -> Bool {
 845        let predicate = NSPredicate(format: "value == %@", value)
 846        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
 847        return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
 848    }
 849}
 850
 851extension LiveSmokeUITests {
 852
 853    /// Discovery is read-only: feed, server-side repo search, grep, and
 854    /// profiles. No cleanup needed.
 855    func testDiscoveryFlows() throws {
 856        // --- feed renders events and navigates ---
 857        selectTab("Feed")
 858        let firstEvent = app.cells.firstMatch
 859        XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events")
 860        firstEvent.tap()
 861        // Wherever the event led, it left the feed root behind.
 862        XCTAssertTrue(app.navigationBars.buttons.firstMatch
 863            .waitForExistence(timeout: 10), "feed row did not navigate")
 864        back()
 865
 866        // --- server-side search: "astronomy" is only a topic, invisible
 867        // to the client-side path/description filter ---
 868        selectTab("Repositories")
 869        let search = app.searchFields.firstMatch
 870        XCTAssertTrue(search.waitForExistence(timeout: 10))
 871        focusAndType(search, "astronomy")
 872        let hit = app.staticTexts["krz/space-wiki"].firstMatch
 873        XCTAssertTrue(hit.waitForExistence(timeout: 15),
 874                      "server-side topic search found nothing")
 875        hit.tap()
 876        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
 877
 878        // --- owner profile from the repo screen ---
 879        // The row's label merges; match it at any element type. It is
 880        // the last row of the links section, below the fold.
 881        let ownerLink = app.descendants(matching: .any)["krz"].firstMatch
 882        XCTAssertTrue(scrollTo(ownerLink), "owner link not reachable")
 883        ownerLink.tap()
 884        XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch
 885            .waitForExistence(timeout: 15), "profile did not load")
 886        XCTAssertTrue(app.staticTexts
 887            .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch
 888            .waitForExistence(timeout: 10), "profile repos missing")
 889        back()
 890
 891        // --- grep inside the repo, last: its search UI owns the screen ---
 892        app.staticTexts["Search in Files"].firstMatch.tap()
 893        let grepField = app.searchFields.firstMatch
 894        XCTAssertTrue(grepField.waitForExistence(timeout: 10))
 895        focusAndType(grepField, "space")
 896        app.keyboards.buttons["search"].firstMatch.tap()
 897        let match = app.cells.firstMatch
 898        XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches")
 899    }
 900}
 901
 902extension LiveSmokeUITests {
 903
 904    /// Releases: list and detail on a real release, an edit round-trip
 905    /// that saves the prefilled content (a no-op write), and the
 906    /// missing-tag refusal on create. Nothing changes state.
 907    func testReleaseFlows() throws {
 908        openRepo("krz/gitbay")
 909        app.staticTexts["Releases"].firstMatch.tap()
 910
 911        // Newest first, and the list has grown past the fold, so the
 912        // first row is whichever release is current.
 913        let row = app.cells.firstMatch
 914        XCTAssertTrue(row.waitForExistence(timeout: 15), "release list empty")
 915        row.tap()
 916
 917        // Notes render and assets carry sizes. The assets sit under the
 918        // notes, which can run past the fold.
 919        XCTAssertTrue(app.descendants(matching: .any)
 920            .matching(identifier: "release-edit-button").firstMatch
 921            .waitForExistence(timeout: 15), "release did not open")
 922        XCTAssertTrue(scrollTo(app.staticTexts
 923            .containing(NSPredicate(format: "label CONTAINS 'SHA256SUMS'")).firstMatch, swipes: 10),
 924            "assets missing")
 925
 926        // Edit sheet prefills; saving unchanged content round-trips.
 927        app.descendants(matching: .any).matching(identifier: "release-edit-button")
 928            .firstMatch.tap()
 929        let title = app.descendants(matching: .any)
 930            .matching(identifier: "compose-title").firstMatch
 931        XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
 932        XCTAssertFalse((title.value as? String ?? "").isEmpty,
 933                       "edit sheet did not prefill")
 934        app.descendants(matching: .any).matching(identifier: "compose-submit")
 935            .firstMatch.tap()
 936        XCTAssertTrue(waitForDisappearance(title, timeout: 15),
 937                      "release edit did not dismiss")
 938
 939        back()
 940
 941        // Create with a tag that does not exist: the server's refusal is
 942        // the UI contract.
 943        app.descendants(matching: .any).matching(identifier: "release-create-button")
 944            .firstMatch.tap()
 945        let tag = app.descendants(matching: .any)
 946            .matching(identifier: "release-tag").firstMatch
 947        XCTAssertTrue(tag.waitForExistence(timeout: 5))
 948        focusAndType(tag, "v9.9.9")
 949        app.descendants(matching: .any).matching(identifier: "release-submit")
 950            .firstMatch.tap()
 951        XCTAssertTrue(app.staticTexts
 952            .containing(NSPredicate(format: "label CONTAINS 'push the tag first'")).firstMatch
 953            .waitForExistence(timeout: 15), "missing-tag refusal not surfaced")
 954        app.buttons["Cancel"].firstMatch.tap()
 955    }
 956}
 957
 958extension LiveSmokeUITests {
 959
 960    /// Account keys and email. Read-only plus refusal paths — no key is
 961    /// added or removed, no mail is sent.
 962    func testAccountFlows() throws {
 963        // Dashboard toolbar -> account screen.
 964        openAccountScreen()
 965
 966        // Real keys render: SSH fingerprints first.
 967        XCTAssertTrue(app.staticTexts
 968            .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
 969            .waitForExistence(timeout: 15), "SSH keys missing")
 970
 971        // Pasting garbage as an SSH key surfaces the server's validation.
 972        // Done while the SSH section is still on screen: labelled keys
 973        // are two lines each, so what follows sits below the fold.
 974        app.descendants(matching: .any).matching(identifier: "add-ssh-key")
 975            .firstMatch.tap()
 976        let paste = app.descendants(matching: .any)
 977            .matching(identifier: "key-paste-text").firstMatch
 978        XCTAssertTrue(paste.waitForExistence(timeout: 5))
 979        focusAndType(paste, "not a key")
 980        app.descendants(matching: .any).matching(identifier: "key-paste-submit")
 981            .firstMatch.tap()
 982        XCTAssertTrue(app.staticTexts
 983            .containing(NSPredicate(format: "label CONTAINS 'not a valid public key'")).firstMatch
 984            .waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
 985        app.buttons["Cancel"].firstMatch.tap()
 986
 987        // The PGP key's UID email, below the keys on a List that does not
 988        // build rows it has not shown.
 989        XCTAssertTrue(scrollTo(app.staticTexts[Self.smokePGPEmail].firstMatch),
 990                      "PGP key UID missing")
 991
 992        // Activity mail flips off and back on; the value comes back from
 993        // the server each time.
 994        let mail = app.switches["Activity by mail"].firstMatch
 995        XCTAssertTrue(scrollTo(mail), "mail toggle not reachable")
 996        let innerMail = mail.switches.firstMatch
 997        let flipMail: () -> Void = {
 998            if innerMail.exists && innerMail != mail {
 999                innerMail.tap()
1000            } else {
1001                mail.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
1002            }
1003        }
1004        let mailWasOn = (mail.value as? String) == "1"
1005        flipMail()
1006        XCTAssertTrue(waitForValue(mail, mailWasOn ? "0" : "1", timeout: 10), "mail toggle did not persist")
1007        flipMail()
1008        XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore")
1009
1010        // The watch preference (v1.19.0) flips the same way.
1011        let watch = app.switches["Watch writable repositories"].firstMatch
1012        XCTAssertTrue(scrollTo(watch), "watch toggle not reachable")
1013        let innerWatch = watch.switches.firstMatch
1014        let flipWatch: () -> Void = {
1015            if innerWatch.exists && innerWatch != watch {
1016                innerWatch.tap()
1017            } else {
1018                watch.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
1019            }
1020        }
1021        let watchWasOn = (watch.value as? String) == "1"
1022        flipWatch()
1023        XCTAssertTrue(waitForValue(watch, watchWasOn ? "0" : "1", timeout: 10), "watch toggle did not persist")
1024        flipWatch()
1025        XCTAssertTrue(waitForValue(watch, watchWasOn ? "1" : "0", timeout: 10), "watch toggle did not restore")
1026
1027        // A bogus verification code is refused, not swallowed.
1028        let code = app.descendants(matching: .any)
1029            .matching(identifier: "email-code").firstMatch
1030        // Email sits below the keys on a List, which does not build rows
1031        // it has not shown.
1032        XCTAssertTrue(scrollTo(code), "email section not reachable")
1033        // The addresses list above the fields, the primary one marked.
1034        XCTAssertTrue(app.staticTexts["primary"].firstMatch.exists, "primary address not marked")
1035        focusAndType(code, "000000")
1036        let verify = app.descendants(matching: .any)
1037            .matching(identifier: "email-verify").firstMatch
1038        XCTAssertTrue(verify.waitForExistence(timeout: 5))
1039        XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed")
1040        verify.tap()
1041        XCTAssertTrue(app.staticTexts
1042            .containing(NSPredicate(format: "label CONTAINS 'invalid, expired'")).firstMatch
1043            .waitForExistence(timeout: 15), "bad-code refusal not surfaced")
1044    }
1045}
1046
1047extension LiveSmokeUITests {
1048
1049    /// Orgs: members render, and a team lives a full life — created,
1050    /// granted a repo, the grant revoked, the team deleted. Everything
1051    /// this test makes, it removes.
1052    func testOrgFlows() throws {
1053        openAccountScreen()
1054
1055        let orgRow = app.staticTexts["krz"].firstMatch
1056        XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
1057        orgRow.tap()
1058
1059        // Members render with roles.
1060        XCTAssertTrue(app.staticTexts[Self.smokeUser].firstMatch
1061            .waitForExistence(timeout: 15), "org members missing")
1062
1063        // Create a team.
1064        let teamField = app.descendants(matching: .any)
1065            .matching(identifier: "org-new-team").firstMatch
1066        XCTAssertTrue(teamField.waitForExistence(timeout: 5))
1067        focusAndType(teamField, "ui-smoke")
1068        app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
1069            .firstMatch.tap()
1070        let teamRow = app.staticTexts["ui-smoke"].firstMatch
1071        XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed")
1072
1073        // Grant it a repo, then revoke.
1074        teamRow.tap()
1075        let repoField = app.textFields["org/repo"].firstMatch
1076        XCTAssertTrue(repoField.waitForExistence(timeout: 10))
1077        focusAndType(repoField, "krz/gitbay-ios")
1078        app.buttons["Grant"].firstMatch.tap()
1079        let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
1080        XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
1081        grantRow.swipeLeft()
1082        app.buttons["Revoke"].firstMatch.tap()
1083        // The confirmation dialog's destructive Revoke.
1084        let confirmRevoke = app.buttons["Revoke"].firstMatch
1085        XCTAssertTrue(confirmRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
1086        confirmRevoke.tap()
1087        XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
1088
1089        back()
1090
1091        // Delete the team, through its confirmation.
1092        let row = app.staticTexts["ui-smoke"].firstMatch
1093        XCTAssertTrue(row.waitForExistence(timeout: 10))
1094        row.swipeLeft()
1095        app.buttons["Delete"].firstMatch.tap()
1096        // The confirmation dialog's destructive Delete.
1097        let confirm = app.buttons["Delete"].firstMatch
1098        XCTAssertTrue(confirm.waitForExistence(timeout: 5))
1099        confirm.tap()
1100        XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
1101    }
1102}
1103
1104extension LiveSmokeUITests {
1105
1106    /// The pre-merge screenshot checkpoint: walks the dense screens and
1107    /// attaches captures. Run per device/appearance; export the
1108    /// attachments from the xcresult. Signs in first when the device has
1109    /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided.
1110    func testScreenshotCheckpoint() throws {
1111        signInIfNeeded()
1112
1113        snap("dashboard")
1114
1115        openRepo("krz/gitbay")
1116        snap("repo")
1117
1118        app.staticTexts["Merge Requests"].firstMatch.tap()
1119        let all = app.segmentedControls.buttons["All"].firstMatch
1120        XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
1121        all.tap()
1122        // A real MR row carries its !number; the picker row does not.
1123        let mrNumber = app.staticTexts
1124            .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
1125        XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All")
1126        snap("mr-list")
1127
1128        mrNumber.tap()
1129        // The detail opens at its header; the diff row sits below the
1130        // fold and a List does not build rows it has not shown.
1131        let diffRow = app.staticTexts["Diff"].firstMatch
1132        XCTAssertTrue(scrollTo(diffRow), "MR detail has no diff row")
1133        snap("mr-detail")
1134
1135        diffRow.tap()
1136        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
1137        snap("diff")
1138    }
1139
1140    private func signInIfNeeded() {
1141        // Signed in already: the tab bar is up and there is no token field.
1142        if app.tabBars.firstMatch.waitForExistence(timeout: 10),
1143           !app.secureTextFields.firstMatch.exists {
1144            return
1145        }
1146        let tokenField = app.secureTextFields.firstMatch
1147        guard tokenField.waitForExistence(timeout: 5) else { return }
1148        guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else {
1149            XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
1150            return
1151        }
1152        focusAndType(tokenField, token + "\n")
1153        XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
1154            .waitForExistence(timeout: 20), "sign-in did not land")
1155        dismissSavePasswordPrompt()
1156    }
1157
1158    /// iOS offers to save whatever went into a SecureField, and the sheet
1159    /// sits over the app until answered — over the dashboard, in one set
1160    /// of store screenshots. It belongs to springboard, not to the app.
1161    private func dismissSavePasswordPrompt() {
1162        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
1163        let notNow = springboard.buttons["Not Now"].firstMatch
1164        if notNow.waitForExistence(timeout: 8) {
1165            notNow.tap()
1166            _ = waitForDisappearance(notNow, timeout: 10)
1167            return
1168        }
1169        let inApp = app.buttons["Not Now"].firstMatch
1170        if inApp.waitForExistence(timeout: 3) {
1171            inApp.tap()
1172            _ = waitForDisappearance(inApp, timeout: 10)
1173            return
1174        }
1175        // No prompt is fine: iOS only offers once per credential.
1176    }
1177
1178    /// Capture a screen, but not while it is still loading. These go to
1179    /// the App Store, and a spinner shipped as a screenshot once already.
1180    private func snap(_ name: String, file: StaticString = #filePath, line: UInt = #line) {
1181        let spinner = app.activityIndicators.firstMatch
1182        if spinner.exists {
1183            XCTAssertTrue(waitForDisappearance(spinner, timeout: 30),
1184                          "\(name) still loading after 30s", file: file, line: line)
1185        }
1186        // A list that has loaded has rows; an empty state has text. Either
1187        // way something must be on screen besides chrome.
1188        let content = app.cells.firstMatch
1189        _ = content.waitForExistence(timeout: 10)
1190
1191        let attachment = XCTAttachment(screenshot: app.screenshot())
1192        attachment.name = name
1193        attachment.lifetime = .keepAlways
1194        add(attachment)
1195    }
1196}
1197
1198extension LiveSmokeUITests {
1199
1200    /// Blame and file editing — the two capabilities that were web-only
1201    /// until krz/gitbay#42 and #43 made them commands. Blame is read-only
1202    /// on a real repo; the edit writes to a scratch repo the runner
1203    /// creates and deletes over SSH.
1204    func testBlameAndEditFlows() throws {
1205        // --- blame on a real file ---
1206        openRepo("krz/gitbay")
1207        app.staticTexts["Files"].firstMatch.tap()
1208        let goMod = app.staticTexts["go.mod"].firstMatch
1209        XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
1210        goMod.tap()
1211        XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu")
1212            .firstMatch.waitForExistence(timeout: 15), "file screen did not open")
1213        app.descendants(matching: .any).matching(identifier: "file-actions-menu")
1214            .firstMatch.tap()
1215        app.buttons["Blame"].firstMatch.tap()
1216        // Attribution shows the commit subject beside the lines.
1217        XCTAssertTrue(app.staticTexts
1218            .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch
1219            .waitForExistence(timeout: 20), "blame rendered no attribution")
1220
1221        // --- edit on the scratch repo ---
1222        // Relaunch rather than reuse the list: `.searchable` keeps the
1223        // previous query, and clearing it from the harness is unreliable.
1224        app.terminate()
1225        app.launch()
1226        openRepo(Self.fixtureRepo)
1227        app.staticTexts["Files"].firstMatch.tap()
1228        let notes = app.staticTexts["notes.txt"].firstMatch
1229        XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
1230        notes.tap()
1231
1232        let menu = app.descendants(matching: .any)
1233            .matching(identifier: "file-actions-menu").firstMatch
1234        XCTAssertTrue(menu.waitForExistence(timeout: 15))
1235        menu.tap()
1236        let edit = app.buttons
1237            .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch
1238        XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing")
1239        edit.tap()
1240
1241        let content = app.descendants(matching: .any)
1242            .matching(identifier: "file-edit-content").firstMatch
1243        XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open")
1244        focusAndType(content, " edited from the app")
1245
1246        let message = app.descendants(matching: .any)
1247            .matching(identifier: "file-edit-message").firstMatch
1248        focusAndType(message, "edit from ios")
1249
1250        app.descendants(matching: .any).matching(identifier: "file-edit-commit")
1251            .firstMatch.tap()
1252        XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss")
1253        // The reloaded file shows the committed text.
1254        XCTAssertTrue(app.staticTexts
1255            .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch
1256            .waitForExistence(timeout: 20), "edit not reflected after commit")
1257    }
1258}
1259
1260
1261extension LiveSmokeUITests {
1262
1263    /// The navigation and profile changes: identity lives in one menu,
1264    /// the dashboard can create a repository, a profile is a real
1265    /// profile, and a log entry opens its commit.
1266    func testProfileAndNavigationFlows() throws {
1267        // --- the dashboard offers creation ---
1268        selectTab("Dashboard")
1269        let create = app.descendants(matching: .any)
1270            .matching(identifier: "dashboard-create-button").firstMatch
1271        XCTAssertTrue(create.waitForExistence(timeout: 15), "dashboard + missing")
1272        create.tap()
1273        let pathField = app.descendants(matching: .any)
1274            .matching(identifier: "repo-create-path").firstMatch
1275        XCTAssertTrue(pathField.waitForExistence(timeout: 10),
1276                      "dashboard + did not open the create sheet")
1277        app.buttons["Cancel"].firstMatch.tap()
1278
1279        // --- identity is its own tab; the account menu rides with it ---
1280        selectTab("My Profile")
1281        let menu = app.descendants(matching: .any)
1282            .matching(identifier: "account-menu").firstMatch
1283        XCTAssertTrue(menu.waitForExistence(timeout: 10),
1284                      "account menu missing from My Profile")
1285        menu.tap()
1286        XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
1287                      "keys not in the account menu")
1288        app.tap() // dismiss the menu; the profile is already on screen
1289
1290        // --- a profile is a profile: description, links, orgs, graph, repos ---
1291        XCTAssertTrue(app.staticTexts
1292            .containing(NSPredicate(format: "label CONTAINS %@", Self.smokeProfileDescription)).firstMatch
1293            .waitForExistence(timeout: 20), "profile description missing")
1294        XCTAssertTrue(header("Organizations").exists,
1295                      "org memberships missing")
1296        XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing")
1297
1298        // --- an org admin can edit the org's profile ---
1299        app.staticTexts["krz"].firstMatch.tap()
1300        XCTAssertTrue(app.descendants(matching: .any)
1301            .matching(identifier: "profile-edit-button").firstMatch
1302            .waitForExistence(timeout: 15), "org profile offers no edit to its admin")
1303        back()
1304
1305        // --- saving the profile succeeds: profile set takes no about flags ---
1306        let edit = app.descendants(matching: .any)
1307            .matching(identifier: "profile-edit-button").firstMatch
1308        XCTAssertTrue(edit.waitForExistence(timeout: 10), "own profile offers no edit")
1309        edit.tap()
1310        let save = app.buttons["profile-edit-save"].firstMatch
1311        XCTAssertTrue(save.waitForExistence(timeout: 10), "edit sheet did not open")
1312        XCTAssertFalse(app.descendants(matching: .any)
1313            .matching(identifier: "profile-edit-about").firstMatch.exists,
1314            "the sheet still edits the about text")
1315        save.tap()
1316        XCTAssertTrue(app.navigationBars["Edit Profile"].waitForNonExistence(timeout: 15),
1317                      "profile save did not dismiss the sheet — the server refused it")
1318        XCTAssertFalse(save.exists, "the edit sheet is still on screen")
1319
1320        // --- the about text is a file: edit it, or offer to create it ---
1321        let aboutAction = app.descendants(matching: .any).matching(NSPredicate(
1322            format: "identifier IN %@", ["profile-about-edit", "profile-about-create"])).firstMatch
1323        XCTAssertTrue(scrollTo(aboutAction),
1324                      "own profile offers neither editing nor creating the about file")
1325
1326        // The graph and the repositories sit below the orgs and links,
1327        // and a List does not build rows it has not shown.
1328        XCTAssertTrue(scrollTo(app.staticTexts
1329            .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch),
1330            "activity graph missing")
1331        XCTAssertTrue(scrollTo(app.staticTexts
1332            .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch),
1333            "repositories missing")
1334
1335        // --- a log entry opens its commit ---
1336        app.terminate()
1337        app.launch()
1338        openRepo("krz/gitbay")
1339        app.staticTexts["History"].firstMatch.tap()
1340        let firstCommit = app.cells.firstMatch
1341        XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
1342        firstCommit.tap()
1343        // The commit screen IS the patch: no navigating away to find it.
1344        // A hunk header (@@) only appears in a rendered diff, and sits
1345        // below the message and the file list.
1346        let hunk = app.staticTexts
1347            .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch
1348        _ = hunk.waitForExistence(timeout: 20)
1349        XCTAssertTrue(scrollTo(hunk, swipes: 10),
1350                      "commit screen did not render the patch inline")
1351    }
1352}
1353
1354extension LiveSmokeUITests {
1355
1356    /// The three gaps that needed no server work: branches and tags,
1357    /// milestones, and an MR's milestone. Read-only except the MR
1358    /// milestone, which is set and cleared back.
1359    func testRefsAndMilestoneFlows() throws {
1360        openRepo("krz/gitbay")
1361
1362        // --- branches and tags, and browsing at a ref ---
1363        app.staticTexts["Branches & Tags"].firstMatch.tap()
1364        let main = app.staticTexts["main"].firstMatch
1365        XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1366        XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
1367                      "the default branch is not marked")
1368        XCTAssertTrue(header("Tags").exists, "tags section missing")
1369        main.tap()
1370        // Tapping a ref browses the repository there.
1371        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1372                      "browsing at a ref showed nothing")
1373        back()
1374        back()
1375
1376        // --- milestones with progress ---
1377        app.staticTexts["Milestones"].firstMatch.tap()
1378        XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
1379                      "milestones did not load")
1380        app.segmentedControls.buttons["All"].firstMatch.tap()
1381        XCTAssertTrue(app.staticTexts
1382            .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch
1383            .waitForExistence(timeout: 20), "milestone progress missing")
1384        back()
1385
1386        // --- an MR's milestone reads back, and can be cleared ---
1387        app.staticTexts["Merge Requests"].firstMatch.tap()
1388        app.segmentedControls.buttons["All"].firstMatch.tap()
1389        let mrNumber = app.staticTexts
1390            .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
1391        XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs")
1392        mrNumber.tap()
1393        let milestone = app.descendants(matching: .any)
1394            .matching(identifier: "mr-milestone-menu").firstMatch
1395        // The nav bar is the "opened" signal; everything else on this
1396        // screen can be below the fold.
1397        XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20),
1398                      "MR detail did not open")
1399        XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing")
1400        milestone.tap()
1401        // The picker offers None plus the open milestones.
1402        XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10),
1403                      "milestone picker did not open")
1404        app.buttons["None"].firstMatch.tap()
1405    }
1406}
1407
1408extension LiveSmokeUITests {
1409
1410    /// Explore and history at a ref — the two capabilities krz/gitbay!101
1411    /// made reachable. Read-only.
1412    func testExploreAndRefLogFlows() throws {
1413        // --- explore lists what the instance hosts ---
1414        selectTab("Explore")
1415        let firstRepo = app.cells.firstMatch
1416        XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1417        // Public repos this account does not own are reachable here.
1418        XCTAssertTrue(app.staticTexts
1419            .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
1420            .waitForExistence(timeout: 10), "explore is not the public listing")
1421        firstRepo.tap()
1422        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1423                      "explore row did not open its repo")
1424
1425        // --- history at a ref ---
1426        app.terminate()
1427        app.launch()
1428        openRepo("krz/gitbay")
1429        // Browse a ref, then ask for its history — the move the tree page
1430        // offers on the web.
1431        app.staticTexts["Branches & Tags"].firstMatch.tap()
1432        let main = app.staticTexts["main"].firstMatch
1433        XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1434        main.tap()
1435        let history = app.descendants(matching: .any)
1436            .matching(identifier: "tree-history-button").firstMatch
1437        XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree")
1438        history.tap()
1439        XCTAssertTrue(app.navigationBars
1440            .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch
1441            .waitForExistence(timeout: 20), "ref history did not open")
1442        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1443                      "ref history rendered no commits")
1444    }
1445}
1446
1447extension LiveSmokeUITests {
1448
1449    /// The wiki — the last capability that was browser-only until
1450    /// krz/gitbay#48. Read-only; editing is a push, on every surface.
1451    func testWikiFlows() throws {
1452        openRepo("krz/gitbay")
1453        app.staticTexts["Wiki"].firstMatch.tap()
1454
1455        // The landing page leads and is marked.
1456        let home = app.staticTexts["Home"].firstMatch
1457        XCTAssertTrue(home.waitForExistence(timeout: 20), "wiki pages did not load")
1458        XCTAssertTrue(app.staticTexts["home"].firstMatch.exists,
1459                      "the landing page is not marked")
1460
1461        // Parity.org is an org page, so this also proves the org renderer
1462        // runs on wiki content and not just READMEs.
1463        let parity = app.staticTexts["Parity"].firstMatch
1464        XCTAssertTrue(parity.exists, "Parity page missing from the listing")
1465        parity.tap()
1466        XCTAssertTrue(app.staticTexts
1467            .containing(NSPredicate(format: "label CONTAINS 'surface'")).firstMatch
1468            .waitForExistence(timeout: 20), "wiki page rendered no prose")
1469        // Org headings render as headings, not as raw #+title:.
1470        XCTAssertFalse(app.staticTexts
1471            .containing(NSPredicate(format: "label CONTAINS '#+title'")).firstMatch.exists,
1472                       "org markup leaked into the rendered page")
1473    }
1474}
1475
1476
1477extension LiveSmokeUITests {
1478
1479    /// Builds on a repo that has a job file: the picker offers what the
1480    /// server names, and the detail screen says more than the log.
1481    func testBuildJobsAndDetail() throws {
1482        openRepo("krz/gitbay")
1483        app.staticTexts["Builds"].firstMatch.tap()
1484
1485        // The picker lists jobs from `build jobs` — no name to type.
1486        let trigger = app.descendants(matching: .any)
1487            .matching(identifier: "build-trigger-button").firstMatch
1488        XCTAssertTrue(trigger.waitForExistence(timeout: 20), "trigger control missing")
1489        XCTAssertTrue(trigger.isEnabled, "trigger is disabled where a job file exists")
1490        trigger.tap()
1491        let job = app.buttons
1492            .containing(NSPredicate(format: "label CONTAINS 'build' AND label CONTAINS 'on push'"))
1493            .firstMatch
1494        XCTAssertTrue(job.waitForExistence(timeout: 10),
1495                      "job menu did not list the job with why it runs")
1496        // Dismiss without queueing a real build.
1497        app.tap()
1498
1499        // The detail screen carries status, ref and timing, not just log.
1500        let firstBuild = app.cells.firstMatch
1501        XCTAssertTrue(firstBuild.waitForExistence(timeout: 20), "no builds listed")
1502        firstBuild.tap()
1503        for label in ["success", "queued"] where app.staticTexts[label].firstMatch.exists {
1504            XCTAssertTrue(true)
1505        }
1506        XCTAssertTrue(app.staticTexts
1507            .containing(NSPredicate(format: "label BEGINSWITH 'queued '")).firstMatch
1508            .waitForExistence(timeout: 20), "build detail shows no timing")
1509    }
1510}
1511
1512extension LiveSmokeUITests {
1513
1514    /// Store screenshots on iPad. It navigates by tab and through
1515    /// Explore rather than reusing the iPhone checkpoint: that one drives
1516    /// the repo list's `.searchable`, which iPadOS lays out differently,
1517    /// and chasing those differences is the iPad pass this release
1518    /// deferred. Tabs and a list row work on both.
1519    func testIPadScreenshotCheckpoint() throws {
1520        signInIfNeeded()
1521        snap("dashboard")
1522
1523        selectTab("Explore")
1524        snap("explore")
1525
1526        // First public repo in the list — no search field involved.
1527        let firstRepo = app.cells.firstMatch
1528        XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1529        firstRepo.tap()
1530        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
1531                      "repo screen did not open")
1532        snap("repo")
1533
1534        selectTab("Feed")
1535        snap("feed")
1536
1537        selectTab("My Profile")
1538        // Any signed-in account can edit its own profile, so this holds
1539        // for store screenshots taken as a real user too.
1540        XCTAssertTrue(app.descendants(matching: .any)
1541            .matching(identifier: "profile-edit-button").firstMatch
1542            .waitForExistence(timeout: 20), "profile did not load")
1543        snap("profile")
1544    }
1545}
1546
1547extension LiveSmokeUITests {
1548
1549    /// Snippets, a full life on the caller's own account: create, add a
1550    /// file, replace it, remove it, change visibility, delete. setUp and
1551    /// tearDown remove anything left by this description.
1552    func testSnippetFlows() throws {
1553        selectTab("My Profile")
1554        app.descendants(matching: .any).matching(identifier: "account-menu").firstMatch.tap()
1555        let snippets = app.buttons["Snippets"].firstMatch
1556        XCTAssertTrue(snippets.waitForExistence(timeout: 5), "Snippets missing from the account menu")
1557        snippets.tap()
1558
1559        // --- create ---
1560        let create = app.descendants(matching: .any)
1561            .matching(identifier: "snippet-create-button").firstMatch
1562        XCTAssertTrue(create.waitForExistence(timeout: 15), "snippet list did not open")
1563        create.tap()
1564        let nameField = app.descendants(matching: .any)
1565            .matching(identifier: "snippet-file-name").firstMatch
1566        XCTAssertTrue(nameField.waitForExistence(timeout: 5), "create sheet did not open")
1567        focusAndType(nameField, "ui-smoke.txt")
1568        focusAndType(app.descendants(matching: .any)
1569            .matching(identifier: "snippet-description").firstMatch, Self.smokeSnippetDescription)
1570        let content = app.descendants(matching: .any)
1571            .matching(identifier: "snippet-content").firstMatch
1572        focusAndType(content, "hello from the live suite")
1573        app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap()
1574        XCTAssertTrue(waitForDisappearance(nameField, timeout: 20), "create did not dismiss")
1575
1576        let row = app.staticTexts[Self.smokeSnippetDescription].firstMatch
1577        XCTAssertTrue(row.waitForExistence(timeout: 15), "created snippet not listed")
1578        row.tap()
1579
1580        // --- detail: the file renders, unlisted by default ---
1581        XCTAssertTrue(app.staticTexts["ui-smoke.txt"].firstMatch.waitForExistence(timeout: 15),
1582                      "snippet file missing")
1583        XCTAssertTrue(app.staticTexts["unlisted"].firstMatch.exists, "default visibility not unlisted")
1584
1585        // --- add a second file ---
1586        let actions = app.descendants(matching: .any)
1587            .matching(identifier: "snippet-actions-menu").firstMatch
1588        XCTAssertTrue(actions.waitForExistence(timeout: 10), "owner actions missing")
1589        actions.tap()
1590        let addFile = app.buttons["Add file"].firstMatch
1591        XCTAssertTrue(addFile.waitForExistence(timeout: 5))
1592        addFile.tap()
1593        let secondName = app.descendants(matching: .any)
1594            .matching(identifier: "snippet-file-name").firstMatch
1595        XCTAssertTrue(secondName.waitForExistence(timeout: 5), "add-file sheet did not open")
1596        focusAndType(secondName, "second.txt")
1597        focusAndType(app.descendants(matching: .any)
1598            .matching(identifier: "snippet-content").firstMatch, "two")
1599        app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap()
1600        XCTAssertTrue(waitForDisappearance(secondName, timeout: 20), "add file did not dismiss")
1601        let second = app.staticTexts["second.txt"].firstMatch
1602        XCTAssertTrue(second.waitForExistence(timeout: 15), "added file missing")
1603
1604        // --- replace its content from the file menu ---
1605        let fileMenu = app.descendants(matching: .any)
1606            .matching(identifier: "snippet-file-menu-second.txt").firstMatch
1607        XCTAssertTrue(scrollTo(fileMenu), "file menu not reachable")
1608        fileMenu.tap()
1609        let edit = app.buttons["Edit"].firstMatch
1610        XCTAssertTrue(edit.waitForExistence(timeout: 5))
1611        edit.tap()
1612        let editor = app.descendants(matching: .any)
1613            .matching(identifier: "snippet-content").firstMatch
1614        XCTAssertTrue(editor.waitForExistence(timeout: 5), "edit sheet did not open")
1615        XCTAssertTrue((editor.value as? String)?.contains("two") == true, "edit sheet did not prefill")
1616        focusAndType(editor, " and three")
1617        app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap()
1618        XCTAssertTrue(waitForDisappearance(editor, timeout: 20), "file save did not dismiss")
1619        XCTAssertTrue(app.staticTexts
1620            .containing(NSPredicate(format: "label CONTAINS 'and three'")).firstMatch
1621            .waitForExistence(timeout: 15), "replaced content not rendered")
1622
1623        // --- remove it, through its confirmation ---
1624        XCTAssertTrue(scrollTo(fileMenu), "file menu not reachable after edit")
1625        fileMenu.tap()
1626        app.buttons["Remove"].firstMatch.tap()
1627        let confirmRemove = app.buttons["Remove"].firstMatch
1628        XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "remove confirmation missing")
1629        confirmRemove.tap()
1630        XCTAssertTrue(waitForDisappearance(second, timeout: 15), "file not removed")
1631
1632        // --- visibility to public ---
1633        actions.tap()
1634        let settings = app.buttons["Description & visibility"].firstMatch
1635        XCTAssertTrue(settings.waitForExistence(timeout: 5))
1636        settings.tap()
1637        let visibility = app.segmentedControls.firstMatch
1638        XCTAssertTrue(visibility.waitForExistence(timeout: 5), "settings sheet did not open")
1639        visibility.buttons["public"].tap()
1640        app.buttons["Save"].firstMatch.tap()
1641        XCTAssertTrue(app.staticTexts["public"].firstMatch.waitForExistence(timeout: 15),
1642                      "visibility change not shown")
1643
1644        // --- delete, through its confirmation; the list no longer has it ---
1645        actions.tap()
1646        app.buttons["Delete snippet"].firstMatch.tap()
1647        let confirmDelete = app.buttons["Delete"].firstMatch
1648        XCTAssertTrue(confirmDelete.waitForExistence(timeout: 5), "delete confirmation missing")
1649        confirmDelete.tap()
1650        XCTAssertTrue(create.waitForExistence(timeout: 15), "did not return to the snippet list")
1651        XCTAssertTrue(waitForDisappearance(row, timeout: 15), "deleted snippet still listed")
1652    }
1653
1654    /// An SSH key's label round-trips: set from the row's swipe action,
1655    /// then restored to what it was (or cleared).
1656    func testSSHKeyLabelFlow() throws {
1657        openAccountScreen()
1658        let fingerprint = app.staticTexts
1659            .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
1660        XCTAssertTrue(fingerprint.waitForExistence(timeout: 15), "SSH keys missing")
1661        let fp = fingerprint.label
1662
1663        // What the key is called now, so it can be put back.
1664        var original = ""
1665        if let data = readCommand(["keys", "list"]),
1666           let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1667           let rows = envelope["data"] as? [[String: Any]],
1668           let row = rows.first(where: { $0["fingerprint"] as? String == fp }) {
1669            original = row["label"] as? String ?? ""
1670        }
1671
1672        let relabel: (String) -> Void = { text in
1673            fingerprint.swipeLeft()
1674            let label = self.app.buttons["Label"].firstMatch
1675            XCTAssertTrue(label.waitForExistence(timeout: 5), "Label swipe action missing")
1676            label.tap()
1677            let field = self.app.alerts.textFields.firstMatch
1678            XCTAssertTrue(field.waitForExistence(timeout: 5), "label alert missing")
1679            self.clearAndType(field, text)
1680            self.app.alerts.buttons["Save"].firstMatch.tap()
1681        }
1682
1683        relabel("ui-smoke")
1684        XCTAssertTrue(app.staticTexts["ui-smoke"].firstMatch.waitForExistence(timeout: 15),
1685                      "label not shown after save")
1686
1687        relabel(original)
1688        XCTAssertTrue(waitForDisappearance(app.staticTexts["ui-smoke"].firstMatch, timeout: 15),
1689                      "label not restored")
1690        if !original.isEmpty {
1691            XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10),
1692                          "original label not back")
1693        }
1694    }
1695
1696    /// Org labels and milestones from the org screen: a label is created
1697    /// and removed; the fixture milestone is reopened and closed again,
1698    /// and creating it a second time surfaces the server's refusal.
1699    func testOrgLabelAndMilestoneFlows() throws {
1700        openAccountScreen()
1701        let orgRow = app.staticTexts[Self.smokeOrg].firstMatch
1702        XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
1703        orgRow.tap()
1704        XCTAssertTrue(app.staticTexts[Self.smokeUser].firstMatch.waitForExistence(timeout: 15),
1705                      "org members missing")
1706
1707        // --- labels ---
1708        let labels = app.staticTexts["Labels"].firstMatch
1709        XCTAssertTrue(scrollTo(labels), "Labels link not reachable")
1710        labels.tap()
1711        let newName = app.descendants(matching: .any)
1712            .matching(identifier: "label-new-name").firstMatch
1713        XCTAssertTrue(newName.waitForExistence(timeout: 15), "org labels did not open")
1714        focusAndType(newName, Self.smokeOrgLabel)
1715        app.descendants(matching: .any).matching(identifier: "label-create-submit").firstMatch.tap()
1716        let chip = app.staticTexts[Self.smokeOrgLabel].firstMatch
1717        XCTAssertTrue(chip.waitForExistence(timeout: 15), "org label not listed after create")
1718
1719        // The org's own screen manages its rows: the menu is there, not
1720        // the read-only mark.
1721        let cell = app.cells.containing(.staticText, identifier: Self.smokeOrgLabel).firstMatch
1722        let menu = cell.buttons.firstMatch
1723        XCTAssertTrue(menu.waitForExistence(timeout: 5), "org label has no menu on the org screen")
1724        menu.tap()
1725        let remove = app.buttons["Remove"].firstMatch
1726        XCTAssertTrue(remove.waitForExistence(timeout: 5))
1727        remove.tap()
1728        let confirmRemove = app.buttons["Remove"].firstMatch
1729        XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "remove confirmation missing")
1730        confirmRemove.tap()
1731        XCTAssertTrue(waitForDisappearance(chip, timeout: 15), "org label not removed")
1732        back()
1733
1734        // --- milestones ---
1735        let milestones = app.staticTexts["Milestones"].firstMatch
1736        XCTAssertTrue(scrollTo(milestones), "Milestones link not reachable")
1737        milestones.tap()
1738        let picker = app.segmentedControls.firstMatch
1739        XCTAssertTrue(picker.waitForExistence(timeout: 15), "org milestones did not open")
1740        picker.buttons["Closed"].tap()
1741        let fixture = app.staticTexts[Self.smokeOrgMilestone].firstMatch
1742        XCTAssertTrue(fixture.waitForExistence(timeout: 15), "fixture milestone not among closed")
1743        fixture.swipeLeft()
1744        let reopen = app.buttons["Reopen"].firstMatch
1745        XCTAssertTrue(reopen.waitForExistence(timeout: 5), "reopen action missing on the org screen")
1746        reopen.tap()
1747        XCTAssertTrue(waitForDisappearance(fixture, timeout: 15), "reopened milestone still among closed")
1748        picker.buttons["Open"].tap()
1749        XCTAssertTrue(fixture.waitForExistence(timeout: 15), "reopened milestone not among open")
1750
1751        // Creating it again is refused with the server's sentence.
1752        app.descendants(matching: .any).matching(identifier: "milestone-create-button").firstMatch.tap()
1753        let title = app.descendants(matching: .any)
1754            .matching(identifier: "milestone-title").firstMatch
1755        XCTAssertTrue(title.waitForExistence(timeout: 5), "create sheet did not open")
1756        focusAndType(title, Self.smokeOrgMilestone)
1757        app.descendants(matching: .any).matching(identifier: "milestone-submit").firstMatch.tap()
1758        XCTAssertTrue(app.staticTexts
1759            .containing(NSPredicate(format: "label CONTAINS[c] 'already'")).firstMatch
1760            .waitForExistence(timeout: 15), "duplicate milestone refusal not surfaced")
1761        app.buttons["Cancel"].firstMatch.tap()
1762
1763        // Back to closed, where the fixture rests.
1764        XCTAssertTrue(fixture.waitForExistence(timeout: 10))
1765        fixture.swipeLeft()
1766        let close = app.buttons["Close"].firstMatch
1767        XCTAssertTrue(close.waitForExistence(timeout: 5), "close action missing on the org screen")
1768        close.tap()
1769        XCTAssertTrue(waitForDisappearance(fixture, timeout: 15), "closed milestone still among open")
1770    }
1771
1772    /// Build list filters on a repo with builds: a status narrows the
1773    /// list, a job narrows it further, and an impossible pair shows the
1774    /// filtered empty state. Clearing brings the list back.
1775    func testBuildFilterFlow() throws {
1776        openRepo("krz/gitbay")
1777        app.staticTexts["Builds"].firstMatch.tap()
1778        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed")
1779
1780        let filter = app.descendants(matching: .any)
1781            .matching(identifier: "build-filter-button").firstMatch
1782        XCTAssertTrue(filter.waitForExistence(timeout: 10), "filter button missing")
1783
1784        let choose: (String, String) -> Void = { menuId, option in
1785            let menu = self.app.descendants(matching: .any).matching(identifier: menuId).firstMatch
1786            XCTAssertTrue(menu.waitForExistence(timeout: 5), "\(menuId) missing")
1787            menu.tap()
1788            let choice = self.app.buttons[option].firstMatch
1789            XCTAssertTrue(choice.waitForExistence(timeout: 5), "\(option) not offered")
1790            choice.tap()
1791        }
1792
1793        // status = success: rows remain and every row says success.
1794        filter.tap()
1795        choose("build-filter-status-menu", "success")
1796        app.buttons["Done"].firstMatch.tap()
1797        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no successful builds")
1798        XCTAssertFalse(app.staticTexts["failure"].firstMatch.exists, "a failed build survived the filter")
1799
1800        // job = test as well: the rows name the job.
1801        filter.tap()
1802        choose("build-filter-job-menu", "test")
1803        app.buttons["Done"].firstMatch.tap()
1804        XCTAssertTrue(app.staticTexts["test"].firstMatch
1805            .waitForExistence(timeout: 20), "job filter did not narrow to test")
1806
1807        // status = pending on a quiet repo: the filtered empty state.
1808        filter.tap()
1809        choose("build-filter-status-menu", "pending")
1810        app.buttons["Done"].firstMatch.tap()
1811        XCTAssertTrue(app.staticTexts["No builds match these filters."].firstMatch
1812            .waitForExistence(timeout: 20), "filtered empty state missing")
1813
1814        // Clear: the list is back.
1815        filter.tap()
1816        app.buttons["Clear filters"].firstMatch.tap()
1817        app.buttons["Done"].firstMatch.tap()
1818        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "list did not come back")
1819    }
1820
1821    /// krz/gitbay has well over thirty builds, so the thirty-first is on
1822    /// the second page. Reaching it means the footer asked for more.
1823    func testBuildPagingFlow() throws {
1824        guard let data = readCommand(["build", "list", "krz/gitbay", "--limit", "31"]),
1825              let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1826              let page = envelope["data"] as? [String: Any],
1827              let items = page["items"] as? [[String: Any]], items.count == 31,
1828              let number = items[30]["number"] as? Int
1829        else { throw XCTSkip("could not read krz/gitbay's builds over the API") }
1830
1831        openRepo("krz/gitbay")
1832        app.staticTexts["Builds"].firstMatch.tap()
1833        XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed")
1834        let thirtyFirst = app.staticTexts
1835            .containing(NSPredicate(format: "label BEGINSWITH %@", "#\(number) ")).firstMatch
1836        XCTAssertTrue(scrollTo(thirtyFirst, swipes: 20),
1837                      "build #\(number), the first row of page two, never appeared")
1838    }
1839
1840    /// Close in favour of another: the first fixture merge request is
1841    /// closed from the app naming the second; the page then says so and
1842    /// links to it.
1843    func testCloseInFavourFlow() throws {
1844        openRepo(Self.fixtureRepo)
1845        app.staticTexts["Merge Requests"].firstMatch.tap()
1846        let superseded = app.staticTexts[Self.supersededTitle].firstMatch
1847        XCTAssertTrue(superseded.waitForExistence(timeout: 15), "fixture MR not listed")
1848        superseded.tap()
1849
1850        app.descendants(matching: .any).matching(identifier: "mr-actions-menu").firstMatch.tap()
1851        let close = app.buttons["Close"].firstMatch
1852        XCTAssertTrue(close.waitForExistence(timeout: 5))
1853        close.tap()
1854        let inFavour = app.buttons["Close in favour of another…"].firstMatch
1855        XCTAssertTrue(inFavour.waitForExistence(timeout: 5), "close dialog has no in-favour choice")
1856        inFavour.tap()
1857
1858        let successor = app.staticTexts[Self.successorTitle].firstMatch
1859        XCTAssertTrue(successor.waitForExistence(timeout: 15), "successor not offered")
1860        successor.tap()
1861        let confirm = app.buttons["Close"].firstMatch
1862        XCTAssertTrue(confirm.waitForExistence(timeout: 5), "in-favour confirmation missing")
1863        confirm.tap()
1864
1865        XCTAssertTrue(app.staticTexts
1866            .containing(NSPredicate(format: "label BEGINSWITH 'closed in favour of !'")).firstMatch
1867            .waitForExistence(timeout: 20), "resolution does not name the successor")
1868        let link = app.staticTexts
1869            .containing(NSPredicate(format: "label BEGINSWITH 'Superseded by !'")).firstMatch
1870        XCTAssertTrue(scrollTo(link), "superseded-by link missing")
1871        link.tap()
1872        XCTAssertTrue(app.staticTexts[Self.successorTitle].firstMatch.waitForExistence(timeout: 20),
1873                      "link did not open the successor")
1874    }
1875
1876    /// A label added from the merge request screen shows as a chip, the
1877    /// list filters to it, and removing it clears the chip.
1878    func testMRLabelFlow() throws {
1879        openRepo(Self.fixtureRepo)
1880        app.staticTexts["Merge Requests"].firstMatch.tap()
1881        let row = app.staticTexts[Self.labelledTitle].firstMatch
1882        XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed")
1883        row.tap()
1884
1885        let field = app.textFields["mr-label-field"].firstMatch
1886        XCTAssertTrue(scrollTo(field), "no label field on the merge request")
1887        field.tap()
1888        field.typeText(Self.smokeMRLabel)
1889        app.buttons["mr-label-add"].firstMatch.tap()
1890        let remove = app.buttons["mr-label-remove-\(Self.smokeMRLabel)"].firstMatch
1891        XCTAssertTrue(remove.waitForExistence(timeout: 15), "added label has no chip")
1892
1893        back()
1894        XCTAssertTrue(app.staticTexts[Self.unlabelledTitle].firstMatch.waitForExistence(timeout: 15),
1895                      "unlabelled fixture MR not listed")
1896        app.descendants(matching: .any).matching(identifier: "mr-filter-button").firstMatch.tap()
1897        let menu = app.descendants(matching: .any).matching(identifier: "filter-label-menu").firstMatch
1898        XCTAssertTrue(menu.waitForExistence(timeout: 10), "MR filter sheet has no label picker")
1899        menu.tap()
1900        let choice = app.buttons[Self.smokeMRLabel].firstMatch
1901        XCTAssertTrue(choice.waitForExistence(timeout: 10), "label not offered in the picker")
1902        choice.tap()
1903        app.buttons["Done"].firstMatch.tap()
1904        XCTAssertTrue(row.waitForExistence(timeout: 15), "label filter dropped the labelled MR")
1905        XCTAssertTrue(app.staticTexts[Self.unlabelledTitle].firstMatch.waitForNonExistence(timeout: 15),
1906                      "label filter did not narrow the list")
1907
1908        row.tap()
1909        XCTAssertTrue(scrollTo(remove), "chip missing on reopen")
1910        remove.tap()
1911        XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown")
1912    }
1913}