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 assume the simulator is already signed in as an account that can /// write to krz/gitbay-ios, and that branch `ui-smoke` exists. 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 = "cmc/ui-smoke" 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. if ProcessInfo.processInfo.environment["GITBAY_UITEST_DARK"] == "1" { app.launchArguments.append("-gb-dark") } app.launch() if name.contains("testRepoManagementFlows") { deleteScratchRepo() } } override func tearDownWithError() throws { if name.contains("testRepoManagementFlows") { deleteScratchRepo() } } /// `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() { guard let token = ProcessInfo.processInfo .environment["GITBAY_UITEST_TOKEN"], !token.isEmpty, let url = URL(string: "https://gitbay.org/api/v1/cmd") else { return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: [ "argv": ["repo", "delete", Self.scratchRepo, "--yes"], ]) let done = DispatchSemaphore(value: 0) URLSession.shared.dataTask(with: request) { _, _, _ in done.signal() }.resume() _ = done.wait(timeout: .now() + 15) } /// 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("krz/gitbay-ios") // --- Issue #4: milestone picker assigns v1.0.0 --- app.staticTexts["Issues"].firstMatch.tap() let issueRow = app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'README.org'")).firstMatch XCTAssertTrue(issueRow.waitForExistence(timeout: 10), "issue #4 not in the list") 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 'v1.0.0'")).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["v1.0.0"].firstMatch .waitForExistence(timeout: 10), "milestone not shown after assign") // --- Issue #4: 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("README.org") == true, "edit sheet did not prefill") 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") back() // to issues list back() // to repo screen // --- MR: create from ui-smoke, then close it --- app.staticTexts["Merge Requests"].firstMatch.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() 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(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10), "repo screen did not open") } private func back() { app.navigationBars.buttons.firstMatch.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() } /// 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 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 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 search = app.searchFields.firstMatch XCTAssertTrue(search.waitForExistence(timeout: 10)) focusAndType(search, "ui-smoke") XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch .waitForExistence(timeout: 15), "created repo not in the list") // --- pin / unpin round-trip on krz/gitbay-ios --- // Reuse the open search to get there. let clear = search.buttons.firstMatch if clear.exists { clear.tap() } focusAndType(search, "krz/gitbay-ios") let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch XCTAssertTrue(repoRow.waitForExistence(timeout: 15)) repoRow.tap() XCTAssertTrue(app.staticTexts["Files"].firstMatch.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() // --- settings: topic and merge-rule round-trips --- app.staticTexts["Settings"].firstMatch.tap() 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: the chip's own x button is the next button. app.scrollViews.buttons.firstMatch.tap() XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove") 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") 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. app.staticTexts["Builds"].firstMatch.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(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10)) // --- owner profile from the repo screen --- // The row's label merges; match it at any element type. app.descendants(matching: .any)["krz"].firstMatch.tap() XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch .waitForExistence(timeout: 15), "profile did not load") XCTAssertTrue(app.staticTexts["Repositories"].firstMatch.exists, "profile repos missing") back() // --- grep inside the repo, last: its search UI owns the screen --- app.staticTexts["Search in Files"].firstMatch.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") app.staticTexts["Releases"].firstMatch.tap() let row = app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'v1.0.0'")).firstMatch XCTAssertTrue(row.waitForExistence(timeout: 15), "release list empty") row.tap() // Notes render and assets carry sizes. XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'SHA256SUMS'")).firstMatch .waitForExistence(timeout: 15), "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") XCTAssertTrue((title.value as? String)?.contains("v1.0.0") == true, "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 and the PGP key's UID email. XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch .waitForExistence(timeout: 15), "SSH keys missing") XCTAssertTrue(app.staticTexts["hello@cleberg.net"].firstMatch .waitForExistence(timeout: 10), "PGP key UID missing") // Pasting garbage as an SSH key surfaces the server's validation. 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() XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'not a valid public key'")).firstMatch .waitForExistence(timeout: 15), "invalid-key refusal not surfaced") app.buttons["Cancel"].firstMatch.tap() // 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") 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'")).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["cmc"].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() 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") app.staticTexts["Merge Requests"].firstMatch.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() XCTAssertTrue(app.staticTexts["Diff"].firstMatch.waitForExistence(timeout: 15), "MR detail did not open") snap("mr-detail") app.staticTexts["Diff"].firstMatch.tap() XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15)) snap("diff") } private func signInIfNeeded() { 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") } private func snap(_ name: String) { 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") app.staticTexts["Files"].firstMatch.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("cmc/ui-smoke-edit") app.staticTexts["Files"].firstMatch.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") 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-Hosting'")).firstMatch .waitForExistence(timeout: 20), "profile description missing") XCTAssertTrue(app.staticTexts["Organizations"].firstMatch.exists, "org memberships missing") XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing") XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch .exists, "activity graph missing") XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH 'Repositories'")).firstMatch .exists, "repositories missing") // --- a log entry opens its commit --- app.terminate() app.launch() openRepo("krz/gitbay") app.staticTexts["History"].firstMatch.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. XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch .waitForExistence(timeout: 20), "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 --- app.staticTexts["Branches & Tags"].firstMatch.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(app.staticTexts["Tags"].firstMatch.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 --- app.staticTexts["Milestones"].firstMatch.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 --- app.staticTexts["Merge Requests"].firstMatch.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(app.staticTexts["Files"].firstMatch.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. app.staticTexts["Branches & Tags"].firstMatch.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") app.staticTexts["Wiki"].firstMatch.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. let parity = app.staticTexts["Parity"].firstMatch XCTAssertTrue(parity.exists, "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") app.staticTexts["Builds"].firstMatch.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") } }