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