gitbayUITests/LiveSmokeUITests.swift
836 lines · 39196 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 // xcodebuild runs tests on simulator clones, so the base
27 // device's appearance never applies; force it per run.
28 if ProcessInfo.processInfo.environment["GITBAY_UITEST_DARK"] == "1" {
29 app.launchArguments.append("-gb-dark")
30 }
31 app.launch()
32 }
33
34 /// Everything in one ordered pass: milestone assign, edit-save,
35 /// MR create, MR close. One test so the flows share navigation and
36 /// the writes happen exactly once.
37 func testCreationAndEditingFlows() throws {
38 openRepo("krz/gitbay-ios")
39
40 // --- Issue #4: milestone picker assigns v1.0.0 ---
41 app.staticTexts["Issues"].firstMatch.tap()
42 let issueRow = app.staticTexts
43 .containing(NSPredicate(format: "label CONTAINS 'README.org'")).firstMatch
44 XCTAssertTrue(issueRow.waitForExistence(timeout: 10), "issue #4 not in the list")
45 issueRow.tap()
46
47 let milestoneMenu = app.descendants(matching: .any)
48 .matching(identifier: "milestone-menu").firstMatch
49 XCTAssertTrue(milestoneMenu.waitForExistence(timeout: 10))
50 milestoneMenu.tap()
51 let milestoneChoice = app.buttons
52 .containing(NSPredicate(format: "label BEGINSWITH 'v1.0.0'")).firstMatch
53 XCTAssertTrue(milestoneChoice.waitForExistence(timeout: 5), "milestone menu did not open")
54 milestoneChoice.tap()
55 // The reload renders the assigned milestone in the header row.
56 XCTAssertTrue(app.staticTexts["v1.0.0"].firstMatch
57 .waitForExistence(timeout: 10), "milestone not shown after assign")
58
59 // --- Issue #4: edit sheet saves (content unchanged) ---
60 app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
61 .firstMatch.tap()
62 let edit = app.buttons["Edit"].firstMatch
63 XCTAssertTrue(edit.waitForExistence(timeout: 5))
64 edit.tap()
65 let title = app.descendants(matching: .any)
66 .matching(identifier: "compose-title").firstMatch
67 XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
68 XCTAssertTrue((title.value as? String)?.contains("README.org") == true,
69 "edit sheet did not prefill")
70 app.descendants(matching: .any).matching(identifier: "compose-submit")
71 .firstMatch.tap()
72 // Sheet dismissal proves the save round-tripped without error.
73 XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
74
75 back() // to issues list
76 back() // to repo screen
77
78 // --- MR: create from ui-smoke, then close it ---
79 app.staticTexts["Merge Requests"].firstMatch.tap()
80 app.descendants(matching: .any).matching(identifier: "mr-create-button")
81 .firstMatch.tap()
82
83 let source = app.descendants(matching: .any)
84 .matching(identifier: "mr-source").firstMatch
85 XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open")
86 focusAndType(source, "ui-smoke")
87
88 let target = app.descendants(matching: .any)
89 .matching(identifier: "mr-target").firstMatch
90 // The default branch prefilled while the sheet loaded.
91 XCTAssertEqual(target.value as? String, "main", "target did not prefill")
92
93 let mrTitle = app.descendants(matching: .any)
94 .matching(identifier: "mr-title").firstMatch
95 focusAndType(mrTitle, "UI smoke: mr create from the app")
96
97 app.descendants(matching: .any).matching(identifier: "mr-submit")
98 .firstMatch.tap()
99
100 let createdRow = app.staticTexts
101 .containing(NSPredicate(format: "label CONTAINS 'UI smoke'")).firstMatch
102 XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list")
103 createdRow.tap()
104
105 app.descendants(matching: .any).matching(identifier: "mr-actions-menu")
106 .firstMatch.tap()
107 let close = app.buttons["Close"].firstMatch
108 XCTAssertTrue(close.waitForExistence(timeout: 5))
109 close.tap()
110 // The confirmation dialog's destructive Close.
111 let confirm = app.buttons["Close"].firstMatch
112 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing")
113 confirm.tap()
114
115 XCTAssertTrue(app.staticTexts["closed"].firstMatch
116 .waitForExistence(timeout: 15), "MR did not show closed after close")
117 }
118
119 // MARK: - Helpers
120
121 private func openRepo(_ path: String) {
122 selectTab("Repositories")
123
124 // `.searchable` keeps the field tucked above the list until it is
125 // scrolled into view.
126 let search = app.searchFields.firstMatch
127 if !search.waitForExistence(timeout: 5) {
128 app.swipeDown()
129 XCTAssertTrue(search.waitForExistence(timeout: 10), "search field never appeared")
130 }
131 focusAndType(search, path)
132
133 let row = app.staticTexts[path].firstMatch
134 XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
135 row.tap()
136 // Repo screen is loaded once its links render.
137 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10),
138 "repo screen did not open")
139 }
140
141 private func back() {
142 app.navigationBars.buttons.firstMatch.tap()
143 }
144
145 /// Keys, PGP and email live behind the profile menu now, not their
146 /// own toolbar button.
147 func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
148 let menu = app.descendants(matching: .any)
149 .matching(identifier: "account-menu").firstMatch
150 XCTAssertTrue(menu.waitForExistence(timeout: 15),
151 "account menu missing", file: file, line: line)
152 menu.tap()
153 let keys = app.buttons["Keys & Email"].firstMatch
154 XCTAssertTrue(keys.waitForExistence(timeout: 5),
155 "Keys & Email missing from the account menu", file: file, line: line)
156 keys.tap()
157 }
158
159 /// Scroll a list until an element is in the hierarchy. Offscreen
160 /// rows do not exist to XCUITest, so waitForExistence alone fails on
161 /// anything below the fold.
162 @discardableResult
163 func scrollTo(_ element: XCUIElement, swipes: Int = 6) -> Bool {
164 for _ in 0..<swipes {
165 if element.exists { return true }
166 app.swipeUp()
167 }
168 return element.exists
169 }
170
171 /// Switch tabs and wait until that tab is actually front. A tap
172 /// dispatched before the app is interactive — which happens right
173 /// after launch when there is no sign-in to slow things down — is
174 /// swallowed silently, so this taps until the tab reports selected.
175 func selectTab(_ name: String,
176 file: StaticString = #filePath, line: UInt = #line) {
177 let tab = app.tabBars.buttons[name].firstMatch
178 XCTAssertTrue(tab.waitForExistence(timeout: 15),
179 "\(name) tab missing", file: file, line: line)
180 // The app is interactive once its own chrome is hittable.
181 XCTAssertTrue(tab.isHittable || tab.waitForExistence(timeout: 5),
182 "\(name) tab never became hittable", file: file, line: line)
183
184 for _ in 0..<4 {
185 if tab.isSelected, app.navigationBars[name].firstMatch.exists { return }
186 tab.tap()
187 if app.navigationBars[name].firstMatch.waitForExistence(timeout: 5) { return }
188 }
189 XCTFail("\(name) tab did not come to front", file: file, line: line)
190 }
191
192 /// Empty a text field before typing. Search fields keep their query
193 /// across navigation, and the clear button is not reliably
194 /// addressable, so this deletes character by character.
195 func clearAndType(_ element: XCUIElement, _ text: String,
196 file: StaticString = #filePath, line: UInt = #line) {
197 element.tap()
198 if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
199 element.tap()
200 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
201 "keyboard never appeared", file: file, line: line)
202 }
203 if let existing = element.value as? String,
204 existing != element.placeholderValue, !existing.isEmpty {
205 element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue,
206 count: existing.count))
207 }
208 element.typeText(text)
209 }
210
211 /// Tap a field and type into it, surviving the focus race: a tap can
212 /// land before the field is ready, and the keystrokes go nowhere.
213 func focusAndType(_ element: XCUIElement, _ text: String,
214 file: StaticString = #filePath, line: UInt = #line) {
215 element.tap()
216 if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
217 element.tap()
218 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
219 "keyboard never appeared for \(element)", file: file, line: line)
220 }
221 element.typeText(text)
222 }
223
224 private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
225 let predicate = NSPredicate(format: "exists == false")
226 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
227 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
228 }
229}
230
231extension LiveSmokeUITests {
232
233 /// Repo management, kept reversible: create a scratch repo (deleted
234 /// by the runner afterwards, deletion is CLI-only by design), pin and
235 /// unpin, a topic round-trip, a merge-rule toggle round-trip, and the
236 /// build-trigger error path on a repo with no job config.
237 func testRepoManagementFlows() throws {
238 // --- repo create first: it ends in the list's search state,
239 // which the next step reuses. (Scratch repo; the runner deletes
240 // it over SSH afterwards — deletion is CLI-only by design.)
241 selectTab("Repositories")
242 app.descendants(matching: .any).matching(identifier: "repo-create-button")
243 .firstMatch.tap()
244 let pathField = app.descendants(matching: .any)
245 .matching(identifier: "repo-create-path").firstMatch
246 XCTAssertTrue(pathField.waitForExistence(timeout: 5))
247 focusAndType(pathField, "ui-smoke")
248 app.switches.firstMatch.tap() // Private on
249 app.descendants(matching: .any).matching(identifier: "repo-create-submit")
250 .firstMatch.tap()
251 // The sheet dismissing proves the create round-tripped; rows are
252 // lazy, so find the new repo through the filter.
253 XCTAssertTrue(waitForDisappearance(pathField, timeout: 15),
254 "create sheet did not dismiss")
255 let search = app.searchFields.firstMatch
256 XCTAssertTrue(search.waitForExistence(timeout: 10))
257 focusAndType(search, "ui-smoke")
258 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
259 .waitForExistence(timeout: 15), "created repo not in the list")
260
261 // --- pin / unpin round-trip on krz/gitbay-ios ---
262 // Reuse the open search to get there.
263 let clear = search.buttons.firstMatch
264 if clear.exists { clear.tap() }
265 focusAndType(search, "krz/gitbay-ios")
266 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
267 XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
268 repoRow.tap()
269 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
270 let menu = app.descendants(matching: .any)
271 .matching(identifier: "repo-actions-menu").firstMatch
272 XCTAssertTrue(menu.waitForExistence(timeout: 10))
273 menu.tap()
274 let pin = app.buttons["Pin"].firstMatch
275 XCTAssertTrue(pin.waitForExistence(timeout: 5), "Pin action missing")
276 pin.tap()
277 // State refreshed from the dashboard: the menu now offers Unpin.
278 menu.tap()
279 let unpin = app.buttons["Unpin"].firstMatch
280 XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take")
281 unpin.tap()
282
283 // --- settings: topic and merge-rule round-trips ---
284 app.staticTexts["Settings"].firstMatch.tap()
285 let addTopic = app.descendants(matching: .any)
286 .matching(identifier: "settings-add-topic").firstMatch
287 XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
288 focusAndType(addTopic, "ios")
289 app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit")
290 .firstMatch.tap()
291 let chip = app.staticTexts["ios"].firstMatch
292 XCTAssertTrue(chip.waitForExistence(timeout: 10), "topic did not appear")
293 // Remove it again: the chip's own x button is the next button.
294 app.scrollViews.buttons.firstMatch.tap()
295 XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
296
297 let resolved = app.switches["Require threads resolved"].firstMatch
298 XCTAssertTrue(resolved.waitForExistence(timeout: 5))
299 // SwiftUI exposes the row as a switch that wraps the real
300 // control; tap the innermost switch when there is one, else the
301 // right edge of the row.
302 let inner = resolved.switches.firstMatch
303 let control: () -> Void = {
304 if inner.exists && inner != resolved {
305 inner.tap()
306 } else {
307 resolved.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
308 }
309 }
310 control()
311 XCTAssertTrue(waitForValue(resolved, "1", timeout: 10), "toggle did not persist on")
312 control()
313 XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off")
314
315 back() // settings -> repo
316
317 // --- build trigger error path (no .gitbay job config here) ---
318 app.staticTexts["Builds"].firstMatch.tap()
319 app.descendants(matching: .any).matching(identifier: "build-trigger-button")
320 .firstMatch.tap()
321 let jobField = app.textFields.firstMatch
322 XCTAssertTrue(jobField.waitForExistence(timeout: 5), "trigger alert missing")
323 focusAndType(jobField, "ci")
324 app.buttons["Trigger"].firstMatch.tap()
325 // "no job X" for an unknown job, "has no .gitbay/ci.yml" when the
326 // repo has no CI config at all.
327 let refusal = app.staticTexts
328 .containing(NSPredicate(format: "label CONTAINS 'no job' OR label CONTAINS 'no .gitbay'")).firstMatch
329 XCTAssertTrue(refusal.waitForExistence(timeout: 15),
330 "trigger refusal not surfaced")
331 }
332
333 private func waitForValue(_ element: XCUIElement, _ value: String,
334 timeout: TimeInterval) -> Bool {
335 let predicate = NSPredicate(format: "value == %@", value)
336 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
337 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
338 }
339}
340
341extension LiveSmokeUITests {
342
343 /// Discovery is read-only: feed, server-side repo search, grep, and
344 /// profiles. No cleanup needed.
345 func testDiscoveryFlows() throws {
346 // --- feed renders events and navigates ---
347 selectTab("Feed")
348 let firstEvent = app.cells.firstMatch
349 XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events")
350 firstEvent.tap()
351 // Wherever the event led, it left the feed root behind.
352 XCTAssertTrue(app.navigationBars.buttons.firstMatch
353 .waitForExistence(timeout: 10), "feed row did not navigate")
354 back()
355
356 // --- server-side search: "astronomy" is only a topic, invisible
357 // to the client-side path/description filter ---
358 selectTab("Repositories")
359 let search = app.searchFields.firstMatch
360 XCTAssertTrue(search.waitForExistence(timeout: 10))
361 focusAndType(search, "astronomy")
362 let hit = app.staticTexts["krz/space-wiki"].firstMatch
363 XCTAssertTrue(hit.waitForExistence(timeout: 15),
364 "server-side topic search found nothing")
365 hit.tap()
366 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
367
368 // --- owner profile from the repo screen ---
369 // The row's label merges; match it at any element type.
370 app.descendants(matching: .any)["krz"].firstMatch.tap()
371 XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch
372 .waitForExistence(timeout: 15), "profile did not load")
373 XCTAssertTrue(app.staticTexts["Repositories"].firstMatch.exists,
374 "profile repos missing")
375 back()
376
377 // --- grep inside the repo, last: its search UI owns the screen ---
378 app.staticTexts["Search in Files"].firstMatch.tap()
379 let grepField = app.searchFields.firstMatch
380 XCTAssertTrue(grepField.waitForExistence(timeout: 10))
381 focusAndType(grepField, "space")
382 app.keyboards.buttons["search"].firstMatch.tap()
383 let match = app.cells.firstMatch
384 XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches")
385 }
386}
387
388extension LiveSmokeUITests {
389
390 /// Releases: list and detail on a real release, an edit round-trip
391 /// that saves the prefilled content (a no-op write), and the
392 /// missing-tag refusal on create. Nothing changes state.
393 func testReleaseFlows() throws {
394 openRepo("krz/gitbay")
395 app.staticTexts["Releases"].firstMatch.tap()
396
397 let row = app.staticTexts
398 .containing(NSPredicate(format: "label CONTAINS 'v1.0.0'")).firstMatch
399 XCTAssertTrue(row.waitForExistence(timeout: 15), "release list empty")
400 row.tap()
401
402 // Notes render and assets carry sizes.
403 XCTAssertTrue(app.staticTexts
404 .containing(NSPredicate(format: "label CONTAINS 'SHA256SUMS'")).firstMatch
405 .waitForExistence(timeout: 15), "assets missing")
406
407 // Edit sheet prefills; saving unchanged content round-trips.
408 app.descendants(matching: .any).matching(identifier: "release-edit-button")
409 .firstMatch.tap()
410 let title = app.descendants(matching: .any)
411 .matching(identifier: "compose-title").firstMatch
412 XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
413 XCTAssertTrue((title.value as? String)?.contains("v1.0.0") == true,
414 "edit sheet did not prefill")
415 app.descendants(matching: .any).matching(identifier: "compose-submit")
416 .firstMatch.tap()
417 XCTAssertTrue(waitForDisappearance(title, timeout: 15),
418 "release edit did not dismiss")
419
420 back()
421
422 // Create with a tag that does not exist: the server's refusal is
423 // the UI contract.
424 app.descendants(matching: .any).matching(identifier: "release-create-button")
425 .firstMatch.tap()
426 let tag = app.descendants(matching: .any)
427 .matching(identifier: "release-tag").firstMatch
428 XCTAssertTrue(tag.waitForExistence(timeout: 5))
429 focusAndType(tag, "v9.9.9")
430 app.descendants(matching: .any).matching(identifier: "release-submit")
431 .firstMatch.tap()
432 XCTAssertTrue(app.staticTexts
433 .containing(NSPredicate(format: "label CONTAINS 'push the tag first'")).firstMatch
434 .waitForExistence(timeout: 15), "missing-tag refusal not surfaced")
435 app.buttons["Cancel"].firstMatch.tap()
436 }
437}
438
439extension LiveSmokeUITests {
440
441 /// Account keys and email. Read-only plus refusal paths — no key is
442 /// added or removed, no mail is sent.
443 func testAccountFlows() throws {
444 // Dashboard toolbar -> account screen.
445 openAccountScreen()
446
447 // Real keys render: SSH fingerprints and the PGP key's UID email.
448 XCTAssertTrue(app.staticTexts
449 .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
450 .waitForExistence(timeout: 15), "SSH keys missing")
451 XCTAssertTrue(app.staticTexts["hello@cleberg.net"].firstMatch
452 .waitForExistence(timeout: 10), "PGP key UID missing")
453
454 // Pasting garbage as an SSH key surfaces the server's validation.
455 app.descendants(matching: .any).matching(identifier: "add-ssh-key")
456 .firstMatch.tap()
457 let paste = app.descendants(matching: .any)
458 .matching(identifier: "key-paste-text").firstMatch
459 XCTAssertTrue(paste.waitForExistence(timeout: 5))
460 focusAndType(paste, "not a key")
461 app.descendants(matching: .any).matching(identifier: "key-paste-submit")
462 .firstMatch.tap()
463 XCTAssertTrue(app.staticTexts
464 .containing(NSPredicate(format: "label CONTAINS 'not a valid public key'")).firstMatch
465 .waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
466 app.buttons["Cancel"].firstMatch.tap()
467
468 // A bogus verification code is refused, not swallowed.
469 let code = app.descendants(matching: .any)
470 .matching(identifier: "email-code").firstMatch
471 XCTAssertTrue(code.waitForExistence(timeout: 5))
472 focusAndType(code, "000000")
473 let verify = app.descendants(matching: .any)
474 .matching(identifier: "email-verify").firstMatch
475 XCTAssertTrue(verify.waitForExistence(timeout: 5))
476 XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed")
477 verify.tap()
478 XCTAssertTrue(app.staticTexts
479 .containing(NSPredicate(format: "label CONTAINS 'invalid, expired'")).firstMatch
480 .waitForExistence(timeout: 15), "bad-code refusal not surfaced")
481 }
482}
483
484extension LiveSmokeUITests {
485
486 /// Orgs: members render, and a team lives a full life — created,
487 /// granted a repo, the grant revoked, the team deleted. Everything
488 /// this test makes, it removes.
489 func testOrgFlows() throws {
490 openAccountScreen()
491
492 let orgRow = app.staticTexts["krz"].firstMatch
493 XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
494 orgRow.tap()
495
496 // Members render with roles.
497 XCTAssertTrue(app.staticTexts["cmc"].firstMatch
498 .waitForExistence(timeout: 15), "org members missing")
499
500 // Create a team.
501 let teamField = app.descendants(matching: .any)
502 .matching(identifier: "org-new-team").firstMatch
503 XCTAssertTrue(teamField.waitForExistence(timeout: 5))
504 focusAndType(teamField, "ui-smoke")
505 app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
506 .firstMatch.tap()
507 let teamRow = app.staticTexts["ui-smoke"].firstMatch
508 XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed")
509
510 // Grant it a repo, then revoke.
511 teamRow.tap()
512 let repoField = app.textFields["org/repo"].firstMatch
513 XCTAssertTrue(repoField.waitForExistence(timeout: 10))
514 focusAndType(repoField, "krz/gitbay-ios")
515 app.buttons["Grant"].firstMatch.tap()
516 let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
517 XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
518 grantRow.swipeLeft()
519 app.buttons["Revoke"].firstMatch.tap()
520 XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
521
522 back()
523
524 // Delete the team, through its confirmation.
525 let row = app.staticTexts["ui-smoke"].firstMatch
526 XCTAssertTrue(row.waitForExistence(timeout: 10))
527 row.swipeLeft()
528 app.buttons["Delete"].firstMatch.tap()
529 // The confirmation dialog's destructive Delete.
530 let confirm = app.buttons["Delete"].firstMatch
531 XCTAssertTrue(confirm.waitForExistence(timeout: 5))
532 confirm.tap()
533 XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
534 }
535}
536
537extension LiveSmokeUITests {
538
539 /// The pre-merge screenshot checkpoint: walks the dense screens and
540 /// attaches captures. Run per device/appearance; export the
541 /// attachments from the xcresult. Signs in first when the device has
542 /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided.
543 func testScreenshotCheckpoint() throws {
544 signInIfNeeded()
545
546 snap("dashboard")
547
548 openRepo("krz/gitbay")
549 snap("repo")
550
551 app.staticTexts["Merge Requests"].firstMatch.tap()
552 let all = app.segmentedControls.buttons["All"].firstMatch
553 XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
554 all.tap()
555 // A real MR row carries its !number; the picker row does not.
556 let mrNumber = app.staticTexts
557 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
558 XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All")
559 snap("mr-list")
560
561 mrNumber.tap()
562 XCTAssertTrue(app.staticTexts["Diff"].firstMatch.waitForExistence(timeout: 15),
563 "MR detail did not open")
564 snap("mr-detail")
565
566 app.staticTexts["Diff"].firstMatch.tap()
567 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
568 snap("diff")
569 }
570
571 private func signInIfNeeded() {
572 let tokenField = app.secureTextFields.firstMatch
573 guard tokenField.waitForExistence(timeout: 5) else { return }
574 guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else {
575 XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
576 return
577 }
578 focusAndType(tokenField, token + "\n")
579 XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
580 .waitForExistence(timeout: 20), "sign-in did not land")
581 }
582
583 private func snap(_ name: String) {
584 let attachment = XCTAttachment(screenshot: app.screenshot())
585 attachment.name = name
586 attachment.lifetime = .keepAlways
587 add(attachment)
588 }
589}
590
591extension LiveSmokeUITests {
592
593 /// Blame and file editing — the two capabilities that were web-only
594 /// until krz/gitbay#42 and #43 made them commands. Blame is read-only
595 /// on a real repo; the edit writes to a scratch repo the runner
596 /// creates and deletes over SSH.
597 func testBlameAndEditFlows() throws {
598 // --- blame on a real file ---
599 openRepo("krz/gitbay")
600 app.staticTexts["Files"].firstMatch.tap()
601 let goMod = app.staticTexts["go.mod"].firstMatch
602 XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
603 goMod.tap()
604 XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu")
605 .firstMatch.waitForExistence(timeout: 15), "file screen did not open")
606 app.descendants(matching: .any).matching(identifier: "file-actions-menu")
607 .firstMatch.tap()
608 app.buttons["Blame"].firstMatch.tap()
609 // Attribution shows the commit subject beside the lines.
610 XCTAssertTrue(app.staticTexts
611 .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch
612 .waitForExistence(timeout: 20), "blame rendered no attribution")
613
614 // --- edit on the scratch repo ---
615 // Relaunch rather than reuse the list: `.searchable` keeps the
616 // previous query, and clearing it from the harness is unreliable.
617 app.terminate()
618 app.launch()
619 openRepo("cmc/ui-smoke-edit")
620 app.staticTexts["Files"].firstMatch.tap()
621 let notes = app.staticTexts["notes.txt"].firstMatch
622 XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
623 notes.tap()
624
625 let menu = app.descendants(matching: .any)
626 .matching(identifier: "file-actions-menu").firstMatch
627 XCTAssertTrue(menu.waitForExistence(timeout: 15))
628 menu.tap()
629 let edit = app.buttons
630 .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch
631 XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing")
632 edit.tap()
633
634 let content = app.descendants(matching: .any)
635 .matching(identifier: "file-edit-content").firstMatch
636 XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open")
637 focusAndType(content, " edited from the app")
638
639 let message = app.descendants(matching: .any)
640 .matching(identifier: "file-edit-message").firstMatch
641 focusAndType(message, "edit from ios")
642
643 app.descendants(matching: .any).matching(identifier: "file-edit-commit")
644 .firstMatch.tap()
645 XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss")
646 // The reloaded file shows the committed text.
647 XCTAssertTrue(app.staticTexts
648 .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch
649 .waitForExistence(timeout: 20), "edit not reflected after commit")
650 }
651}
652
653
654extension LiveSmokeUITests {
655
656 /// The navigation and profile changes: identity lives in one menu,
657 /// the dashboard can create a repository, a profile is a real
658 /// profile, and a log entry opens its commit.
659 func testProfileAndNavigationFlows() throws {
660 // --- the dashboard offers creation ---
661 selectTab("Dashboard")
662 let create = app.descendants(matching: .any)
663 .matching(identifier: "dashboard-create-button").firstMatch
664 XCTAssertTrue(create.waitForExistence(timeout: 15), "dashboard + missing")
665 create.tap()
666 let pathField = app.descendants(matching: .any)
667 .matching(identifier: "repo-create-path").firstMatch
668 XCTAssertTrue(pathField.waitForExistence(timeout: 10),
669 "dashboard + did not open the create sheet")
670 app.buttons["Cancel"].firstMatch.tap()
671
672 // --- identity is one menu: profile and keys together ---
673 let menu = app.descendants(matching: .any)
674 .matching(identifier: "account-menu").firstMatch
675 XCTAssertTrue(menu.waitForExistence(timeout: 10))
676 menu.tap()
677 XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
678 "keys not in the account menu")
679 let myProfile = app.buttons["My Profile"].firstMatch
680 XCTAssertTrue(myProfile.exists, "profile not in the account menu")
681 myProfile.tap()
682
683 // --- a profile is a profile: description, links, orgs, graph, repos ---
684 XCTAssertTrue(app.staticTexts
685 .containing(NSPredicate(format: "label CONTAINS 'Self-Hosting'")).firstMatch
686 .waitForExistence(timeout: 20), "profile description missing")
687 XCTAssertTrue(app.staticTexts["Organizations"].firstMatch.exists,
688 "org memberships missing")
689 XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing")
690 XCTAssertTrue(app.staticTexts
691 .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch
692 .exists, "activity graph missing")
693 XCTAssertTrue(app.staticTexts
694 .containing(NSPredicate(format: "label BEGINSWITH 'Repositories'")).firstMatch
695 .exists, "repositories missing")
696
697 // --- a log entry opens its commit ---
698 app.terminate()
699 app.launch()
700 openRepo("krz/gitbay")
701 app.staticTexts["History"].firstMatch.tap()
702 let firstCommit = app.cells.firstMatch
703 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
704 firstCommit.tap()
705 // The commit screen shows its changed files and can open the patch.
706 XCTAssertTrue(app.descendants(matching: .any)
707 .matching(identifier: "commit-diff-button").firstMatch
708 .waitForExistence(timeout: 20), "commit did not open")
709 app.descendants(matching: .any).matching(identifier: "commit-diff-button")
710 .firstMatch.tap()
711 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
712 "commit diff rendered nothing")
713 }
714}
715
716extension LiveSmokeUITests {
717
718 /// The three gaps that needed no server work: branches and tags,
719 /// milestones, and an MR's milestone. Read-only except the MR
720 /// milestone, which is set and cleared back.
721 func testRefsAndMilestoneFlows() throws {
722 openRepo("krz/gitbay")
723
724 // --- branches and tags, and browsing at a ref ---
725 app.staticTexts["Branches & Tags"].firstMatch.tap()
726 let main = app.staticTexts["main"].firstMatch
727 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
728 XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
729 "the default branch is not marked")
730 XCTAssertTrue(app.staticTexts["Tags"].firstMatch.exists, "tags section missing")
731 main.tap()
732 // Tapping a ref browses the repository there.
733 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
734 "browsing at a ref showed nothing")
735 back()
736 back()
737
738 // --- milestones with progress ---
739 app.staticTexts["Milestones"].firstMatch.tap()
740 XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
741 "milestones did not load")
742 app.segmentedControls.buttons["All"].firstMatch.tap()
743 XCTAssertTrue(app.staticTexts
744 .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch
745 .waitForExistence(timeout: 20), "milestone progress missing")
746 back()
747
748 // --- an MR's milestone reads back, and can be cleared ---
749 app.staticTexts["Merge Requests"].firstMatch.tap()
750 app.segmentedControls.buttons["All"].firstMatch.tap()
751 let mrNumber = app.staticTexts
752 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
753 XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs")
754 mrNumber.tap()
755 let milestone = app.descendants(matching: .any)
756 .matching(identifier: "mr-milestone-menu").firstMatch
757 // The nav bar is the "opened" signal; everything else on this
758 // screen can be below the fold.
759 XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20),
760 "MR detail did not open")
761 XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing")
762 milestone.tap()
763 // The picker offers None plus the open milestones.
764 XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10),
765 "milestone picker did not open")
766 app.buttons["None"].firstMatch.tap()
767 }
768}
769
770extension LiveSmokeUITests {
771
772 /// Explore and history at a ref — the two capabilities krz/gitbay!101
773 /// made reachable. Read-only.
774 func testExploreAndRefLogFlows() throws {
775 // --- explore lists what the instance hosts ---
776 selectTab("Explore")
777 let firstRepo = app.cells.firstMatch
778 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
779 // Public repos this account does not own are reachable here.
780 XCTAssertTrue(app.staticTexts
781 .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
782 .waitForExistence(timeout: 10), "explore is not the public listing")
783 firstRepo.tap()
784 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 20),
785 "explore row did not open its repo")
786
787 // --- history at a ref ---
788 app.terminate()
789 app.launch()
790 openRepo("krz/gitbay")
791 // Browse a ref, then ask for its history — the move the tree page
792 // offers on the web.
793 app.staticTexts["Branches & Tags"].firstMatch.tap()
794 let main = app.staticTexts["main"].firstMatch
795 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
796 main.tap()
797 let history = app.descendants(matching: .any)
798 .matching(identifier: "tree-history-button").firstMatch
799 XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree")
800 history.tap()
801 XCTAssertTrue(app.navigationBars
802 .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch
803 .waitForExistence(timeout: 20), "ref history did not open")
804 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
805 "ref history rendered no commits")
806 }
807}
808
809extension LiveSmokeUITests {
810
811 /// The wiki — the last capability that was browser-only until
812 /// krz/gitbay#48. Read-only; editing is a push, on every surface.
813 func testWikiFlows() throws {
814 openRepo("krz/gitbay")
815 app.staticTexts["Wiki"].firstMatch.tap()
816
817 // The landing page leads and is marked.
818 let home = app.staticTexts["Home"].firstMatch
819 XCTAssertTrue(home.waitForExistence(timeout: 20), "wiki pages did not load")
820 XCTAssertTrue(app.staticTexts["home"].firstMatch.exists,
821 "the landing page is not marked")
822
823 // Parity.org is an org page, so this also proves the org renderer
824 // runs on wiki content and not just READMEs.
825 let parity = app.staticTexts["Parity"].firstMatch
826 XCTAssertTrue(parity.exists, "Parity page missing from the listing")
827 parity.tap()
828 XCTAssertTrue(app.staticTexts
829 .containing(NSPredicate(format: "label CONTAINS 'surface'")).firstMatch
830 .waitForExistence(timeout: 20), "wiki page rendered no prose")
831 // Org headings render as headings, not as raw #+title:.
832 XCTAssertFalse(app.staticTexts
833 .containing(NSPredicate(format: "label CONTAINS '#+title'")).firstMatch.exists,
834 "org markup leaked into the rendered page")
835 }
836}