gitbayUITests/LiveSmokeUITests.swift
2030 lines · 100724 bytes
54 symbols in this file
LiveSmokeUITestssetUpWithErrortearDownWithErrordeleteSmokeSnippetsensureOrgFixturesopenSupersedeFixturesopenSmokeMergeRequestscloseSmokeMergeRequestsdeleteScratchRepoensureFixtureMilestoneensureFixtureIssuerequireFixtureBranchrunCommandreadCommandcalltestCreationAndEditingFlowsopenRepobackrepoLinkmoreLinkopenRepoSettingsopenAccountScreenrequireFixtureRepoheaderscrollToselectTabclearAndTypefocusAndTypewaitForDisappearancetestRepoManagementFlowswaitForValuetestDiscoveryFlowstestReleaseFlowstestAccountFlowstestOrgFlowstestScreenshotCheckpointsignInIfNeededdismissSavePasswordPromptsnaptestBlameAndEditFlowstestProfileAndNavigationFlowstestRefsAndMilestoneFlowstestExploreAndRefLogFlowstestWikiFlowstestBuildJobsAndDetailtestIPadScreenshotCheckpointtestSnippetFlowstestSSHKeyLabelFlowtestOrgLabelAndMilestoneFlowstestBuildFilterFlowtestBuildPagingFlowtestCloseInFavourFlowtestMRLabelFlowtestMarkupPreviewFlow
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 run as the `ios-smoke` account, a krz admin that owns the
14/// scratch and fixture repositories (smoke-account.sh provisions it).
15/// Everything they write goes to those, never to krz/gitbay-ios. A
16/// signed-out device is signed in at setUp from
17/// TEST_RUNNER_GITBAY_UITEST_TOKEN.
18final class LiveSmokeUITests: XCTestCase {
19
20 private var app: XCUIApplication!
21
22 /// The scratch repo testRepoManagementFlows creates. A run that dies
23 /// before deleting it leaves it behind, and then every later run
24 /// fails at "create sheet did not dismiss" — the create is refused
25 /// because the name is taken, which reads like a UI bug and is not.
26 /// Removed before and after, so neither a crashed run nor this one
27 /// can strand it.
28 private static let scratchRepo = "\(smokeUser)/ui-smoke"
29
30 /// The account the suite runs as. smoke-account.sh creates it with
31 /// the profile description, PGP key and fixture repository the
32 /// assertions below read back.
33 static let smokeUser = "ios-smoke"
34 static let smokeProfileDescription = "Live UI smoke account for gitbay-ios"
35 static let smokePGPEmail = "ios-smoke+pgp@gitbay.org"
36
37 /// The repository the write flows work in: the fixture issue and
38 /// merge requests, and the file testBlameAndEditFlows edits.
39 /// smoke-account.sh seeds it, with `main` and `ui-smoke` divergent.
40 static let fixtureRepo = "\(smokeUser)/ui-smoke-edit"
41
42 override func setUpWithError() throws {
43 try XCTSkipUnless(
44 ProcessInfo.processInfo.environment["GITBAY_UITEST_LIVE"] == "1",
45 "live UI smoke is opt-in; set TEST_RUNNER_GITBAY_UITEST_LIVE=1"
46 )
47 continueAfterFailure = false
48 app = XCUIApplication()
49 // xcodebuild runs tests on simulator clones, so the base
50 // device's appearance never applies; force it per run.
51 switch ProcessInfo.processInfo.environment["GITBAY_UITEST_DARK"] {
52 case "1": app.launchArguments.append("-gb-dark")
53 case "0": app.launchArguments.append("-gb-light")
54 default: break // follow the device, as a real launch does
55 }
56 app.launch()
57 // A fresh device — a clone on another runtime, say — starts on
58 // the token screen; the flows assume a signed-in app.
59 signInIfNeeded()
60 if name.contains("testRepoManagementFlows") {
61 deleteScratchRepo()
62 }
63 if name.contains("testCreationAndEditingFlows") {
64 try requireFixtureRepo()
65 ensureFixtureMilestone()
66 ensureFixtureIssue()
67 try requireFixtureBranch()
68 }
69 if name.contains("testBlameAndEditFlows") {
70 try requireFixtureRepo()
71 }
72 if name.contains("testSnippetFlows") {
73 deleteSmokeSnippets()
74 }
75 if name.contains("testOrgLabelAndMilestoneFlows") {
76 ensureOrgFixtures()
77 }
78 if name.contains("testCloseInFavourFlow") {
79 try requireFixtureRepo()
80 try requireFixtureBranch()
81 closeSmokeMergeRequests()
82 try openSupersedeFixtures()
83 }
84 if name.contains("testMRLabelFlow") {
85 try requireFixtureRepo()
86 try requireFixtureBranch()
87 closeSmokeMergeRequests()
88 _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main",
89 "--title", Self.labelledTitle])
90 _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "main", "--target", "ui-smoke",
91 "--title", Self.unlabelledTitle])
92 let open = openSmokeMergeRequests().map { $0.title }
93 try XCTSkipUnless(open.contains(Self.labelledTitle) && open.contains(Self.unlabelledTitle),
94 "could not open both label fixture merge requests on \(Self.fixtureRepo)")
95 }
96 if name.contains("testMarkupPreviewFlow") {
97 try requireFixtureRepo()
98 ensureFixtureIssue()
99 }
100 }
101
102 override func tearDownWithError() throws {
103 if name.contains("testRepoManagementFlows") {
104 deleteScratchRepo()
105 }
106 if name.contains("testSnippetFlows") {
107 deleteSmokeSnippets()
108 }
109 if name.contains("testCloseInFavourFlow") {
110 closeSmokeMergeRequests()
111 }
112 if name.contains("testMRLabelFlow") {
113 closeSmokeMergeRequests()
114 _ = runCommand(["label", "remove", Self.fixtureRepo, Self.smokeMRLabel])
115 }
116 }
117
118 // MARK: - Fixtures for the v1.2.0 flows
119
120 static let smokeSnippetDescription = "UI smoke snippet"
121
122 /// Remove every snippet the flow made, before and after, so a run
123 /// that died mid-way cannot strand one or hit the per-account cap.
124 private func deleteSmokeSnippets() {
125 guard let data = readCommand(["snippet", "list"]),
126 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
127 let rows = envelope["data"] as? [[String: Any]] else { return }
128 for row in rows where row["description"] as? String == Self.smokeSnippetDescription {
129 if let id = row["id"] as? String {
130 _ = runCommand(["snippet", "delete", id])
131 }
132 }
133 }
134
135 static let smokeOrg = "krz"
136 static let smokeOrgLabel = "ui-smoke-label"
137 static let smokeOrgMilestone = "ui-smoke milestone"
138
139 /// The org milestone is a fixture, reused and left closed: the
140 /// server has no milestone delete, so creating one per run would
141 /// pile them up. The label is removed by the flow itself; a stale
142 /// one is removed here first.
143 private func ensureOrgFixtures() {
144 _ = runCommand(["org", "label", "remove", Self.smokeOrg, Self.smokeOrgLabel])
145 guard let data = readCommand(["org", "milestone", "list", Self.smokeOrg, "--state", "all"]),
146 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
147 else { return }
148 let rows = envelope["data"] as? [[String: Any]] ?? []
149 if let row = rows.first(where: { $0["title"] as? String == Self.smokeOrgMilestone }) {
150 if row["state"] as? String == "open" {
151 _ = runCommand(["org", "milestone", "close", Self.smokeOrg, Self.smokeOrgMilestone])
152 }
153 } else {
154 _ = runCommand(["org", "milestone", "create", Self.smokeOrg, Self.smokeOrgMilestone])
155 _ = runCommand(["org", "milestone", "close", Self.smokeOrg, Self.smokeOrgMilestone])
156 }
157 }
158
159 static let supersededTitle = "UI smoke: superseded"
160 static let successorTitle = "UI smoke: successor"
161 static let labelledTitle = "UI smoke: labels"
162 static let unlabelledTitle = "UI smoke: unlabelled"
163 static let smokeMRLabel = "ui-smoke-mr-label"
164
165 /// Close in favour needs two open merge requests. The fixture branch
166 /// gives one (ui-smoke -> main); main has commits ui-smoke lacks, so
167 /// the reverse (main -> ui-smoke) is the other. Both are the API's
168 /// to open; the flow closes the first from the app and tearDown
169 /// closes whatever is left.
170 private func openSupersedeFixtures() throws {
171 _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main",
172 "--title", Self.supersededTitle])
173 _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "main", "--target", "ui-smoke",
174 "--title", Self.successorTitle])
175 let open = openSmokeMergeRequests().map { $0.title }
176 try XCTSkipUnless(open.contains(Self.supersededTitle) && open.contains(Self.successorTitle), """
177 could not open both fixture merge requests on \(Self.fixtureRepo) \
178 (have \(open)); the ui-smoke branch may be level with main.
179 """)
180 }
181
182 private func openSmokeMergeRequests() -> [(number: Int, title: String)] {
183 guard let data = readCommand(["mr", "list", Self.fixtureRepo, "--state", "open"]),
184 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
185 let rows = envelope["data"] as? [[String: Any]] else { return [] }
186 return rows.compactMap { row in
187 guard let number = row["number"] as? Int, let title = row["title"] as? String,
188 title.hasPrefix("UI smoke:") else { return nil }
189 return (number, title)
190 }
191 }
192
193 private func closeSmokeMergeRequests() {
194 for mr in openSmokeMergeRequests() {
195 _ = runCommand(["mr", "close", Self.fixtureRepo, String(mr.number)])
196 }
197 }
198
199 /// `repo delete` over the JSON API — the app has no delete screen
200 /// (deletion is SSH-only by design), so cleanup cannot go through
201 /// the UI. A repo that is not there answers exit 3, which is the
202 /// outcome we want anyway.
203 private func deleteScratchRepo() {
204 _ = runCommand(["repo", "delete", Self.scratchRepo, "--yes"])
205 // The rename round-trip's halfway name, in case a run died there.
206 _ = runCommand(["repo", "delete", Self.scratchRepo + "-2", "--yes"])
207 }
208
209 /// The milestone the editing flow assigns. Created rather than
210 /// required: unlike a branch, the API can make one.
211 static let fixtureMilestone = "v1.0.0"
212
213 private func ensureFixtureMilestone() {
214 guard let data = readCommand(["milestone", "list", Self.fixtureRepo, "--state", "all"]),
215 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
216 else { return }
217 let rows = envelope["data"] as? [[String: Any]] ?? []
218 if rows.contains(where: { $0["title"] as? String == Self.fixtureMilestone }) { return }
219 _ = runCommand(["milestone", "create", Self.fixtureRepo, Self.fixtureMilestone])
220 }
221
222 /// The issue the editing flow works on. It is a fixture, not a real
223 /// issue: pinning the test to whatever issue happened to be open put
224 /// it at the mercy of the project moving on, and it duly broke when
225 /// that issue was closed (krz/gitbay-ios#8).
226 static let fixtureIssueTitle = "ui-smoke fixture: do not close"
227
228 /// Guarantee the fixture issue exists and is open. It is reused
229 /// rather than recreated, so runs do not pile up closed issues.
230 private func ensureFixtureIssue() {
231 guard let data = readCommand(
232 ["issue", "list", Self.fixtureRepo, "--state", "open"]),
233 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
234 else { return }
235
236 let issues = envelope["data"] as? [[String: Any]] ?? []
237 if issues.contains(where: { $0["title"] as? String == Self.fixtureIssueTitle }) {
238 return
239 }
240 _ = runCommand(
241 ["issue", "create", Self.fixtureRepo, "--title", Self.fixtureIssueTitle, "--file", "-"],
242 stdin: "Created by the live smoke suite. It exercises the milestone "
243 + "picker and the edit sheet, and is reused every run."
244 )
245 }
246
247 /// The MR flow needs a source branch carrying a commit main does not
248 /// have. Nothing in the app or the API can create a branch — that is
249 /// a push — so this only checks, and says so plainly rather than
250 /// letting the test fail later at "created MR not in the list".
251 private func requireFixtureBranch() throws {
252 guard let data = readCommand(["repo", "refs", Self.fixtureRepo]),
253 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
254 let refs = envelope["data"] as? [String: Any],
255 let branches = refs["branches"] as? [[String: Any]]
256 else { return } // no token, or the instance is unreachable
257 let names = branches.compactMap { $0["name"] as? String }
258 try XCTSkipUnless(names.contains("ui-smoke"), """
259 \(Self.fixtureRepo) has no ui-smoke branch. The MR flows open \
260 one merge request each way between it and main, so each side \
261 needs a commit the other lacks; smoke-account.sh seeds both.
262 """)
263 }
264
265 /// POST /api/v1/cmd. Writes the suite needs but the app cannot make.
266 @discardableResult
267 private func runCommand(_ argv: [String], stdin: String? = nil) -> Data? {
268 var body: [String: Any] = ["argv": argv]
269 if let stdin { body["stdin"] = stdin }
270 return call(method: "POST", path: "/api/v1/cmd", body: body)
271 }
272
273 /// GET /api/v1/read, for the reads that decide what a test sets up.
274 private func readCommand(_ argv: [String]) -> Data? {
275 let query = argv
276 .map { "argv=" + ($0.addingPercentEncoding(
277 withAllowedCharacters: .alphanumerics) ?? $0) }
278 .joined(separator: "&")
279 return call(method: "GET", path: "/api/v1/read?" + query, body: nil)
280 }
281
282 private func call(method: String, path: String, body: [String: Any]?) -> Data? {
283 guard let token = ProcessInfo.processInfo
284 .environment["GITBAY_UITEST_TOKEN"], !token.isEmpty,
285 let url = URL(string: "https://gitbay.org" + path)
286 else { return nil }
287
288 var request = URLRequest(url: url)
289 request.httpMethod = method
290 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
291 if let body {
292 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
293 request.httpBody = try? JSONSerialization.data(withJSONObject: body)
294 }
295
296 var result: Data?
297 let done = DispatchSemaphore(value: 0)
298 URLSession.shared.dataTask(with: request) { data, _, _ in
299 result = data
300 done.signal()
301 }.resume()
302 _ = done.wait(timeout: .now() + 15)
303 return result
304 }
305
306 /// Everything in one ordered pass: milestone assign, edit-save,
307 /// MR create, MR close. One test so the flows share navigation and
308 /// the writes happen exactly once.
309 func testCreationAndEditingFlows() throws {
310 openRepo(Self.fixtureRepo)
311
312 // --- the fixture issue: milestone picker assigns the fixture ---
313 repoLink("issues").tap()
314 let issueRow = app.staticTexts
315 .containing(NSPredicate(format: "label CONTAINS 'ui-smoke fixture'")).firstMatch
316 XCTAssertTrue(issueRow.waitForExistence(timeout: 10),
317 "fixture issue not in the list; setUp should have created it")
318 issueRow.tap()
319
320 let milestoneMenu = app.descendants(matching: .any)
321 .matching(identifier: "milestone-menu").firstMatch
322 XCTAssertTrue(milestoneMenu.waitForExistence(timeout: 10))
323 milestoneMenu.tap()
324 let milestoneChoice = app.buttons
325 .containing(NSPredicate(format: "label BEGINSWITH %@", Self.fixtureMilestone)).firstMatch
326 XCTAssertTrue(milestoneChoice.waitForExistence(timeout: 5), "milestone menu did not open")
327 milestoneChoice.tap()
328 // The reload renders the assigned milestone in the header row.
329 XCTAssertTrue(app.staticTexts[Self.fixtureMilestone].firstMatch
330 .waitForExistence(timeout: 10), "milestone not shown after assign")
331
332 // --- the fixture issue: edit sheet saves (content unchanged) ---
333 app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
334 .firstMatch.tap()
335 let edit = app.buttons["Edit"].firstMatch
336 XCTAssertTrue(edit.waitForExistence(timeout: 5))
337 edit.tap()
338 let title = app.descendants(matching: .any)
339 .matching(identifier: "compose-title").firstMatch
340 XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
341 XCTAssertTrue((title.value as? String)?.contains("ui-smoke fixture") == true,
342 "edit sheet did not prefill")
343 // Save the body as org, reopen, and the picker must start on Org:
344 // the sheet has to read the stored format, not its own default.
345 let format = app.segmentedControls.matching(identifier: "compose-format").firstMatch
346 XCTAssertTrue(format.waitForExistence(timeout: 5), "format picker missing")
347 XCTAssertTrue(format.buttons["Markdown"].isSelected, "fixture did not open on Markdown")
348 format.buttons["Org"].tap()
349 app.descendants(matching: .any).matching(identifier: "compose-submit")
350 .firstMatch.tap()
351 // Sheet dismissal proves the save round-tripped without error.
352 XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
353
354 app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
355 .firstMatch.tap()
356 XCTAssertTrue(edit.waitForExistence(timeout: 5))
357 edit.tap()
358 XCTAssertTrue(format.waitForExistence(timeout: 5), "edit sheet did not reopen")
359 XCTAssertTrue(format.buttons["Org"].isSelected, "edit sheet did not open on the stored format")
360 // Restore the fixture to markdown.
361 format.buttons["Markdown"].tap()
362 app.descendants(matching: .any).matching(identifier: "compose-submit")
363 .firstMatch.tap()
364 XCTAssertTrue(waitForDisappearance(title, timeout: 15), "edit save did not dismiss")
365
366 back() // to issues list
367 back() // to repo screen
368
369 // --- MR: create from ui-smoke, then close it ---
370 repoLink("merge-requests").tap()
371 app.descendants(matching: .any).matching(identifier: "mr-create-button")
372 .firstMatch.tap()
373
374 let source = app.descendants(matching: .any)
375 .matching(identifier: "mr-source").firstMatch
376 XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open")
377 focusAndType(source, "ui-smoke")
378
379 let target = app.descendants(matching: .any)
380 .matching(identifier: "mr-target").firstMatch
381 // The default branch prefilled while the sheet loaded.
382 XCTAssertEqual(target.value as? String, "main", "target did not prefill")
383
384 let mrTitle = app.descendants(matching: .any)
385 .matching(identifier: "mr-title").firstMatch
386 focusAndType(mrTitle, "UI smoke: mr create from the app")
387
388 app.descendants(matching: .any).matching(identifier: "mr-submit")
389 .firstMatch.tap()
390
391 let createdRow = app.staticTexts
392 .containing(NSPredicate(format: "label CONTAINS 'UI smoke'")).firstMatch
393 XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list")
394 createdRow.tap()
395
396 // The gates are shown before any merge is tried.
397 let gates = header("Gates")
398 XCTAssertTrue(scrollTo(gates, swipes: 8), "gates section missing")
399
400 // A fresh MR has one revision; the section lists it.
401 let revisions = header("Revisions")
402 XCTAssertTrue(scrollTo(revisions, swipes: 8), "revisions section missing")
403 XCTAssertTrue(app.staticTexts["v1"].firstMatch.exists, "first revision not listed")
404
405 app.descendants(matching: .any).matching(identifier: "mr-actions-menu")
406 .firstMatch.tap()
407 let close = app.buttons["Close"].firstMatch
408 XCTAssertTrue(close.waitForExistence(timeout: 5))
409 close.tap()
410 // The confirmation dialog's destructive Close.
411 let confirm = app.buttons["Close"].firstMatch
412 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "close confirmation missing")
413 confirm.tap()
414
415 XCTAssertTrue(app.staticTexts["Closed"].firstMatch
416 .waitForExistence(timeout: 15), "MR did not show closed after close")
417 }
418
419 // MARK: - Helpers
420
421 private func openRepo(_ path: String) {
422 selectTab("Repositories")
423
424 // `.searchable` keeps the field tucked above the list until it is
425 // scrolled into view.
426 let search = app.searchFields.firstMatch
427 if !search.waitForExistence(timeout: 5) {
428 app.swipeDown()
429 XCTAssertTrue(search.waitForExistence(timeout: 10), "search field never appeared")
430 }
431 focusAndType(search, path)
432
433 let row = app.staticTexts[path].firstMatch
434 XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list")
435 row.tap()
436 // Repo screen is loaded once its links render.
437 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10),
438 "repo screen did not open")
439 }
440
441 private func back() {
442 app.navigationBars.buttons.firstMatch.tap()
443 }
444
445 /// One of the four links in the grid at the top of the repo screen.
446 private func repoLink(_ id: String) -> XCUIElement {
447 app.descendants(matching: .any).matching(identifier: "repo-link-\(id)").firstMatch
448 }
449
450 /// A button in the repo screen's More section, above the README.
451 private func moreLink(_ title: String) -> XCUIElement {
452 let link = app.buttons[title].firstMatch
453 XCTAssertTrue(scrollTo(link), "\(title) not under More")
454 return link
455 }
456
457 /// Settings is in the repo screen's actions menu.
458 private func openRepoSettings() {
459 let menu = app.descendants(matching: .any)
460 .matching(identifier: "repo-actions-menu").firstMatch
461 XCTAssertTrue(menu.waitForExistence(timeout: 10), "repo actions menu missing")
462 menu.tap()
463 let settings = app.buttons["Settings"].firstMatch
464 XCTAssertTrue(settings.waitForExistence(timeout: 5), "Settings missing from the actions menu")
465 settings.tap()
466 }
467
468 /// Keys, PGP and email live behind the account menu, which is on the
469 /// My Profile tab and nowhere else.
470 func openAccountScreen(file: StaticString = #filePath, line: UInt = #line) {
471 selectTab("My Profile")
472 let menu = app.descendants(matching: .any)
473 .matching(identifier: "account-menu").firstMatch
474 XCTAssertTrue(menu.waitForExistence(timeout: 15),
475 "account menu missing", file: file, line: line)
476 menu.tap()
477 let keys = app.buttons["Keys & Email"].firstMatch
478 XCTAssertTrue(keys.waitForExistence(timeout: 5),
479 "Keys & Email missing from the account menu", file: file, line: line)
480 keys.tap()
481 }
482
483 /// Seeding a repository's first commit is a push, which no test can
484 /// make, so this only checks — and says why, rather than failing
485 /// later at "ios-smoke/ui-smoke-edit not in the repo list".
486 private func requireFixtureRepo() throws {
487 guard let data = readCommand(["repo", "show", Self.fixtureRepo]),
488 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
489 else { return } // no token, or the instance is unreachable
490 try XCTSkipUnless(envelope["data"] != nil, """
491 \(Self.fixtureRepo) is missing. The write flows work in it; \
492 gitbayUITests/smoke-account.sh seeds it (or push it by hand, \
493 see that script) and run again.
494 """)
495 }
496
497 /// Scroll a list until an element is in the hierarchy. Offscreen
498 /// rows do not exist to XCUITest, so waitForExistence alone fails on
499 /// anything below the fold.
500 @discardableResult
501 /// A list section header. iOS 18 renders these uppercased and iOS 26
502 /// as written, so the match ignores case.
503 func header(_ text: String) -> XCUIElement {
504 app.staticTexts.matching(NSPredicate(format: "label ==[c] %@", text)).firstMatch
505 }
506
507 func scrollTo(_ element: XCUIElement, swipes: Int = 6) -> Bool {
508 for _ in 0..<swipes {
509 if element.exists { return true }
510 app.swipeUp()
511 }
512 return element.exists
513 }
514
515 /// Switch tabs and wait until that tab is actually front. A tap
516 /// dispatched before the app is interactive — which happens right
517 /// after launch when there is no sign-in to slow things down — is
518 /// swallowed silently, so this taps until the tab reports selected.
519 func selectTab(_ name: String,
520 file: StaticString = #filePath, line: UInt = #line) {
521 // iPadOS renders a TabView as a sidebar or top bar rather than a
522 // bottom tab bar, so the control is not under app.tabBars there.
523 var tab = app.tabBars.buttons[name].firstMatch
524 if !tab.waitForExistence(timeout: 5) {
525 tab = app.buttons[name].firstMatch
526 }
527 XCTAssertTrue(tab.waitForExistence(timeout: 15),
528 "\(name) tab missing", file: file, line: line)
529 // The app is interactive once its own chrome is hittable.
530 XCTAssertTrue(tab.isHittable || tab.waitForExistence(timeout: 5),
531 "\(name) tab never became hittable", file: file, line: line)
532
533 for _ in 0..<4 {
534 if tab.isSelected, app.navigationBars[name].firstMatch.exists { return }
535 tab.tap()
536 if app.navigationBars[name].firstMatch.waitForExistence(timeout: 5) { return }
537 }
538 XCTFail("\(name) tab did not come to front", file: file, line: line)
539 }
540
541 /// Empty a text field before typing. Search fields keep their query
542 /// across navigation, and the clear button is not reliably
543 /// addressable, so this deletes character by character.
544 func clearAndType(_ element: XCUIElement, _ text: String,
545 file: StaticString = #filePath, line: UInt = #line) {
546 element.tap()
547 if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
548 element.tap()
549 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
550 "keyboard never appeared", file: file, line: line)
551 }
552 if let existing = element.value as? String,
553 existing != element.placeholderValue, !existing.isEmpty {
554 element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue,
555 count: existing.count))
556 }
557 element.typeText(text)
558 }
559
560 /// Tap a field and type into it, surviving the focus race: a tap can
561 /// land before the field is ready, and the keystrokes go nowhere.
562 func focusAndType(_ element: XCUIElement, _ text: String,
563 file: StaticString = #filePath, line: UInt = #line) {
564 element.tap()
565 if !app.keyboards.firstMatch.waitForExistence(timeout: 5) {
566 element.tap()
567 XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5),
568 "keyboard never appeared for \(element)", file: file, line: line)
569 }
570 element.typeText(text)
571 }
572
573 private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
574 let predicate = NSPredicate(format: "exists == false")
575 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
576 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
577 }
578}
579
580extension LiveSmokeUITests {
581
582 /// Repo management, kept reversible: create a scratch repo (deleted
583 /// by the runner afterwards, deletion is CLI-only by design), pin and
584 /// unpin, a topic round-trip, a merge-rule toggle round-trip, and the
585 /// build-trigger error path on a repo with no job config.
586 func testRepoManagementFlows() throws {
587 // --- repo create first: it ends in the list's search state,
588 // which the next step reuses. (Scratch repo; the runner deletes
589 // it over SSH afterwards — deletion is CLI-only by design.)
590 selectTab("Repositories")
591 app.descendants(matching: .any).matching(identifier: "repo-create-button")
592 .firstMatch.tap()
593 let newRepo = app.buttons["New Repository"].firstMatch
594 XCTAssertTrue(newRepo.waitForExistence(timeout: 5), "create menu did not open")
595 newRepo.tap()
596 let pathField = app.descendants(matching: .any)
597 .matching(identifier: "repo-create-path").firstMatch
598 XCTAssertTrue(pathField.waitForExistence(timeout: 5))
599 focusAndType(pathField, "ui-smoke")
600 app.switches.firstMatch.tap() // Private on
601 app.descendants(matching: .any).matching(identifier: "repo-create-submit")
602 .firstMatch.tap()
603 // The sheet dismissing proves the create round-tripped; rows are
604 // lazy, so find the new repo through the filter.
605 XCTAssertTrue(waitForDisappearance(pathField, timeout: 15),
606 "create sheet did not dismiss")
607 let createSearch = app.searchFields.firstMatch
608 XCTAssertTrue(createSearch.waitForExistence(timeout: 10))
609 focusAndType(createSearch, "ui-smoke")
610 XCTAssertTrue(app.staticTexts[Self.scratchRepo].firstMatch
611 .waitForExistence(timeout: 15), "created repo not in the list")
612
613 // --- access: grant a user on the scratch repo, then revoke ---
614 app.staticTexts[Self.scratchRepo].firstMatch.tap()
615 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10))
616 openRepoSettings()
617 let grantUser = app.descendants(matching: .any)
618 .matching(identifier: "settings-grant-user").firstMatch
619 XCTAssertTrue(scrollTo(grantUser, swipes: 8), "access section not reachable")
620 focusAndType(grantUser, "apple-review")
621 app.descendants(matching: .any).matching(identifier: "settings-grant-submit")
622 .firstMatch.tap()
623 let grantRow = app.staticTexts["apple-review"].firstMatch
624 XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
625 grantRow.swipeLeft()
626 app.buttons["Revoke"].firstMatch.tap()
627 let confirmGrantRevoke = app.buttons["Revoke"].firstMatch
628 XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
629 confirmGrantRevoke.tap()
630 XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
631
632 // --- webhooks: add one, see it listed, remove it ---
633 let webhooksLink = app.descendants(matching: .any)
634 .matching(identifier: "settings-webhooks-link").firstMatch
635 XCTAssertTrue(scrollTo(webhooksLink, swipes: 8), "webhooks link not reachable")
636 webhooksLink.tap()
637 let addHook = app.descendants(matching: .any)
638 .matching(identifier: "webhook-add-button").firstMatch
639 XCTAssertTrue(addHook.waitForExistence(timeout: 10), "webhooks did not load")
640 addHook.tap()
641 let hookURL = app.descendants(matching: .any)
642 .matching(identifier: "webhook-url").firstMatch
643 XCTAssertTrue(hookURL.waitForExistence(timeout: 5), "webhook sheet did not open")
644 focusAndType(hookURL, "https://example.com/ui-smoke")
645 app.descendants(matching: .any).matching(identifier: "webhook-submit")
646 .firstMatch.tap()
647 let hookRow = app.staticTexts["https://example.com/ui-smoke"].firstMatch
648 XCTAssertTrue(hookRow.waitForExistence(timeout: 15), "webhook not listed")
649 hookRow.swipeLeft()
650 app.buttons["Remove"].firstMatch.tap()
651 let confirmHookRemove = app.buttons["Remove"].firstMatch
652 XCTAssertTrue(confirmHookRemove.waitForExistence(timeout: 5), "webhook remove confirmation missing")
653 confirmHookRemove.tap()
654 XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed")
655 back() // webhooks -> settings
656
657 // --- rename the scratch repo there and back ---
658 // Each rename pushes the renamed repository; the round-trip ends
659 // on the scratch repo again, several screens deep.
660 var firstRename = true
661 var currentPath = Self.scratchRepo
662 let rename: (String, String) -> Void = { newName, expectedPath in
663 if firstRename {
664 firstRename = false // already on the settings screen
665 } else {
666 self.openRepoSettings()
667 }
668 let renameButton = self.app.descendants(matching: .any)
669 .matching(identifier: "settings-rename-button").firstMatch
670 XCTAssertTrue(self.scrollTo(renameButton, swipes: 10), "rename button not reachable")
671 renameButton.tap()
672 let field = self.app.textFields["New name"].firstMatch
673 XCTAssertTrue(field.waitForExistence(timeout: 5), "rename alert did not open")
674 field.tap()
675 // Prefilled with the current name; clear it before typing.
676 field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) + newName)
677 // Rename stays disabled until the current path is typed.
678 let renameAction = self.app.alerts.buttons["Rename"].firstMatch
679 XCTAssertFalse(renameAction.isEnabled, "rename enabled before the path was typed")
680 let confirm = self.app.textFields["type \(currentPath) to confirm"].firstMatch
681 XCTAssertTrue(confirm.exists, "rename confirmation field missing")
682 confirm.tap()
683 confirm.typeText(currentPath)
684 renameAction.tap()
685 // The settings screen offers the renamed repository at the
686 // top; the list is scrolled to its end, so go back up first.
687 let renamedLink = self.app.descendants(matching: .any)
688 .matching(identifier: "settings-renamed-link").firstMatch
689 sleep(2)
690 for _ in 0..<6 where !renamedLink.exists { self.app.swipeDown() }
691 XCTAssertTrue(renamedLink.waitForExistence(timeout: 10), "rename did not land")
692 renamedLink.tap()
693 // The repo screen's title is the name alone; the header
694 // splits the path into the owner link and the rest.
695 XCTAssertTrue(self.app.navigationBars.staticTexts[newName].firstMatch
696 .waitForExistence(timeout: 15), "renamed repo did not open as \(expectedPath)")
697 currentPath = expectedPath
698 }
699 rename("ui-smoke-2", Self.scratchRepo + "-2")
700 rename("ui-smoke", Self.scratchRepo)
701
702 // --- pin / unpin round-trip on krz/gitbay-ios ---
703 // Start the list fresh; the rename left the stack several deep.
704 app.terminate()
705 app.launch()
706 selectTab("Repositories")
707 let search = app.searchFields.firstMatch
708 XCTAssertTrue(search.waitForExistence(timeout: 10))
709 focusAndType(search, "krz/gitbay-ios")
710 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
711 XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
712 repoRow.tap()
713 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10))
714 let menu = app.descendants(matching: .any)
715 .matching(identifier: "repo-actions-menu").firstMatch
716 XCTAssertTrue(menu.waitForExistence(timeout: 10))
717 menu.tap()
718 let pin = app.buttons["Pin"].firstMatch
719 XCTAssertTrue(pin.waitForExistence(timeout: 5), "Pin action missing")
720 pin.tap()
721 // State refreshed from the dashboard: the menu now offers Unpin.
722 menu.tap()
723 let unpin = app.buttons["Unpin"].firstMatch
724 XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take")
725 unpin.tap()
726
727 // Bookmark it, find it in the bookmarks list, then unbookmark.
728 // A run that died between the two leaves it bookmarked; undo
729 // that first so the step starts from the same state every time.
730 menu.tap()
731 let leftover = app.buttons["Unbookmark"].firstMatch
732 if leftover.waitForExistence(timeout: 3) {
733 leftover.tap()
734 XCTAssertTrue(waitForDisappearance(leftover, timeout: 10))
735 menu.tap()
736 }
737 let bookmark = app.buttons["Bookmark"].firstMatch
738 XCTAssertTrue(bookmark.waitForExistence(timeout: 5), "Bookmark action missing")
739 bookmark.tap()
740 // An active search hides the list's toolbar, so start the list
741 // fresh: relaunching lands on a list with the bookmarks button.
742 app.terminate()
743 app.launch()
744 selectTab("Repositories")
745 let bookmarksButton = app.descendants(matching: .any)
746 .matching(identifier: "repo-bookmarks-button").firstMatch
747 XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 15), "bookmarks button missing")
748 bookmarksButton.tap()
749 let bookmarkedRow = app.staticTexts["krz/gitbay-ios"].firstMatch
750 XCTAssertTrue(bookmarkedRow.waitForExistence(timeout: 15),
751 "bookmarked repo not in the bookmarks list")
752 // Open it from the bookmarks list and undo the bookmark there.
753 bookmarkedRow.tap()
754 XCTAssertTrue(menu.waitForExistence(timeout: 10))
755 menu.tap()
756 let unbookmark = app.buttons["Unbookmark"].firstMatch
757 XCTAssertTrue(unbookmark.waitForExistence(timeout: 10), "bookmark did not take")
758 unbookmark.tap()
759
760 // --- settings: topic and merge-rule round-trips ---
761 openRepoSettings()
762 let addTopic = app.descendants(matching: .any)
763 .matching(identifier: "settings-add-topic").firstMatch
764 XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
765 focusAndType(addTopic, "ios")
766 app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit")
767 .firstMatch.tap()
768 let chip = app.staticTexts["ios"].firstMatch
769 XCTAssertTrue(chip.waitForExistence(timeout: 10), "topic did not appear")
770 // Remove it again through the chip's own x button — by name, since
771 // the repository carries other topics — and it asks first.
772 app.descendants(matching: .any).matching(identifier: "settings-remove-topic-ios")
773 .firstMatch.tap()
774 let confirmRemove = app.buttons["Remove"].firstMatch
775 XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "topic remove confirmation missing")
776 confirmRemove.tap()
777 XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
778
779 // Merge-requests-only sits with the protected branches.
780 let mergeOnly = app.switches["Merge requests only"].firstMatch
781 XCTAssertTrue(scrollTo(mergeOnly), "merge-only toggle not reachable")
782 let innerMergeOnly = mergeOnly.switches.firstMatch
783 let flipMergeOnly: () -> Void = {
784 if innerMergeOnly.exists && innerMergeOnly != mergeOnly {
785 innerMergeOnly.tap()
786 } else {
787 mergeOnly.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
788 }
789 }
790 flipMergeOnly()
791 XCTAssertTrue(waitForValue(mergeOnly, "1", timeout: 10), "merge-only toggle did not persist on")
792 flipMergeOnly()
793 XCTAssertTrue(waitForValue(mergeOnly, "0", timeout: 10), "merge-only toggle did not persist off")
794
795 // A tag glob is protected, listed, and unprotected.
796 let tagGlob = app.descendants(matching: .any)
797 .matching(identifier: "settings-protect-tag").firstMatch
798 XCTAssertTrue(scrollTo(tagGlob), "protected tags section not reachable")
799 focusAndType(tagGlob, "ui-smoke-*")
800 app.descendants(matching: .any).matching(identifier: "settings-protect-tag-submit")
801 .firstMatch.tap()
802 let globRow = app.staticTexts["ui-smoke-*"].firstMatch
803 XCTAssertTrue(globRow.waitForExistence(timeout: 10), "tag glob not listed")
804 // By identifier: the protected branch above has an Unprotect too.
805 app.descendants(matching: .any).matching(identifier: "settings-unprotect-tag-ui-smoke-*")
806 .firstMatch.tap()
807 XCTAssertTrue(waitForDisappearance(globRow, timeout: 10), "tag glob not unprotected")
808
809 // Merge rules sit below the protected branches and tags.
810 let resolved = app.switches["Require threads resolved"].firstMatch
811 // The merge-requirements section sits below the fold, and a List
812 // does not build rows it has not shown, so it must be scrolled
813 // into existence before it can be queried.
814 XCTAssertTrue(scrollTo(resolved), "merge requirements section not reachable")
815 // SwiftUI exposes the row as a switch that wraps the real
816 // control; tap the innermost switch when there is one, else the
817 // right edge of the row.
818 let inner = resolved.switches.firstMatch
819 let control: () -> Void = {
820 if inner.exists && inner != resolved {
821 inner.tap()
822 } else {
823 resolved.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
824 }
825 }
826 control()
827 XCTAssertTrue(waitForValue(resolved, "1", timeout: 10), "toggle did not persist on")
828 control()
829 XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off")
830
831 // The code-owner rule is one more toggle in the same section.
832 let codeowners = app.switches["Require code owner review"].firstMatch
833 XCTAssertTrue(scrollTo(codeowners), "code owner toggle not reachable")
834 let innerCodeowners = codeowners.switches.firstMatch
835 let flipCodeowners: () -> Void = {
836 if innerCodeowners.exists && innerCodeowners != codeowners {
837 innerCodeowners.tap()
838 } else {
839 codeowners.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
840 }
841 }
842 flipCodeowners()
843 XCTAssertTrue(waitForValue(codeowners, "1", timeout: 10), "code owner toggle did not persist on")
844 flipCodeowners()
845 XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off")
846
847
848 back() // settings -> repo
849
850 // --- nothing to trigger without a job file ---
851 // This repo has no .gitbay/ci.yml, so `build jobs` returns none
852 // and the control is gated rather than failing after a guess.
853 repoLink("builds").tap()
854 let trigger = app.descendants(matching: .any)
855 .matching(identifier: "build-trigger-button").firstMatch
856 XCTAssertTrue(trigger.waitForExistence(timeout: 15), "trigger control missing")
857 // Assert on behaviour, not on isEnabled: a disabled SwiftUI Menu
858 // still reports itself enabled to XCUITest.
859 trigger.tap()
860 let anyJob = app.buttons.containing(
861 NSPredicate(format: "label CONTAINS 'on push' OR label CONTAINS 'schedule '"))
862 .firstMatch
863 XCTAssertFalse(anyJob.waitForExistence(timeout: 3),
864 "a repo with no job file offered a job to trigger")
865
866 }
867
868 private func waitForValue(_ element: XCUIElement, _ value: String,
869 timeout: TimeInterval) -> Bool {
870 let predicate = NSPredicate(format: "value == %@", value)
871 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
872 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
873 }
874}
875
876extension LiveSmokeUITests {
877
878 /// Discovery is read-only: feed, server-side repo search, grep, and
879 /// profiles. No cleanup needed.
880 func testDiscoveryFlows() throws {
881 // --- feed renders events and navigates ---
882 selectTab("Feed")
883 let firstEvent = app.cells.firstMatch
884 XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events")
885 firstEvent.tap()
886 // Wherever the event led, it left the feed root behind.
887 XCTAssertTrue(app.navigationBars.buttons.firstMatch
888 .waitForExistence(timeout: 10), "feed row did not navigate")
889 back()
890
891 // --- server-side search: "astronomy" is only a topic, invisible
892 // to the client-side path/description filter ---
893 selectTab("Repositories")
894 let search = app.searchFields.firstMatch
895 XCTAssertTrue(search.waitForExistence(timeout: 10))
896 focusAndType(search, "astronomy")
897 let hit = app.staticTexts["krz/space-wiki"].firstMatch
898 XCTAssertTrue(hit.waitForExistence(timeout: 15),
899 "server-side topic search found nothing")
900 hit.tap()
901 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 10))
902
903 // --- owner profile from the repo screen ---
904 let ownerLink = app.descendants(matching: .any)
905 .matching(identifier: "repo-owner-link").firstMatch
906 XCTAssertTrue(ownerLink.waitForExistence(timeout: 10), "owner link missing")
907 ownerLink.tap()
908 // The owner's description and About are theirs to change; the
909 // repository list, below a long About, is what the profile always has.
910 XCTAssertTrue(app.navigationBars["krz"].waitForExistence(timeout: 15),
911 "profile did not load")
912 XCTAssertTrue(scrollTo(app.staticTexts
913 .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch),
914 "profile repos missing")
915 back()
916
917 // --- grep inside the repo, last: its search UI owns the screen ---
918 moreLink("Search in Files").tap()
919 let grepField = app.searchFields.firstMatch
920 XCTAssertTrue(grepField.waitForExistence(timeout: 10))
921 focusAndType(grepField, "space")
922 app.keyboards.buttons["search"].firstMatch.tap()
923 let match = app.cells.firstMatch
924 XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches")
925 }
926}
927
928extension LiveSmokeUITests {
929
930 /// Releases: list and detail on a real release, an edit round-trip
931 /// that saves the prefilled content (a no-op write), and the
932 /// missing-tag refusal on create. Nothing changes state.
933 func testReleaseFlows() throws {
934 openRepo("krz/gitbay")
935 moreLink("Releases").tap()
936
937 // Newest first, and the list has grown past the fold, so the
938 // first row is whichever release is current.
939 let row = app.cells.firstMatch
940 XCTAssertTrue(row.waitForExistence(timeout: 15), "release list empty")
941 row.tap()
942
943 // Notes render and assets carry sizes. The assets sit under the
944 // notes, which can run past the fold.
945 XCTAssertTrue(app.descendants(matching: .any)
946 .matching(identifier: "release-edit-button").firstMatch
947 .waitForExistence(timeout: 15), "release did not open")
948 XCTAssertTrue(scrollTo(app.staticTexts
949 .containing(NSPredicate(format: "label CONTAINS 'SHA256SUMS'")).firstMatch, swipes: 10),
950 "assets missing")
951
952 // Edit sheet prefills; saving unchanged content round-trips.
953 app.descendants(matching: .any).matching(identifier: "release-edit-button")
954 .firstMatch.tap()
955 let title = app.descendants(matching: .any)
956 .matching(identifier: "compose-title").firstMatch
957 XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
958 XCTAssertFalse((title.value as? String ?? "").isEmpty,
959 "edit sheet did not prefill")
960 app.descendants(matching: .any).matching(identifier: "compose-submit")
961 .firstMatch.tap()
962 XCTAssertTrue(waitForDisappearance(title, timeout: 15),
963 "release edit did not dismiss")
964
965 back()
966
967 // Create with a tag that does not exist: the server's refusal is
968 // the UI contract.
969 app.descendants(matching: .any).matching(identifier: "release-create-button")
970 .firstMatch.tap()
971 let tag = app.descendants(matching: .any)
972 .matching(identifier: "release-tag").firstMatch
973 XCTAssertTrue(tag.waitForExistence(timeout: 5))
974 focusAndType(tag, "v9.9.9")
975 app.descendants(matching: .any).matching(identifier: "release-submit")
976 .firstMatch.tap()
977 XCTAssertTrue(app.staticTexts
978 .containing(NSPredicate(format: "label CONTAINS 'push the tag first'")).firstMatch
979 .waitForExistence(timeout: 15), "missing-tag refusal not surfaced")
980 app.buttons["Cancel"].firstMatch.tap()
981 }
982}
983
984extension LiveSmokeUITests {
985
986 /// Account keys and email. Read-only plus refusal paths — no key is
987 /// added or removed, no mail is sent.
988 func testAccountFlows() throws {
989 // Dashboard toolbar -> account screen.
990 openAccountScreen()
991
992 // Real keys render: SSH fingerprints first.
993 XCTAssertTrue(app.staticTexts
994 .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
995 .waitForExistence(timeout: 15), "SSH keys missing")
996
997 // Pasting garbage as an SSH key surfaces the server's validation.
998 // Done while the SSH section is still on screen: labelled keys
999 // are two lines each, so what follows sits below the fold.
1000 app.descendants(matching: .any).matching(identifier: "add-ssh-key")
1001 .firstMatch.tap()
1002 let paste = app.descendants(matching: .any)
1003 .matching(identifier: "key-paste-text").firstMatch
1004 XCTAssertTrue(paste.waitForExistence(timeout: 5))
1005 focusAndType(paste, "not a key")
1006 app.descendants(matching: .any).matching(identifier: "key-paste-submit")
1007 .firstMatch.tap()
1008 // An expiring token (the suite's are) is refused before the key is
1009 // parsed; either refusal reaching the sheet passes.
1010 XCTAssertTrue(app.staticTexts
1011 .containing(NSPredicate(format:
1012 "label CONTAINS 'not a valid public key' OR label CONTAINS 'token without an expiry'"))
1013 .firstMatch.waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
1014 app.buttons["Cancel"].firstMatch.tap()
1015
1016 // The PGP key's UID email, below the keys on a List that does not
1017 // build rows it has not shown.
1018 XCTAssertTrue(scrollTo(app.staticTexts[Self.smokePGPEmail].firstMatch),
1019 "PGP key UID missing")
1020
1021 // Activity mail flips off and back on; the value comes back from
1022 // the server each time.
1023 let mail = app.switches["Activity by mail"].firstMatch
1024 XCTAssertTrue(scrollTo(mail), "mail toggle not reachable")
1025 let innerMail = mail.switches.firstMatch
1026 let flipMail: () -> Void = {
1027 if innerMail.exists && innerMail != mail {
1028 innerMail.tap()
1029 } else {
1030 mail.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
1031 }
1032 }
1033 let mailWasOn = (mail.value as? String) == "1"
1034 flipMail()
1035 XCTAssertTrue(waitForValue(mail, mailWasOn ? "0" : "1", timeout: 10), "mail toggle did not persist")
1036 flipMail()
1037 XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore")
1038
1039 // The watch preference (v1.19.0) flips the same way.
1040 let watch = app.switches["Watch writable repositories"].firstMatch
1041 XCTAssertTrue(scrollTo(watch), "watch toggle not reachable")
1042 let innerWatch = watch.switches.firstMatch
1043 let flipWatch: () -> Void = {
1044 if innerWatch.exists && innerWatch != watch {
1045 innerWatch.tap()
1046 } else {
1047 watch.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
1048 }
1049 }
1050 let watchWasOn = (watch.value as? String) == "1"
1051 flipWatch()
1052 XCTAssertTrue(waitForValue(watch, watchWasOn ? "0" : "1", timeout: 10), "watch toggle did not persist")
1053 flipWatch()
1054 XCTAssertTrue(waitForValue(watch, watchWasOn ? "1" : "0", timeout: 10), "watch toggle did not restore")
1055
1056 // A bogus verification code is refused, not swallowed.
1057 let code = app.descendants(matching: .any)
1058 .matching(identifier: "email-code").firstMatch
1059 // Email sits below the keys on a List, which does not build rows
1060 // it has not shown.
1061 XCTAssertTrue(scrollTo(code), "email section not reachable")
1062 // The addresses list above the fields, the primary one marked.
1063 XCTAssertTrue(app.staticTexts["primary"].firstMatch.exists, "primary address not marked")
1064 focusAndType(code, "000000")
1065 let verify = app.descendants(matching: .any)
1066 .matching(identifier: "email-verify").firstMatch
1067 XCTAssertTrue(verify.waitForExistence(timeout: 5))
1068 XCTAssertTrue(verify.isEnabled, "verify stayed disabled — code text never landed")
1069 verify.tap()
1070 XCTAssertTrue(app.staticTexts
1071 .containing(NSPredicate(format:
1072 "label CONTAINS 'invalid, expired' OR label CONTAINS 'token without an expiry'"))
1073 .firstMatch.waitForExistence(timeout: 15), "bad-code refusal not surfaced")
1074 }
1075}
1076
1077extension LiveSmokeUITests {
1078
1079 /// Orgs: members render, and a team lives a full life — created,
1080 /// granted a repo, the grant revoked, the team deleted. Everything
1081 /// this test makes, it removes.
1082 func testOrgFlows() throws {
1083 openAccountScreen()
1084
1085 let orgRow = app.staticTexts["krz"].firstMatch
1086 XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
1087 orgRow.tap()
1088
1089 // Members render with roles.
1090 XCTAssertTrue(app.staticTexts[Self.smokeUser].firstMatch
1091 .waitForExistence(timeout: 15), "org members missing")
1092
1093 // Create a team.
1094 let teamField = app.descendants(matching: .any)
1095 .matching(identifier: "org-new-team").firstMatch
1096 XCTAssertTrue(teamField.waitForExistence(timeout: 5))
1097 focusAndType(teamField, "ui-smoke")
1098 app.descendants(matching: .any).matching(identifier: "org-new-team-submit")
1099 .firstMatch.tap()
1100 let teamRow = app.staticTexts["ui-smoke"].firstMatch
1101 XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed")
1102
1103 // Grant it a repo, then revoke.
1104 teamRow.tap()
1105 let repoField = app.textFields["org/repo"].firstMatch
1106 XCTAssertTrue(repoField.waitForExistence(timeout: 10))
1107 focusAndType(repoField, "krz/gitbay-ios")
1108 app.buttons["Grant"].firstMatch.tap()
1109 let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch
1110 XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed")
1111 grantRow.swipeLeft()
1112 app.buttons["Revoke"].firstMatch.tap()
1113 // The confirmation dialog's destructive Revoke.
1114 let confirmRevoke = app.buttons["Revoke"].firstMatch
1115 XCTAssertTrue(confirmRevoke.waitForExistence(timeout: 5), "revoke confirmation missing")
1116 confirmRevoke.tap()
1117 XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked")
1118
1119 back()
1120
1121 // Delete the team, through its confirmation.
1122 let row = app.staticTexts["ui-smoke"].firstMatch
1123 XCTAssertTrue(row.waitForExistence(timeout: 10))
1124 row.swipeLeft()
1125 app.buttons["Delete"].firstMatch.tap()
1126 // The confirmation dialog's destructive Delete.
1127 let confirm = app.buttons["Delete"].firstMatch
1128 XCTAssertTrue(confirm.waitForExistence(timeout: 5))
1129 confirm.tap()
1130 XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
1131 }
1132}
1133
1134extension LiveSmokeUITests {
1135
1136 /// The pre-merge screenshot checkpoint: walks the dense screens and
1137 /// attaches captures. Run per device/appearance; export the
1138 /// attachments from the xcresult. Signs in first when the device has
1139 /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided.
1140 func testScreenshotCheckpoint() throws {
1141 signInIfNeeded()
1142
1143 snap("dashboard")
1144
1145 openRepo("krz/gitbay")
1146 snap("repo")
1147
1148 repoLink("merge-requests").tap()
1149 let all = app.segmentedControls.buttons["All"].firstMatch
1150 XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
1151 all.tap()
1152 // A real MR row carries its !number; the picker row does not.
1153 let mrNumber = app.staticTexts
1154 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
1155 XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All")
1156 snap("mr-list")
1157
1158 mrNumber.tap()
1159 // Captured at its header, before scrolling down to the diff row.
1160 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15), "MR detail did not open")
1161 snap("mr-detail")
1162
1163 // The diff row sits below the fold and a List does not build rows
1164 // it has not shown.
1165 let diffRow = app.staticTexts["Diff"].firstMatch
1166 XCTAssertTrue(scrollTo(diffRow), "MR detail has no diff row")
1167
1168 diffRow.tap()
1169 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
1170 snap("diff")
1171 }
1172
1173 private func signInIfNeeded() {
1174 // Signed in already: the tab bar is up and there is no token field.
1175 if app.tabBars.firstMatch.waitForExistence(timeout: 10),
1176 !app.secureTextFields.firstMatch.exists {
1177 return
1178 }
1179 let tokenField = app.secureTextFields.firstMatch
1180 guard tokenField.waitForExistence(timeout: 5) else { return }
1181 guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else {
1182 XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
1183 return
1184 }
1185 focusAndType(tokenField, token + "\n")
1186 XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
1187 .waitForExistence(timeout: 20), "sign-in did not land")
1188 dismissSavePasswordPrompt()
1189 }
1190
1191 /// iOS offers to save whatever went into a SecureField, and the sheet
1192 /// sits over the app until answered — over the dashboard, in one set
1193 /// of store screenshots. It belongs to springboard, not to the app.
1194 private func dismissSavePasswordPrompt() {
1195 let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
1196 let notNow = springboard.buttons["Not Now"].firstMatch
1197 if notNow.waitForExistence(timeout: 8) {
1198 notNow.tap()
1199 _ = waitForDisappearance(notNow, timeout: 10)
1200 return
1201 }
1202 let inApp = app.buttons["Not Now"].firstMatch
1203 if inApp.waitForExistence(timeout: 3) {
1204 inApp.tap()
1205 _ = waitForDisappearance(inApp, timeout: 10)
1206 return
1207 }
1208 // No prompt is fine: iOS only offers once per credential.
1209 }
1210
1211 /// Capture a screen, but not while it is still loading. These go to
1212 /// the App Store, and a spinner shipped as a screenshot once already.
1213 private func snap(_ name: String, file: StaticString = #filePath, line: UInt = #line) {
1214 let spinner = app.activityIndicators.firstMatch
1215 if spinner.exists {
1216 XCTAssertTrue(waitForDisappearance(spinner, timeout: 30),
1217 "\(name) still loading after 30s", file: file, line: line)
1218 }
1219 // A list that has loaded has rows; an empty state has text. Either
1220 // way something must be on screen besides chrome.
1221 let content = app.cells.firstMatch
1222 _ = content.waitForExistence(timeout: 10)
1223
1224 let attachment = XCTAttachment(screenshot: app.screenshot())
1225 attachment.name = name
1226 attachment.lifetime = .keepAlways
1227 add(attachment)
1228 }
1229}
1230
1231extension LiveSmokeUITests {
1232
1233 /// Blame and file editing — the two capabilities that were web-only
1234 /// until krz/gitbay#42 and #43 made them commands. Blame is read-only
1235 /// on a real repo; the edit writes to a scratch repo the runner
1236 /// creates and deletes over SSH.
1237 func testBlameAndEditFlows() throws {
1238 // --- blame on a real file ---
1239 openRepo("krz/gitbay")
1240 repoLink("files").tap()
1241 let goMod = app.staticTexts["go.mod"].firstMatch
1242 XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree")
1243 goMod.tap()
1244 XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu")
1245 .firstMatch.waitForExistence(timeout: 15), "file screen did not open")
1246 app.descendants(matching: .any).matching(identifier: "file-actions-menu")
1247 .firstMatch.tap()
1248 app.buttons["Blame"].firstMatch.tap()
1249 // Attribution shows the commit subject beside the lines.
1250 XCTAssertTrue(app.staticTexts
1251 .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch
1252 .waitForExistence(timeout: 20), "blame rendered no attribution")
1253
1254 // --- edit on the scratch repo ---
1255 // Relaunch rather than reuse the list: `.searchable` keeps the
1256 // previous query, and clearing it from the harness is unreliable.
1257 app.terminate()
1258 app.launch()
1259 openRepo(Self.fixtureRepo)
1260 repoLink("files").tap()
1261 let notes = app.staticTexts["notes.txt"].firstMatch
1262 XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree")
1263 notes.tap()
1264
1265 let menu = app.descendants(matching: .any)
1266 .matching(identifier: "file-actions-menu").firstMatch
1267 XCTAssertTrue(menu.waitForExistence(timeout: 15))
1268 menu.tap()
1269 let edit = app.buttons
1270 .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch
1271 XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing")
1272 edit.tap()
1273
1274 let content = app.descendants(matching: .any)
1275 .matching(identifier: "file-edit-content").firstMatch
1276 XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open")
1277 focusAndType(content, " edited from the app")
1278
1279 let message = app.descendants(matching: .any)
1280 .matching(identifier: "file-edit-message").firstMatch
1281 focusAndType(message, "edit from ios")
1282
1283 app.descendants(matching: .any).matching(identifier: "file-edit-commit")
1284 .firstMatch.tap()
1285 XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss")
1286 // The reloaded file shows the committed text.
1287 XCTAssertTrue(app.staticTexts
1288 .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch
1289 .waitForExistence(timeout: 20), "edit not reflected after commit")
1290 }
1291}
1292
1293
1294extension LiveSmokeUITests {
1295
1296 /// The navigation and profile changes: identity lives in one menu,
1297 /// the dashboard can create a repository, a profile is a real
1298 /// profile, and a log entry opens its commit.
1299 func testProfileAndNavigationFlows() throws {
1300 // --- the dashboard offers creation ---
1301 selectTab("Dashboard")
1302 let create = app.descendants(matching: .any)
1303 .matching(identifier: "dashboard-create-button").firstMatch
1304 XCTAssertTrue(create.waitForExistence(timeout: 15), "dashboard + missing")
1305 create.tap()
1306 let pathField = app.descendants(matching: .any)
1307 .matching(identifier: "repo-create-path").firstMatch
1308 XCTAssertTrue(pathField.waitForExistence(timeout: 10),
1309 "dashboard + did not open the create sheet")
1310 app.buttons["Cancel"].firstMatch.tap()
1311
1312 // --- identity is its own tab; the account menu rides with it ---
1313 selectTab("My Profile")
1314 let menu = app.descendants(matching: .any)
1315 .matching(identifier: "account-menu").firstMatch
1316 XCTAssertTrue(menu.waitForExistence(timeout: 10),
1317 "account menu missing from My Profile")
1318 menu.tap()
1319 XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
1320 "keys not in the account menu")
1321 // ios-smoke is not an instance admin.
1322 XCTAssertFalse(app.descendants(matching: .any)
1323 .matching(identifier: "account-menu-admin").firstMatch.exists,
1324 "a non-admin is offered Admin")
1325 app.tap() // dismiss the menu; the profile is already on screen
1326
1327 // --- a profile is a profile: description, links, orgs, graph, repos ---
1328 XCTAssertTrue(app.staticTexts
1329 .containing(NSPredicate(format: "label CONTAINS %@", Self.smokeProfileDescription)).firstMatch
1330 .waitForExistence(timeout: 20), "profile description missing")
1331 XCTAssertTrue(header("Organizations").exists,
1332 "org memberships missing")
1333 XCTAssertTrue(app.staticTexts["krz"].firstMatch.exists, "org row missing")
1334
1335 // --- an org admin can edit the org's profile ---
1336 app.staticTexts["krz"].firstMatch.tap()
1337 XCTAssertTrue(app.descendants(matching: .any)
1338 .matching(identifier: "profile-edit-button").firstMatch
1339 .waitForExistence(timeout: 15), "org profile offers no edit to its admin")
1340 back()
1341
1342 // --- saving the profile succeeds: profile set takes no about flags ---
1343 let edit = app.descendants(matching: .any)
1344 .matching(identifier: "profile-edit-button").firstMatch
1345 XCTAssertTrue(edit.waitForExistence(timeout: 10), "own profile offers no edit")
1346 edit.tap()
1347 let save = app.buttons["profile-edit-save"].firstMatch
1348 XCTAssertTrue(save.waitForExistence(timeout: 10), "edit sheet did not open")
1349 XCTAssertFalse(app.descendants(matching: .any)
1350 .matching(identifier: "profile-edit-about").firstMatch.exists,
1351 "the sheet still edits the about text")
1352 save.tap()
1353 XCTAssertTrue(app.navigationBars["Edit Profile"].waitForNonExistence(timeout: 15),
1354 "profile save did not dismiss the sheet — the server refused it")
1355 XCTAssertFalse(save.exists, "the edit sheet is still on screen")
1356
1357 // --- the about text is a file: edit it, or offer to create it ---
1358 let aboutAction = app.descendants(matching: .any).matching(NSPredicate(
1359 format: "identifier IN %@", ["profile-about-edit", "profile-about-create"])).firstMatch
1360 XCTAssertTrue(scrollTo(aboutAction),
1361 "own profile offers neither editing nor creating the about file")
1362
1363 // The graph and the repositories sit below the orgs and links,
1364 // and a List does not build rows it has not shown.
1365 XCTAssertTrue(scrollTo(app.staticTexts
1366 .containing(NSPredicate(format: "label CONTAINS 'contributions in the last year'")).firstMatch),
1367 "activity graph missing")
1368 XCTAssertTrue(scrollTo(app.staticTexts
1369 .containing(NSPredicate(format: "label BEGINSWITH[c] 'Repositories'")).firstMatch),
1370 "repositories missing")
1371
1372 // --- a log entry opens its commit ---
1373 app.terminate()
1374 app.launch()
1375 openRepo("krz/gitbay")
1376 moreLink("History").tap()
1377 let firstCommit = app.cells.firstMatch
1378 XCTAssertTrue(firstCommit.waitForExistence(timeout: 20), "history is empty")
1379 firstCommit.tap()
1380 // The commit screen IS the patch: no navigating away to find it.
1381 // A hunk header (@@) only appears in a rendered diff, and sits
1382 // below the message and the file list.
1383 let hunk = app.staticTexts
1384 .containing(NSPredicate(format: "label BEGINSWITH '@@'")).firstMatch
1385 _ = hunk.waitForExistence(timeout: 20)
1386 XCTAssertTrue(scrollTo(hunk, swipes: 10),
1387 "commit screen did not render the patch inline")
1388 }
1389}
1390
1391extension LiveSmokeUITests {
1392
1393 /// The three gaps that needed no server work: branches and tags,
1394 /// milestones, and an MR's milestone. Read-only except the MR
1395 /// milestone, which is set and cleared back.
1396 func testRefsAndMilestoneFlows() throws {
1397 openRepo("krz/gitbay")
1398
1399 // --- branches and tags, and browsing at a ref ---
1400 moreLink("Branches & Tags").tap()
1401 let main = app.staticTexts["main"].firstMatch
1402 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1403 XCTAssertTrue(app.staticTexts["default"].firstMatch.exists,
1404 "the default branch is not marked")
1405 XCTAssertTrue(header("Tags").exists, "tags section missing")
1406 main.tap()
1407 // Tapping a ref browses the repository there.
1408 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1409 "browsing at a ref showed nothing")
1410 back()
1411 back()
1412
1413 // --- milestones with progress ---
1414 moreLink("Milestones").tap()
1415 XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 15),
1416 "milestones did not load")
1417 app.segmentedControls.buttons["All"].firstMatch.tap()
1418 XCTAssertTrue(app.staticTexts
1419 .containing(NSPredicate(format: "label CONTAINS 'closed'")).firstMatch
1420 .waitForExistence(timeout: 20), "milestone progress missing")
1421 back()
1422
1423 // --- an MR's milestone reads back, and can be cleared ---
1424 repoLink("merge-requests").tap()
1425 app.segmentedControls.buttons["All"].firstMatch.tap()
1426 let mrNumber = app.staticTexts
1427 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
1428 XCTAssertTrue(mrNumber.waitForExistence(timeout: 20), "no MRs")
1429 mrNumber.tap()
1430 let milestone = app.descendants(matching: .any)
1431 .matching(identifier: "mr-milestone-menu").firstMatch
1432 // The nav bar is the "opened" signal; everything else on this
1433 // screen can be below the fold.
1434 XCTAssertTrue(app.navigationBars.element.waitForExistence(timeout: 20),
1435 "MR detail did not open")
1436 XCTAssertTrue(scrollTo(milestone, swipes: 8), "MR milestone row missing")
1437 milestone.tap()
1438 // The picker offers None plus the open milestones.
1439 XCTAssertTrue(app.buttons["None"].firstMatch.waitForExistence(timeout: 10),
1440 "milestone picker did not open")
1441 app.buttons["None"].firstMatch.tap()
1442 }
1443}
1444
1445extension LiveSmokeUITests {
1446
1447 /// Explore and history at a ref — the two capabilities krz/gitbay!101
1448 /// made reachable. Read-only.
1449 func testExploreAndRefLogFlows() throws {
1450 // --- explore lists what the instance hosts ---
1451 selectTab("Explore")
1452 let firstRepo = app.cells.firstMatch
1453 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1454 // Public repos this account does not own are reachable here.
1455 XCTAssertTrue(app.staticTexts
1456 .containing(NSPredicate(format: "label CONTAINS 'audit-labs/'")).firstMatch
1457 .waitForExistence(timeout: 10), "explore is not the public listing")
1458 firstRepo.tap()
1459 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 20),
1460 "explore row did not open its repo")
1461
1462 // --- history at a ref ---
1463 app.terminate()
1464 app.launch()
1465 openRepo("krz/gitbay")
1466 // Browse a ref, then ask for its history — the move the tree page
1467 // offers on the web.
1468 moreLink("Branches & Tags").tap()
1469 let main = app.staticTexts["main"].firstMatch
1470 XCTAssertTrue(main.waitForExistence(timeout: 20), "refs did not load")
1471 main.tap()
1472 let history = app.descendants(matching: .any)
1473 .matching(identifier: "tree-history-button").firstMatch
1474 XCTAssertTrue(history.waitForExistence(timeout: 20), "no History on the tree")
1475 history.tap()
1476 XCTAssertTrue(app.navigationBars
1477 .containing(NSPredicate(format: "identifier CONTAINS 'main'")).firstMatch
1478 .waitForExistence(timeout: 20), "ref history did not open")
1479 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20),
1480 "ref history rendered no commits")
1481 }
1482}
1483
1484extension LiveSmokeUITests {
1485
1486 /// The wiki — the last capability that was browser-only until
1487 /// krz/gitbay#48. Read-only; editing is a push, on every surface.
1488 func testWikiFlows() throws {
1489 openRepo("krz/gitbay")
1490 moreLink("Wiki").tap()
1491
1492 // The landing page leads and is marked.
1493 let home = app.staticTexts["Home"].firstMatch
1494 XCTAssertTrue(home.waitForExistence(timeout: 20), "wiki pages did not load")
1495 XCTAssertTrue(app.staticTexts["home"].firstMatch.exists,
1496 "the landing page is not marked")
1497
1498 // Parity.org is an org page, so this also proves the org renderer
1499 // runs on wiki content and not just READMEs.
1500 // Below the fold since the Architecture pages sort above it.
1501 let parity = app.staticTexts["Parity"].firstMatch
1502 XCTAssertTrue(scrollTo(parity), "Parity page missing from the listing")
1503 parity.tap()
1504 XCTAssertTrue(app.staticTexts
1505 .containing(NSPredicate(format: "label CONTAINS 'surface'")).firstMatch
1506 .waitForExistence(timeout: 20), "wiki page rendered no prose")
1507 // Org headings render as headings, not as raw #+title:.
1508 XCTAssertFalse(app.staticTexts
1509 .containing(NSPredicate(format: "label CONTAINS '#+title'")).firstMatch.exists,
1510 "org markup leaked into the rendered page")
1511 }
1512}
1513
1514
1515extension LiveSmokeUITests {
1516
1517 /// Builds on a repo that has a job file: the picker offers what the
1518 /// server names, and the detail screen says more than the log.
1519 func testBuildJobsAndDetail() throws {
1520 openRepo("krz/gitbay")
1521 repoLink("builds").tap()
1522
1523 // The picker lists jobs from `build jobs` — no name to type.
1524 let trigger = app.descendants(matching: .any)
1525 .matching(identifier: "build-trigger-button").firstMatch
1526 XCTAssertTrue(trigger.waitForExistence(timeout: 20), "trigger control missing")
1527 XCTAssertTrue(trigger.isEnabled, "trigger is disabled where a job file exists")
1528 trigger.tap()
1529 let job = app.buttons
1530 .containing(NSPredicate(format: "label CONTAINS 'build' AND label CONTAINS 'on push'"))
1531 .firstMatch
1532 XCTAssertTrue(job.waitForExistence(timeout: 10),
1533 "job menu did not list the job with why it runs")
1534 // Dismiss without queueing a real build.
1535 app.tap()
1536
1537 // The detail screen carries status, ref and timing, not just log.
1538 let firstBuild = app.cells.firstMatch
1539 XCTAssertTrue(firstBuild.waitForExistence(timeout: 20), "no builds listed")
1540 firstBuild.tap()
1541 for label in ["success", "queued"] where app.staticTexts[label].firstMatch.exists {
1542 XCTAssertTrue(true)
1543 }
1544 XCTAssertTrue(app.staticTexts
1545 .containing(NSPredicate(format: "label BEGINSWITH 'queued '")).firstMatch
1546 .waitForExistence(timeout: 20), "build detail shows no timing")
1547 }
1548}
1549
1550extension LiveSmokeUITests {
1551
1552 /// Store screenshots on iPad. It navigates by tab and through
1553 /// Explore rather than reusing the iPhone checkpoint: that one drives
1554 /// the repo list's `.searchable`, which iPadOS lays out differently,
1555 /// and chasing those differences is the iPad pass this release
1556 /// deferred. Tabs and a list row work on both.
1557 func testIPadScreenshotCheckpoint() throws {
1558 signInIfNeeded()
1559 // The split view's detail is empty until a row is chosen; open the
1560 // first issue or merge request so the shot is not half placeholder.
1561 let item = app.staticTexts
1562 .matching(NSPredicate(format: "label MATCHES '.+/.+[#!][0-9]+'")).firstMatch
1563 XCTAssertTrue(item.waitForExistence(timeout: 20), "dashboard listed no items")
1564 item.tap()
1565 XCTAssertTrue(waitForDisappearance(app.staticTexts["Nothing selected"].firstMatch, timeout: 15),
1566 "dashboard detail did not open")
1567 snap("dashboard")
1568
1569 selectTab("Explore")
1570 snap("explore")
1571
1572 // First public repo in the list — no search field involved.
1573 let firstRepo = app.cells.firstMatch
1574 XCTAssertTrue(firstRepo.waitForExistence(timeout: 20), "explore listed nothing")
1575 firstRepo.tap()
1576 XCTAssertTrue(repoLink("files").waitForExistence(timeout: 20),
1577 "repo screen did not open")
1578 snap("repo")
1579
1580 selectTab("Feed")
1581 snap("feed")
1582
1583 selectTab("My Profile")
1584 // Any signed-in account can edit its own profile, so this holds
1585 // for store screenshots taken as a real user too.
1586 XCTAssertTrue(app.descendants(matching: .any)
1587 .matching(identifier: "profile-edit-button").firstMatch
1588 .waitForExistence(timeout: 20), "profile did not load")
1589 snap("profile")
1590 }
1591}
1592
1593extension LiveSmokeUITests {
1594
1595 /// Snippets, a full life on the caller's own account: create, add a
1596 /// file, replace it, remove it, change visibility, delete. setUp and
1597 /// tearDown remove anything left by this description.
1598 func testSnippetFlows() throws {
1599 selectTab("My Profile")
1600 app.descendants(matching: .any).matching(identifier: "account-menu").firstMatch.tap()
1601 let snippets = app.buttons["Snippets"].firstMatch
1602 XCTAssertTrue(snippets.waitForExistence(timeout: 5), "Snippets missing from the account menu")
1603 snippets.tap()
1604
1605 // --- create ---
1606 let create = app.descendants(matching: .any)
1607 .matching(identifier: "snippet-create-button").firstMatch
1608 XCTAssertTrue(create.waitForExistence(timeout: 15), "snippet list did not open")
1609 create.tap()
1610 let nameField = app.descendants(matching: .any)
1611 .matching(identifier: "snippet-file-name").firstMatch
1612 XCTAssertTrue(nameField.waitForExistence(timeout: 5), "create sheet did not open")
1613 focusAndType(nameField, "ui-smoke.txt")
1614 focusAndType(app.descendants(matching: .any)
1615 .matching(identifier: "snippet-description").firstMatch, Self.smokeSnippetDescription)
1616 let content = app.descendants(matching: .any)
1617 .matching(identifier: "snippet-content").firstMatch
1618 focusAndType(content, "hello from the live suite")
1619 app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap()
1620 XCTAssertTrue(waitForDisappearance(nameField, timeout: 20), "create did not dismiss")
1621
1622 let row = app.staticTexts[Self.smokeSnippetDescription].firstMatch
1623 XCTAssertTrue(row.waitForExistence(timeout: 15), "created snippet not listed")
1624 row.tap()
1625
1626 // --- detail: the file renders, unlisted by default ---
1627 XCTAssertTrue(app.staticTexts["ui-smoke.txt"].firstMatch.waitForExistence(timeout: 15),
1628 "snippet file missing")
1629 XCTAssertTrue(app.staticTexts["unlisted"].firstMatch.exists, "default visibility not unlisted")
1630
1631 // --- add a second file ---
1632 let actions = app.descendants(matching: .any)
1633 .matching(identifier: "snippet-actions-menu").firstMatch
1634 XCTAssertTrue(actions.waitForExistence(timeout: 10), "owner actions missing")
1635 actions.tap()
1636 let addFile = app.buttons["Add file"].firstMatch
1637 XCTAssertTrue(addFile.waitForExistence(timeout: 5))
1638 addFile.tap()
1639 let secondName = app.descendants(matching: .any)
1640 .matching(identifier: "snippet-file-name").firstMatch
1641 XCTAssertTrue(secondName.waitForExistence(timeout: 5), "add-file sheet did not open")
1642 focusAndType(secondName, "second.txt")
1643 focusAndType(app.descendants(matching: .any)
1644 .matching(identifier: "snippet-content").firstMatch, "two")
1645 app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap()
1646 XCTAssertTrue(waitForDisappearance(secondName, timeout: 20), "add file did not dismiss")
1647 let second = app.staticTexts["second.txt"].firstMatch
1648 XCTAssertTrue(second.waitForExistence(timeout: 15), "added file missing")
1649
1650 // --- replace its content from the file menu ---
1651 let fileMenu = app.descendants(matching: .any)
1652 .matching(identifier: "snippet-file-menu-second.txt").firstMatch
1653 XCTAssertTrue(scrollTo(fileMenu), "file menu not reachable")
1654 fileMenu.tap()
1655 let edit = app.buttons["Edit"].firstMatch
1656 XCTAssertTrue(edit.waitForExistence(timeout: 5))
1657 edit.tap()
1658 let editor = app.descendants(matching: .any)
1659 .matching(identifier: "snippet-content").firstMatch
1660 XCTAssertTrue(editor.waitForExistence(timeout: 5), "edit sheet did not open")
1661 XCTAssertTrue((editor.value as? String)?.contains("two") == true, "edit sheet did not prefill")
1662 focusAndType(editor, " and three")
1663 app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap()
1664 XCTAssertTrue(waitForDisappearance(editor, timeout: 20), "file save did not dismiss")
1665 XCTAssertTrue(app.staticTexts
1666 .containing(NSPredicate(format: "label CONTAINS 'and three'")).firstMatch
1667 .waitForExistence(timeout: 15), "replaced content not rendered")
1668
1669 // --- remove it, through its confirmation ---
1670 XCTAssertTrue(scrollTo(fileMenu), "file menu not reachable after edit")
1671 fileMenu.tap()
1672 app.buttons["Remove"].firstMatch.tap()
1673 let confirmRemove = app.buttons["Remove"].firstMatch
1674 XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "remove confirmation missing")
1675 confirmRemove.tap()
1676 XCTAssertTrue(waitForDisappearance(second, timeout: 15), "file not removed")
1677
1678 // --- visibility to public ---
1679 actions.tap()
1680 let settings = app.buttons["Description & visibility"].firstMatch
1681 XCTAssertTrue(settings.waitForExistence(timeout: 5))
1682 settings.tap()
1683 let visibility = app.segmentedControls.firstMatch
1684 XCTAssertTrue(visibility.waitForExistence(timeout: 5), "settings sheet did not open")
1685 visibility.buttons["public"].tap()
1686 app.buttons["Save"].firstMatch.tap()
1687 XCTAssertTrue(app.staticTexts["public"].firstMatch.waitForExistence(timeout: 15),
1688 "visibility change not shown")
1689
1690 // --- delete, through its confirmation; the list no longer has it ---
1691 actions.tap()
1692 app.buttons["Delete snippet"].firstMatch.tap()
1693 let confirmDelete = app.buttons["Delete"].firstMatch
1694 XCTAssertTrue(confirmDelete.waitForExistence(timeout: 5), "delete confirmation missing")
1695 confirmDelete.tap()
1696 XCTAssertTrue(create.waitForExistence(timeout: 15), "did not return to the snippet list")
1697 XCTAssertTrue(waitForDisappearance(row, timeout: 15), "deleted snippet still listed")
1698 }
1699
1700 /// An SSH key's label round-trips: set from the row's swipe action,
1701 /// then restored to what it was (or cleared).
1702 func testSSHKeyLabelFlow() throws {
1703 openAccountScreen()
1704 let fingerprint = app.staticTexts
1705 .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
1706 XCTAssertTrue(fingerprint.waitForExistence(timeout: 15), "SSH keys missing")
1707 let fp = fingerprint.label
1708
1709 // What the key is called now, so it can be put back.
1710 var original = ""
1711 if let data = readCommand(["keys", "list"]),
1712 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1713 let rows = envelope["data"] as? [[String: Any]],
1714 let row = rows.first(where: { $0["fingerprint"] as? String == fp }) {
1715 original = row["label"] as? String ?? ""
1716 }
1717
1718 let relabel: (String) -> Void = { text in
1719 fingerprint.swipeLeft()
1720 let label = self.app.buttons["Label"].firstMatch
1721 XCTAssertTrue(label.waitForExistence(timeout: 5), "Label swipe action missing")
1722 label.tap()
1723 let field = self.app.alerts.textFields.firstMatch
1724 XCTAssertTrue(field.waitForExistence(timeout: 5), "label alert missing")
1725 self.clearAndType(field, text)
1726 self.app.alerts.buttons["Save"].firstMatch.tap()
1727 }
1728
1729 relabel("ui-smoke")
1730 XCTAssertTrue(app.staticTexts["ui-smoke"].firstMatch.waitForExistence(timeout: 15),
1731 "label not shown after save")
1732
1733 relabel(original)
1734 XCTAssertTrue(waitForDisappearance(app.staticTexts["ui-smoke"].firstMatch, timeout: 15),
1735 "label not restored")
1736 if !original.isEmpty {
1737 XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10),
1738 "original label not back")
1739 }
1740
1741 // Removal asks for the fingerprint's first eight characters and
1742 // stays disabled until they are typed. Cancelled: the key stays.
1743 fingerprint.swipeLeft()
1744 let remove = app.buttons["Remove"].firstMatch
1745 XCTAssertTrue(remove.waitForExistence(timeout: 5), "Remove swipe action missing")
1746 remove.tap()
1747 let confirm = app.alerts.textFields.firstMatch
1748 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "remove alert missing")
1749 let removeAction = app.alerts.buttons["Remove"].firstMatch
1750 XCTAssertFalse(removeAction.isEnabled, "remove enabled before confirmation")
1751 confirm.tap()
1752 confirm.typeText(String(fp.dropFirst("SHA256:".count).prefix(8)))
1753 XCTAssertTrue(removeAction.isEnabled, "remove not enabled by the typed confirmation")
1754 app.alerts.buttons["Cancel"].firstMatch.tap()
1755 XCTAssertTrue(fingerprint.waitForExistence(timeout: 5), "key gone after cancel")
1756 }
1757
1758 /// Org labels and milestones from the org screen: a label is created
1759 /// and removed; the fixture milestone is reopened and closed again,
1760 /// and creating it a second time surfaces the server's refusal.
1761 func testOrgLabelAndMilestoneFlows() throws {
1762 openAccountScreen()
1763 let orgRow = app.staticTexts[Self.smokeOrg].firstMatch
1764 XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
1765 orgRow.tap()
1766 XCTAssertTrue(app.staticTexts[Self.smokeUser].firstMatch.waitForExistence(timeout: 15),
1767 "org members missing")
1768
1769 // --- labels ---
1770 let labels = app.staticTexts["Labels"].firstMatch
1771 XCTAssertTrue(scrollTo(labels), "Labels link not reachable")
1772 labels.tap()
1773 let newName = app.descendants(matching: .any)
1774 .matching(identifier: "label-new-name").firstMatch
1775 XCTAssertTrue(newName.waitForExistence(timeout: 15), "org labels did not open")
1776 focusAndType(newName, Self.smokeOrgLabel)
1777 app.descendants(matching: .any).matching(identifier: "label-create-submit").firstMatch.tap()
1778 let chip = app.staticTexts[Self.smokeOrgLabel].firstMatch
1779 XCTAssertTrue(chip.waitForExistence(timeout: 15), "org label not listed after create")
1780
1781 // The org's own screen manages its rows: the menu is there, not
1782 // the read-only mark.
1783 let cell = app.cells.containing(.staticText, identifier: Self.smokeOrgLabel).firstMatch
1784 let menu = cell.buttons.firstMatch
1785 XCTAssertTrue(menu.waitForExistence(timeout: 5), "org label has no menu on the org screen")
1786 menu.tap()
1787 let remove = app.buttons["Remove"].firstMatch
1788 XCTAssertTrue(remove.waitForExistence(timeout: 5))
1789 remove.tap()
1790 let confirmRemove = app.buttons["Remove"].firstMatch
1791 XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "remove confirmation missing")
1792 confirmRemove.tap()
1793 XCTAssertTrue(waitForDisappearance(chip, timeout: 15), "org label not removed")
1794 back()
1795
1796 // --- milestones ---
1797 let milestones = app.staticTexts["Milestones"].firstMatch
1798 XCTAssertTrue(scrollTo(milestones), "Milestones link not reachable")
1799 milestones.tap()
1800 let picker = app.segmentedControls.firstMatch
1801 XCTAssertTrue(picker.waitForExistence(timeout: 15), "org milestones did not open")
1802 picker.buttons["Closed"].tap()
1803 let fixture = app.staticTexts[Self.smokeOrgMilestone].firstMatch
1804 XCTAssertTrue(fixture.waitForExistence(timeout: 15), "fixture milestone not among closed")
1805 fixture.swipeLeft()
1806 let reopen = app.buttons["Reopen"].firstMatch
1807 XCTAssertTrue(reopen.waitForExistence(timeout: 5), "reopen action missing on the org screen")
1808 reopen.tap()
1809 XCTAssertTrue(waitForDisappearance(fixture, timeout: 15), "reopened milestone still among closed")
1810 picker.buttons["Open"].tap()
1811 XCTAssertTrue(fixture.waitForExistence(timeout: 15), "reopened milestone not among open")
1812
1813 // Creating it again is refused with the server's sentence.
1814 app.descendants(matching: .any).matching(identifier: "milestone-create-button").firstMatch.tap()
1815 let title = app.descendants(matching: .any)
1816 .matching(identifier: "milestone-title").firstMatch
1817 XCTAssertTrue(title.waitForExistence(timeout: 5), "create sheet did not open")
1818 focusAndType(title, Self.smokeOrgMilestone)
1819 app.descendants(matching: .any).matching(identifier: "milestone-submit").firstMatch.tap()
1820 XCTAssertTrue(app.staticTexts
1821 .containing(NSPredicate(format: "label CONTAINS[c] 'already'")).firstMatch
1822 .waitForExistence(timeout: 15), "duplicate milestone refusal not surfaced")
1823 app.buttons["Cancel"].firstMatch.tap()
1824
1825 // Back to closed, where the fixture rests.
1826 XCTAssertTrue(fixture.waitForExistence(timeout: 10))
1827 fixture.swipeLeft()
1828 let close = app.buttons["Close"].firstMatch
1829 XCTAssertTrue(close.waitForExistence(timeout: 5), "close action missing on the org screen")
1830 close.tap()
1831 XCTAssertTrue(waitForDisappearance(fixture, timeout: 15), "closed milestone still among open")
1832 }
1833
1834 /// Build list filters on a repo with builds: a status narrows the
1835 /// list, a job narrows it further, and an impossible pair shows the
1836 /// filtered empty state. Clearing brings the list back.
1837 func testBuildFilterFlow() throws {
1838 openRepo("krz/gitbay")
1839 repoLink("builds").tap()
1840 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed")
1841
1842 let filter = app.descendants(matching: .any)
1843 .matching(identifier: "build-filter-button").firstMatch
1844 XCTAssertTrue(filter.waitForExistence(timeout: 10), "filter button missing")
1845
1846 let choose: (String, String) -> Void = { menuId, option in
1847 let menu = self.app.descendants(matching: .any).matching(identifier: menuId).firstMatch
1848 XCTAssertTrue(menu.waitForExistence(timeout: 5), "\(menuId) missing")
1849 menu.tap()
1850 let choice = self.app.buttons[option].firstMatch
1851 XCTAssertTrue(choice.waitForExistence(timeout: 5), "\(option) not offered")
1852 choice.tap()
1853 }
1854
1855 // status = success: rows remain and every row says success.
1856 filter.tap()
1857 choose("build-filter-status-menu", "success")
1858 app.buttons["Done"].firstMatch.tap()
1859 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no successful builds")
1860 XCTAssertFalse(app.staticTexts["failure"].firstMatch.exists, "a failed build survived the filter")
1861
1862 // job = test as well: the rows name the job.
1863 filter.tap()
1864 choose("build-filter-job-menu", "test")
1865 app.buttons["Done"].firstMatch.tap()
1866 XCTAssertTrue(app.staticTexts["test"].firstMatch
1867 .waitForExistence(timeout: 20), "job filter did not narrow to test")
1868
1869 // status = pending on a quiet repo: the filtered empty state.
1870 filter.tap()
1871 choose("build-filter-status-menu", "pending")
1872 app.buttons["Done"].firstMatch.tap()
1873 XCTAssertTrue(app.staticTexts["No builds match these filters."].firstMatch
1874 .waitForExistence(timeout: 20), "filtered empty state missing")
1875
1876 // Clear: the list is back.
1877 filter.tap()
1878 app.buttons["Clear filters"].firstMatch.tap()
1879 app.buttons["Done"].firstMatch.tap()
1880 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "list did not come back")
1881 }
1882
1883 /// krz/gitbay has well over thirty builds, so the thirty-first is on
1884 /// the second page. Reaching it means the footer asked for more.
1885 func testBuildPagingFlow() throws {
1886 guard let data = readCommand(["build", "list", "krz/gitbay", "--limit", "31"]),
1887 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1888 let page = envelope["data"] as? [String: Any],
1889 let items = page["items"] as? [[String: Any]], items.count == 31,
1890 let number = items[30]["number"] as? Int
1891 else { throw XCTSkip("could not read krz/gitbay's builds over the API") }
1892
1893 openRepo("krz/gitbay")
1894 repoLink("builds").tap()
1895 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed")
1896 let thirtyFirst = app.staticTexts
1897 .containing(NSPredicate(format: "label BEGINSWITH %@", "#\(number) ")).firstMatch
1898 XCTAssertTrue(scrollTo(thirtyFirst, swipes: 20),
1899 "build #\(number), the first row of page two, never appeared")
1900 }
1901
1902 /// Close in favour of another: the first fixture merge request is
1903 /// closed from the app naming the second; the page then says so and
1904 /// links to it.
1905 func testCloseInFavourFlow() throws {
1906 openRepo(Self.fixtureRepo)
1907 repoLink("merge-requests").tap()
1908 let superseded = app.staticTexts[Self.supersededTitle].firstMatch
1909 XCTAssertTrue(superseded.waitForExistence(timeout: 15), "fixture MR not listed")
1910 superseded.tap()
1911
1912 app.descendants(matching: .any).matching(identifier: "mr-actions-menu").firstMatch.tap()
1913 let close = app.buttons["Close"].firstMatch
1914 XCTAssertTrue(close.waitForExistence(timeout: 5))
1915 close.tap()
1916 let inFavour = app.buttons["Close in favour of another…"].firstMatch
1917 XCTAssertTrue(inFavour.waitForExistence(timeout: 5), "close dialog has no in-favour choice")
1918 inFavour.tap()
1919
1920 let successor = app.staticTexts[Self.successorTitle].firstMatch
1921 XCTAssertTrue(successor.waitForExistence(timeout: 15), "successor not offered")
1922 successor.tap()
1923 let confirm = app.buttons["Close"].firstMatch
1924 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "in-favour confirmation missing")
1925 confirm.tap()
1926
1927 XCTAssertTrue(app.staticTexts
1928 .containing(NSPredicate(format: "label BEGINSWITH 'closed in favour of !'")).firstMatch
1929 .waitForExistence(timeout: 20), "resolution does not name the successor")
1930 let link = app.staticTexts
1931 .containing(NSPredicate(format: "label BEGINSWITH 'Superseded by !'")).firstMatch
1932 XCTAssertTrue(scrollTo(link), "superseded-by link missing")
1933 link.tap()
1934 XCTAssertTrue(app.staticTexts[Self.successorTitle].firstMatch.waitForExistence(timeout: 20),
1935 "link did not open the successor")
1936 }
1937
1938 /// A label added from the merge request screen shows as a chip, the
1939 /// list filters to it, and removing it clears the chip.
1940 func testMRLabelFlow() throws {
1941 openRepo(Self.fixtureRepo)
1942 repoLink("merge-requests").tap()
1943 let row = app.staticTexts[Self.labelledTitle].firstMatch
1944 XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed")
1945 row.tap()
1946
1947 let field = app.textFields["mr-label-field"].firstMatch
1948 XCTAssertTrue(scrollTo(field), "no label field on the merge request")
1949 field.tap()
1950 field.typeText(Self.smokeMRLabel)
1951 app.buttons["mr-label-add"].firstMatch.tap()
1952 let remove = app.buttons["mr-label-remove-\(Self.smokeMRLabel)"].firstMatch
1953 XCTAssertTrue(remove.waitForExistence(timeout: 15), "added label has no chip")
1954
1955 back()
1956 XCTAssertTrue(app.staticTexts[Self.unlabelledTitle].firstMatch.waitForExistence(timeout: 15),
1957 "unlabelled fixture MR not listed")
1958 app.descendants(matching: .any).matching(identifier: "mr-filter-button").firstMatch.tap()
1959 let menu = app.descendants(matching: .any).matching(identifier: "filter-label-menu").firstMatch
1960 XCTAssertTrue(menu.waitForExistence(timeout: 10), "MR filter sheet has no label picker")
1961 menu.tap()
1962 let choice = app.buttons[Self.smokeMRLabel].firstMatch
1963 XCTAssertTrue(choice.waitForExistence(timeout: 10), "label not offered in the picker")
1964 choice.tap()
1965 app.buttons["Done"].firstMatch.tap()
1966 XCTAssertTrue(row.waitForExistence(timeout: 15), "label filter dropped the labelled MR")
1967 XCTAssertTrue(app.staticTexts[Self.unlabelledTitle].firstMatch.waitForNonExistence(timeout: 15),
1968 "label filter did not narrow the list")
1969
1970 row.tap()
1971 XCTAssertTrue(scrollTo(remove), "chip missing on reopen")
1972 remove.tap()
1973 XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown")
1974 }
1975
1976 /// A comment's preview renders its markup before anything is sent:
1977 /// the bold markers are gone and the word remains. Nothing is posted.
1978 func testMarkupPreviewFlow() throws {
1979 openRepo(Self.fixtureRepo)
1980 repoLink("issues").tap()
1981 let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch
1982 XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed")
1983 issue.tap()
1984
1985 let field = app.descendants(matching: .any)
1986 .matching(identifier: "issue-comment-field").firstMatch
1987 XCTAssertTrue(scrollTo(field, swipes: 10), "no comment field on the issue")
1988 let draft = "**Bold preview**"
1989 focusAndType(field, draft)
1990
1991 let preview = app.descendants(matching: .any)
1992 .matching(identifier: "issue-comment-preview").firstMatch
1993 XCTAssertTrue(scrollTo(preview), "no preview row for a non-empty comment")
1994 // The keyboard can cover the row right after typing.
1995 for _ in 0..<3 where !preview.isHittable { app.swipeUp() }
1996 preview.tap()
1997 let rendered = app.staticTexts
1998 .containing(NSPredicate(format: "label CONTAINS 'Bold preview'")).firstMatch
1999 if !rendered.waitForExistence(timeout: 3) {
2000 // The row's identifier can land on the cell rather than the toggle.
2001 app.buttons["Preview"].firstMatch.tap()
2002 }
2003 XCTAssertTrue(rendered.waitForExistence(timeout: 5), "preview did not render the draft")
2004 XCTAssertFalse(app.staticTexts
2005 .containing(NSPredicate(format: "label CONTAINS '**Bold'")).firstMatch.exists,
2006 "preview shows the raw markup")
2007
2008 // Kept on success too: this is how the preview's look is checked
2009 // in light and dark without signing a simulator in by hand.
2010 // The rendered text sits under the keyboard, below the comment
2011 // field. A drag on the list moves the row above the keyboard for
2012 // the screenshot; the wait lets the scroll settle.
2013 if app.keyboards.firstMatch.exists {
2014 app.swipeDown()
2015 _ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
2016 }
2017 XCTAssertTrue(scrollTo(rendered), "rendered preview scrolled away")
2018 let shot = XCTAttachment(screenshot: app.screenshot())
2019 shot.name = "markup preview"
2020 shot.lifetime = .keepAlways
2021 add(shot)
2022
2023 // Leave the fixture as found: clear the draft, send nothing.
2024 XCTAssertTrue(scrollTo(field), "comment field scrolled away")
2025 field.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.9)).tap()
2026 let typed = (field.value as? String)?.count ?? draft.count
2027 field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: typed))
2028 XCTAssertTrue(preview.waitForNonExistence(timeout: 5), "preview row outlived an empty comment")
2029 }
2030}