Commit a977490925
Unsigned
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 | } |