Commit 6f729def95
Unsigned
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -104,6 +104,10 @@ private struct RouteDestinations: ViewModifier { | ||
| 104 | 104 | LabelListView(client: client, repo: repo) |
| 105 | 105 | case .bookmarks: |
| 106 | 106 | BookmarkListView(client: client) |
| 107 | case .webhooks(let repo): | |
| 108 | WebhookListView(client: client, repo: repo) | |
| 109 | case .webhookDeliveries(let repo): | |
| 110 | WebhookDeliveriesView(client: client, repo: repo) | |
| 107 | 111 | case .wiki(let repo): |
| 108 | 112 | WikiView(client: client, repo: repo) |
| 109 | 113 | case .wikiPage(let repo, let page): |
gitbay/Repos/WebhookModels.swift added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One row of `webhook list`. | |
| 4 | nonisolated struct Webhook: Decodable, Sendable, Hashable, Identifiable { | |
| 5 | let id: Int64 | |
| 6 | let url: String | |
| 7 | let events: String | |
| 8 | let active: Bool | |
| 9 | let hasSecret: Bool | |
| 10 | ||
| 11 | enum CodingKeys: String, CodingKey { | |
| 12 | case id, url, events, active | |
| 13 | case hasSecret = "has_secret" | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | /// One row of `webhook deliveries`. | |
| 18 | nonisolated struct WebhookDelivery: Decodable, Sendable, Hashable, Identifiable { | |
| 19 | let id: Int64 | |
| 20 | let url: String | |
| 21 | let event: String | |
| 22 | let status: String | |
| 23 | let attempts: Int | |
| 24 | let lastStatus: Int? | |
| 25 | let lastError: String? | |
| 26 | ||
| 27 | enum CodingKeys: String, CodingKey { | |
| 28 | case id, url, event, status, attempts | |
| 29 | case lastStatus = "last_status" | |
| 30 | case lastError = "last_error" | |
| 31 | } | |
| 32 | } | |
gitbay/Repos/WebhookViewModels.swift added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `webhook list`, `webhook add` and `webhook remove` for one repository. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final class WebhookListViewModel { | |
| 8 | ||
| 9 | private(set) var state: LoadState<[Webhook]> = .loading | |
| 10 | private(set) var actionError: String? | |
| 11 | private(set) var working = false | |
| 12 | ||
| 13 | private let client: GitbayClient | |
| 14 | let repoPath: String | |
| 15 | ||
| 16 | init(client: GitbayClient, repoPath: String) { | |
| 17 | self.client = client | |
| 18 | self.repoPath = repoPath | |
| 19 | } | |
| 20 | ||
| 21 | func load() async { | |
| 22 | state = .loading | |
| 23 | do { | |
| 24 | let hooks = try await client.readList(["webhook", "list", repoPath], of: Webhook.self) | |
| 25 | state = hooks.isEmpty ? .empty("No webhooks yet.") : .loaded(hooks) | |
| 26 | } catch { | |
| 27 | state = .from(error) | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | /// `webhook add <repo> <url> [--secret <s>] [--events <e>]` — an empty | |
| 32 | /// secret or event list is left out, so the server applies its default. | |
| 33 | func add(url: String, secret: String, events: String) async -> Bool { | |
| 34 | let url = url.trimmingCharacters(in: .whitespaces) | |
| 35 | guard !url.isEmpty else { return false } | |
| 36 | var argv = ["webhook", "add", repoPath, url] | |
| 37 | let secret = secret.trimmingCharacters(in: .whitespaces) | |
| 38 | if !secret.isEmpty { argv += ["--secret", secret] } | |
| 39 | let events = events.trimmingCharacters(in: .whitespaces) | |
| 40 | if !events.isEmpty { argv += ["--events", events] } | |
| 41 | await perform(argv) | |
| 42 | return actionError == nil | |
| 43 | } | |
| 44 | ||
| 45 | func remove(id: Int64) async { | |
| 46 | await perform(["webhook", "remove", repoPath, String(id)]) | |
| 47 | } | |
| 48 | ||
| 49 | private func perform(_ argv: [String]) async { | |
| 50 | working = true | |
| 51 | actionError = nil | |
| 52 | defer { working = false } | |
| 53 | do { | |
| 54 | try await client.run(argv) | |
| 55 | await load() | |
| 56 | } catch let error as GitbayError { | |
| 57 | actionError = error.userFacingMessage | |
| 58 | } catch { | |
| 59 | actionError = GitbayError.transport(error).userFacingMessage | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | /// `webhook deliveries` and `webhook redeliver`. | |
| 65 | @Observable | |
| 66 | @MainActor | |
| 67 | final class WebhookDeliveriesViewModel { | |
| 68 | ||
| 69 | private(set) var state: LoadState<[WebhookDelivery]> = .loading | |
| 70 | private(set) var actionError: String? | |
| 71 | private(set) var working = false | |
| 72 | ||
| 73 | private let client: GitbayClient | |
| 74 | let repoPath: String | |
| 75 | ||
| 76 | init(client: GitbayClient, repoPath: String) { | |
| 77 | self.client = client | |
| 78 | self.repoPath = repoPath | |
| 79 | } | |
| 80 | ||
| 81 | func load() async { | |
| 82 | state = .loading | |
| 83 | do { | |
| 84 | let rows = try await client.readList( | |
| 85 | ["webhook", "deliveries", repoPath], of: WebhookDelivery.self) | |
| 86 | state = rows.isEmpty ? .empty("No deliveries yet.") : .loaded(rows) | |
| 87 | } catch { | |
| 88 | state = .from(error) | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | func redeliver(id: Int64) async { | |
| 93 | working = true | |
| 94 | actionError = nil | |
| 95 | defer { working = false } | |
| 96 | do { | |
| 97 | try await client.run(["webhook", "redeliver", repoPath, String(id)]) | |
| 98 | await load() | |
| 99 | } catch let error as GitbayError { | |
| 100 | actionError = error.userFacingMessage | |
| 101 | } catch { | |
| 102 | actionError = GitbayError.transport(error).userFacingMessage | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
gitbay/Views/Repos/RepoRoute.swift +2
| @@ -18,6 +18,8 @@ nonisolated enum RepoRoute: Hashable { | ||
| 18 | 18 | case wikiPage(repo: String, page: String) |
| 19 | 19 | case commit(repo: String, sha: String) |
| 20 | 20 | case bookmarks |
| 21 | case webhooks(repo: String) | |
| 22 | case webhookDeliveries(repo: String) | |
| 21 | 23 | case profile(String) |
| 22 | 24 | case account |
| 23 | 25 | case addAccount |
gitbay/Views/Repos/RepoSettingsView.swift +11
| @@ -34,6 +34,7 @@ struct RepoSettingsView: View { | ||
| 34 | 34 | mergeRulesSection(loaded) |
| 35 | 35 | daemonSection(loaded) |
| 36 | 36 | accessSection |
| 37 | webhooksSection | |
| 37 | 38 | depsSection() |
| 38 | 39 | } |
| 39 | 40 | } |
| @@ -309,6 +310,16 @@ struct RepoSettingsView: View { | ||
| 309 | 310 | } |
| 310 | 311 | } |
| 311 | 312 | |
| 313 | private var webhooksSection: some View { | |
| 314 | Section { | |
| 315 | NavigationLink(value: RepoRoute.webhooks(repo: model.repoPath)) { | |
| 316 | Label("Webhooks", systemImage: "antenna.radiowaves.left.and.right") | |
| 317 | .font(.gbSans(.subheadline)) | |
| 318 | } | |
| 319 | .accessibilityIdentifier("settings-webhooks-link") | |
| 320 | } | |
| 321 | } | |
| 322 | ||
| 312 | 323 | private func depsSection() -> some View { |
| 313 | 324 | Section { |
| 314 | 325 | if let deps = model.deps { |
gitbay/Views/Repos/WebhookListView.swift added +213
| @@ -0,0 +1,213 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// A repository's webhooks: list, add, remove, and the way to its | |
| 4 | /// deliveries. Reached from repo settings. | |
| 5 | struct WebhookListView: View { | |
| 6 | ||
| 7 | @State private var model: WebhookListViewModel | |
| 8 | @State private var adding = false | |
| 9 | @State private var removingHook: Webhook? | |
| 10 | ||
| 11 | init(client: GitbayClient, repo: String) { | |
| 12 | _model = State(initialValue: WebhookListViewModel(client: client, repoPath: repo)) | |
| 13 | } | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | List { | |
| 17 | if let error = model.actionError { | |
| 18 | Section { | |
| 19 | GBNotice(error, .gbWarn) | |
| 20 | } | |
| 21 | } | |
| 22 | Section { | |
| 23 | NavigationLink(value: RepoRoute.webhookDeliveries(repo: model.repoPath)) { | |
| 24 | Label("Deliveries", systemImage: "paperplane") | |
| 25 | .font(.gbSans(.subheadline)) | |
| 26 | } | |
| 27 | .accessibilityIdentifier("webhook-deliveries-link") | |
| 28 | } | |
| 29 | Section { | |
| 30 | ForEach(model.state.value ?? []) { hook in | |
| 31 | VStack(alignment: .leading, spacing: 3) { | |
| 32 | Text(hook.url) | |
| 33 | .font(.gbMono(.caption)) | |
| 34 | .lineLimit(2) | |
| 35 | HStack(spacing: 6) { | |
| 36 | GBChip(hook.events, .secondary) | |
| 37 | if hook.hasSecret { | |
| 38 | GBChip("signed", .secondary) | |
| 39 | } | |
| 40 | if !hook.active { | |
| 41 | GBChip("inactive", .gbWarn) | |
| 42 | } | |
| 43 | } | |
| 44 | } | |
| 45 | .padding(.vertical, 2) | |
| 46 | .swipeActions { | |
| 47 | Button("Remove", role: .destructive) { | |
| 48 | removingHook = hook | |
| 49 | } | |
| 50 | .disabled(model.working) | |
| 51 | } | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
| 55 | .overlay { LoadStateOverlay(state: model.state) } | |
| 56 | .navigationTitle("Webhooks") | |
| 57 | .navigationBarTitleDisplayMode(.inline) | |
| 58 | .toolbar { | |
| 59 | ToolbarItem(placement: .topBarTrailing) { | |
| 60 | Button { | |
| 61 | adding = true | |
| 62 | } label: { | |
| 63 | Image(systemName: "plus") | |
| 64 | } | |
| 65 | .disabled(model.working) | |
| 66 | .accessibilityIdentifier("webhook-add-button") | |
| 67 | } | |
| 68 | } | |
| 69 | .sheet(isPresented: $adding) { | |
| 70 | WebhookAddSheet(model: model) { adding = false } | |
| 71 | } | |
| 72 | .confirmationDialog( | |
| 73 | "Remove the webhook for \(removingHook?.url ?? "")?", | |
| 74 | isPresented: Binding( | |
| 75 | get: { removingHook != nil }, | |
| 76 | set: { if !$0 { removingHook = nil } } | |
| 77 | ), | |
| 78 | titleVisibility: .visible | |
| 79 | ) { | |
| 80 | Button("Remove", role: .destructive) { | |
| 81 | if let hook = removingHook { | |
| 82 | Task { await model.remove(id: hook.id) } | |
| 83 | } | |
| 84 | removingHook = nil | |
| 85 | } | |
| 86 | Button("Cancel", role: .cancel) {} | |
| 87 | } | |
| 88 | .task { await model.load() } | |
| 89 | .refreshable { await model.load() } | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | /// `webhook add` — URL, optional signing secret, optional event list. | |
| 94 | private struct WebhookAddSheet: View { | |
| 95 | let model: WebhookListViewModel | |
| 96 | let onAdded: () -> Void | |
| 97 | ||
| 98 | @State private var url = "" | |
| 99 | @State private var secret = "" | |
| 100 | @State private var events = "" | |
| 101 | ||
| 102 | var body: some View { | |
| 103 | NavigationStack { | |
| 104 | Form { | |
| 105 | Section { | |
| 106 | TextField("https://…", text: $url) | |
| 107 | .autocorrectionDisabled() | |
| 108 | .textInputAutocapitalization(.never) | |
| 109 | .keyboardType(.URL) | |
| 110 | .accessibilityIdentifier("webhook-url") | |
| 111 | SecureField("Secret (optional)", text: $secret) | |
| 112 | .autocorrectionDisabled() | |
| 113 | .textInputAutocapitalization(.never) | |
| 114 | } footer: { | |
| 115 | Text("With a secret, every delivery is signed so the receiver can verify it.") | |
| 116 | } | |
| 117 | Section { | |
| 118 | TextField("Events", text: $events) | |
| 119 | .autocorrectionDisabled() | |
| 120 | .textInputAutocapitalization(.never) | |
| 121 | } footer: { | |
| 122 | Text("Comma-separated, such as push,issue.created. Empty means every event.") | |
| 123 | } | |
| 124 | if let error = model.actionError { | |
| 125 | Section { | |
| 126 | GBNotice(error, .gbWarn) | |
| 127 | } | |
| 128 | } | |
| 129 | } | |
| 130 | .navigationTitle("New Webhook") | |
| 131 | .navigationBarTitleDisplayMode(.inline) | |
| 132 | .toolbar { | |
| 133 | ToolbarItem(placement: .cancellationAction) { | |
| 134 | Button("Cancel") { onAdded() } | |
| 135 | .disabled(model.working) | |
| 136 | } | |
| 137 | ToolbarItem(placement: .confirmationAction) { | |
| 138 | if model.working { | |
| 139 | ProgressView() | |
| 140 | } else { | |
| 141 | Button("Add") { | |
| 142 | Task { | |
| 143 | if await model.add(url: url, secret: secret, events: events) { | |
| 144 | onAdded() | |
| 145 | } | |
| 146 | } | |
| 147 | } | |
| 148 | .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 149 | .accessibilityIdentifier("webhook-submit") | |
| 150 | } | |
| 151 | } | |
| 152 | } | |
| 153 | } | |
| 154 | .interactiveDismissDisabled(model.working) | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | /// `webhook deliveries` — recent deliveries, newest first, with a swipe | |
| 159 | /// to requeue one. | |
| 160 | struct WebhookDeliveriesView: View { | |
| 161 | ||
| 162 | @State private var model: WebhookDeliveriesViewModel | |
| 163 | ||
| 164 | init(client: GitbayClient, repo: String) { | |
| 165 | _model = State(initialValue: WebhookDeliveriesViewModel(client: client, repoPath: repo)) | |
| 166 | } | |
| 167 | ||
| 168 | var body: some View { | |
| 169 | List { | |
| 170 | if let error = model.actionError { | |
| 171 | Section { | |
| 172 | GBNotice(error, .gbWarn) | |
| 173 | } | |
| 174 | } | |
| 175 | ForEach(model.state.value ?? []) { delivery in | |
| 176 | VStack(alignment: .leading, spacing: 3) { | |
| 177 | HStack(spacing: 6) { | |
| 178 | Text(delivery.event) | |
| 179 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 180 | GBChip(delivery.status, delivery.status == "delivered" ? .gbDone : .gbWarn) | |
| 181 | Spacer() | |
| 182 | if let code = delivery.lastStatus { | |
| 183 | Text(String(code)) | |
| 184 | .font(.gbMono(.caption)) | |
| 185 | .foregroundStyle(.secondary) | |
| 186 | } | |
| 187 | } | |
| 188 | Text(delivery.url) | |
| 189 | .font(.gbMono(.caption2)) | |
| 190 | .foregroundStyle(.secondary) | |
| 191 | .lineLimit(1) | |
| 192 | Text("\(delivery.attempts) attempt\(delivery.attempts == 1 ? "" : "s")" | |
| 193 | + (delivery.lastError.map { " — \($0)" } ?? "")) | |
| 194 | .font(.gbSans(.caption)) | |
| 195 | .foregroundStyle(.tertiary) | |
| 196 | .lineLimit(2) | |
| 197 | } | |
| 198 | .padding(.vertical, 2) | |
| 199 | .swipeActions { | |
| 200 | Button("Redeliver") { | |
| 201 | Task { await model.redeliver(id: delivery.id) } | |
| 202 | } | |
| 203 | .disabled(model.working) | |
| 204 | } | |
| 205 | } | |
| 206 | } | |
| 207 | .overlay { LoadStateOverlay(state: model.state) } | |
| 208 | .navigationTitle("Deliveries") | |
| 209 | .navigationBarTitleDisplayMode(.inline) | |
| 210 | .task { await model.load() } | |
| 211 | .refreshable { await model.load() } | |
| 212 | } | |
| 213 | } | |
gitbayTests/WebhookTests.swift added +146
| @@ -0,0 +1,146 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | |
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 17 | return try #require(body["argv"] as? [String]) | |
| 18 | } | |
| 19 | ||
| 20 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | |
| 21 | ||
| 22 | private let hooksJSON = """ | |
| 23 | {"protocol_version":1,"data":[\ | |
| 24 | {"id":4,"url":"https://ci.example/hook","events":"*","active":true,"has_secret":true},\ | |
| 25 | {"id":9,"url":"https://chat.example/in","events":"push,issue.created","active":false,"has_secret":false}\ | |
| 26 | ],"exit_code":0} | |
| 27 | """ | |
| 28 | ||
| 29 | private let deliveriesJSON = """ | |
| 30 | {"protocol_version":1,"data":[\ | |
| 31 | {"id":71,"url":"https://ci.example/hook","event":"push","status":"delivered","attempts":1,"last_status":200},\ | |
| 32 | {"id":70,"url":"https://chat.example/in","event":"issue.created","status":"failed","attempts":3,\ | |
| 33 | "last_status":502,"last_error":"bad gateway"}\ | |
| 34 | ],"exit_code":0} | |
| 35 | """ | |
| 36 | ||
| 37 | @MainActor | |
| 38 | struct WebhookListViewModelTests { | |
| 39 | ||
| 40 | private func loaded() async throws -> (WebhookListViewModel, StubProtocol.Box) { | |
| 41 | let (client, stub) = try makeClient() | |
| 42 | stub.enqueue(.init(status: 200, json: hooksJSON, match: "argv=list")) | |
| 43 | let model = WebhookListViewModel(client: client, repoPath: "krz/gitbay") | |
| 44 | await model.load() | |
| 45 | return (model, stub) | |
| 46 | } | |
| 47 | ||
| 48 | @Test func listsHooksWithTheirEventsAndSecretFlag() async throws { | |
| 49 | let (model, stub) = try await loaded() | |
| 50 | ||
| 51 | let hooks = try #require(model.state.value) | |
| 52 | #expect(hooks.map(\.id) == [4, 9]) | |
| 53 | #expect(hooks[0].hasSecret && hooks[0].active) | |
| 54 | #expect(hooks[1].events == "push,issue.created") | |
| 55 | #expect(stub.seen.first?.url.query() == "argv=webhook&argv=list&argv=krz/gitbay") | |
| 56 | } | |
| 57 | ||
| 58 | @Test func noHooksIsAnEmptyState() async throws { | |
| 59 | let (client, stub) = try makeClient() | |
| 60 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[],"exit_code":0}"#)) | |
| 61 | let model = WebhookListViewModel(client: client, repoPath: "krz/gitbay") | |
| 62 | ||
| 63 | await model.load() | |
| 64 | ||
| 65 | guard case .empty = model.state else { | |
| 66 | Issue.record("expected .empty, got \(model.state)") | |
| 67 | return | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | @Test func addSendsSecretAndEventsOnlyWhenGiven() async throws { | |
| 72 | let (model, stub) = try await loaded() | |
| 73 | for _ in 0..<2 { | |
| 74 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":10,"url":"u","events":"*"},"exit_code":0}"#, match: "cmd")) | |
| 75 | stub.enqueue(.init(status: 200, json: hooksJSON, match: "argv=list")) | |
| 76 | } | |
| 77 | ||
| 78 | let first = await model.add(url: " https://a.example/h ", secret: "s3", events: "push") | |
| 79 | let second = await model.add(url: "https://b.example/h", secret: "", events: "") | |
| 80 | ||
| 81 | #expect(first && second) | |
| 82 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | |
| 83 | #expect(writes == [ | |
| 84 | ["webhook", "add", "krz/gitbay", "https://a.example/h", "--secret", "s3", "--events", "push"], | |
| 85 | ["webhook", "add", "krz/gitbay", "https://b.example/h"], | |
| 86 | ]) | |
| 87 | } | |
| 88 | ||
| 89 | @Test func removeSendsTheIdThenReloads() async throws { | |
| 90 | let (model, stub) = try await loaded() | |
| 91 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 92 | stub.enqueue(.init(status: 200, json: hooksJSON, match: "argv=list")) | |
| 93 | ||
| 94 | await model.remove(id: 9) | |
| 95 | ||
| 96 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 97 | #expect(try argvOf(write) == ["webhook", "remove", "krz/gitbay", "9"]) | |
| 98 | #expect(stub.seen.filter { $0.method == "GET" }.count == 2) | |
| 99 | } | |
| 100 | ||
| 101 | @Test func aRefusedAddSurfacesAndReturnsFalse() async throws { | |
| 102 | let (model, stub) = try await loaded() | |
| 103 | stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#, match: "cmd")) | |
| 104 | ||
| 105 | let ok = await model.add(url: "https://a.example/h", secret: "", events: "") | |
| 106 | ||
| 107 | #expect(!ok) | |
| 108 | #expect(model.actionError == "admin access required") | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | @MainActor | |
| 113 | struct WebhookDeliveriesViewModelTests { | |
| 114 | ||
| 115 | @Test func listsDeliveriesWithStatusAndLastError() async throws { | |
| 116 | let (client, stub) = try makeClient() | |
| 117 | stub.enqueue(.init(status: 200, json: deliveriesJSON)) | |
| 118 | let model = WebhookDeliveriesViewModel(client: client, repoPath: "krz/gitbay") | |
| 119 | ||
| 120 | await model.load() | |
| 121 | ||
| 122 | let rows = try #require(model.state.value) | |
| 123 | #expect(rows.map(\.id) == [71, 70]) | |
| 124 | #expect(rows[1].status == "failed") | |
| 125 | #expect(rows[1].lastStatus == 502) | |
| 126 | #expect(rows[1].lastError == "bad gateway") | |
| 127 | #expect(rows[0].lastError == nil) | |
| 128 | #expect(stub.seen.first?.url.query() == "argv=webhook&argv=deliveries&argv=krz/gitbay") | |
| 129 | } | |
| 130 | ||
| 131 | @Test func redeliverSendsTheDeliveryIdThenReloads() async throws { | |
| 132 | let (client, stub) = try makeClient() | |
| 133 | stub.enqueue(.init(status: 200, json: deliveriesJSON, match: "argv=deliveries")) | |
| 134 | let model = WebhookDeliveriesViewModel(client: client, repoPath: "krz/gitbay") | |
| 135 | await model.load() | |
| 136 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"requeued":70},"exit_code":0}"#, match: "cmd")) | |
| 137 | stub.enqueue(.init(status: 200, json: deliveriesJSON, match: "argv=deliveries")) | |
| 138 | ||
| 139 | await model.redeliver(id: 70) | |
| 140 | ||
| 141 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 142 | #expect(try argvOf(write) == ["webhook", "redeliver", "krz/gitbay", "70"]) | |
| 143 | #expect(stub.seen.filter { $0.method == "GET" }.count == 2) | |
| 144 | #expect(model.actionError == nil) | |
| 145 | } | |
| 146 | } | |
gitbayUITests/LiveSmokeUITests.swift +25
| @@ -444,6 +444,31 @@ extension LiveSmokeUITests { | ||
| 444 | 444 | XCTAssertTrue(confirmGrantRevoke.waitForExistence(timeout: 5), "revoke confirmation missing") |
| 445 | 445 | confirmGrantRevoke.tap() |
| 446 | 446 | XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked") |
| 447 | ||
| 448 | // --- webhooks: add one, see it listed, remove it --- | |
| 449 | let webhooksLink = app.descendants(matching: .any) | |
| 450 | .matching(identifier: "settings-webhooks-link").firstMatch | |
| 451 | XCTAssertTrue(scrollTo(webhooksLink, swipes: 8), "webhooks link not reachable") | |
| 452 | webhooksLink.tap() | |
| 453 | let addHook = app.descendants(matching: .any) | |
| 454 | .matching(identifier: "webhook-add-button").firstMatch | |
| 455 | XCTAssertTrue(addHook.waitForExistence(timeout: 10), "webhooks did not load") | |
| 456 | addHook.tap() | |
| 457 | let hookURL = app.descendants(matching: .any) | |
| 458 | .matching(identifier: "webhook-url").firstMatch | |
| 459 | XCTAssertTrue(hookURL.waitForExistence(timeout: 5), "webhook sheet did not open") | |
| 460 | focusAndType(hookURL, "https://example.invalid/ui-smoke") | |
| 461 | app.descendants(matching: .any).matching(identifier: "webhook-submit") | |
| 462 | .firstMatch.tap() | |
| 463 | let hookRow = app.staticTexts["https://example.invalid/ui-smoke"].firstMatch | |
| 464 | XCTAssertTrue(hookRow.waitForExistence(timeout: 15), "webhook not listed") | |
| 465 | hookRow.swipeLeft() | |
| 466 | app.buttons["Remove"].firstMatch.tap() | |
| 467 | let confirmHookRemove = app.buttons["Remove"].firstMatch | |
| 468 | XCTAssertTrue(confirmHookRemove.waitForExistence(timeout: 5), "webhook remove confirmation missing") | |
| 469 | confirmHookRemove.tap() | |
| 470 | XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed") | |
| 471 | back() // webhooks -> settings | |
| 447 | 472 | back() // settings -> repo |
| 448 | 473 | back() // repo -> list |
| 449 | 474 | |