Commit a977490925

a977490925fdab849a8acc71d09fa3c308efa5f1

parent: e1b934face

Unsigned

cmc <hello@cleberg.net> · 2026-08-28 18:45 UTC

tests: own the fixtures the live suite depends on

Three tests asserted on whatever the instance happened to contain, and
broke as the project moved on (#8):

- the editing flow followed issue #4, which got closed. setUp now
  guarantees a fixture issue and reuses it, so runs do not pile up.
- a profile's section header is "Repositories 12", so an exact match on
  "Repositories" never matched.
- the MR flow needs the ui-smoke branch, which had been deleted. A
  branch is a push, which no test can make, so setUp skips with the
  reason instead of failing later at "created MR not in the list".

The MR detail assertion was a second, unrelated cause: the diff row sits
below the fold, and a List does not build rows it has not shown.

Layout: unified · split

gitbayUITests/LiveSmokeUITests.swift +97 −18
@@ -40,6 +40,10 @@ final class LiveSmokeUITests: XCTestCase {
40 if name.contains("testRepoManagementFlows") { 40 if name.contains("testRepoManagementFlows") {
41 deleteScratchRepo() 41 deleteScratchRepo()
42 } 42 }
43 if name.contains("testCreationAndEditingFlows") {
44 ensureFixtureIssue()
45 try requireFixtureBranch()
46 }
43 } 47 }
44 48
45 override func tearDownWithError() throws { 49 override func tearDownWithError() throws {
@@ -53,22 +57,93 @@ final class LiveSmokeUITests: XCTestCase {
53 /// the UI. A repo that is not there answers exit 3, which is the 57 /// the UI. A repo that is not there answers exit 3, which is the
54 /// outcome we want anyway. 58 /// outcome we want anyway.
55 private func deleteScratchRepo() { 59 private func deleteScratchRepo() {
60 _ = runCommand(["repo", "delete", Self.scratchRepo, "--yes"])
61 }
62
63 /// The issue the editing flow works on. It is a fixture, not a real
64 /// issue: pinning the test to whatever issue happened to be open put
65 /// it at the mercy of the project moving on, and it duly broke when
66 /// that issue was closed (krz/gitbay-ios#8).
67 static let fixtureIssueTitle = "ui-smoke fixture: do not close"
68
69 /// Guarantee the fixture issue exists and is open. It is reused
70 /// rather than recreated, so runs do not pile up closed issues.
71 private func ensureFixtureIssue() {
72 guard let data = readCommand(
73 ["issue", "list", Self.fixtureRepo, "--state", "open"]),
74 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
75 else { return }
76
77 let issues = envelope["data"] as? [[String: Any]] ?? []
78 if issues.contains(where: { $0["title"] as? String == Self.fixtureIssueTitle }) {
79 return
80 }
81 _ = runCommand(
82 ["issue", "create", Self.fixtureRepo, "--title", Self.fixtureIssueTitle, "--file", "-"],
83 stdin: "Created by the live smoke suite. It exercises the milestone "
84 + "picker and the edit sheet, and is reused every run."
85 )
86 }
87
88 static let fixtureRepo = "krz/gitbay-ios"
89
90 /// The MR flow needs a source branch carrying a commit main does not
91 /// have. Nothing in the app or the API can create a branch — that is
92 /// a push — so this only checks, and says so plainly rather than
93 /// letting the test fail later at "created MR not in the list".
94 private func requireFixtureBranch() throws {
95 guard let data = readCommand(["repo", "refs", Self.fixtureRepo]),
96 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
97 let refs = envelope["data"] as? [String: Any],
98 let branches = refs["branches"] as? [[String: Any]]
99 else { return } // no token, or the instance is unreachable
100 let names = branches.compactMap { $0["name"] as? String }
101 try XCTSkipUnless(names.contains("ui-smoke"), """
102 krz/gitbay-ios has no ui-smoke branch. The MR flow opens a \
103 merge request from it; recreate it with a commit main does \
104 not have (see .gitbay/ui-smoke.md on that branch).
105 """)
106 }
107
108 /// POST /api/v1/cmd. Writes the suite needs but the app cannot make.
109 @discardableResult
110 private func runCommand(_ argv: [String], stdin: String? = nil) -> Data? {
111 var body: [String: Any] = ["argv": argv]
112 if let stdin { body["stdin"] = stdin }
113 return call(method: "POST", path: "/api/v1/cmd", body: body)
114 }
115
116 /// GET /api/v1/read, for the reads that decide what a test sets up.
117 private func readCommand(_ argv: [String]) -> Data? {
118 let query = argv
119 .map { "argv=" + ($0.addingPercentEncoding(
120 withAllowedCharacters: .alphanumerics) ?? $0) }
121 .joined(separator: "&")
122 return call(method: "GET", path: "/api/v1/read?" + query, body: nil)
123 }
124
125 private func call(method: String, path: String, body: [String: Any]?) -> Data? {
56 guard let token = ProcessInfo.processInfo 126 guard let token = ProcessInfo.processInfo
57 .environment["GITBAY_UITEST_TOKEN"], !token.isEmpty, 127 .environment["GITBAY_UITEST_TOKEN"], !token.isEmpty,
58 let url = URL(string: "https://gitbay.org/api/v1/cmd") 128 let url = URL(string: "https://gitbay.org" + path)
59 else { return } 129 else { return nil }
60 130
61 var request = URLRequest(url: url) 131 var request = URLRequest(url: url)
62 request.httpMethod = "POST" 132 request.httpMethod = method
63 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") 133 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
64 request.setValue("application/json", forHTTPHeaderField: "Content-Type") 134 if let body {
65 request.httpBody = try? JSONSerialization.data(withJSONObject: [ 135 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
66 "argv": ["repo", "delete", Self.scratchRepo, "--yes"], 136 request.httpBody = try? JSONSerialization.data(withJSONObject: body)
67 ]) 137 }
68 138
139 var result: Data?
69 let done = DispatchSemaphore(value: 0) 140 let done = DispatchSemaphore(value: 0)
70 URLSession.shared.dataTask(with: request) { _, _, _ in done.signal() }.resume() 141 URLSession.shared.dataTask(with: request) { data, _, _ in
142 result = data
143 done.signal()
144 }.resume()
71 _ = done.wait(timeout: .now() + 15) 145 _ = done.wait(timeout: .now() + 15)
146 return result
72 } 147 }
73 148
74 /// Everything in one ordered pass: milestone assign, edit-save, 149 /// Everything in one ordered pass: milestone assign, edit-save,
@@ -77,11 +152,12 @@ final class LiveSmokeUITests: XCTestCase {
77 func testCreationAndEditingFlows() throws { 152 func testCreationAndEditingFlows() throws {
78 openRepo("krz/gitbay-ios") 153 openRepo("krz/gitbay-ios")
79 154
80 // --- Issue #4: milestone picker assigns v1.0.0 --- 155 // --- the fixture issue: milestone picker assigns v1.0.0 ---
81 app.staticTexts["Issues"].firstMatch.tap() 156 app.staticTexts["Issues"].firstMatch.tap()
82 let issueRow = app.staticTexts 157 let issueRow = app.staticTexts
83 .containing(NSPredicate(format: "label CONTAINS 'README.org'")).firstMatch 158 .containing(NSPredicate(format: "label CONTAINS 'ui-smoke fixture'")).firstMatch
84 XCTAssertTrue(issueRow.waitForExistence(timeout: 10), "issue #4 not in the list") 159 XCTAssertTrue(issueRow.waitForExistence(timeout: 10),
160 "fixture issue not in the list; setUp should have created it")
85 issueRow.tap() 161 issueRow.tap()
86 162
87 let milestoneMenu = app.descendants(matching: .any) 163 let milestoneMenu = app.descendants(matching: .any)
@@ -96,7 +172,7 @@ final class LiveSmokeUITests: XCTestCase {
96 XCTAssertTrue(app.staticTexts["v1.0.0"].firstMatch 172 XCTAssertTrue(app.staticTexts["v1.0.0"].firstMatch
97 .waitForExistence(timeout: 10), "milestone not shown after assign") 173 .waitForExistence(timeout: 10), "milestone not shown after assign")
98 174
99 // --- Issue #4: edit sheet saves (content unchanged) --- 175 // --- the fixture issue: edit sheet saves (content unchanged) ---
100 app.descendants(matching: .any).matching(identifier: "issue-actions-menu") 176 app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
101 .firstMatch.tap() 177 .firstMatch.tap()
102 let edit = app.buttons["Edit"].firstMatch 178 let edit = app.buttons["Edit"].firstMatch
@@ -105,7 +181,7 @@ final class LiveSmokeUITests: XCTestCase {
105 let title = app.descendants(matching: .any) 181 let title = app.descendants(matching: .any)
106 .matching(identifier: "compose-title").firstMatch 182 .matching(identifier: "compose-title").firstMatch
107 XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open") 183 XCTAssertTrue(title.waitForExistence(timeout: 5), "edit sheet did not open")
108 XCTAssertTrue((title.value as? String)?.contains("README.org") == true, 184 XCTAssertTrue((title.value as? String)?.contains("ui-smoke fixture") == true,
109 "edit sheet did not prefill") 185 "edit sheet did not prefill")
110 app.descendants(matching: .any).matching(identifier: "compose-submit") 186 app.descendants(matching: .any).matching(identifier: "compose-submit")
111 .firstMatch.tap() 187 .firstMatch.tap()
@@ -415,8 +491,9 @@ extension LiveSmokeUITests {
415 app.descendants(matching: .any)["krz"].firstMatch.tap() 491 app.descendants(matching: .any)["krz"].firstMatch.tap()
416 XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch 492 XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch
417 .waitForExistence(timeout: 15), "profile did not load") 493 .waitForExistence(timeout: 15), "profile did not load")
418 XCTAssertTrue(app.staticTexts["Repositories"].firstMatch.exists, 494 XCTAssertTrue(app.staticTexts
419 "profile repos missing") 495 .containing(NSPredicate(format: "label BEGINSWITH 'Repositories'")).firstMatch
496 .waitForExistence(timeout: 10), "profile repos missing")
420 back() 497 back()
421 498
422 // --- grep inside the repo, last: its search UI owns the screen --- 499 // --- grep inside the repo, last: its search UI owns the screen ---
@@ -606,11 +683,13 @@ extension LiveSmokeUITests {
606 snap("mr-list") 683 snap("mr-list")
607 684
608 mrNumber.tap() 685 mrNumber.tap()
609 XCTAssertTrue(app.staticTexts["Diff"].firstMatch.waitForExistence(timeout: 15), 686 // The detail opens at its header; the diff row sits below the
610 "MR detail did not open") 687 // fold and a List does not build rows it has not shown.
688 let diffRow = app.staticTexts["Diff"].firstMatch
689 XCTAssertTrue(scrollTo(diffRow), "MR detail has no diff row")
611 snap("mr-detail") 690 snap("mr-detail")
612 691
613 app.staticTexts["Diff"].firstMatch.tap() 692 diffRow.tap()
614 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15)) 693 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
615 snap("diff") 694 snap("diff")
616 } 695 }