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