gitbayUITests/LiveSmokeUITests.swift

5411753023a5e2958f17b523903abce380941107
gitbay-ios/gitbayUITests/LiveSmokeUITests.swift history · blame · raw

475 lines · 22023 bytes

  1import XCTest
  2
  3/// Live smoke flows against gitbay.org, driven by accessibility — the
  4/// coordinate-free verification the simulator's input pipeline cannot be
  5/// trusted to do (krz/gitbay-ios#5).
  6///
  7/// These WRITE to the live instance (an MR is opened and closed, a
  8/// milestone assigned), so they only run when explicitly asked:
  9///
 10///     TEST_RUNNER_GITBAY_UITEST_LIVE=1 xcodebuild test \
 11///       -only-testing:gitbayUITests ...
 12///
 13/// They assume the simulator is already signed in as an account that can
 14/// write to krz/gitbay-ios, and that branch `ui-smoke` exists.
 15final class LiveSmokeUITests: XCTestCase {
 16
 17    private var app: XCUIApplication!
 18
 19    override func setUpWithError() throws {
 20        try XCTSkipUnless(
 21            ProcessInfo.processInfo.environment["GITBAY_UITEST_LIVE"] == "1",
 22            "live UI smoke is opt-in; set TEST_RUNNER_GITBAY_UITEST_LIVE=1"
 23        )
 24        continueAfterFailure = false
 25        app = XCUIApplication()
 26        app.launch()
 27    }
 28
 29    /// Everything in one ordered pass: milestone assign, edit-save,
 30    /// MR create, MR close. One test so the flows share navigation and
 31    /// the writes happen exactly once.
 32    func testCreationAndEditingFlows() throws {
 33        openRepo("krz/gitbay-ios")
 34
 35        // --- Issue #4: milestone picker assigns v1.0.0 ---
 36        app.staticTexts["Issues"].firstMatch.tap()
 37        let issueRow = app.staticTexts
 38            .containing(NSPredicate(format: "label CONTAINS 'README.org'")).firstMatch
 39        XCTAssertTrue(issueRow.waitForExistence(timeout: 10), "issue #4 not in the list")
 40        issueRow.tap()
 41
 42        let milestoneMenu = app.descendants(matching: .any)
 43            .matching(identifier: "milestone-menu").firstMatch
 44        XCTAssertTrue(milestoneMenu.waitForExistence(timeout: 10))
 45        milestoneMenu.tap()
 46        let milestoneChoice = app.buttons
 47            .containing(NSPredicate(format: "label BEGINSWITH 'v1.0.0'")).firstMatch
 48        XCTAssertTrue(milestoneChoice.waitForExistence(timeout: 5), "milestone menu did not open")
 49        milestoneChoice.tap()
 50        // The reload renders the assigned milestone in the header row.
 51        XCTAssertTrue(app.staticTexts["v1.0.0"].firstMatch
 52            .waitForExistence(timeout: 10), "milestone not shown after assign")
 53
 54        // --- Issue #4: edit sheet saves (content unchanged) ---
 55        app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
 56            .firstMatch.tap()
 57        let edit = app.buttons["Edit"].firstMatch
 58        XCTAssertTrue(edit.waitForExistence(timeout: 5))
 59        edit.tap()
 60        let title = app.descendants(matching: .any)
 61            .matching(identifier: "compose-title").firstMatch
 62        XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
 63        XCTAssertTrue((title.value as? String)?.contains("README.org") == true,
 64                      "edit sheet did not prefill")
 65        app.descendants(matching: .any).matching(identifier: "compose-submit")
 66            .firstMatch.tap()
 67        // Sheet dismissal proves the save round-tripped without error.
 68        XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
 69
 70        back() // to issues list
 71        back() // to repo screen
 72
 73        // --- MR: create from ui-smoke, then close it ---
 74        app.staticTexts["Merge Requests"].firstMatch.tap()
 75        app.descendants(matching: .any).matching(identifier: "mr-create-button")
 76            .firstMatch.tap()
 77
 78        let source = app.descendants(matching: .any)
 79            .matching(identifier: "mr-source").firstMatch
 80        XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open")
 81        source.tap()
 82        source.typeText("ui-smoke")
 83
 84        let target = app.descendants(matching: .any)
 85            .matching(identifier: "mr-target").firstMatch
 86        // The default branch prefilled while the sheet loaded.
 87        XCTAssertEqual(target.value as? String, "main", "target did not prefill")
 88
 89        let mrTitle = app.descendants(matching: .any)
 90            .matching(identifier: "mr-title").firstMatch
 91        mrTitle.tap()
 92        mrTitle.typeText("UI smoke: mr create from the app")
 93
 94        app.descendants(matching: .any).matching(identifier: "mr-submit")
 95            .firstMatch.tap()
 96
 97        let createdRow = app.staticTexts
 98            .containing(NSPredicate(format: "label CONTAINS 'UI smoke'")).firstMatch
 99        XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list")
100        createdRow.tap()
101
102        app.descendants(matching: .any).matching(identifier: "mr-actions-menu")
103            .firstMatch.tap()
104        let close = app.buttons["Close"].firstMatch
105        XCTAssertTrue(close.waitForExistence(timeout: 5))
106        close.tap()
107        // The confirmation dialog's destructive Close.
108        let confirm = app.buttons["Close"].firstMatch
109        XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing")
110        confirm.tap()
111
112        XCTAssertTrue(app.staticTexts["closed"].firstMatch
113            .waitForExistence(timeout: 15), "MR did not show closed after close")
114    }
115
116    // MARK: - Helpers
117
118    private func openRepo(_ path: String) {
119        let tab = app.buttons["Repositories"].firstMatch
120        XCTAssertTrue(tab.waitForExistence(timeout: 10))
121        tab.tap()
122        let search = app.searchFields.firstMatch
123        XCTAssertTrue(search.waitForExistence(timeout: 10))
124        search.tap()
125        search.typeText(path)
126        let row = app.staticTexts[path].firstMatch
127        XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
128        row.tap()
129        // Repo screen is loaded once its links render.
130        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
131    }
132
133    private func back() {
134        app.navigationBars.buttons.firstMatch.tap()
135    }
136
137    private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
138        let predicate = NSPredicate(format: "exists == false")
139        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
140        return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
141    }
142}
143
144extension LiveSmokeUITests {
145
146    /// Repo management, kept reversible: create a scratch repo (deleted
147    /// by the runner afterwards, deletion is CLI-only by design), pin and
148    /// unpin, a topic round-trip, a merge-rule toggle round-trip, and the
149    /// build-trigger error path on a repo with no job config.
150    func testRepoManagementFlows() throws {
151        // --- repo create first: it ends in the list's search state,
152        // which the next step reuses. (Scratch repo; the runner deletes
153        // it over SSH afterwards — deletion is CLI-only by design.)
154        let tab = app.buttons["Repositories"].firstMatch
155        XCTAssertTrue(tab.waitForExistence(timeout: 10))
156        tab.tap()
157        app.descendants(matching: .any).matching(identifier: "repo-create-button")
158            .firstMatch.tap()
159        let pathField = app.descendants(matching: .any)
160            .matching(identifier: "repo-create-path").firstMatch
161        XCTAssertTrue(pathField.waitForExistence(timeout: 5))
162        pathField.tap()
163        pathField.typeText("ui-smoke")
164        app.switches.firstMatch.tap() // Private on
165        app.descendants(matching: .any).matching(identifier: "repo-create-submit")
166            .firstMatch.tap()
167        // The sheet dismissing proves the create round-tripped; rows are
168        // lazy, so find the new repo through the filter.
169        XCTAssertTrue(waitForDisappearance(pathField, timeout: 15),
170                      "create sheet did not dismiss")
171        let search = app.searchFields.firstMatch
172        XCTAssertTrue(search.waitForExistence(timeout: 10))
173        search.tap()
174        search.typeText("ui-smoke")
175        XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
176            .waitForExistence(timeout: 15), "created repo not in the list")
177
178        // --- pin / unpin round-trip on krz/gitbay-ios ---
179        // Reuse the open search to get there.
180        let clear = search.buttons.firstMatch
181        if clear.exists { clear.tap() } else { search.tap() }
182        search.typeText("krz/gitbay-ios")
183        let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
184        XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
185        repoRow.tap()
186        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
187        let menu = app.descendants(matching: .any)
188            .matching(identifier: "repo-actions-menu").firstMatch
189        XCTAssertTrue(menu.waitForExistence(timeout: 10))
190        menu.tap()
191        let pin = app.buttons["Pin"].firstMatch
192        XCTAssertTrue(pin.waitForExistence(timeout: 5), "Pin action missing")
193        pin.tap()
194        // State refreshed from the dashboard: the menu now offers Unpin.
195        menu.tap()
196        let unpin = app.buttons["Unpin"].firstMatch
197        XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take")
198        unpin.tap()
199
200        // --- settings: topic and merge-rule round-trips ---
201        app.staticTexts["Settings"].firstMatch.tap()
202        let addTopic = app.descendants(matching: .any)
203            .matching(identifier: "settings-add-topic").firstMatch
204        XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
205        addTopic.tap()
206        addTopic.typeText("ios")
207        app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit")
208            .firstMatch.tap()
209        let chip = app.staticTexts["ios"].firstMatch
210        XCTAssertTrue(chip.waitForExistence(timeout: 10), "topic did not appear")
211        // Remove it again: the chip's own x button is the next button.
212        app.scrollViews.buttons.firstMatch.tap()
213        XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
214
215        let resolved = app.switches["Require threads resolved"].firstMatch
216        XCTAssertTrue(resolved.waitForExistence(timeout: 5))
217        // SwiftUI exposes the row as a switch that wraps the real
218        // control; tap the innermost switch when there is one, else the
219        // right edge of the row.
220        let inner = resolved.switches.firstMatch
221        let control: () -> Void = {
222            if inner.exists && inner != resolved {
223                inner.tap()
224            } else {
225                resolved.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
226            }
227        }
228        control()
229        XCTAssertTrue(waitForValue(resolved, "1", timeout: 10), "toggle did not persist on")
230        control()
231        XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off")
232
233        back() // settings -> repo
234
235        // --- build trigger error path (no .gitbay job config here) ---
236        app.staticTexts["Builds"].firstMatch.tap()
237        app.descendants(matching: .any).matching(identifier: "build-trigger-button")
238            .firstMatch.tap()
239        let jobField = app.textFields.firstMatch
240        XCTAssertTrue(jobField.waitForExistence(timeout: 5), "trigger alert missing")
241        jobField.tap()
242        jobField.typeText("ci")
243        app.buttons["Trigger"].firstMatch.tap()
244        // "no job X" for an unknown job, "has no .gitbay/ci.yml" when the
245        // repo has no CI config at all.
246        let refusal = app.staticTexts
247            .containing(NSPredicate(format: "label CONTAINS 'no job' OR label CONTAINS 'no .gitbay'")).firstMatch
248        XCTAssertTrue(refusal.waitForExistence(timeout: 15),
249                      "trigger refusal not surfaced")
250    }
251
252    private func waitForValue(_ element: XCUIElement, _ value: String,
253                              timeout: TimeInterval) -> Bool {
254        let predicate = NSPredicate(format: "value == %@", value)
255        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
256        return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
257    }
258}
259
260extension LiveSmokeUITests {
261
262    /// Discovery is read-only: feed, server-side repo search, grep, and
263    /// profiles. No cleanup needed.
264    func testDiscoveryFlows() throws {
265        // --- feed renders events and navigates ---
266        let feedTab = app.buttons["Feed"].firstMatch
267        XCTAssertTrue(feedTab.waitForExistence(timeout: 10))
268        feedTab.tap()
269        let firstEvent = app.cells.firstMatch
270        XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events")
271        firstEvent.tap()
272        // Wherever the event led, it left the feed root behind.
273        XCTAssertTrue(app.navigationBars.buttons.firstMatch
274            .waitForExistence(timeout: 10), "feed row did not navigate")
275        back()
276
277        // --- server-side search: "astronomy" is only a topic, invisible
278        // to the client-side path/description filter ---
279        let repoTab = app.buttons["Repositories"].firstMatch
280        repoTab.tap()
281        let search = app.searchFields.firstMatch
282        XCTAssertTrue(search.waitForExistence(timeout: 10))
283        search.tap()
284        search.typeText("astronomy")
285        let hit = app.staticTexts["krz/space-wiki"].firstMatch
286        XCTAssertTrue(hit.waitForExistence(timeout: 15),
287                      "server-side topic search found nothing")
288        hit.tap()
289        XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
290
291        // --- owner profile from the repo screen ---
292        // The row's label merges; match it at any element type.
293        app.descendants(matching: .any)["krz"].firstMatch.tap()
294        XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch
295            .waitForExistence(timeout: 15), "profile did not load")
296        XCTAssertTrue(app.staticTexts["Repositories"].firstMatch.exists,
297                      "profile repos missing")
298        back()
299
300        // --- grep inside the repo, last: its search UI owns the screen ---
301        app.staticTexts["Search in Files"].firstMatch.tap()
302        let grepField = app.searchFields.firstMatch
303        XCTAssertTrue(grepField.waitForExistence(timeout: 10))
304        grepField.tap()
305        grepField.typeText("space")
306        app.keyboards.buttons["search"].firstMatch.tap()
307        let match = app.cells.firstMatch
308        XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches")
309    }
310}
311
312extension LiveSmokeUITests {
313
314    /// Releases: list and detail on a real release, an edit round-trip
315    /// that saves the prefilled content (a no-op write), and the
316    /// missing-tag refusal on create. Nothing changes state.
317    func testReleaseFlows() throws {
318        openRepo("krz/gitbay")
319        app.staticTexts["Releases"].firstMatch.tap()
320
321        let row = app.staticTexts
322            .containing(NSPredicate(format: "label CONTAINS 'v1.0.0'")).firstMatch
323        XCTAssertTrue(row.waitForExistence(timeout: 15), "release list empty")
324        row.tap()
325
326        // Notes render and assets carry sizes.
327        XCTAssertTrue(app.staticTexts
328            .containing(NSPredicate(format: "label CONTAINS 'SHA256SUMS'")).firstMatch
329            .waitForExistence(timeout: 15), "assets missing")
330
331        // Edit sheet prefills; saving unchanged content round-trips.
332        app.descendants(matching: .any).matching(identifier: "release-edit-button")
333            .firstMatch.tap()
334        let title = app.descendants(matching: .any)
335            .matching(identifier: "compose-title").firstMatch
336        XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
337        XCTAssertTrue((title.value as? String)?.contains("v1.0.0") == true,
338                      "edit sheet did not prefill")
339        app.descendants(matching: .any).matching(identifier: "compose-submit")
340            .firstMatch.tap()
341        XCTAssertTrue(waitForDisappearance(title, timeout: 15),
342                      "release edit did not dismiss")
343
344        back()
345
346        // Create with a tag that does not exist: the server's refusal is
347        // the UI contract.
348        app.descendants(matching: .any).matching(identifier: "release-create-button")
349            .firstMatch.tap()
350        let tag = app.descendants(matching: .any)
351            .matching(identifier: "release-tag").firstMatch
352        XCTAssertTrue(tag.waitForExistence(timeout: 5))
353        tag.tap()
354        tag.typeText("v9.9.9")
355        app.descendants(matching: .any).matching(identifier: "release-submit")
356            .firstMatch.tap()
357        XCTAssertTrue(app.staticTexts
358            .containing(NSPredicate(format: "label CONTAINS 'push the tag first'")).firstMatch
359            .waitForExistence(timeout: 15), "missing-tag refusal not surfaced")
360        app.buttons["Cancel"].firstMatch.tap()
361    }
362}
363
364extension LiveSmokeUITests {
365
366    /// Account keys and email. Read-only plus refusal paths — no key is
367    /// added or removed, no mail is sent.
368    func testAccountFlows() throws {
369        // Dashboard toolbar -> account screen.
370        let accountLink = app.descendants(matching: .any)
371            .matching(identifier: "account-screen-link").firstMatch
372        XCTAssertTrue(accountLink.waitForExistence(timeout: 10))
373        accountLink.tap()
374
375        // Real keys render: SSH fingerprints and the PGP key's UID email.
376        XCTAssertTrue(app.staticTexts
377            .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
378            .waitForExistence(timeout: 15), "SSH keys missing")
379        XCTAssertTrue(app.staticTexts["hello@cleberg.net"].firstMatch
380            .waitForExistence(timeout: 10), "PGP key UID missing")
381
382        // Pasting garbage as an SSH key surfaces the server's validation.
383        app.descendants(matching: .any).matching(identifier: "add-ssh-key")
384            .firstMatch.tap()
385        let paste = app.descendants(matching: .any)
386            .matching(identifier: "key-paste-text").firstMatch
387        XCTAssertTrue(paste.waitForExistence(timeout: 5))
388        paste.tap()
389        paste.typeText("not a key")
390        app.descendants(matching: .any).matching(identifier: "key-paste-submit")
391            .firstMatch.tap()
392        XCTAssertTrue(app.staticTexts
393            .containing(NSPredicate(format: "label CONTAINS 'not a valid public key'")).firstMatch
394            .waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
395        app.buttons["Cancel"].firstMatch.tap()
396
397        // A bogus verification code is refused, not swallowed.
398        let code = app.descendants(matching: .any)
399            .matching(identifier: "email-code").firstMatch
400        XCTAssertTrue(code.waitForExistence(timeout: 5))
401        code.tap()
402        XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
403        code.typeText("000000")
404        if (code.value as? String)?.contains("000000") != true {
405            // The first keystrokes can race focus; type once more.
406            code.typeText("000000")
407        }
408        let verify = app.descendants(matching: .any)
409            .matching(identifier: "email-verify").firstMatch
410        XCTAssertTrue(verify.waitForExistence(timeout: 5))
411        XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed")
412        verify.tap()
413        XCTAssertTrue(app.staticTexts
414            .containing(NSPredicate(format: "label CONTAINS 'invalid, expired'")).firstMatch
415            .waitForExistence(timeout: 15), "bad-code refusal not surfaced")
416    }
417}
418
419extension LiveSmokeUITests {
420
421    /// Orgs: members render, and a team lives a full life — created,
422    /// granted a repo, the grant revoked, the team deleted. Everything
423    /// this test makes, it removes.
424    func testOrgFlows() throws {
425        let accountLink = app.descendants(matching: .any)
426            .matching(identifier: "account-screen-link").firstMatch
427        XCTAssertTrue(accountLink.waitForExistence(timeout: 10))
428        accountLink.tap()
429
430        let orgRow = app.staticTexts["krz"].firstMatch
431        XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
432        orgRow.tap()
433
434        // Members render with roles.
435        XCTAssertTrue(app.staticTexts["cmc"].firstMatch
436            .waitForExistence(timeout: 15), "org members missing")
437
438        // Create a team.
439        let teamField = app.descendants(matching: .any)
440            .matching(identifier: "org-new-team").firstMatch
441        XCTAssertTrue(teamField.waitForExistence(timeout: 5))
442        teamField.tap()
443        teamField.typeText("ui-smoke")
444        app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
445            .firstMatch.tap()
446        let teamRow = app.staticTexts["ui-smoke"].firstMatch
447        XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed")
448
449        // Grant it a repo, then revoke.
450        teamRow.tap()
451        let repoField = app.textFields["org/repo"].firstMatch
452        XCTAssertTrue(repoField.waitForExistence(timeout: 10))
453        repoField.tap()
454        repoField.typeText("krz/gitbay-ios")
455        app.buttons["Grant"].firstMatch.tap()
456        let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
457        XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
458        grantRow.swipeLeft()
459        app.buttons["Revoke"].firstMatch.tap()
460        XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
461
462        back()
463
464        // Delete the team, through its confirmation.
465        let row = app.staticTexts["ui-smoke"].firstMatch
466        XCTAssertTrue(row.waitForExistence(timeout: 10))
467        row.swipeLeft()
468        app.buttons["Delete"].firstMatch.tap()
469        // The confirmation dialog's destructive Delete.
470        let confirm = app.buttons["Delete"].firstMatch
471        XCTAssertTrue(confirm.waitForExistence(timeout: 5))
472        confirm.tap()
473        XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
474    }
475}