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