Admin: worker queues and accounts, as the web has them !129

merged merged by cmc on 2026-09-23 00:23 UTC · krz/gitbay-ios:admin into main

17 files changed, +879 −147

Layout: unified · split

gitbay/Admin/AdminModels.swift added +239
@@ -0,0 +1,239 @@
1import Foundation
2
3/// The admin-only half of `dashboard`: the server build and every
4/// background worker's backlog. The web's `/admin` page is this read.
5/// Both are absent for anyone who is not an instance admin.
6nonisolated struct AdminDashboard: Decodable, Sendable, Hashable {
7 let queues: AdminQueues?
8 let server: Server?
9
10 nonisolated struct Server: Decodable, Sendable, Hashable {
11 let commit: String?
12 }
13}
14
15/// `dashboard`'s `queues` block.
16nonisolated struct AdminQueues: Decodable, Sendable, Hashable {
17 let webhooks: Queue
18 let mail: Queue
19 let push: Queue
20 let mirrors: Queue
21 let builds: Queue
22 let deps: Queue
23
24 /// Each queue reports a different subset of these counts; the
25 /// absent ones stay nil.
26 nonisolated struct Queue: Decodable, Sendable, Hashable {
27 let pending: Int?
28 let retrying: Int?
29 let failed: Int?
30 let running: Int?
31 let dirty: Int?
32 let errors: Int?
33 let oldestPending: String?
34 let items: [Row]?
35
36 enum CodingKeys: String, CodingKey {
37 case pending, retrying, failed, running, dirty, errors, items
38 case oldestPending = "oldest_pending"
39 }
40
41 var rows: [Row] { items ?? [] }
42 }
43
44 /// A row of any queue; each kind fills its own fields. Timestamps
45 /// stay strings: a pending build's `started_at` is `""`, which no
46 /// date decoder accepts.
47 nonisolated struct Row: Decodable, Sendable, Hashable {
48 let repo: String?
49 let url: String?
50 let recipient: String?
51 let subject: String?
52 let title: String?
53 let deviceID: Int64?
54 let direction: String?
55 let number: Int64?
56 let job: String?
57 let status: String?
58 let attempts: Int64?
59 let lastStatus: Int64?
60 let lastError: String?
61 let failedAt: String?
62 let lastSync: String?
63 let lastCheck: String?
64 let startedAt: String?
65 let createdAt: String?
66
67 enum CodingKeys: String, CodingKey {
68 case repo, url, recipient, subject, title, direction, number, job, status, attempts
69 case deviceID = "device_id"
70 case lastStatus = "last_status"
71 case lastError = "last_error"
72 case failedAt = "failed_at"
73 case lastSync = "last_sync"
74 case lastCheck = "last_check"
75 case startedAt = "started_at"
76 case createdAt = "created_at"
77 }
78 }
79
80 /// The web's six sections, in its order and its words
81 /// (`internal/web/templates/admin.html`). `when` renders a timestamp;
82 /// tests pass a fixed one.
83 func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection] {
84 func oldest(_ queue: Queue) -> String {
85 guard let at = queue.oldestPending, !at.isEmpty else { return "" }
86 return " · oldest pending \(when(at))"
87 }
88 func n(_ value: Int?) -> Int { value ?? 0 }
89 func attempts(_ row: Row) -> String? { row.attempts.map { "\($0) attempts" } }
90 func state(_ row: Row, failed: String) -> String {
91 guard let at = row.failedAt, !at.isEmpty else { return "retrying" }
92 return "\(failed) \(when(at))"
93 }
94 func lines(_ queue: Queue, _ make: (Row) -> (String, [String?])) -> [QueueLine] {
95 queue.rows.enumerated().map { index, row in
96 let (title, parts) = make(row)
97 let detail = parts.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ")
98 return QueueLine(id: index, title: title, detail: detail)
99 }
100 }
101
102 return [
103 QueueSection(
104 id: "webhooks", heading: "Webhook deliveries", count: n(webhooks.pending),
105 summary: "\(n(webhooks.pending)) pending · \(n(webhooks.retrying)) retrying · \(n(webhooks.failed)) dead-lettered" + oldest(webhooks),
106 rows: lines(webhooks) { row in
107 let error = [row.lastStatus.map(String.init), row.lastError]
108 .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
109 return (row.repo ?? "", [row.url, attempts(row), state(row, failed: "dead-lettered"), error])
110 },
111 empty: "Nothing retrying or dead-lettered"),
112 QueueSection(
113 id: "mail", heading: "Mail", count: n(mail.pending),
114 summary: "\(n(mail.pending)) pending · \(n(mail.retrying)) retrying · \(n(mail.failed)) failed" + oldest(mail),
115 rows: lines(mail) { row in
116 (row.recipient ?? "", [row.subject, attempts(row), state(row, failed: "failed"), row.lastError])
117 },
118 empty: "Nothing retrying or failed"),
119 // A push row names the device id, never the token.
120 QueueSection(
121 id: "push", heading: "Push", count: n(push.pending),
122 summary: "\(n(push.pending)) pending · \(n(push.retrying)) retrying · \(n(push.failed)) failed" + oldest(push),
123 rows: lines(push) { row in
124 ("device \(row.deviceID.map(String.init) ?? "?")",
125 [row.title, attempts(row), state(row, failed: "failed"), row.lastError])
126 },
127 empty: "Nothing retrying or failed"),
128 QueueSection(
129 id: "mirrors", heading: "Mirrors", count: n(mirrors.errors),
130 summary: "\(n(mirrors.dirty)) waiting for a sync · \(n(mirrors.errors)) with a failed last sync",
131 rows: lines(mirrors) { row in
132 let sync = row.lastSync.flatMap { $0.isEmpty ? nil : "last sync \(when($0))" } ?? "never synced"
133 return (row.repo ?? "", [row.direction, row.url, sync, row.lastError])
134 },
135 empty: "Every mirror's last sync succeeded"),
136 QueueSection(
137 id: "builds", heading: "Builds", count: n(builds.pending),
138 summary: "\(n(builds.pending)) pending · \(n(builds.running)) running" + oldest(builds),
139 rows: lines(builds) { row in
140 let started = row.startedAt.flatMap { $0.isEmpty ? nil : $0 }
141 let since = (started ?? row.createdAt).map { "since \(when($0))" }
142 return ("\(row.repo ?? "") #\(row.number.map(String.init) ?? "?")", [row.job, row.status, since])
143 },
144 empty: "No build running or pending"),
145 QueueSection(
146 id: "deps", heading: "Dependency checks", count: n(deps.errors),
147 summary: nil,
148 rows: lines(deps) { row in
149 let check = row.lastCheck.flatMap { $0.isEmpty ? nil : "last check \(when($0))" } ?? "never checked"
150 return (row.repo ?? "", [check, row.lastError])
151 },
152 empty: "No check has failed"),
153 ]
154 }
155
156 /// "5 minutes ago", or the timestamp itself when it does not parse.
157 static func relative(_ timestamp: String) -> String {
158 let fractional = ISO8601DateFormatter()
159 fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
160 guard let date = fractional.date(from: timestamp) ?? ISO8601DateFormatter().date(from: timestamp)
161 else { return timestamp }
162 return date.formatted(.relative(presentation: .named))
163 }
164}
165
166/// One section of the admin screen: a heading with its count, the counts
167/// line, the rows, and what an empty queue says.
168nonisolated struct QueueSection: Sendable, Hashable, Identifiable {
169 let id: String
170 let heading: String
171 let count: Int
172 let summary: String?
173 let rows: [QueueLine]
174 let empty: String
175}
176
177nonisolated struct QueueLine: Sendable, Hashable, Identifiable {
178 let id: Int
179 let title: String
180 let detail: String
181}
182
183/// One row of `admin user list`.
184nonisolated struct AdminUser: Decodable, Sendable, Hashable, Identifiable {
185 let username: String
186 /// active, pending or disabled.
187 let state: String
188 let admin: Bool
189 let createdAt: Date
190 /// Absent for an account that has never signed in.
191 let lastSeen: Date?
192
193 enum CodingKeys: String, CodingKey {
194 case username, state, admin
195 case createdAt = "created_at"
196 case lastSeen = "last_seen"
197 }
198
199 var id: String { username }
200
201 /// The role button the web draws: Demote for an admin, Promote for
202 /// anyone else.
203 var roleAction: AdminAction { admin ? .demote : .promote }
204 /// Only an active account can be promoted; the web draws the button
205 /// disabled for the rest.
206 var canChangeRole: Bool { admin || state == "active" }
207 var accessAction: AdminAction { state == "disabled" ? .enable : .disable }
208}
209
210nonisolated enum AdminAction: String, Sendable, Hashable {
211 case promote, demote, disable, enable
212
213 /// Demote and disable take something away from the account, and the
214 /// web asks for the username typed before either.
215 var needsTypedName: Bool { self == .demote || self == .disable }
216
217 var title: String { rawValue.capitalized }
218
219 func argv(_ username: String) -> [String] { ["admin", "user", rawValue, username] }
220
221 func message(for username: String) -> String {
222 switch self {
223 case .promote: "\(username) becomes an instance admin."
224 case .demote: "\(username) stops being an instance admin. Type the username to confirm."
225 case .disable: "Signs \(username) out of the web and revokes their API tokens; SSH is refused until the account is enabled. Type the username to confirm."
226 case .enable: "\(username) can sign in again."
227 }
228 }
229}
230
231/// The web's five filters over `admin user list`.
232nonisolated enum AdminUserFilter: String, CaseIterable, Identifiable, Sendable {
233 case all, active, pending, disabled, admin
234
235 var id: String { rawValue }
236 var label: String { self == .admin ? "Admins" : rawValue.capitalized }
237
238 func flags() -> [String] { self == .all ? [] : ["--state", rawValue] }
239}
gitbay/Admin/AdminViewModels.swift added +106
@@ -0,0 +1,106 @@
1import Foundation
2import Observation
3
4/// Whether the signed-in account is an instance admin, from `whoami`. A
5/// failed read counts as not: the account menu then leaves Admin out.
6enum AdminCheck {
7
8 nonisolated private struct Who: Decodable, Sendable {
9 let admin: Bool?
10 }
11
12 static func isAdmin(_ client: GitbayClient) async -> Bool {
13 ((try? await client.read(["whoami"], as: Who.self))?.admin) ?? false
14 }
15}
16
17/// The admin screen: `dashboard`'s server build and worker queues, the
18/// read the web's /admin page makes.
19@Observable
20@MainActor
21final class AdminOverviewViewModel {
22
23 nonisolated struct Overview: Sendable, Hashable {
24 let commit: String?
25 let sections: [QueueSection]
26 }
27
28 private(set) var state: LoadState<Overview> = .loading
29
30 private let client: GitbayClient
31
32 init(client: GitbayClient) {
33 self.client = client
34 }
35
36 func load() async {
37 do {
38 let dashboard = try await client.read(["dashboard"], as: AdminDashboard.self)
39 guard let queues = dashboard.queues else {
40 state = .empty("Only instance admins can see the worker queues.")
41 return
42 }
43 state = .loaded(Overview(commit: dashboard.server?.commit, sections: queues.sections()))
44 } catch {
45 state = .from(error)
46 }
47 }
48}
49
50/// `admin user list`, narrowed by the web's five filters, and the four
51/// writes /admin/users makes per row.
52@Observable
53@MainActor
54final class AdminUsersViewModel {
55
56 let list: PagedListModel<AdminUser>
57 var filter = AdminUserFilter.all {
58 didSet {
59 guard filter != oldValue else { return }
60 list.argv = ["admin", "user", "list"] + filter.flags()
61 reloadTask?.cancel()
62 reloadTask = Task { await list.reload() }
63 }
64 }
65 private var reloadTask: Task<Void, Never>?
66 private(set) var actionError: String?
67 private(set) var working = false
68
69 private let client: GitbayClient
70
71 init(client: GitbayClient) {
72 self.client = client
73 list = PagedListModel(
74 client: client,
75 argv: ["admin", "user", "list"],
76 emptyMessage: "No account matches"
77 )
78 }
79
80 func load() async {
81 reloadTask?.cancel()
82 await list.reload()
83 }
84
85 /// The list's `.task`: reads only while nothing is shown yet.
86 func loadIfNeeded() async {
87 guard list.needsLoad else { return }
88 await load()
89 }
90
91 func perform(_ action: AdminAction, on username: String) async {
92 working = true
93 actionError = nil
94 defer { working = false }
95 do {
96 try await client.run(action.argv(username))
97 } catch let error as GitbayError {
98 actionError = error.userFacingMessage
99 } catch {
100 actionError = GitbayError.transport(error).userFacingMessage
101 }
102 reloadTask?.cancel()
103 reloadTask = Task { await list.reload() }
104 await reloadTask?.value
105 }
106}
gitbay/ContentView.swift +13 −1
@@ -10,6 +10,10 @@ struct ContentView: View {
10 @State private var selectedTab = Tabs.dashboard 10 @State private var selectedTab = Tabs.dashboard
11 @State private var dashboardPath = NavigationPath() 11 @State private var dashboardPath = NavigationPath()
12 12
13 /// whoami's admin flag, read on the My Profile tab, decides whether
14 /// the account menu offers Admin.
15 @State private var isAdmin = false
16
13 /// Which account `dashboardPath`'s routes were resolved against. 17 /// Which account `dashboardPath`'s routes were resolved against.
14 /// 18 ///
15 /// `.id(account.id)` sits on the `TabView`, not on `ContentView`, so 19 /// `.id(account.id)` sits on the `TabView`, not on `ContentView`, so
@@ -50,7 +54,11 @@ struct ContentView: View {
50 NavigationStack { 54 NavigationStack {
51 ProfileView(client: client, name: account.username, 55 ProfileView(client: client, name: account.username,
52 title: "My Profile") 56 title: "My Profile")
53 .toolbar { AccountMenu() } 57 .toolbar { AccountMenu(isAdmin: isAdmin) }
58 .task {
59 isAdmin = false
60 isAdmin = await AdminCheck.isAdmin(client)
61 }
54 .navigationDestinations(client: client) 62 .navigationDestinations(client: client)
55 } 63 }
56 } 64 }
@@ -203,6 +211,10 @@ private struct RouteDestinations: ViewModifier {
203 SignInView() 211 SignInView()
204 case .notifications: 212 case .notifications:
205 NotificationsView(client: client) 213 NotificationsView(client: client)
214 case .admin:
215 AdminView(client: client)
216 case .adminUsers:
217 AdminUsersView(client: client)
206 } 218 }
207 } 219 }
208 220
gitbay/Views/Admin/AdminUsersView.swift added +122
@@ -0,0 +1,122 @@
1import SwiftUI
2
3/// The web's /admin/users: every account, filtered by state, with the
4/// role and access action each row allows. Demote and disable ask for
5/// the username typed, as the web does.
6struct AdminUsersView: View {
7
8 @State private var model: AdminUsersViewModel
9 @State private var pending: Pending?
10 @State private var typed = ""
11
12 nonisolated struct Pending: Hashable, Sendable {
13 let action: AdminAction
14 let username: String
15 }
16
17 init(client: GitbayClient) {
18 _model = State(initialValue: AdminUsersViewModel(client: client))
19 }
20
21 var body: some View {
22 List {
23 Picker("State", selection: Bindable(model).filter) {
24 ForEach(AdminUserFilter.allCases) { filter in
25 Text(filter.label).tag(filter)
26 }
27 }
28 .pickerStyle(.segmented)
29 .listRowBackground(Color.clear)
30 .listRowInsets(EdgeInsets())
31 .accessibilityIdentifier("admin-user-filter")
32
33 if let error = model.actionError {
34 Section {
35 GBNotice(error, .gbWarn)
36 }
37 }
38 ForEach(model.list.state.value ?? []) { user in
39 row(user)
40 }
41 PageFooter(list: model.list)
42 }
43 .overlay { LoadStateOverlay(state: model.list.state) }
44 .navigationTitle("Accounts")
45 .navigationBarTitleDisplayMode(.inline)
46 .task { await model.loadIfNeeded() }
47 .refreshable { await model.load() }
48 .alert(
49 pending.map { "\($0.action.title) \($0.username)?" } ?? "",
50 isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }),
51 presenting: pending
52 ) { pending in
53 if pending.action.needsTypedName {
54 TextField(pending.username, text: $typed)
55 .autocorrectionDisabled()
56 .textInputAutocapitalization(.never)
57 }
58 Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) {
59 Task { await model.perform(pending.action, on: pending.username) }
60 }
61 .disabled(pending.action.needsTypedName
62 && typed.trimmingCharacters(in: .whitespaces) != pending.username)
63 Button("Cancel", role: .cancel) {}
64 } message: { pending in
65 Text(pending.action.message(for: pending.username))
66 }
67 }
68
69 private func row(_ user: AdminUser) -> some View {
70 HStack {
71 VStack(alignment: .leading, spacing: 3) {
72 HStack(spacing: 6) {
73 Text(user.username)
74 .font(.gbSans(.subheadline).weight(.medium))
75 if user.admin {
76 GBChip("admin", .secondary)
77 }
78 GBChip(user.state, stateColor(user.state))
79 }
80 Text(seen(user))
81 .font(.gbSans(.caption))
82 .foregroundStyle(.secondary)
83 }
84 Spacer()
85 Menu {
86 Button(user.roleAction.title) { ask(user.roleAction, user) }
87 .disabled(!user.canChangeRole || model.working)
88 Button(user.accessAction.title,
89 role: user.accessAction == .disable ? .destructive : nil) {
90 ask(user.accessAction, user)
91 }
92 .disabled(model.working)
93 } label: {
94 Image(systemName: "ellipsis.circle")
95 }
96 .accessibilityIdentifier("admin-user-menu-\(user.username)")
97 .accessibilityLabel("Actions for \(user.username)")
98 }
99 }
100
101 private func ask(_ action: AdminAction, _ user: AdminUser) {
102 typed = ""
103 pending = Pending(action: action, username: user.username)
104 }
105
106 /// The web's chip classes: open for active, closed for disabled,
107 /// neutral otherwise.
108 private func stateColor(_ state: String) -> Color {
109 switch state {
110 case "active": .gbOK
111 case "disabled": .gbBad
112 default: .secondary
113 }
114 }
115
116 private func seen(_ user: AdminUser) -> String {
117 let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))"
118 let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" }
119 ?? "never seen"
120 return "\(created) · \(last)"
121 }
122}
gitbay/Views/Admin/AdminView.swift added +59
@@ -0,0 +1,59 @@
1import SwiftUI
2
3/// The web's /admin page: the server build, a way to the accounts, and
4/// every worker queue's backlog in the web's order and words.
5struct AdminView: View {
6
7 @State private var model: AdminOverviewViewModel
8
9 init(client: GitbayClient) {
10 _model = State(initialValue: AdminOverviewViewModel(client: client))
11 }
12
13 var body: some View {
14 List {
15 if let overview = model.state.value {
16 Section {
17 NavigationLink(value: RepoRoute.adminUsers) {
18 Label("Accounts", systemImage: "person.2")
19 }
20 .accessibilityIdentifier("admin-accounts")
21 } footer: {
22 if let commit = overview.commit {
23 Text("Server build \(commit).")
24 }
25 }
26 ForEach(overview.sections) { section in
27 Section {
28 if section.rows.isEmpty {
29 Text(section.empty)
30 .font(.gbSans(.subheadline))
31 .foregroundStyle(.secondary)
32 } else {
33 ForEach(section.rows) { line in
34 VStack(alignment: .leading, spacing: 2) {
35 Text(line.title)
36 .font(.gbSans(.subheadline).weight(.medium))
37 Text(line.detail)
38 .font(.gbSans(.caption))
39 .foregroundStyle(.secondary)
40 }
41 }
42 }
43 } header: {
44 Text("\(section.heading) \(section.count)")
45 } footer: {
46 if let summary = section.summary {
47 Text(summary)
48 }
49 }
50 }
51 }
52 }
53 .overlay { LoadStateOverlay(state: model.state) }
54 .navigationTitle("Admin")
55 .navigationBarTitleDisplayMode(.inline)
56 .task { await model.load() }
57 .refreshable { await model.load() }
58 }
59}
gitbay/Views/Issues/IssueView.swift −6
@@ -172,7 +172,6 @@ struct IssueView: View {
172 HStack { 172 HStack {
173 TextField("Comment", text: $commentText, axis: .vertical) 173 TextField("Comment", text: $commentText, axis: .vertical)
174 .lineLimit(1...5) 174 .lineLimit(1...5)
175 .accessibilityIdentifier("issue-comment-field")
176 Picker("Format", selection: $commentFormat) { 175 Picker("Format", selection: $commentFormat) {
177 Text("Markdown").tag("md") 176 Text("Markdown").tag("md")
178 Text("Org").tag("org") 177 Text("Org").tag("org")
@@ -191,11 +190,6 @@ struct IssueView: View {
191 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 190 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
192 || model.working) 191 || model.working)
193 } 192 }
194 if !commentText.isEmpty {
195 MarkupPreview(text: commentText, format: commentFormat,
196 identifier: "issue-comment-preview")
197 .font(.gbSans(.subheadline))
198 }
199 } 193 }
200 } 194 }
201 195
gitbay/Views/MRs/MRListView.swift −1
@@ -131,7 +131,6 @@ private struct MRCreateSheet: View {
131 } 131 }
132 .pickerStyle(.segmented) 132 .pickerStyle(.segmented)
133 .accessibilityIdentifier("mr-format") 133 .accessibilityIdentifier("mr-format")
134 MarkupPreview(text: bodyText, format: format, identifier: "mr-create-preview")
135 } 134 }
136 Section { 135 Section {
137 Toggle("Draft", isOn: Bindable(model).draft) 136 Toggle("Draft", isOn: Bindable(model).draft)
gitbay/Views/MRs/MRView.swift −6
@@ -537,7 +537,6 @@ struct MRView: View {
537 HStack { 537 HStack {
538 TextField("Comment", text: $commentText, axis: .vertical) 538 TextField("Comment", text: $commentText, axis: .vertical)
539 .lineLimit(1...5) 539 .lineLimit(1...5)
540 .accessibilityIdentifier("mr-comment-field")
541 Picker("Format", selection: $commentFormat) { 540 Picker("Format", selection: $commentFormat) {
542 Text("Markdown").tag("md") 541 Text("Markdown").tag("md")
543 Text("Org").tag("org") 542 Text("Org").tag("org")
@@ -556,11 +555,6 @@ struct MRView: View {
556 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 555 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
557 || model.working) 556 || model.working)
558 } 557 }
559 if !commentText.isEmpty {
560 MarkupPreview(text: commentText, format: commentFormat,
561 identifier: "mr-comment-preview")
562 .font(.gbSans(.subheadline))
563 }
564 } 558 }
565 } 559 }
566 560
gitbay/Views/Releases/ReleaseListView.swift −3
@@ -108,9 +108,6 @@ private struct ReleaseCreateSheet: View {
108 TextEditor(text: $notes) 108 TextEditor(text: $notes)
109 .frame(minHeight: 140) 109 .frame(minHeight: 140)
110 .autocorrectionDisabled() 110 .autocorrectionDisabled()
111 // release create sends no --format, and release notes
112 // render as markdown.
113 MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview")
114 } 111 }
115 if let error = model.createError { 112 if let error = model.createError {
116 Section { 113 Section {
gitbay/Views/Repos/FileEditSheet.swift −5
@@ -28,11 +28,6 @@ struct FileEditSheet: View {
28 } footer: { 28 } footer: {
29 Text("Commits to \(branch) as an unsigned commit.") 29 Text("Commits to \(branch) as an unsigned commit.")
30 } 30 }
31 if let format = MarkupPreview.format(forFile: model.fileName) {
32 Section {
33 MarkupPreview(text: content, format: format, identifier: "file-edit-preview")
34 }
35 }
36 Section("Message") { 31 Section("Message") {
37 TextField("edit \(model.fileName)", text: $message) 32 TextField("edit \(model.fileName)", text: $message)
38 .autocorrectionDisabled() 33 .autocorrectionDisabled()
gitbay/Views/Repos/RepoListView.swift +10
@@ -254,6 +254,10 @@ struct AccountMenu: ToolbarContent {
254 254
255 @Environment(SessionStore.self) private var session 255 @Environment(SessionStore.self) private var session
256 256
257 /// Whether the signed-in account is an instance admin, read from
258 /// `whoami` on the My Profile tab and passed in.
259 let isAdmin: Bool
260
257 var body: some ToolbarContent { 261 var body: some ToolbarContent {
258 ToolbarItem(placement: .topBarTrailing) { 262 ToolbarItem(placement: .topBarTrailing) {
259 Menu { 263 Menu {
@@ -265,6 +269,12 @@ struct AccountMenu: ToolbarContent {
265 NavigationLink(value: SnippetRoute.list(owner: nil)) { 269 NavigationLink(value: SnippetRoute.list(owner: nil)) {
266 Label("Snippets", systemImage: "scissors") 270 Label("Snippets", systemImage: "scissors")
267 } 271 }
272 if isAdmin {
273 NavigationLink(value: RepoRoute.admin) {
274 Label("Admin", systemImage: "gearshape.2")
275 }
276 .accessibilityIdentifier("account-menu-admin")
277 }
268 } 278 }
269 } 279 }
270 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty { 280 if !session.accounts.filter({ $0.id != session.current?.id }).isEmpty {
gitbay/Views/Repos/RepoRoute.swift +2
@@ -24,4 +24,6 @@ nonisolated enum RepoRoute: Hashable {
24 case account 24 case account
25 case addAccount 25 case addAccount
26 case notifications 26 case notifications
27 case admin
28 case adminUsers
27} 29}
gitbay/Views/Shared/ComposeSheet.swift −1
@@ -73,7 +73,6 @@ struct ComposeSheet: View {
73 .pickerStyle(.segmented) 73 .pickerStyle(.segmented)
74 .accessibilityIdentifier("compose-format") 74 .accessibilityIdentifier("compose-format")
75 } 75 }
76 MarkupPreview(text: bodyText, format: format, identifier: "compose-preview")
77 } 76 }
78 if let errorMessage { 77 if let errorMessage {
79 Section { 78 Section {
gitbay/Views/Shared/MarkupPreview.swift deleted −42
@@ -1,42 +0,0 @@
1import SwiftUI
2
3/// A collapsed "Preview" row under a markup editor. Expanded, it renders
4/// the draft with `FormattedBodyView`, the view the app's threads use, so
5/// what it shows is what the app will show once the text is sent. It sits
6/// under the editor rather than replacing it, so the draft stays in view.
7struct MarkupPreview: View {
8
9 let text: String
10 let format: String?
11 let identifier: String
12
13 @State private var expanded = false
14
15 var body: some View {
16 DisclosureGroup(isExpanded: $expanded) {
17 if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
18 Text("Nothing to preview.")
19 .font(.gbSans(.subheadline))
20 .foregroundStyle(.secondary)
21 } else {
22 FormattedBodyView(text: text, format: format)
23 .padding(.vertical, 4)
24 }
25 } label: {
26 Text("Preview")
27 .font(.gbSans(.subheadline))
28 }
29 .accessibilityIdentifier(identifier)
30 }
31
32 /// The format a file's contents render in, or nil for a file shown as
33 /// source. The same extensions the forge renders a README or wiki
34 /// page from.
35 static func format(forFile name: String) -> String? {
36 switch (name as NSString).pathExtension.lowercased() {
37 case "org": "org"
38 case "md", "markdown": "md"
39 default: nil
40 }
41 }
42}
gitbayTests/AdminTests.swift added +324
@@ -0,0 +1,324 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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/// `dashboard` as an admin sees it, trimmed to the admin blocks, with
16/// one row in every queue that lists rows.
17private let adminDashboardJSON = """
18 {"protocol_version":1,"data":{"unread":0,\
19 "server":{"commit":"db7503f06f11"},\
20 "queues":{\
21 "webhooks":{"pending":2,"retrying":1,"failed":1,"oldest_pending":"2026-09-22T04:00:00Z","items":[\
22 {"id":9,"repo":"krz/gitbay","url":"https://ci.example.com/hook","attempts":5,\
23 "last_status":502,"last_error":"bad gateway","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T03:00:00Z"}]},\
24 "mail":{"pending":0,"retrying":1,"failed":0,"items":[\
25 {"id":4,"recipient":"a@example.com","subject":"krz/gitbay#12","attempts":2,\
26 "last_error":"dial tcp: timeout","created_at":"2026-09-22T03:00:00Z"}]},\
27 "mirrors":{"dirty":3,"errors":1,"items":[\
28 {"id":1,"repo":"krz/solar","direction":"push","url":"https://github.com/x/solar",\
29 "last_error":"auth failed"}]},\
30 "builds":{"pending":1,"running":1,"items":[\
31 {"repo":"krz/gitbay","number":1480,"job":"test","status":"running",\
32 "created_at":"2026-09-22T04:40:00Z","started_at":"2026-09-22T04:41:00Z"},\
33 {"repo":"krz/gitbay","number":1481,"job":"build","status":"pending",\
34 "created_at":"2026-09-22T04:42:00Z","started_at":""}]},\
35 "deps":{"errors":0,"items":[]},\
36 "push":{"pending":0,"retrying":0,"failed":1,"items":[\
37 {"id":7,"device_id":3,"title":"krz/gitbay!450 merged","attempts":3,\
38 "last_error":"BadDeviceToken","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T04:00:00Z"}]}\
39 }},"exit_code":0}
40 """
41
42nonisolated private struct DashboardEnvelope: Decodable { let data: AdminDashboard }
43
44private func sections() throws -> [QueueSection] {
45 let dashboard = try GitbayClient.decoder()
46 .decode(DashboardEnvelope.self, from: Data(adminDashboardJSON.utf8)).data
47 let queues = try #require(dashboard.queues)
48 // A fixed stand-in for relative time keeps the text deterministic.
49 return queues.sections(when: { "at \($0)" })
50}
51
52struct AdminQueueSectionTests {
53
54 @Test func sixSectionsInTheWebsOrder() throws {
55 let all = try sections()
56 #expect(all.map(\.heading) == [
57 "Webhook deliveries", "Mail", "Push", "Mirrors", "Builds", "Dependency checks",
58 ])
59 #expect(all.map(\.count) == [2, 0, 0, 1, 1, 0])
60 }
61
62 @Test func countsLinesUseTheWebsWords() throws {
63 let all = try sections()
64 #expect(all[0].summary == "2 pending · 1 retrying · 1 dead-lettered · oldest pending at 2026-09-22T04:00:00Z")
65 #expect(all[1].summary == "0 pending · 1 retrying · 0 failed")
66 #expect(all[3].summary == "3 waiting for a sync · 1 with a failed last sync")
67 #expect(all[4].summary == "1 pending · 1 running")
68 #expect(all[5].summary == nil)
69 }
70
71 @Test func rowsSayWhatTheWebsTableColumnsSay() throws {
72 let all = try sections()
73 #expect(all[0].rows.map(\.title) == ["krz/gitbay"])
74 #expect(all[0].rows[0].detail
75 == "https://ci.example.com/hook · 5 attempts · dead-lettered at 2026-09-22T05:00:00Z · 502 bad gateway")
76 #expect(all[1].rows[0].title == "a@example.com")
77 #expect(all[1].rows[0].detail == "krz/gitbay#12 · 2 attempts · retrying · dial tcp: timeout")
78 #expect(all[2].rows[0].title == "device 3")
79 #expect(all[2].rows[0].detail
80 == "krz/gitbay!450 merged · 3 attempts · failed at 2026-09-22T05:00:00Z · BadDeviceToken")
81 #expect(all[3].rows[0].detail == "push · https://github.com/x/solar · never synced · auth failed")
82 // A pending build has started_at "" and reports since it was queued.
83 #expect(all[4].rows.map(\.title) == ["krz/gitbay #1480", "krz/gitbay #1481"])
84 #expect(all[4].rows[0].detail == "test · running · since at 2026-09-22T04:41:00Z")
85 #expect(all[4].rows[1].detail == "build · pending · since at 2026-09-22T04:42:00Z")
86 }
87
88 @Test func anEmptyQueueSaysSoInTheWebsWords() throws {
89 let deps = try #require(try sections().last)
90 #expect(deps.rows.isEmpty)
91 #expect(deps.empty == "No check has failed")
92 }
93
94 /// Everyone else's dashboard omits both blocks.
95 @Test func aNonAdminDashboardHasNoQueues() throws {
96 let json = #"{"protocol_version":1,"data":{"unread":3},"exit_code":0}"#
97 let dashboard = try GitbayClient.decoder().decode(DashboardEnvelope.self, from: Data(json.utf8)).data
98 #expect(dashboard.queues == nil)
99 #expect(dashboard.server == nil)
100 }
101
102 @Test func anUnparseableTimestampIsShownAsIs() {
103 #expect(AdminQueues.relative("soon") == "soon")
104 }
105}
106
107private func user(_ name: String, state: String, admin: Bool = false) throws -> AdminUser {
108 let json = """
109 {"username":"\(name)","state":"\(state)","admin":\(admin),\
110 "created_at":"2026-08-28T21:55:00.752Z"}
111 """
112 return try GitbayClient.decoder().decode(AdminUser.self, from: Data(json.utf8))
113}
114
115struct AdminUserActionTests {
116
117 @Test func anAdminIsDemotedAndCanBeDisabled() throws {
118 let u = try user("cmc", state: "active", admin: true)
119 #expect(u.roleAction == .demote)
120 #expect(u.canChangeRole)
121 #expect(u.accessAction == .disable)
122 }
123
124 @Test func anActiveAccountCanBePromoted() throws {
125 let u = try user("blotter-ci", state: "active")
126 #expect(u.roleAction == .promote)
127 #expect(u.canChangeRole)
128 }
129
130 /// The web draws Promote on every non-admin row but disables it for
131 /// an account that is not active.
132 @Test func onlyAnActiveAccountCanBePromoted() throws {
133 #expect(try user("new", state: "pending").canChangeRole == false)
134 #expect(try user("gone", state: "disabled").canChangeRole == false)
135 }
136
137 @Test func aDisabledAccountIsEnabledAndAnyOtherDisabled() throws {
138 #expect(try user("gone", state: "disabled").accessAction == .enable)
139 #expect(try user("new", state: "pending").accessAction == .disable)
140 }
141
142 @Test func demoteAndDisableAskForTheTypedName() {
143 #expect(AdminAction.demote.needsTypedName)
144 #expect(AdminAction.disable.needsTypedName)
145 #expect(!AdminAction.promote.needsTypedName)
146 #expect(!AdminAction.enable.needsTypedName)
147 }
148
149 @Test func eachActionIsItsAdminUserCommand() {
150 #expect(AdminAction.promote.argv("x") == ["admin", "user", "promote", "x"])
151 #expect(AdminAction.demote.argv("x") == ["admin", "user", "demote", "x"])
152 #expect(AdminAction.disable.argv("x") == ["admin", "user", "disable", "x"])
153 #expect(AdminAction.enable.argv("x") == ["admin", "user", "enable", "x"])
154 }
155
156 @Test func theFiltersAreTheWebsFive() {
157 #expect(AdminUserFilter.allCases.map(\.label) == ["All", "Active", "Pending", "Disabled", "Admins"])
158 #expect(AdminUserFilter.all.flags().isEmpty)
159 #expect(AdminUserFilter.admin.flags() == ["--state", "admin"])
160 }
161}
162
163@MainActor
164struct AdminCheckTests {
165
166 @Test func whoamisAdminFlagDecides() async throws {
167 let (client, stub) = try makeClient()
168 stub.enqueue(.init(status: 200, json:
169 #"{"protocol_version":1,"data":{"username":"cmc","admin":true,"key_scope":"full"}}"#))
170 #expect(await AdminCheck.isAdmin(client))
171 #expect(stub.seen.first?.url.query() == "argv=whoami")
172 }
173
174 @Test func aNonAdminIsNot() async throws {
175 let (client, stub) = try makeClient()
176 stub.enqueue(.init(status: 200, json:
177 #"{"protocol_version":1,"data":{"username":"ios-smoke","admin":false}}"#))
178 #expect(await AdminCheck.isAdmin(client) == false)
179 }
180
181 /// The menu just leaves the entry out when the read fails.
182 @Test func aFailedReadIsNotAnAdmin() async throws {
183 let (client, stub) = try makeClient()
184 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
185 #expect(await AdminCheck.isAdmin(client) == false)
186 }
187}
188
189@MainActor
190struct AdminOverviewViewModelTests {
191
192 @Test func readsDashboardIntoSections() async throws {
193 let (client, stub) = try makeClient()
194 stub.enqueue(.init(status: 200, json: adminDashboardJSON))
195 let model = AdminOverviewViewModel(client: client)
196
197 await model.load()
198
199 let overview = try #require(model.state.value)
200 #expect(overview.commit == "db7503f06f11")
201 #expect(overview.sections.count == 6)
202 #expect(stub.seen.first?.url.query() == "argv=dashboard")
203 }
204
205 @Test func aDashboardWithoutQueuesIsAnEmptyState() async throws {
206 let (client, stub) = try makeClient()
207 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"unread":0},"exit_code":0}"#))
208 let model = AdminOverviewViewModel(client: client)
209
210 await model.load()
211
212 guard case .empty(let message) = model.state else {
213 Issue.record("expected .empty, got \(model.state)")
214 return
215 }
216 #expect(message == "Only instance admins can see the worker queues.")
217 }
218}
219
220private let userPageJSON = """
221 {"protocol_version":1,"data":{"items":[\
222 {"username":"apple-review","state":"active","admin":false,"created_at":"2026-08-28T21:55:00.752Z",\
223 "last_seen":"2026-09-21T07:05:26.201Z"},\
224 {"username":"bhargavkk","state":"pending","admin":false,"created_at":"2026-09-11T01:50:04.063Z"}],\
225 "next":"YWRtaW4tdXNlcjpiaGFyZ2F2a2s"},"exit_code":0}
226 """
227
228@MainActor
229struct AdminUsersViewModelTests {
230
231 @Test func listsEveryAccountByDefault() async throws {
232 let (client, stub) = try makeClient()
233 stub.enqueue(.init(status: 200, json: userPageJSON))
234 let model = AdminUsersViewModel(client: client)
235
236 await model.load()
237
238 let users = try #require(model.list.state.value)
239 #expect(users.map(\.username) == ["apple-review", "bhargavkk"])
240 #expect(users[0].lastSeen != nil)
241 #expect(users[1].lastSeen == nil)
242 #expect(model.list.hasMore)
243 #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50")
244 }
245
246 @Test func loadIfNeededOnlyReadsOnce() async throws {
247 let (client, stub) = try makeClient()
248 stub.enqueue(.init(status: 200, json: userPageJSON))
249 let model = AdminUsersViewModel(client: client)
250
251 await model.loadIfNeeded()
252 await model.loadIfNeeded()
253
254 #expect(stub.seen.count == 1)
255 #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50")
256 }
257
258 @Test func aFilterReloadsWithItsState() async throws {
259 let (client, stub) = try makeClient()
260 stub.enqueue(.init(status: 200, json: userPageJSON))
261 let model = AdminUsersViewModel(client: client)
262 await model.load()
263
264 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
265 model.filter = .disabled
266 await until {
267 if case .empty = model.list.state { return true }
268 return false
269 }
270
271 #expect(stub.seen.last?.url.query()
272 == "argv=admin&argv=user&argv=list&argv=--state&argv=disabled&argv=--limit&argv=50")
273 guard case .empty(let message) = model.list.state else { return }
274 #expect(message == "No account matches")
275 }
276
277 @Test func anActionSendsItsCommandAndReloads() async throws {
278 let (client, stub) = try makeClient()
279 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "cmd"))
280 stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list"))
281 let model = AdminUsersViewModel(client: client)
282
283 await model.perform(.disable, on: "bhargavkk")
284
285 let write = try #require(stub.seen.first { $0.method == "POST" })
286 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
287 #expect(body["argv"] as? [String] == ["admin", "user", "disable", "bhargavkk"])
288 #expect(model.actionError == nil)
289 #expect(model.list.state.value?.count == 2)
290 }
291
292 /// The last-admin refusal is a real server failure (exit 1, HTTP 500),
293 /// which the client retries once before surfacing it.
294 @Test func aRefusalIsShownVerbatim() async throws {
295 let (client, stub) = try makeClient()
296 let refusal = StubProtocol.Stub(status: 500, json:
297 #"{"protocol_version":1,"error":"that is the only instance admin; promote someone else first","exit_code":1}"#,
298 match: "cmd")
299 stub.enqueue(refusal)
300 stub.enqueue(refusal)
301 stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list"))
302 let model = AdminUsersViewModel(client: client)
303
304 await model.perform(.demote, on: "cmc")
305
306 #expect(model.actionError == "that is the only instance admin; promote someone else first")
307 #expect(model.list.state.value?.count == 2)
308 }
309
310 /// A refusal must still reload the list, so a stale row does not
311 /// linger after the account changed state meanwhile.
312 @Test func aRefusedActionStillReloadsTheList() async throws {
313 let (client, stub) = try makeClient()
314 stub.enqueue(.init(status: 403, json:
315 #"{"protocol_version":1,"error":"account changed state","exit_code":4}"#, match: "cmd"))
316 stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list"))
317 let model = AdminUsersViewModel(client: client)
318
319 await model.perform(.disable, on: "bhargavkk")
320
321 #expect(model.actionError == "account changed state")
322 #expect(stub.seen.contains { $0.url.query()?.contains("argv=list") == true })
323 }
324}
gitbayTests/MarkupPreviewTests.swift deleted −23
@@ -1,23 +0,0 @@
1import Testing
2@testable import gitbay
3
4struct MarkupPreviewFormatTests {
5
6 @Test func orgFilesRenderAsOrg() {
7 #expect(MarkupPreview.format(forFile: "README.org") == "org")
8 #expect(MarkupPreview.format(forFile: "profile/README.org") == "org")
9 }
10
11 @Test func markdownFilesRenderAsMarkdown() {
12 #expect(MarkupPreview.format(forFile: "README.md") == "md")
13 #expect(MarkupPreview.format(forFile: "notes.markdown") == "md")
14 #expect(MarkupPreview.format(forFile: "CHANGES.MD") == "md")
15 }
16
17 /// Source files show as source, so there is nothing to preview.
18 @Test func otherFilesHaveNoPreview() {
19 #expect(MarkupPreview.format(forFile: "main.swift") == nil)
20 #expect(MarkupPreview.format(forFile: "Makefile") == nil)
21 #expect(MarkupPreview.format(forFile: "notes.txt") == nil)
22 }
23}
gitbayUITests/LiveSmokeUITests.swift +4 −59
@@ -93,10 +93,6 @@ final class LiveSmokeUITests: XCTestCase {
93 try XCTSkipUnless(open.contains(Self.labelledTitle) && open.contains(Self.unlabelledTitle), 93 try XCTSkipUnless(open.contains(Self.labelledTitle) && open.contains(Self.unlabelledTitle),
94 "could not open both label fixture merge requests on \(Self.fixtureRepo)") 94 "could not open both label fixture merge requests on \(Self.fixtureRepo)")
95 } 95 }
96 if name.contains("testMarkupPreviewFlow") {
97 try requireFixtureRepo()
98 ensureFixtureIssue()
99 }
100 } 96 }
101 97
102 override func tearDownWithError() throws { 98 override func tearDownWithError() throws {
@@ -1289,6 +1285,10 @@ extension LiveSmokeUITests {
1289 menu.tap() 1285 menu.tap()
1290 XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5), 1286 XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
1291 "keys not in the account menu") 1287 "keys not in the account menu")
1288 // ios-smoke is not an instance admin.
1289 XCTAssertFalse(app.descendants(matching: .any)
1290 .matching(identifier: "account-menu-admin").firstMatch.exists,
1291 "a non-admin is offered Admin")
1292 app.tap() // dismiss the menu; the profile is already on screen 1292 app.tap() // dismiss the menu; the profile is already on screen
1293 1293
1294 // --- a profile is a profile: description, links, orgs, graph, repos --- 1294 // --- a profile is a profile: description, links, orgs, graph, repos ---
@@ -1914,59 +1914,4 @@ extension LiveSmokeUITests {
1914 remove.tap() 1914 remove.tap()
1915 XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown") 1915 XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown")
1916 } 1916 }
1917
1918 /// A comment's preview renders its markup before anything is sent:
1919 /// the bold markers are gone and the word remains. Nothing is posted.
1920 func testMarkupPreviewFlow() throws {
1921 openRepo(Self.fixtureRepo)
1922 app.staticTexts["Issues"].firstMatch.tap()
1923 let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch
1924 XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed")
1925 issue.tap()
1926
1927 let field = app.descendants(matching: .any)
1928 .matching(identifier: "issue-comment-field").firstMatch
1929 XCTAssertTrue(scrollTo(field, swipes: 10), "no comment field on the issue")
1930 let draft = "**Bold preview**"
1931 focusAndType(field, draft)
1932
1933 let preview = app.descendants(matching: .any)
1934 .matching(identifier: "issue-comment-preview").firstMatch
1935 XCTAssertTrue(scrollTo(preview), "no preview row for a non-empty comment")
1936 // The keyboard can cover the row right after typing.
1937 for _ in 0..<3 where !preview.isHittable { app.swipeUp() }
1938 preview.tap()
1939 let rendered = app.staticTexts
1940 .containing(NSPredicate(format: "label CONTAINS 'Bold preview'")).firstMatch
1941 if !rendered.waitForExistence(timeout: 3) {
1942 // The row's identifier can land on the cell rather than the toggle.
1943 app.buttons["Preview"].firstMatch.tap()
1944 }
1945 XCTAssertTrue(rendered.waitForExistence(timeout: 5), "preview did not render the draft")
1946 XCTAssertFalse(app.staticTexts
1947 .containing(NSPredicate(format: "label CONTAINS '**Bold'")).firstMatch.exists,
1948 "preview shows the raw markup")
1949
1950 // Kept on success too: this is how the preview's look is checked
1951 // in light and dark without signing a simulator in by hand.
1952 // The rendered text sits under the keyboard, below the comment
1953 // field. A drag on the list moves the row above the keyboard for
1954 // the screenshot; the wait lets the scroll settle.
1955 if app.keyboards.firstMatch.exists {
1956 app.swipeDown()
1957 _ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
1958 }
1959 XCTAssertTrue(scrollTo(rendered), "rendered preview scrolled away")
1960 let shot = XCTAttachment(screenshot: app.screenshot())
1961 shot.name = "markup preview"
1962 shot.lifetime = .keepAlways
1963 add(shot)
1964
1965 // Leave the fixture as found: clear the draft, send nothing.
1966 XCTAssertTrue(scrollTo(field), "comment field scrolled away")
1967 field.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.9)).tap()
1968 let typed = (field.value as? String)?.count ?? draft.count
1969 field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: typed))
1970 XCTAssertTrue(preview.waitForNonExistence(timeout: 5), "preview row outlived an empty comment")
1971 }
1972} 1917}