gitbayUITests/LiveSmokeUITests.swift

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

1787 lines · 87890 bytes

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