import XCTest /// Live smoke flows against gitbay.org, driven by accessibility — the /// coordinate-free verification the simulator's input pipeline cannot be /// trusted to do (krz/gitbay-ios#5). /// /// These WRITE to the live instance (an MR is opened and closed, a /// milestone assigned), so they only run when explicitly asked: /// /// TEST_RUNNER_GITBAY_UITEST_LIVE=1 xcodebuild test \ /// -only-testing:gitbayUITests ... /// /// They run as the `ios-smoke` account, a krz admin that owns the /// scratch and fixture repositories (smoke-account.sh provisions it). /// Everything they write goes to those, never to krz/gitbay-ios. A /// signed-out device is signed in at setUp from /// TEST_RUNNER_GITBAY_UITEST_TOKEN. final class LiveSmokeUITests: XCTestCase { private var app: XCUIApplication! /// The scratch repo testRepoManagementFlows creates. A run that dies /// before deleting it leaves it behind, and then every later run /// fails at "create sheet did not dismiss" — the create is refused /// because the name is taken, which reads like a UI bug and is not. /// Removed before and after, so neither a crashed run nor this one /// can strand it. private static let scratchRepo = "\(smokeUser)/ui-smoke" /// The account the suite runs as. smoke-account.sh creates it with /// the profile description, PGP key and fixture repository the /// assertions below read back. static let smokeUser = "ios-smoke" static let smokeProfileDescription = "Live UI smoke account for gitbay-ios" static let smokePGPEmail = "ios-smoke+pgp@gitbay.org" /// The repository the write flows work in: the fixture issue and /// merge requests, and the file testBlameAndEditFlows edits. /// smoke-account.sh seeds it, with `main` and `ui-smoke` divergent. static let fixtureRepo = "\(smokeUser)/ui-smoke-edit" override func setUpWithError() throws { try XCTSkipUnless( ProcessInfo.processInfo.environment["GITBAY_UITEST_LIVE"] == "1", "live UI smoke is opt-in; set TEST_RUNNER_GITBAY_UITEST_LIVE=1" ) continueAfterFailure = false app = XCUIApplication() // xcodebuild runs tests on simulator clones, so the base // device's appearance never applies; force it per run. switch ProcessInfo.processInfo.environment["GITBAY_UITEST_DARK"] { case "1": app.launchArguments.append("-gb-dark") case "0": app.launchArguments.append("-gb-light") default: break // follow the device, as a real launch does } app.launch() // A fresh device — a clone on another runtime, say — starts on // the token screen; the flows assume a signed-in app. signInIfNeeded() if name.contains("testRepoManagementFlows") { deleteScratchRepo() } if name.contains("testCreationAndEditingFlows") { try requireFixtureRepo() ensureFixtureMilestone() ensureFixtureIssue() try requireFixtureBranch() } if name.contains("testBlameAndEditFlows") { try requireFixtureRepo() } if name.contains("testSnippetFlows") { deleteSmokeSnippets() } if name.contains("testOrgLabelAndMilestoneFlows") { ensureOrgFixtures() } if name.contains("testCloseInFavourFlow") { try requireFixtureRepo() try requireFixtureBranch() closeSmokeMergeRequests() try openSupersedeFixtures() } if name.contains("testMRLabelFlow") { try requireFixtureRepo() try requireFixtureBranch() closeSmokeMergeRequests() _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main", "--title", Self.labelledTitle]) _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "main", "--target", "ui-smoke", "--title", Self.unlabelledTitle]) let open = openSmokeMergeRequests().map { $0.title } try XCTSkipUnless(open.contains(Self.labelledTitle) && open.contains(Self.unlabelledTitle), "could not open both label fixture merge requests on \(Self.fixtureRepo)") } if name.contains("testMarkupPreviewFlow") { try requireFixtureRepo() ensureFixtureIssue() } } override func tearDownWithError() throws { if name.contains("testRepoManagementFlows") { deleteScratchRepo() } if name.contains("testSnippetFlows") { deleteSmokeSnippets() } if name.contains("testCloseInFavourFlow") { closeSmokeMergeRequests() } if name.contains("testMRLabelFlow") { closeSmokeMergeRequests() _ = runCommand(["label", "remove", Self.fixtureRepo, Self.smokeMRLabel]) } } // MARK: - Fixtures for the v1.2.0 flows static let smokeSnippetDescription = "UI smoke snippet" /// Remove every snippet the flow made, before and after, so a run /// that died mid-way cannot strand one or hit the per-account cap. private func deleteSmokeSnippets() { guard let data = readCommand(["snippet", "list"]), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let rows = envelope["data"] as? [[String: Any]] else { return } for row in rows where row["description"] as? String == Self.smokeSnippetDescription { if let id = row["id"] as? String { _ = runCommand(["snippet", "delete", id]) } } } static let smokeOrg = "krz" static let smokeOrgLabel = "ui-smoke-label" static let smokeOrgMilestone = "ui-smoke milestone" /// The org milestone is a fixture, reused and left closed: the /// server has no milestone delete, so creating one per run would /// pile them up. The label is removed by the flow itself; a stale /// one is removed here first. private func ensureOrgFixtures() { _ = runCommand(["org", "label", "remove", Self.smokeOrg, Self.smokeOrgLabel]) guard let data = readCommand(["org", "milestone", "list", Self.smokeOrg, "--state", "all"]), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } let rows = envelope["data"] as? [[String: Any]] ?? [] if let row = rows.first(where: { $0["title"] as? String == Self.smokeOrgMilestone }) { if row["state"] as? String == "open" { _ = runCommand(["org", "milestone", "close", Self.smokeOrg, Self.smokeOrgMilestone]) } } else { _ = runCommand(["org", "milestone", "create", Self.smokeOrg, Self.smokeOrgMilestone]) _ = runCommand(["org", "milestone", "close", Self.smokeOrg, Self.smokeOrgMilestone]) } } static let supersededTitle = "UI smoke: superseded" static let successorTitle = "UI smoke: successor" static let labelledTitle = "UI smoke: labels" static let unlabelledTitle = "UI smoke: unlabelled" static let smokeMRLabel = "ui-smoke-mr-label" /// Close in favour needs two open merge requests. The fixture branch /// gives one (ui-smoke -> main); main has commits ui-smoke lacks, so /// the reverse (main -> ui-smoke) is the other. Both are the API's /// to open; the flow closes the first from the app and tearDown /// closes whatever is left. private func openSupersedeFixtures() throws { _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main", "--title", Self.supersededTitle]) _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "main", "--target", "ui-smoke", "--title", Self.successorTitle]) let open = openSmokeMergeRequests().map { $0.title } try XCTSkipUnless(open.contains(Self.supersededTitle) && open.contains(Self.successorTitle), """ could not open both fixture merge requests on \(Self.fixtureRepo) \ (have \(open)); the ui-smoke branch may be level with main. """) } private func openSmokeMergeRequests() -> [(number: Int, title: String)] { guard let data = readCommand(["mr", "list", Self.fixtureRepo, "--state", "open"]), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let rows = envelope["data"] as? [[String: Any]] else { return [] } return rows.compactMap { row in guard let number = row["number"] as? Int, let title = row["title"] as? String, title.hasPrefix("UI smoke:") else { return nil } return (number, title) } } private func closeSmokeMergeRequests() { for mr in openSmokeMergeRequests() { _ = runCommand(["mr", "close", Self.fixtureRepo, String(mr.number)]) } } /// `repo delete` over the JSON API — the app has no delete screen /// (deletion is SSH-only by design), so cleanup cannot go through /// the UI. A repo that is not there answers exit 3, which is the /// outcome we want anyway. private func deleteScratchRepo() { _ = runCommand(["repo", "delete", Self.scratchRepo, "--yes"]) // The rename round-trip's halfway name, in case a run died there. _ = runCommand(["repo", "delete", Self.scratchRepo + "-2", "--yes"]) } /// The milestone the editing flow assigns. Created rather than /// required: unlike a branch, the API can make one. static let fixtureMilestone = "v1.0.0" private func ensureFixtureMilestone() { guard let data = readCommand(["milestone", "list", Self.fixtureRepo, "--state", "all"]), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } let rows = envelope["data"] as? [[String: Any]] ?? [] if rows.contains(where: { $0["title"] as? String == Self.fixtureMilestone }) { return } _ = runCommand(["milestone", "create", Self.fixtureRepo, Self.fixtureMilestone]) } /// The issue the editing flow works on. It is a fixture, not a real /// issue: pinning the test to whatever issue happened to be open put /// it at the mercy of the project moving on, and it duly broke when /// that issue was closed (krz/gitbay-ios#8). static let fixtureIssueTitle = "ui-smoke fixture: do not close" /// Guarantee the fixture issue exists and is open. It is reused /// rather than recreated, so runs do not pile up closed issues. private func ensureFixtureIssue() { guard let data = readCommand( ["issue", "list", Self.fixtureRepo, "--state", "open"]), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } let issues = envelope["data"] as? [[String: Any]] ?? [] if issues.contains(where: { $0["title"] as? String == Self.fixtureIssueTitle }) { return } _ = runCommand( ["issue", "create", Self.fixtureRepo, "--title", Self.fixtureIssueTitle, "--file", "-"], stdin: "Created by the live smoke suite. It exercises the milestone " + "picker and the edit sheet, and is reused every run." ) } /// The MR flow needs a source branch carrying a commit main does not /// have. Nothing in the app or the API can create a branch — that is /// a push — so this only checks, and says so plainly rather than /// letting the test fail later at "created MR not in the list". private func requireFixtureBranch() throws { guard let data = readCommand(["repo", "refs", Self.fixtureRepo]), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let refs = envelope["data"] as? [String: Any], let branches = refs["branches"] as? [[String: Any]] else { return } // no token, or the instance is unreachable let names = branches.compactMap { $0["name"] as? String } try XCTSkipUnless(names.contains("ui-smoke"), """ \(Self.fixtureRepo) has no ui-smoke branch. The MR flows open \ one merge request each way between it and main, so each side \ needs a commit the other lacks; smoke-account.sh seeds both. """) } /// POST /api/v1/cmd. Writes the suite needs but the app cannot make. @discardableResult private func runCommand(_ argv: [String], stdin: String? = nil) -> Data? { var body: [String: Any] = ["argv": argv] if let stdin { body["stdin"] = stdin } return call(method: "POST", path: "/api/v1/cmd", body: body) } /// GET /api/v1/read, for the reads that decide what a test sets up. private func readCommand(_ argv: [String]) -> Data? { let query = argv .map { "argv=" + ($0.addingPercentEncoding( withAllowedCharacters: .alphanumerics) ?? $0) } .joined(separator: "&") return call(method: "GET", path: "/api/v1/read?" + query, body: nil) } private func call(method: String, path: String, body: [String: Any]?) -> Data? { guard let token = ProcessInfo.processInfo .environment["GITBAY_UITEST_TOKEN"], !token.isEmpty, let url = URL(string: "https://gitbay.org" + path) else { return nil } var request = URLRequest(url: url) request.httpMethod = method request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") if let body { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: body) } var result: Data? let done = DispatchSemaphore(value: 0) URLSession.shared.dataTask(with: request) { data, _, _ in result = data done.signal() }.resume() _ = done.wait(timeout: .now() + 15) return result } /// Everything in one ordered pass: milestone assign, edit-save, /// MR create, MR close. One test so the flows share navigation and /// the writes happen exactly once. func testCreationAndEditingFlows() throws { openRepo(Self.fixtureRepo) // --- the fixture issue: milestone picker assigns the fixture --- repoLink("issues").tap() let issueRow = app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'ui-smoke fixture'")).firstMatch XCTAssertTrue(issueRow.waitForExistence(timeout: 10), "fixture issue not in the list; setUp should have created it") issueRow.tap() let milestoneMenu = app.descendants(matching: .any) .matching(identifier: "milestone-menu").firstMatch XCTAssertTrue(milestoneMenu.waitForExistence(timeout: 10)) milestoneMenu.tap() let milestoneChoice = app.buttons .containing(NSPredicate(format: "label BEGINSWITH %@", Self.fixtureMilestone)).firstMatch XCTAssertTrue(milestoneChoice.waitForExistence(timeout: 5), "milestone menu did not open") milestoneChoice.tap() // The reload renders the assigned milestone in the header row. XCTAssertTrue(app.staticTexts[Self.fixtureMilestone].firstMatch .waitForExistence(timeout: 10), "milestone not shown after assign") // --- the fixture issue: edit sheet saves (content unchanged) --- app.descendants(matching: .any).matching(identifier: "issue-actions-menu") .firstMatch.tap() let edit = app.buttons["Edit"].firstMatch XCTAssertTrue(edit.waitForExistence(timeout: 5)) edit.tap() let title = app.descendants(matching: .any) .matching(identifier: "compose-title").firstMatch XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open") XCTAssertTrue((title.value as? String)?.contains("ui-smoke fixture") == true, "edit sheet did not prefill") // Save the body as org, reopen, and the picker must start on Org: // the sheet has to read the stored format, not its own default. let format = app.segmentedControls.matching(identifier: "compose-format").firstMatch XCTAssertTrue(format.waitForExistence(timeout: 5), "format picker missing") XCTAssertTrue(format.buttons["Markdown"].isSelected, "fixture did not open on Markdown") format.buttons["Org"].tap() app.descendants(matching: .any).matching(identifier: "compose-submit") .firstMatch.tap() // Sheet dismissal proves the save round-tripped without error. XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss") app.descendants(matching: .any).matching(identifier: "issue-actions-menu") .firstMatch.tap() XCTAssertTrue(edit.waitForExistence(timeout: 5)) edit.tap() XCTAssertTrue(format.waitForExistence(timeout: 5), "edit sheet did not reopen") XCTAssertTrue(format.buttons["Org"].isSelected, "edit sheet did not open on the stored format") // Restore the fixture to markdown. format.buttons["Markdown"].tap() app.descendants(matching: .any).matching(identifier: "compose-submit") .firstMatch.tap() XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss") back() // to issues list back() // to repo screen // --- MR: create from ui-smoke, then close it --- repoLink("merge-requests").tap() app.descendants(matching: .any).matching(identifier: "mr-create-button") .firstMatch.tap() let source = app.descendants(matching: .any) .matching(identifier: "mr-source").firstMatch XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open") focusAndType(source, "ui-smoke") let target = app.descendants(matching: .any) .matching(identifier: "mr-target").firstMatch // The default branch prefilled while the sheet loaded. XCTAssertEqual(target.value as? String, "main", "target did not prefill") let mrTitle = app.descendants(matching: .any) .matching(identifier: "mr-title").firstMatch focusAndType(mrTitle, "UI smoke: mr create from the app") app.descendants(matching: .any).matching(identifier: "mr-submit") .firstMatch.tap() let createdRow = app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'UI smoke'")).firstMatch XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list") createdRow.tap() // The gates are shown before any merge is tried. let gates = header("Gates") XCTAssertTrue(scrollTo(gates, swipes: 8), "gates section missing") // A fresh MR has one revision; the section lists it. let revisions = header("Revisions") XCTAssertTrue(scrollTo(revisions, swipes: 8), "revisions section missing") XCTAssertTrue(app.staticTexts["v1"].firstMatch.exists, "first revision not listed") app.descendants(matching: .any).matching(identifier: "mr-actions-menu") .firstMatch.tap() let close = app.buttons["Close"].firstMatch XCTAssertTrue(close.waitForExistence(timeout: 5)) close.tap() // The confirmation dialog's destructive Close. let confirm = app.buttons["Close"].firstMatch XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing") confirm.tap() XCTAssertTrue(app.staticTexts["Closed"].firstMatch .waitForExistence(timeout: 15), "MR did not show closed after close") } // MARK: - Helpers private func openRepo(_ path: String) { selectTab("Repositories") // `.searchable` keeps the field tucked above the list until it is // scrolled into view. let search = app.searchFields.firstMatch if !search.waitForExistence(timeout: 5) { app.swipeDown() XCTAssertTrue(search.waitForExistence(timeout: 10), "search field never appeared") } focusAndType(search, path) let row = app.staticTexts[path].firstMatch XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list") row.tap() // Repo screen is loaded once its links render. XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10), "repo screen did not open") } private func back() { app.navigationBars.buttons.firstMatch.tap() } /// One of the four links in the grid at the top of the repo screen. private func repoLink(_ id: String) -> XCUIElement { app.descendants(matching: .any).matching(identifier: "repo-link-\(id)").firstMatch } /// A button in the repo screen's More section, above the README. private func moreLink(_ title: String) -> XCUIElement { let link = app.buttons[title].firstMatch XCTAssertTrue(scrollTo(link), "\(title) not under More") return link } /// Settings is in the repo screen's actions menu. private func openRepoSettings() { let menu = app.descendants(matching: .any) .matching(identifier: "repo-actions-menu").firstMatch XCTAssertTrue(menu.waitForExistence(timeout: 10), "repo actions menu missing") menu.tap() let settings = app.buttons["Settings"].firstMatch XCTAssertTrue(settings.waitForExistence(timeout: 5), "Settings missing from the actions menu") settings.tap() } /// Keys, PGP and email live behind the account menu, which is on the /// My Profile tab and nowhere else. func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) { selectTab("My Profile") let menu = app.descendants(matching: .any) .matching(identifier: "account-menu").firstMatch XCTAssertTrue(menu.waitForExistence(timeout: 15), "account menu missing", file: file, line: line) menu.tap() let keys = app.buttons["Keys & Email"].firstMatch XCTAssertTrue(keys.waitForExistence(timeout: 5), "Keys & Email missing from the account menu", file: file, line: line) keys.tap() } /// Seeding a repository's first commit is a push, which no test can /// make, so this only checks — and says why, rather than failing /// later at "ios-smoke/ui-smoke-edit not in the repo list". private func requireFixtureRepo() throws { guard let data = readCommand(["repo", "show", Self.fixtureRepo]), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } // no token, or the instance is unreachable try XCTSkipUnless(envelope["data"] != nil, """ \(Self.fixtureRepo) is missing. The write flows work in it; \ gitbayUITests/smoke-account.sh seeds it (or push it by hand, \ see that script) and run again. """) } /// Scroll a list until an element is in the hierarchy. Offscreen /// rows do not exist to XCUITest, so waitForExistence alone fails on /// anything below the fold. @discardableResult /// A list section header. iOS 18 renders these uppercased and iOS 26 /// as written, so the match ignores case. func header(_ text: String) -> XCUIElement { app.staticTexts.matching(NSPredicate(format: "label ==[c] %@", text)).firstMatch } func scrollTo(_ element: XCUIElement, swipes: Int = 6) -> Bool { for _ in 0.. Bool { let predicate = NSPredicate(format: "exists == false") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } } extension LiveSmokeUITests { /// Repo management, kept reversible: create a scratch repo (deleted /// by the runner afterwards, deletion is CLI-only by design), pin and /// unpin, a topic round-trip, a merge-rule toggle round-trip, and the /// build-trigger error path on a repo with no job config. func testRepoManagementFlows() throws { // --- repo create first: it ends in the list's search state, // which the next step reuses. (Scratch repo; the runner deletes // it over SSH afterwards — deletion is CLI-only by design.) selectTab("Repositories") app.descendants(matching: .any).matching(identifier: "repo-create-button") .firstMatch.tap() let newRepo = app.buttons["New Repository"].firstMatch XCTAssertTrue(newRepo.waitForExistence(timeout: 5), "create menu did not open") newRepo.tap() let pathField = app.descendants(matching: .any) .matching(identifier: "repo-create-path").firstMatch XCTAssertTrue(pathField.waitForExistence(timeout: 5)) focusAndType(pathField, "ui-smoke") app.switches.firstMatch.tap() // Private on app.descendants(matching: .any).matching(identifier: "repo-create-submit") .firstMatch.tap() // The sheet dismissing proves the create round-tripped; rows are // lazy, so find the new repo through the filter. XCTAssertTrue(waitForDisappearance(pathField, timeout: 15), "create sheet did not dismiss") let createSearch = app.searchFields.firstMatch XCTAssertTrue(createSearch.waitForExistence(timeout: 10)) focusAndType(createSearch, "ui-smoke") XCTAssertTrue(app.staticTexts[Self.scratchRepo].firstMatch .waitForExistence(timeout: 15), "created repo not in the list") // --- access: grant a user on the scratch repo, then revoke --- app.staticTexts[Self.scratchRepo].firstMatch.tap() XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10)) openRepoSettings() let grantUser = app.descendants(matching: .any) .matching(identifier: "settings-grant-user").firstMatch XCTAssertTrue(scrollTo(grantUser, swipes: 8), "access section not reachable") focusAndType(grantUser, "apple-review") app.descendants(matching: .any).matching(identifier: "settings-grant-submit") .firstMatch.tap() let grantRow = app.staticTexts["apple-review"].firstMatch XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed") grantRow.swipeLeft() app.buttons["Revoke"].firstMatch.tap() let confirmGrantRevoke = app.buttons["Revoke"].firstMatch XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing") confirmGrantRevoke.tap() XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked") // --- webhooks: add one, see it listed, remove it --- let webhooksLink = app.descendants(matching: .any) .matching(identifier: "settings-webhooks-link").firstMatch XCTAssertTrue(scrollTo(webhooksLink, swipes: 8), "webhooks link not reachable") webhooksLink.tap() let addHook = app.descendants(matching: .any) .matching(identifier: "webhook-add-button").firstMatch XCTAssertTrue(addHook.waitForExistence(timeout: 10), "webhooks did not load") addHook.tap() let hookURL = app.descendants(matching: .any) .matching(identifier: "webhook-url").firstMatch XCTAssertTrue(hookURL.waitForExistence(timeout: 5), "webhook sheet did not open") focusAndType(hookURL, "https://example.com/ui-smoke") app.descendants(matching: .any).matching(identifier: "webhook-submit") .firstMatch.tap() let hookRow = app.staticTexts["https://example.com/ui-smoke"].firstMatch XCTAssertTrue(hookRow.waitForExistence(timeout: 15), "webhook not listed") hookRow.swipeLeft() app.buttons["Remove"].firstMatch.tap() let confirmHookRemove = app.buttons["Remove"].firstMatch XCTAssertTrue(confirmHookRemove.waitForExistence(timeout: 5), "webhook remove confirmation missing") confirmHookRemove.tap() XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed") back() // webhooks -> settings // --- rename the scratch repo there and back --- // Each rename pushes the renamed repository; the round-trip ends // on the scratch repo again, several screens deep. var firstRename = true var currentPath = Self.scratchRepo let rename: (String, String) -> Void = { newName, expectedPath in if firstRename { firstRename = false // already on the settings screen } else { self.openRepoSettings() } let renameButton = self.app.descendants(matching: .any) .matching(identifier: "settings-rename-button").firstMatch XCTAssertTrue(self.scrollTo(renameButton, swipes: 10), "rename button not reachable") renameButton.tap() let field = self.app.textFields["New name"].firstMatch XCTAssertTrue(field.waitForExistence(timeout: 5), "rename alert did not open") field.tap() // Prefilled with the current name; clear it before typing. field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) + newName) // Rename stays disabled until the current path is typed. let renameAction = self.app.alerts.buttons["Rename"].firstMatch XCTAssertFalse(renameAction.isEnabled, "rename enabled before the path was typed") let confirm = self.app.textFields["type \(currentPath) to confirm"].firstMatch XCTAssertTrue(confirm.exists, "rename confirmation field missing") confirm.tap() confirm.typeText(currentPath) renameAction.tap() // The settings screen offers the renamed repository at the // top; the list is scrolled to its end, so go back up first. let renamedLink = self.app.descendants(matching: .any) .matching(identifier: "settings-renamed-link").firstMatch sleep(2) for _ in 0..<6 where !renamedLink.exists { self.app.swipeDown() } XCTAssertTrue(renamedLink.waitForExistence(timeout: 10), "rename did not land") renamedLink.tap() // The repo screen's title is the name alone; the header // splits the path into the owner link and the rest. XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)") currentPath = expectedPath } rename("ui-smoke-2", Self.scratchRepo + "-2") rename("ui-smoke", Self.scratchRepo) // --- pin / unpin round-trip on krz/gitbay-ios --- // Start the list fresh; the rename left the stack several deep. app.terminate() app.launch() selectTab("Repositories") let search = app.searchFields.firstMatch XCTAssertTrue(search.waitForExistence(timeout: 10)) focusAndType(search, "krz/gitbay-ios") let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch XCTAssertTrue(repoRow.waitForExistence(timeout: 15)) repoRow.tap() XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10)) let menu = app.descendants(matching: .any) .matching(identifier: "repo-actions-menu").firstMatch XCTAssertTrue(menu.waitForExistence(timeout: 10)) menu.tap() let pin = app.buttons["Pin"].firstMatch XCTAssertTrue(pin.waitForExistence(timeout: 5), "Pin action missing") pin.tap() // State refreshed from the dashboard: the menu now offers Unpin. menu.tap() let unpin = app.buttons["Unpin"].firstMatch XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take") unpin.tap() // Bookmark it, find it in the bookmarks list, then unbookmark. // A run that died between the two leaves it bookmarked; undo // that first so the step starts from the same state every time. menu.tap() let leftover = app.buttons["Unbookmark"].firstMatch if leftover.waitForExistence(timeout: 3) { leftover.tap() XCTAssertTrue(waitForDisappearance(leftover, timeout: 10)) menu.tap() } let bookmark = app.buttons["Bookmark"].firstMatch XCTAssertTrue(bookmark.waitForExistence(timeout: 5), "Bookmark action missing") bookmark.tap() // An active search hides the list's toolbar, so start the list // fresh: relaunching lands on a list with the bookmarks button. app.terminate() app.launch() selectTab("Repositories") let bookmarksButton = app.descendants(matching: .any) .matching(identifier: "repo-bookmarks-button").firstMatch XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 15), "bookmarks button missing") bookmarksButton.tap() let bookmarkedRow = app.staticTexts["krz/gitbay-ios"].firstMatch XCTAssertTrue(bookmarkedRow.waitForExistence(timeout: 15), "bookmarked repo not in the bookmarks list") // Open it from the bookmarks list and undo the bookmark there. bookmarkedRow.tap() XCTAssertTrue(menu.waitForExistence(timeout: 10)) menu.tap() let unbookmark = app.buttons["Unbookmark"].firstMatch XCTAssertTrue(unbookmark.waitForExistence(timeout: 10), "bookmark did not take") unbookmark.tap() // --- settings: topic and merge-rule round-trips --- openRepoSettings() let addTopic = app.descendants(matching: .any) .matching(identifier: "settings-add-topic").firstMatch XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load") focusAndType(addTopic, "ios") app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit") .firstMatch.tap() let chip = app.staticTexts["ios"].firstMatch XCTAssertTrue(chip.waitForExistence(timeout: 10), "topic did not appear") // Remove it again through the chip's own x button — by name, since // the repository carries other topics — and it asks first. app.descendants(matching: .any).matching(identifier: "settings-remove-topic-ios") .firstMatch.tap() let confirmRemove = app.buttons["Remove"].firstMatch XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "topic remove confirmation missing") confirmRemove.tap() XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove") // Merge-requests-only sits with the protected branches. let mergeOnly = app.switches["Merge requests only"].firstMatch XCTAssertTrue(scrollTo(mergeOnly), "merge-only toggle not reachable") let innerMergeOnly = mergeOnly.switches.firstMatch let flipMergeOnly: () -> Void = { if innerMergeOnly.exists && innerMergeOnly != mergeOnly { innerMergeOnly.tap() } else { mergeOnly.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap() } } flipMergeOnly() XCTAssertTrue(waitForValue(mergeOnly, "1", timeout: 10), "merge-only toggle did not persist on") flipMergeOnly() XCTAssertTrue(waitForValue(mergeOnly, "0", timeout: 10), "merge-only toggle did not persist off") // A tag glob is protected, listed, and unprotected. let tagGlob = app.descendants(matching: .any) .matching(identifier: "settings-protect-tag").firstMatch XCTAssertTrue(scrollTo(tagGlob), "protected tags section not reachable") focusAndType(tagGlob, "ui-smoke-*") app.descendants(matching: .any).matching(identifier: "settings-protect-tag-submit") .firstMatch.tap() let globRow = app.staticTexts["ui-smoke-*"].firstMatch XCTAssertTrue(globRow.waitForExistence(timeout: 10), "tag glob not listed") // By identifier: the protected branch above has an Unprotect too. app.descendants(matching: .any).matching(identifier: "settings-unprotect-tag-ui-smoke-*") .firstMatch.tap() XCTAssertTrue(waitForDisappearance(globRow, timeout: 10), "tag glob not unprotected") // Merge rules sit below the protected branches and tags. let resolved = app.switches["Require threads resolved"].firstMatch // The merge-requirements section sits below the fold, and a List // does not build rows it has not shown, so it must be scrolled // into existence before it can be queried. XCTAssertTrue(scrollTo(resolved), "merge requirements section not reachable") // SwiftUI exposes the row as a switch that wraps the real // control; tap the innermost switch when there is one, else the // right edge of the row. let inner = resolved.switches.firstMatch let control: () -> Void = { if inner.exists && inner != resolved { inner.tap() } else { resolved.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap() } } control() XCTAssertTrue(waitForValue(resolved, "1", timeout: 10), "toggle did not persist on") control() XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off") // The code-owner rule is one more toggle in the same section. let codeowners = app.switches["Require code owner review"].firstMatch XCTAssertTrue(scrollTo(codeowners), "code owner toggle not reachable") let innerCodeowners = codeowners.switches.firstMatch let flipCodeowners: () -> Void = { if innerCodeowners.exists && innerCodeowners != codeowners { innerCodeowners.tap() } else { codeowners.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap() } } flipCodeowners() XCTAssertTrue(waitForValue(codeowners, "1", timeout: 10), "code owner toggle did not persist on") flipCodeowners() XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off") back() // settings -> repo // --- nothing to trigger without a job file --- // This repo has no .gitbay/ci.yml, so `build jobs` returns none // and the control is gated rather than failing after a guess. repoLink("builds").tap() let trigger = app.descendants(matching: .any) .matching(identifier: "build-trigger-button").firstMatch XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing") // Assert on behaviour, not on isEnabled: a disabled SwiftUI Menu // still reports itself enabled to XCUITest. trigger.tap() let anyJob = app.buttons.containing( NSPredicate(format: "label CONTAINS 'on push' OR label CONTAINS 'schedule '")) .firstMatch XCTAssertFalse(anyJob.waitForExistence(timeout: 3), "a repo with no job file offered a job to trigger") } private func waitForValue(_ element: XCUIElement, _ value: String, timeout: TimeInterval) -> Bool { let predicate = NSPredicate(format: "value == %@", value) let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } } extension LiveSmokeUITests { /// Discovery is read-only: feed, server-side repo search, grep, and /// profiles. No cleanup needed. func testDiscoveryFlows() throws { // --- feed renders events and navigates --- selectTab("Feed") let firstEvent = app.cells.firstMatch XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events") firstEvent.tap() // Wherever the event led, it left the feed root behind. XCTAssertTrue(app.navigationBars.buttons.firstMatch .waitForExistence(timeout: 10), "feed row did not navigate") back() // --- server-side search: "astronomy" is only a topic, invisible // to the client-side path/description filter --- selectTab("Repositories") let search = app.searchFields.firstMatch XCTAssertTrue(search.waitForExistence(timeout: 10)) focusAndType(search, "astronomy") let hit = app.staticTexts["krz/space-wiki"].firstMatch XCTAssertTrue(hit.waitForExistence(timeout: 15), "server-side topic search found nothing") hit.tap() XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10)) // --- owner profile from the repo screen --- let ownerLink = app.descendants(matching: .any) .matching(identifier: "repo-owner-link").firstMatch XCTAssertTrue(ownerLink.waitForExistence(timeout: 10), "owner link missing") ownerLink.tap() // The owner's description and About are theirs to change; the // repository list, below a long About, is what the profile always has. XCTAssertTrue(app.navigationBars["krz"].waitForExistence(timeout: 15), "profile did not load") XCTAssertTrue(scrollTo(app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch), "profile repos missing") back() // --- grep inside the repo, last: its search UI owns the screen --- moreLink("Search in Files").tap() let grepField = app.searchFields.firstMatch XCTAssertTrue(grepField.waitForExistence(timeout: 10)) focusAndType(grepField, "space") app.keyboards.buttons["search"].firstMatch.tap() let match = app.cells.firstMatch XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches") } } extension LiveSmokeUITests { /// Releases: list and detail on a real release, an edit round-trip /// that saves the prefilled content (a no-op write), and the /// missing-tag refusal on create. Nothing changes state. func testReleaseFlows() throws { openRepo("krz/gitbay") moreLink("Releases").tap() // Newest first, and the list has grown past the fold, so the // first row is whichever release is current. let row = app.cells.firstMatch XCTAssertTrue(row.waitForExistence(timeout: 15), "release list empty") row.tap() // Notes render and assets carry sizes. The assets sit under the // notes, which can run past the fold. XCTAssertTrue(app.descendants(matching: .any) .matching(identifier: "release-edit-button").firstMatch .waitForExistence(timeout: 15), "release did not open") XCTAssertTrue(scrollTo(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'SHA256SUMS'")).firstMatch, swipes: 10), "assets missing") // Edit sheet prefills; saving unchanged content round-trips. app.descendants(matching: .any).matching(identifier: "release-edit-button") .firstMatch.tap() let title = app.descendants(matching: .any) .matching(identifier: "compose-title").firstMatch XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open") XCTAssertFalse((title.value as? String ?? "").isEmpty, "edit sheet did not prefill") app.descendants(matching: .any).matching(identifier: "compose-submit") .firstMatch.tap() XCTAssertTrue(waitForDisappearance(title, timeout: 15), "release edit did not dismiss") back() // Create with a tag that does not exist: the server's refusal is // the UI contract. app.descendants(matching: .any).matching(identifier: "release-create-button") .firstMatch.tap() let tag = app.descendants(matching: .any) .matching(identifier: "release-tag").firstMatch XCTAssertTrue(tag.waitForExistence(timeout: 5)) focusAndType(tag, "v9.9.9") app.descendants(matching: .any).matching(identifier: "release-submit") .firstMatch.tap() XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'push the tag first'")).firstMatch .waitForExistence(timeout: 15), "missing-tag refusal not surfaced") app.buttons["Cancel"].firstMatch.tap() } } extension LiveSmokeUITests { /// Account keys and email. Read-only plus refusal paths — no key is /// added or removed, no mail is sent. func testAccountFlows() throws { // Dashboard toolbar -> account screen. openAccountScreen() // Real keys render: SSH fingerprints first. XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch .waitForExistence(timeout: 15), "SSH keys missing") // Pasting garbage as an SSH key surfaces the server's validation. // Done while the SSH section is still on screen: labelled keys // are two lines each, so what follows sits below the fold. app.descendants(matching: .any).matching(identifier: "add-ssh-key") .firstMatch.tap() let paste = app.descendants(matching: .any) .matching(identifier: "key-paste-text").firstMatch XCTAssertTrue(paste.waitForExistence(timeout: 5)) focusAndType(paste, "not a key") app.descendants(matching: .any).matching(identifier: "key-paste-submit") .firstMatch.tap() // An expiring token (the suite's are) is refused before the key is // parsed; either refusal reaching the sheet passes. XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'not a valid public key' OR label CONTAINS 'token without an expiry'")) .firstMatch.waitForExistence(timeout: 15), "invalid-key refusal not surfaced") app.buttons["Cancel"].firstMatch.tap() // The PGP key's UID email, below the keys on a List that does not // build rows it has not shown. XCTAssertTrue(scrollTo(app.staticTexts[Self.smokePGPEmail].firstMatch), "PGP key UID missing") // Activity mail flips off and back on; the value comes back from // the server each time. let mail = app.switches["Activity by mail"].firstMatch XCTAssertTrue(scrollTo(mail), "mail toggle not reachable") let innerMail = mail.switches.firstMatch let flipMail: () -> Void = { if innerMail.exists && innerMail != mail { innerMail.tap() } else { mail.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap() } } let mailWasOn = (mail.value as? String) == "1" flipMail() XCTAssertTrue(waitForValue(mail, mailWasOn ? "0" : "1", timeout: 10), "mail toggle did not persist") flipMail() XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore") // The watch preference (v1.19.0) flips the same way. let watch = app.switches["Watch writable repositories"].firstMatch XCTAssertTrue(scrollTo(watch), "watch toggle not reachable") let innerWatch = watch.switches.firstMatch let flipWatch: () -> Void = { if innerWatch.exists && innerWatch != watch { innerWatch.tap() } else { watch.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap() } } let watchWasOn = (watch.value as? String) == "1" flipWatch() XCTAssertTrue(waitForValue(watch, watchWasOn ? "0" : "1", timeout: 10), "watch toggle did not persist") flipWatch() XCTAssertTrue(waitForValue(watch, watchWasOn ? "1" : "0", timeout: 10), "watch toggle did not restore") // A bogus verification code is refused, not swallowed. let code = app.descendants(matching: .any) .matching(identifier: "email-code").firstMatch // Email sits below the keys on a List, which does not build rows // it has not shown. XCTAssertTrue(scrollTo(code), "email section not reachable") // The addresses list above the fields, the primary one marked. XCTAssertTrue(app.staticTexts["primary"].firstMatch.exists, "primary address not marked") focusAndType(code, "000000") let verify = app.descendants(matching: .any) .matching(identifier: "email-verify").firstMatch XCTAssertTrue(verify.waitForExistence(timeout: 5)) XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed") verify.tap() XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'invalid, expired' OR label CONTAINS 'token without an expiry'")) .firstMatch.waitForExistence(timeout: 15), "bad-code refusal not surfaced") } } extension LiveSmokeUITests { /// Orgs: members render, and a team lives a full life — created, /// granted a repo, the grant revoked, the team deleted. Everything /// this test makes, it removes. func testOrgFlows() throws { openAccountScreen() let orgRow = app.staticTexts["krz"].firstMatch XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing") orgRow.tap() // Members render with roles. XCTAssertTrue(app.staticTexts[Self.smokeUser].firstMatch .waitForExistence(timeout: 15), "org members missing") // Create a team. let teamField = app.descendants(matching: .any) .matching(identifier: "org-new-team").firstMatch XCTAssertTrue(teamField.waitForExistence(timeout: 5)) focusAndType(teamField, "ui-smoke") app.descendants(matching: .any).matching(identifier: "org-new-team-submit") .firstMatch.tap() let teamRow = app.staticTexts["ui-smoke"].firstMatch XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed") // Grant it a repo, then revoke. teamRow.tap() let repoField = app.textFields["org/repo"].firstMatch XCTAssertTrue(repoField.waitForExistence(timeout: 10)) focusAndType(repoField, "krz/gitbay-ios") app.buttons["Grant"].firstMatch.tap() let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed") grantRow.swipeLeft() app.buttons["Revoke"].firstMatch.tap() // The confirmation dialog's destructive Revoke. let confirmRevoke = app.buttons["Revoke"].firstMatch XCTAssertTrue(confirmRevoke.waitForExistence(timeout: 5), "revoke confirmation missing") confirmRevoke.tap() XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked") back() // Delete the team, through its confirmation. let row = app.staticTexts["ui-smoke"].firstMatch XCTAssertTrue(row.waitForExistence(timeout: 10)) row.swipeLeft() app.buttons["Delete"].firstMatch.tap() // The confirmation dialog's destructive Delete. let confirm = app.buttons["Delete"].firstMatch XCTAssertTrue(confirm.waitForExistence(timeout: 5)) confirm.tap() XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted") } } extension LiveSmokeUITests { /// The pre-merge screenshot checkpoint: walks the dense screens and /// attaches captures. Run per device/appearance; export the /// attachments from the xcresult. Signs in first when the device has /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided. func testScreenshotCheckpoint() throws { signInIfNeeded() snap("dashboard") openRepo("krz/gitbay") snap("repo") repoLink("merge-requests").tap() let all = app.segmentedControls.buttons["All"].firstMatch XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing") all.tap() // A real MR row carries its !number; the picker row does not. let mrNumber = app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All") snap("mr-list") mrNumber.tap() // The detail opens at its header; the diff row sits below the // fold and a List does not build rows it has not shown. let diffRow = app.staticTexts["Diff"].firstMatch XCTAssertTrue(scrollTo(diffRow), "MR detail has no diff row") snap("mr-detail") diffRow.tap() XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15)) snap("diff") } private func signInIfNeeded() { // Signed in already: the tab bar is up and there is no token field. if app.tabBars.firstMatch.waitForExistence(timeout: 10), !app.secureTextFields.firstMatch.exists { return } let tokenField = app.secureTextFields.firstMatch guard tokenField.waitForExistence(timeout: 5) else { return } guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else { XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided") return } focusAndType(tokenField, token + "\n") XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch .waitForExistence(timeout: 20), "sign-in did not land") dismissSavePasswordPrompt() } /// iOS offers to save whatever went into a SecureField, and the sheet /// sits over the app until answered — over the dashboard, in one set /// of store screenshots. It belongs to springboard, not to the app. private func dismissSavePasswordPrompt() { let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") let notNow = springboard.buttons["Not Now"].firstMatch if notNow.waitForExistence(timeout: 8) { notNow.tap() _ = waitForDisappearance(notNow, timeout: 10) return } let inApp = app.buttons["Not Now"].firstMatch if inApp.waitForExistence(timeout: 3) { inApp.tap() _ = waitForDisappearance(inApp, timeout: 10) return } // No prompt is fine: iOS only offers once per credential. } /// Capture a screen, but not while it is still loading. These go to /// the App Store, and a spinner shipped as a screenshot once already. private func snap(_ name: String, file: StaticString = #filePath, line: UInt = #line) { let spinner = app.activityIndicators.firstMatch if spinner.exists { XCTAssertTrue(waitForDisappearance(spinner, timeout: 30), "\(name) still loading after 30s", file: file, line: line) } // A list that has loaded has rows; an empty state has text. Either // way something must be on screen besides chrome. let content = app.cells.firstMatch _ = content.waitForExistence(timeout: 10) let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = name attachment.lifetime = .keepAlways add(attachment) } } extension LiveSmokeUITests { /// Blame and file editing — the two capabilities that were web-only /// until krz/gitbay#42 and #43 made them commands. Blame is read-only /// on a real repo; the edit writes to a scratch repo the runner /// creates and deletes over SSH. func testBlameAndEditFlows() throws { // --- blame on a real file --- openRepo("krz/gitbay") repoLink("files").tap() let goMod = app.staticTexts["go.mod"].firstMatch XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree") goMod.tap() XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu") .firstMatch.waitForExistence(timeout: 15), "file screen did not open") app.descendants(matching: .any).matching(identifier: "file-actions-menu") .firstMatch.tap() app.buttons["Blame"].firstMatch.tap() // Attribution shows the commit subject beside the lines. XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch .waitForExistence(timeout: 20), "blame rendered no attribution") // --- edit on the scratch repo --- // Relaunch rather than reuse the list: `.searchable` keeps the // previous query, and clearing it from the harness is unreliable. app.terminate() app.launch() openRepo(Self.fixtureRepo) repoLink("files").tap() let notes = app.staticTexts["notes.txt"].firstMatch XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree") notes.tap() let menu = app.descendants(matching: .any) .matching(identifier: "file-actions-menu").firstMatch XCTAssertTrue(menu.waitForExistence(timeout: 15)) menu.tap() let edit = app.buttons .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing") edit.tap() let content = app.descendants(matching: .any) .matching(identifier: "file-edit-content").firstMatch XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open") focusAndType(content, " edited from the app") let message = app.descendants(matching: .any) .matching(identifier: "file-edit-message").firstMatch focusAndType(message, "edit from ios") app.descendants(matching: .any).matching(identifier: "file-edit-commit") .firstMatch.tap() XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss") // The reloaded file shows the committed text. XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch .waitForExistence(timeout: 20), "edit not reflected after commit") } } extension LiveSmokeUITests { /// The navigation and profile changes: identity lives in one menu, /// the dashboard can create a repository, a profile is a real /// profile, and a log entry opens its commit. func testProfileAndNavigationFlows() throws { // --- the dashboard offers creation --- selectTab("Dashboard") let create = app.descendants(matching: .any) .matching(identifier: "dashboard-create-button").firstMatch XCTAssertTrue(create.waitForExistence(timeout: 15), "dashboard + missing") create.tap() let pathField = app.descendants(matching: .any) .matching(identifier: "repo-create-path").firstMatch XCTAssertTrue(pathField.waitForExistence(timeout: 10), "dashboard + did not open the create sheet") app.buttons["Cancel"].firstMatch.tap() // --- identity is its own tab; the account menu rides with it --- selectTab("My Profile") let menu = app.descendants(matching: .any) .matching(identifier: "account-menu").firstMatch XCTAssertTrue(menu.waitForExistence(timeout: 10), "account menu missing from My Profile") menu.tap() XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5), "keys not in the account menu") // ios-smoke is not an instance admin. XCTAssertFalse(app.descendants(matching: .any) .matching(identifier: "account-menu-admin").firstMatch.exists, "a non-admin is offered Admin") app.tap() // dismiss the menu; the profile is already on screen // --- a profile is a profile: description, links, orgs, graph, repos --- XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS %@", Self.smokeProfileDescription)).firstMatch .waitForExistence(timeout: 20), "profile description missing") XCTAssertTrue(header("Organizations").exists, "org memberships missing") XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing") // --- an org admin can edit the org's profile --- app.staticTexts["krz"].firstMatch.tap() XCTAssertTrue(app.descendants(matching: .any) .matching(identifier: "profile-edit-button").firstMatch .waitForExistence(timeout: 15), "org profile offers no edit to its admin") back() // --- saving the profile succeeds: profile set takes no about flags --- let edit = app.descendants(matching: .any) .matching(identifier: "profile-edit-button").firstMatch XCTAssertTrue(edit.waitForExistence(timeout: 10), "own profile offers no edit") edit.tap() let save = app.buttons["profile-edit-save"].firstMatch XCTAssertTrue(save.waitForExistence(timeout: 10), "edit sheet did not open") XCTAssertFalse(app.descendants(matching: .any) .matching(identifier: "profile-edit-about").firstMatch.exists, "the sheet still edits the about text") save.tap() XCTAssertTrue(app.navigationBars["Edit Profile"].waitForNonExistence(timeout: 15), "profile save did not dismiss the sheet — the server refused it") XCTAssertFalse(save.exists, "the edit sheet is still on screen") // --- the about text is a file: edit it, or offer to create it --- let aboutAction = app.descendants(matching: .any).matching(NSPredicate( format: "identifier IN %@", ["profile-about-edit", "profile-about-create"])).firstMatch XCTAssertTrue(scrollTo(aboutAction), "own profile offers neither editing nor creating the about file") // The graph and the repositories sit below the orgs and links, // and a List does not build rows it has not shown. XCTAssertTrue(scrollTo(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch), "activity graph missing") XCTAssertTrue(scrollTo(app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch), "repositories missing") // --- a log entry opens its commit --- app.terminate() app.launch() openRepo("krz/gitbay") moreLink("History").tap() let firstCommit = app.cells.firstMatch XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty") firstCommit.tap() // The commit screen IS the patch: no navigating away to find it. // A hunk header (@@) only appears in a rendered diff, and sits // below the message and the file list. let hunk = app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch _ = hunk.waitForExistence(timeout: 20) XCTAssertTrue(scrollTo(hunk, swipes: 10), "commit screen did not render the patch inline") } } extension LiveSmokeUITests { /// The three gaps that needed no server work: branches and tags, /// milestones, and an MR's milestone. Read-only except the MR /// milestone, which is set and cleared back. func testRefsAndMilestoneFlows() throws { openRepo("krz/gitbay") // --- branches and tags, and browsing at a ref --- moreLink("Branches & Tags").tap() let main = app.staticTexts["main"].firstMatch XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load") XCTAssertTrue(app.staticTexts["default"].firstMatch.exists, "the default branch is not marked") XCTAssertTrue(header("Tags").exists, "tags section missing") main.tap() // Tapping a ref browses the repository there. XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "browsing at a ref showed nothing") back() back() // --- milestones with progress --- moreLink("Milestones").tap() XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15), "milestones did not load") app.segmentedControls.buttons["All"].firstMatch.tap() XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch .waitForExistence(timeout: 20), "milestone progress missing") back() // --- an MR's milestone reads back, and can be cleared --- repoLink("merge-requests").tap() app.segmentedControls.buttons["All"].firstMatch.tap() let mrNumber = app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs") mrNumber.tap() let milestone = app.descendants(matching: .any) .matching(identifier: "mr-milestone-menu").firstMatch // The nav bar is the "opened" signal; everything else on this // screen can be below the fold. XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20), "MR detail did not open") XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing") milestone.tap() // The picker offers None plus the open milestones. XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10), "milestone picker did not open") app.buttons["None"].firstMatch.tap() } } extension LiveSmokeUITests { /// Explore and history at a ref — the two capabilities krz/gitbay!101 /// made reachable. Read-only. func testExploreAndRefLogFlows() throws { // --- explore lists what the instance hosts --- selectTab("Explore") let firstRepo = app.cells.firstMatch XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing") // Public repos this account does not own are reachable here. XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch .waitForExistence(timeout: 10), "explore is not the public listing") firstRepo.tap() XCTAssertTrue(repoLink("files").waitForExistence(timeout: 20), "explore row did not open its repo") // --- history at a ref --- app.terminate() app.launch() openRepo("krz/gitbay") // Browse a ref, then ask for its history — the move the tree page // offers on the web. moreLink("Branches & Tags").tap() let main = app.staticTexts["main"].firstMatch XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load") main.tap() let history = app.descendants(matching: .any) .matching(identifier: "tree-history-button").firstMatch XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree") history.tap() XCTAssertTrue(app.navigationBars .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch .waitForExistence(timeout: 20), "ref history did not open") XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "ref history rendered no commits") } } extension LiveSmokeUITests { /// The wiki — the last capability that was browser-only until /// krz/gitbay#48. Read-only; editing is a push, on every surface. func testWikiFlows() throws { openRepo("krz/gitbay") moreLink("Wiki").tap() // The landing page leads and is marked. let home = app.staticTexts["Home"].firstMatch XCTAssertTrue(home.waitForExistence(timeout: 20), "wiki pages did not load") XCTAssertTrue(app.staticTexts["home"].firstMatch.exists, "the landing page is not marked") // Parity.org is an org page, so this also proves the org renderer // runs on wiki content and not just READMEs. // Below the fold since the Architecture pages sort above it. let parity = app.staticTexts["Parity"].firstMatch XCTAssertTrue(scrollTo(parity), "Parity page missing from the listing") parity.tap() XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'surface'")).firstMatch .waitForExistence(timeout: 20), "wiki page rendered no prose") // Org headings render as headings, not as raw #+title:. XCTAssertFalse(app.staticTexts .containing(NSPredicate(format: "label CONTAINS '#+title'")).firstMatch.exists, "org markup leaked into the rendered page") } } extension LiveSmokeUITests { /// Builds on a repo that has a job file: the picker offers what the /// server names, and the detail screen says more than the log. func testBuildJobsAndDetail() throws { openRepo("krz/gitbay") repoLink("builds").tap() // The picker lists jobs from `build jobs` — no name to type. let trigger = app.descendants(matching: .any) .matching(identifier: "build-trigger-button").firstMatch XCTAssertTrue(trigger.waitForExistence(timeout: 20), "trigger control missing") XCTAssertTrue(trigger.isEnabled, "trigger is disabled where a job file exists") trigger.tap() let job = app.buttons .containing(NSPredicate(format: "label CONTAINS 'build' AND label CONTAINS 'on push'")) .firstMatch XCTAssertTrue(job.waitForExistence(timeout: 10), "job menu did not list the job with why it runs") // Dismiss without queueing a real build. app.tap() // The detail screen carries status, ref and timing, not just log. let firstBuild = app.cells.firstMatch XCTAssertTrue(firstBuild.waitForExistence(timeout: 20), "no builds listed") firstBuild.tap() for label in ["success", "queued"] where app.staticTexts[label].firstMatch.exists { XCTAssertTrue(true) } XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH 'queued '")).firstMatch .waitForExistence(timeout: 20), "build detail shows no timing") } } extension LiveSmokeUITests { /// Store screenshots on iPad. It navigates by tab and through /// Explore rather than reusing the iPhone checkpoint: that one drives /// the repo list's `.searchable`, which iPadOS lays out differently, /// and chasing those differences is the iPad pass this release /// deferred. Tabs and a list row work on both. func testIPadScreenshotCheckpoint() throws { signInIfNeeded() snap("dashboard") selectTab("Explore") snap("explore") // First public repo in the list — no search field involved. let firstRepo = app.cells.firstMatch XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing") firstRepo.tap() XCTAssertTrue(repoLink("files").waitForExistence(timeout: 20), "repo screen did not open") snap("repo") selectTab("Feed") snap("feed") selectTab("My Profile") // Any signed-in account can edit its own profile, so this holds // for store screenshots taken as a real user too. XCTAssertTrue(app.descendants(matching: .any) .matching(identifier: "profile-edit-button").firstMatch .waitForExistence(timeout: 20), "profile did not load") snap("profile") } } extension LiveSmokeUITests { /// Snippets, a full life on the caller's own account: create, add a /// file, replace it, remove it, change visibility, delete. setUp and /// tearDown remove anything left by this description. func testSnippetFlows() throws { selectTab("My Profile") app.descendants(matching: .any).matching(identifier: "account-menu").firstMatch.tap() let snippets = app.buttons["Snippets"].firstMatch XCTAssertTrue(snippets.waitForExistence(timeout: 5), "Snippets missing from the account menu") snippets.tap() // --- create --- let create = app.descendants(matching: .any) .matching(identifier: "snippet-create-button").firstMatch XCTAssertTrue(create.waitForExistence(timeout: 15), "snippet list did not open") create.tap() let nameField = app.descendants(matching: .any) .matching(identifier: "snippet-file-name").firstMatch XCTAssertTrue(nameField.waitForExistence(timeout: 5), "create sheet did not open") focusAndType(nameField, "ui-smoke.txt") focusAndType(app.descendants(matching: .any) .matching(identifier: "snippet-description").firstMatch, Self.smokeSnippetDescription) let content = app.descendants(matching: .any) .matching(identifier: "snippet-content").firstMatch focusAndType(content, "hello from the live suite") app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap() XCTAssertTrue(waitForDisappearance(nameField, timeout: 20), "create did not dismiss") let row = app.staticTexts[Self.smokeSnippetDescription].firstMatch XCTAssertTrue(row.waitForExistence(timeout: 15), "created snippet not listed") row.tap() // --- detail: the file renders, unlisted by default --- XCTAssertTrue(app.staticTexts["ui-smoke.txt"].firstMatch.waitForExistence(timeout: 15), "snippet file missing") XCTAssertTrue(app.staticTexts["unlisted"].firstMatch.exists, "default visibility not unlisted") // --- add a second file --- let actions = app.descendants(matching: .any) .matching(identifier: "snippet-actions-menu").firstMatch XCTAssertTrue(actions.waitForExistence(timeout: 10), "owner actions missing") actions.tap() let addFile = app.buttons["Add file"].firstMatch XCTAssertTrue(addFile.waitForExistence(timeout: 5)) addFile.tap() let secondName = app.descendants(matching: .any) .matching(identifier: "snippet-file-name").firstMatch XCTAssertTrue(secondName.waitForExistence(timeout: 5), "add-file sheet did not open") focusAndType(secondName, "second.txt") focusAndType(app.descendants(matching: .any) .matching(identifier: "snippet-content").firstMatch, "two") app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap() XCTAssertTrue(waitForDisappearance(secondName, timeout: 20), "add file did not dismiss") let second = app.staticTexts["second.txt"].firstMatch XCTAssertTrue(second.waitForExistence(timeout: 15), "added file missing") // --- replace its content from the file menu --- let fileMenu = app.descendants(matching: .any) .matching(identifier: "snippet-file-menu-second.txt").firstMatch XCTAssertTrue(scrollTo(fileMenu), "file menu not reachable") fileMenu.tap() let edit = app.buttons["Edit"].firstMatch XCTAssertTrue(edit.waitForExistence(timeout: 5)) edit.tap() let editor = app.descendants(matching: .any) .matching(identifier: "snippet-content").firstMatch XCTAssertTrue(editor.waitForExistence(timeout: 5), "edit sheet did not open") XCTAssertTrue((editor.value as? String)?.contains("two") == true, "edit sheet did not prefill") focusAndType(editor, " and three") app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap() XCTAssertTrue(waitForDisappearance(editor, timeout: 20), "file save did not dismiss") XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'and three'")).firstMatch .waitForExistence(timeout: 15), "replaced content not rendered") // --- remove it, through its confirmation --- XCTAssertTrue(scrollTo(fileMenu), "file menu not reachable after edit") fileMenu.tap() app.buttons["Remove"].firstMatch.tap() let confirmRemove = app.buttons["Remove"].firstMatch XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "remove confirmation missing") confirmRemove.tap() XCTAssertTrue(waitForDisappearance(second, timeout: 15), "file not removed") // --- visibility to public --- actions.tap() let settings = app.buttons["Description & visibility"].firstMatch XCTAssertTrue(settings.waitForExistence(timeout: 5)) settings.tap() let visibility = app.segmentedControls.firstMatch XCTAssertTrue(visibility.waitForExistence(timeout: 5), "settings sheet did not open") visibility.buttons["public"].tap() app.buttons["Save"].firstMatch.tap() XCTAssertTrue(app.staticTexts["public"].firstMatch.waitForExistence(timeout: 15), "visibility change not shown") // --- delete, through its confirmation; the list no longer has it --- actions.tap() app.buttons["Delete snippet"].firstMatch.tap() let confirmDelete = app.buttons["Delete"].firstMatch XCTAssertTrue(confirmDelete.waitForExistence(timeout: 5), "delete confirmation missing") confirmDelete.tap() XCTAssertTrue(create.waitForExistence(timeout: 15), "did not return to the snippet list") XCTAssertTrue(waitForDisappearance(row, timeout: 15), "deleted snippet still listed") } /// An SSH key's label round-trips: set from the row's swipe action, /// then restored to what it was (or cleared). func testSSHKeyLabelFlow() throws { openAccountScreen() let fingerprint = app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch XCTAssertTrue(fingerprint.waitForExistence(timeout: 15), "SSH keys missing") let fp = fingerprint.label // What the key is called now, so it can be put back. var original = "" if let data = readCommand(["keys", "list"]), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let rows = envelope["data"] as? [[String: Any]], let row = rows.first(where: { $0["fingerprint"] as? String == fp }) { original = row["label"] as? String ?? "" } let relabel: (String) -> Void = { text in fingerprint.swipeLeft() let label = self.app.buttons["Label"].firstMatch XCTAssertTrue(label.waitForExistence(timeout: 5), "Label swipe action missing") label.tap() let field = self.app.alerts.textFields.firstMatch XCTAssertTrue(field.waitForExistence(timeout: 5), "label alert missing") self.clearAndType(field, text) self.app.alerts.buttons["Save"].firstMatch.tap() } relabel("ui-smoke") XCTAssertTrue(app.staticTexts["ui-smoke"].firstMatch.waitForExistence(timeout: 15), "label not shown after save") relabel(original) XCTAssertTrue(waitForDisappearance(app.staticTexts["ui-smoke"].firstMatch, timeout: 15), "label not restored") if !original.isEmpty { XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10), "original label not back") } // Removal asks for the fingerprint's first eight characters and // stays disabled until they are typed. Cancelled: the key stays. fingerprint.swipeLeft() let remove = app.buttons["Remove"].firstMatch XCTAssertTrue(remove.waitForExistence(timeout: 5), "Remove swipe action missing") remove.tap() let confirm = app.alerts.textFields.firstMatch XCTAssertTrue(confirm.waitForExistence(timeout: 5), "remove alert missing") let removeAction = app.alerts.buttons["Remove"].firstMatch XCTAssertFalse(removeAction.isEnabled, "remove enabled before confirmation") confirm.tap() confirm.typeText(String(fp.dropFirst("SHA256:".count).prefix(8))) XCTAssertTrue(removeAction.isEnabled, "remove not enabled by the typed confirmation") app.alerts.buttons["Cancel"].firstMatch.tap() XCTAssertTrue(fingerprint.waitForExistence(timeout: 5), "key gone after cancel") } /// Org labels and milestones from the org screen: a label is created /// and removed; the fixture milestone is reopened and closed again, /// and creating it a second time surfaces the server's refusal. func testOrgLabelAndMilestoneFlows() throws { openAccountScreen() let orgRow = app.staticTexts[Self.smokeOrg].firstMatch XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing") orgRow.tap() XCTAssertTrue(app.staticTexts[Self.smokeUser].firstMatch.waitForExistence(timeout: 15), "org members missing") // --- labels --- let labels = app.staticTexts["Labels"].firstMatch XCTAssertTrue(scrollTo(labels), "Labels link not reachable") labels.tap() let newName = app.descendants(matching: .any) .matching(identifier: "label-new-name").firstMatch XCTAssertTrue(newName.waitForExistence(timeout: 15), "org labels did not open") focusAndType(newName, Self.smokeOrgLabel) app.descendants(matching: .any).matching(identifier: "label-create-submit").firstMatch.tap() let chip = app.staticTexts[Self.smokeOrgLabel].firstMatch XCTAssertTrue(chip.waitForExistence(timeout: 15), "org label not listed after create") // The org's own screen manages its rows: the menu is there, not // the read-only mark. let cell = app.cells.containing(.staticText, identifier: Self.smokeOrgLabel).firstMatch let menu = cell.buttons.firstMatch XCTAssertTrue(menu.waitForExistence(timeout: 5), "org label has no menu on the org screen") menu.tap() let remove = app.buttons["Remove"].firstMatch XCTAssertTrue(remove.waitForExistence(timeout: 5)) remove.tap() let confirmRemove = app.buttons["Remove"].firstMatch XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "remove confirmation missing") confirmRemove.tap() XCTAssertTrue(waitForDisappearance(chip, timeout: 15), "org label not removed") back() // --- milestones --- let milestones = app.staticTexts["Milestones"].firstMatch XCTAssertTrue(scrollTo(milestones), "Milestones link not reachable") milestones.tap() let picker = app.segmentedControls.firstMatch XCTAssertTrue(picker.waitForExistence(timeout: 15), "org milestones did not open") picker.buttons["Closed"].tap() let fixture = app.staticTexts[Self.smokeOrgMilestone].firstMatch XCTAssertTrue(fixture.waitForExistence(timeout: 15), "fixture milestone not among closed") fixture.swipeLeft() let reopen = app.buttons["Reopen"].firstMatch XCTAssertTrue(reopen.waitForExistence(timeout: 5), "reopen action missing on the org screen") reopen.tap() XCTAssertTrue(waitForDisappearance(fixture, timeout: 15), "reopened milestone still among closed") picker.buttons["Open"].tap() XCTAssertTrue(fixture.waitForExistence(timeout: 15), "reopened milestone not among open") // Creating it again is refused with the server's sentence. app.descendants(matching: .any).matching(identifier: "milestone-create-button").firstMatch.tap() let title = app.descendants(matching: .any) .matching(identifier: "milestone-title").firstMatch XCTAssertTrue(title.waitForExistence(timeout: 5), "create sheet did not open") focusAndType(title, Self.smokeOrgMilestone) app.descendants(matching: .any).matching(identifier: "milestone-submit").firstMatch.tap() XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS[c] 'already'")).firstMatch .waitForExistence(timeout: 15), "duplicate milestone refusal not surfaced") app.buttons["Cancel"].firstMatch.tap() // Back to closed, where the fixture rests. XCTAssertTrue(fixture.waitForExistence(timeout: 10)) fixture.swipeLeft() let close = app.buttons["Close"].firstMatch XCTAssertTrue(close.waitForExistence(timeout: 5), "close action missing on the org screen") close.tap() XCTAssertTrue(waitForDisappearance(fixture, timeout: 15), "closed milestone still among open") } /// Build list filters on a repo with builds: a status narrows the /// list, a job narrows it further, and an impossible pair shows the /// filtered empty state. Clearing brings the list back. func testBuildFilterFlow() throws { openRepo("krz/gitbay") repoLink("builds").tap() XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed") let filter = app.descendants(matching: .any) .matching(identifier: "build-filter-button").firstMatch XCTAssertTrue(filter.waitForExistence(timeout: 10), "filter button missing") let choose: (String, String) -> Void = { menuId, option in let menu = self.app.descendants(matching: .any).matching(identifier: menuId).firstMatch XCTAssertTrue(menu.waitForExistence(timeout: 5), "\(menuId) missing") menu.tap() let choice = self.app.buttons[option].firstMatch XCTAssertTrue(choice.waitForExistence(timeout: 5), "\(option) not offered") choice.tap() } // status = success: rows remain and every row says success. filter.tap() choose("build-filter-status-menu", "success") app.buttons["Done"].firstMatch.tap() XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no successful builds") XCTAssertFalse(app.staticTexts["failure"].firstMatch.exists, "a failed build survived the filter") // job = test as well: the rows name the job. filter.tap() choose("build-filter-job-menu", "test") app.buttons["Done"].firstMatch.tap() XCTAssertTrue(app.staticTexts["test"].firstMatch .waitForExistence(timeout: 20), "job filter did not narrow to test") // status = pending on a quiet repo: the filtered empty state. filter.tap() choose("build-filter-status-menu", "pending") app.buttons["Done"].firstMatch.tap() XCTAssertTrue(app.staticTexts["No builds match these filters."].firstMatch .waitForExistence(timeout: 20), "filtered empty state missing") // Clear: the list is back. filter.tap() app.buttons["Clear filters"].firstMatch.tap() app.buttons["Done"].firstMatch.tap() XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "list did not come back") } /// krz/gitbay has well over thirty builds, so the thirty-first is on /// the second page. Reaching it means the footer asked for more. func testBuildPagingFlow() throws { guard let data = readCommand(["build", "list", "krz/gitbay", "--limit", "31"]), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let page = envelope["data"] as? [String: Any], let items = page["items"] as? [[String: Any]], items.count == 31, let number = items[30]["number"] as? Int else { throw XCTSkip("could not read krz/gitbay's builds over the API") } openRepo("krz/gitbay") repoLink("builds").tap() XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed") let thirtyFirst = app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH %@", "#\(number) ")).firstMatch XCTAssertTrue(scrollTo(thirtyFirst, swipes: 20), "build #\(number), the first row of page two, never appeared") } /// Close in favour of another: the first fixture merge request is /// closed from the app naming the second; the page then says so and /// links to it. func testCloseInFavourFlow() throws { openRepo(Self.fixtureRepo) repoLink("merge-requests").tap() let superseded = app.staticTexts[Self.supersededTitle].firstMatch XCTAssertTrue(superseded.waitForExistence(timeout: 15), "fixture MR not listed") superseded.tap() app.descendants(matching: .any).matching(identifier: "mr-actions-menu").firstMatch.tap() let close = app.buttons["Close"].firstMatch XCTAssertTrue(close.waitForExistence(timeout: 5)) close.tap() let inFavour = app.buttons["Close in favour of another…"].firstMatch XCTAssertTrue(inFavour.waitForExistence(timeout: 5), "close dialog has no in-favour choice") inFavour.tap() let successor = app.staticTexts[Self.successorTitle].firstMatch XCTAssertTrue(successor.waitForExistence(timeout: 15), "successor not offered") successor.tap() let confirm = app.buttons["Close"].firstMatch XCTAssertTrue(confirm.waitForExistence(timeout: 5), "in-favour confirmation missing") confirm.tap() XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH 'closed in favour of !'")).firstMatch .waitForExistence(timeout: 20), "resolution does not name the successor") let link = app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH 'Superseded by !'")).firstMatch XCTAssertTrue(scrollTo(link), "superseded-by link missing") link.tap() XCTAssertTrue(app.staticTexts[Self.successorTitle].firstMatch.waitForExistence(timeout: 20), "link did not open the successor") } /// A label added from the merge request screen shows as a chip, the /// list filters to it, and removing it clears the chip. func testMRLabelFlow() throws { openRepo(Self.fixtureRepo) repoLink("merge-requests").tap() let row = app.staticTexts[Self.labelledTitle].firstMatch XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed") row.tap() let field = app.textFields["mr-label-field"].firstMatch XCTAssertTrue(scrollTo(field), "no label field on the merge request") field.tap() field.typeText(Self.smokeMRLabel) app.buttons["mr-label-add"].firstMatch.tap() let remove = app.buttons["mr-label-remove-\(Self.smokeMRLabel)"].firstMatch XCTAssertTrue(remove.waitForExistence(timeout: 15), "added label has no chip") back() XCTAssertTrue(app.staticTexts[Self.unlabelledTitle].firstMatch.waitForExistence(timeout: 15), "unlabelled fixture MR not listed") app.descendants(matching: .any).matching(identifier: "mr-filter-button").firstMatch.tap() let menu = app.descendants(matching: .any).matching(identifier: "filter-label-menu").firstMatch XCTAssertTrue(menu.waitForExistence(timeout: 10), "MR filter sheet has no label picker") menu.tap() let choice = app.buttons[Self.smokeMRLabel].firstMatch XCTAssertTrue(choice.waitForExistence(timeout: 10), "label not offered in the picker") choice.tap() app.buttons["Done"].firstMatch.tap() XCTAssertTrue(row.waitForExistence(timeout: 15), "label filter dropped the labelled MR") XCTAssertTrue(app.staticTexts[Self.unlabelledTitle].firstMatch.waitForNonExistence(timeout: 15), "label filter did not narrow the list") row.tap() XCTAssertTrue(scrollTo(remove), "chip missing on reopen") remove.tap() XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown") } /// A comment's preview renders its markup before anything is sent: /// the bold markers are gone and the word remains. Nothing is posted. func testMarkupPreviewFlow() throws { openRepo(Self.fixtureRepo) repoLink("issues").tap() let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed") issue.tap() let field = app.descendants(matching: .any) .matching(identifier: "issue-comment-field").firstMatch XCTAssertTrue(scrollTo(field, swipes: 10), "no comment field on the issue") let draft = "**Bold preview**" focusAndType(field, draft) let preview = app.descendants(matching: .any) .matching(identifier: "issue-comment-preview").firstMatch XCTAssertTrue(scrollTo(preview), "no preview row for a non-empty comment") // The keyboard can cover the row right after typing. for _ in 0..<3 where !preview.isHittable { app.swipeUp() } preview.tap() let rendered = app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'Bold preview'")).firstMatch if !rendered.waitForExistence(timeout: 3) { // The row's identifier can land on the cell rather than the toggle. app.buttons["Preview"].firstMatch.tap() } XCTAssertTrue(rendered.waitForExistence(timeout: 5), "preview did not render the draft") XCTAssertFalse(app.staticTexts .containing(NSPredicate(format: "label CONTAINS '**Bold'")).firstMatch.exists, "preview shows the raw markup") // Kept on success too: this is how the preview's look is checked // in light and dark without signing a simulator in by hand. // The rendered text sits under the keyboard, below the comment // field. A drag on the list moves the row above the keyboard for // the screenshot; the wait lets the scroll settle. if app.keyboards.firstMatch.exists { app.swipeDown() _ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3) } XCTAssertTrue(scrollTo(rendered), "rendered preview scrolled away") let shot = XCTAttachment(screenshot: app.screenshot()) shot.name = "markup preview" shot.lifetime = .keepAlways add(shot) // Leave the fixture as found: clear the draft, send nothing. XCTAssertTrue(scrollTo(field), "comment field scrolled away") field.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.9)).tap() let typed = (field.value as? String)?.count ?? draft.count field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: typed)) XCTAssertTrue(preview.waitForNonExistence(timeout: 5), "preview row outlived an empty comment") } }