gitbayUITests/LiveSmokeUITests.swift

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

2030 lines · 100724 bytes

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