tests: own the fixtures the live suite depends on !24
1 file changed, +97 −18
gitbayUITests/LiveSmokeUITests.swift +97 −18
| @@ -40,6 +40,10 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 40 | 40 | if name.contains("testRepoManagementFlows") { |
| 41 | 41 | deleteScratchRepo() |
| 42 | 42 | } |
| 43 | if name.contains("testCreationAndEditingFlows") { | |
| 44 | ensureFixtureIssue() | |
| 45 | try requireFixtureBranch() | |
| 46 | } | |
| 43 | 47 | } |
| 44 | 48 | |
| 45 | 49 | override func tearDownWithError() throws { |
| @@ -53,22 +57,93 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 53 | 57 | /// the UI. A repo that is not there answers exit 3, which is the |
| 54 | 58 | /// outcome we want anyway. |
| 55 | 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 | 126 | guard let token = ProcessInfo.processInfo |
| 57 | 127 | .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 } | |
| 60 | 130 | |
| 61 | 131 | var request = URLRequest(url: url) |
| 62 | request.httpMethod = "POST" | |
| 132 | request.httpMethod = method | |
| 63 | 133 | 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 | } | |
| 68 | 138 | |
| 139 | var result: Data? | |
| 69 | 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 | 145 | _ = done.wait(timeout: .now() + 15) |
| 146 | return result | |
| 72 | 147 | } |
| 73 | 148 | |
| 74 | 149 | /// Everything in one ordered pass: milestone assign, edit-save, |
| @@ -77,11 +152,12 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 77 | 152 | func testCreationAndEditingFlows() throws { |
| 78 | 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 | 156 | app.staticTexts["Issues"].firstMatch.tap() |
| 82 | 157 | 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") | |
| 85 | 161 | issueRow.tap() |
| 86 | 162 | |
| 87 | 163 | let milestoneMenu = app.descendants(matching: .any) |
| @@ -96,7 +172,7 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 96 | 172 | XCTAssertTrue(app.staticTexts["v1.0.0"].firstMatch |
| 97 | 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 | 176 | app.descendants(matching: .any).matching(identifier: "issue-actions-menu") |
| 101 | 177 | .firstMatch.tap() |
| 102 | 178 | let edit = app.buttons["Edit"].firstMatch |
| @@ -105,7 +181,7 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 105 | 181 | let title = app.descendants(matching: .any) |
| 106 | 182 | .matching(identifier: "compose-title").firstMatch |
| 107 | 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 | 185 | "edit sheet did not prefill") |
| 110 | 186 | app.descendants(matching: .any).matching(identifier: "compose-submit") |
| 111 | 187 | .firstMatch.tap() |
| @@ -415,8 +491,9 @@ extension LiveSmokeUITests { | ||
| 415 | 491 | app.descendants(matching: .any)["krz"].firstMatch.tap() |
| 416 | 492 | XCTAssertTrue(app.staticTexts["warez for the public"].firstMatch |
| 417 | 493 | .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") | |
| 420 | 497 | back() |
| 421 | 498 | |
| 422 | 499 | // --- grep inside the repo, last: its search UI owns the screen --- |
| @@ -606,11 +683,13 @@ extension LiveSmokeUITests { | ||
| 606 | 683 | snap("mr-list") |
| 607 | 684 | |
| 608 | 685 | 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") | |
| 611 | 690 | snap("mr-detail") |
| 612 | 691 | |
| 613 | app.staticTexts["Diff"].firstMatch.tap() | |
| 692 | diffRow.tap() | |
| 614 | 693 | XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15)) |
| 615 | 694 | snap("diff") |
| 616 | 695 | } |