gitbayUITests/LiveSmokeUITests.swift
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}