a native ios client for gitbay

client ios swift

https://gitbay.org

Commit 51b6a14eab

51b6a14eab49ab08e890597653fcf02f8f38f3d6

parent: 5adcccd544

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-28T18:45:32Z

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.
gitbayUITests/LiveSmokeUITests.swift +97 −18
@@ -40,6 +40,10 @@ final class LiveSmokeUITests: XCTestCase {
4040 if name.contains("testRepoManagementFlows") {
4141 deleteScratchRepo()
4242 }
43 if name.contains("testCreationAndEditingFlows") {
44 ensureFixtureIssue()
45 try requireFixtureBranch()
46 }
4347 }
4448
4549 override func tearDownWithError() throws {
@@ -53,22 +57,93 @@ final class LiveSmokeUITests: XCTestCase {
5357 /// the UI. A repo that is not there answers exit 3, which is the
5458 /// outcome we want anyway.
5559 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? {
56126 guard let token = ProcessInfo.processInfo
57127 .environment["GITBAY_UITEST_TOKEN"], !token.isEmpty,
58 let url = URL(string: "https://gitbay.org/api/v1/cmd")
59 else { return }
128 let url = URL(string: "https://gitbay.org" + path)
129 else { return nil }
60130
61131 var request = URLRequest(url: url)
62 request.httpMethod = "POST"
132 request.httpMethod = method
63133 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
64 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
65 request.httpBody = try? JSONSerialization.data(withJSONObject: [
66 "argv": ["repo", "delete", Self.scratchRepo, "--yes"],
67 ])
134 if let body {
135 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
136 request.httpBody = try? JSONSerialization.data(withJSONObject: body)
137 }
68138
139 var result: Data?
69140 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()
71145 _ = done.wait(timeout: .now() + 15)
146 return result
72147 }
73148
74149 /// Everything in one ordered pass: milestone assign, edit-save,
@@ -77,11 +152,12 @@ final class LiveSmokeUITests: XCTestCase {
77152 func testCreationAndEditingFlows() throws {
78153 openRepo("krz/gitbay-ios")
79154
80 // --- Issue #4: milestone picker assigns v1.0.0 ---
155 // --- the fixture issue: milestone picker assigns v1.0.0 ---
81156 app.staticTexts["Issues"].firstMatch.tap()
82157 let issueRow = app.staticTexts
83 .containing(NSPredicate(format: "label CONTAINS 'README.org'")).firstMatch
84 XCTAssertTrue(issueRow.waitForExistence(timeout: 10), "issue #4 not in the list")
158 .containing(NSPredicate(format: "label CONTAINS 'ui-smoke fixture'")).firstMatch
159 XCTAssertTrue(issueRow.waitForExistence(timeout: 10),
160 "fixture issue not in the list; setUp should have created it")
85161 issueRow.tap()
86162
87163 let milestoneMenu = app.descendants(matching: .any)
@@ -96,7 +172,7 @@ final class LiveSmokeUITests: XCTestCase {
96172 XCTAssertTrue(app.staticTexts["v1.0.0"].firstMatch
97173 .waitForExistence(timeout: 10), "milestone not shown after assign")
98174
99 // --- Issue #4: edit sheet saves (content unchanged) ---
175 // --- the fixture issue: edit sheet saves (content unchanged) ---
100176 app.descendants(matching: .any).matching(identifier: "issue-actions-menu")
101177 .firstMatch.tap()
102178 let edit = app.buttons["Edit"].firstMatch
@@ -105,7 +181,7 @@ final class LiveSmokeUITests: XCTestCase {
105181 let title = app.descendants(matching: .any)
106182 .matching(identifier: "compose-title").firstMatch
107183 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,
109185 "edit sheet did not prefill")
110186 app.descendants(matching: .any).matching(identifier: "compose-submit")
111187 .firstMatch.tap()
@@ -415,8 +491,9 @@ extension LiveSmokeUITests {
415491 app.descendants(matching: .any)["krz"].firstMatch.tap()
416492 XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch
417493 .waitForExistence(timeout: 15), "profile did not load")
418 XCTAssertTrue(app.staticTexts["Repositories"].firstMatch.exists,
419 "profile repos missing")
494 XCTAssertTrue(app.staticTexts
495 .containing(NSPredicate(format: "label BEGINSWITH 'Repositories'")).firstMatch
496 .waitForExistence(timeout: 10), "profile repos missing")
420497 back()
421498
422499 // --- grep inside the repo, last: its search UI owns the screen ---
@@ -606,11 +683,13 @@ extension LiveSmokeUITests {
606683 snap("mr-list")
607684
608685 mrNumber.tap()
609 XCTAssertTrue(app.staticTexts["Diff"].firstMatch.waitForExistence(timeout: 15),
610 "MR detail did not open")
686 // The detail opens at its header; the diff row sits below the
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")
611690 snap("mr-detail")
612691
613 app.staticTexts["Diff"].firstMatch.tap()
692 diffRow.tap()
614693 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
615694 snap("diff")
616695 }