mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
chore: formatting and lints
This commit is contained in:
parent
a7491daffc
commit
69f7923a52
34 changed files with 99 additions and 86 deletions
|
|
@ -1,2 +1,3 @@
|
|||
--swiftversion 6.2
|
||||
--disable redundantMemberwiseInit
|
||||
--disable swiftTestingTestCaseNames
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@
|
|||
mainGroup = DEC49F182F3CE05200E7DD54;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
DE00000000000005000000AA /* XCLocalSwiftPackageReference "../../ForgejoKit" */,
|
||||
DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */,
|
||||
DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */,
|
||||
DEC49F812F3D173F00E7DD54 /* XCRemoteSwiftPackageReference "HighlightSwift" */,
|
||||
);
|
||||
|
|
@ -633,14 +633,15 @@
|
|||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
DE00000000000005000000AA /* XCLocalSwiftPackageReference "../../ForgejoKit" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = ../../ForgejoKit;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://codeberg.org/secana/ForgejoKit.git";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 0.8.0;
|
||||
};
|
||||
};
|
||||
DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/gonzalezreal/textual";
|
||||
|
|
@ -662,12 +663,12 @@
|
|||
/* Begin XCSwiftPackageProductDependency section */
|
||||
DE00000000000002000000AA /* ForgejoKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DE00000000000005000000AA /* XCLocalSwiftPackageReference "../../ForgejoKit" */;
|
||||
package = DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */;
|
||||
productName = ForgejoKit;
|
||||
};
|
||||
DE00000000000004000000AA /* ForgejoKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DE00000000000005000000AA /* XCLocalSwiftPackageReference "../../ForgejoKit" */;
|
||||
package = DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */;
|
||||
productName = ForgejoKit;
|
||||
};
|
||||
DEC49F6D2F3D023400E7DD54 /* Textual */ = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
{
|
||||
"originHash" : "931ec0beeaf4e6a5eaa0afab6f815f97bc126bda7a2c9f001c10de58585e766f",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "forgejokit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://codeberg.org/secana/ForgejoKit.git",
|
||||
"state" : {
|
||||
"revision" : "485c147e7f9762c06f51029de47464fb41abc4af",
|
||||
"version" : "0.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "highlightswift",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ struct ContentView: View {
|
|||
defaultInstance.lastUsed = Date()
|
||||
try? modelContext.save()
|
||||
} catch {
|
||||
// Auto-login failed — fall through to instance list
|
||||
// Auto-login failed, fall through to instance list
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ enum HTMLParser {
|
|||
}
|
||||
}
|
||||
|
||||
// 2. Inline HTML — check paragraphs for inline tags
|
||||
// 2. Inline HTML, check paragraphs for inline tags
|
||||
if let inlinePattern = inlineHTMLPattern {
|
||||
let paragraphs = splitIntoParagraphs(masked)
|
||||
for para in paragraphs {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ struct WorkflowStatusStyle {
|
|||
let label: String
|
||||
|
||||
/// Maps Forgejo's single status value to a UI style. Used for runs, jobs,
|
||||
/// and steps — they share the same status vocabulary. Forgejo's known
|
||||
/// and steps, they share the same status vocabulary. Forgejo's known
|
||||
/// statuses: success, failure, cancelled, skipped, running, waiting,
|
||||
/// blocked, unknown. Anything else falls back to a question-mark glyph.
|
||||
static func forStatus(_ status: String) -> WorkflowStatusStyle {
|
||||
|
|
@ -122,7 +122,7 @@ extension WorkflowRun {
|
|||
}
|
||||
|
||||
/// Elapsed time as TimeInterval. Forgejo serialises a Go `time.Duration`
|
||||
/// — int64 nanoseconds — so divide by 1_000_000_000. Falls back to the
|
||||
/// , int64 nanoseconds, so divide by 1_000_000_000. Falls back to the
|
||||
/// timestamps only when both are sane (post-2000).
|
||||
var duration: TimeInterval? {
|
||||
if let nanos = durationNanos, nanos > 0 {
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ class AuthenticationService {
|
|||
username: instance.username,
|
||||
)
|
||||
} catch {
|
||||
// Keychain delete failed — log in debug builds
|
||||
// Keychain delete failed, log in debug builds
|
||||
#if DEBUG
|
||||
print("Keychain delete failed during logout: \(error)")
|
||||
#endif
|
||||
|
|
@ -171,7 +171,7 @@ class AuthenticationService {
|
|||
isAuthenticated = true
|
||||
return
|
||||
} catch {
|
||||
// Token is invalid/expired — only fall through to password if this is not a token-only instance
|
||||
// Token is invalid/expired, only fall through to password if this is not a token-only instance
|
||||
if useTokenAuth {
|
||||
throw SessionRestoreError.fromTokenValidationError(error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import ForgejoKit
|
|||
import SwiftUI
|
||||
|
||||
/// Markdown to insert when an attachment is uploaded. Images use embed syntax
|
||||
/// (``) so they render inline; everything else (logs, PDFs, archives,
|
||||
/// …) uses link syntax (`[name](url)`) so it appears as a clickable link rather
|
||||
/// than a broken inline image.
|
||||
/// (``) so they render inline; every other file type (logs, PDFs,
|
||||
/// archives, and so on) uses link syntax (`[name](url)`) so it appears as a
|
||||
/// clickable link rather than a broken inline image.
|
||||
func attachmentMarkdown(for attachment: Attachment) -> String {
|
||||
if attachment.isImage {
|
||||
")"
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ struct CommitHistoryView: View {
|
|||
repo: repo,
|
||||
)
|
||||
} catch {
|
||||
// Non-critical — branch selector stays disabled
|
||||
// Non-critical, branch selector stays disabled
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ struct HomeView: View {
|
|||
do {
|
||||
unreadCount = try await notificationService.fetchUnreadCount()
|
||||
} catch {
|
||||
// Silently ignore — badge is non-critical, keep the prior count
|
||||
// Silently ignore, badge is non-critical, keep the prior count
|
||||
return
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ struct IssueListView: View {
|
|||
} description: {
|
||||
Text(
|
||||
stateFilter == .open
|
||||
? "All clear — no open issues to review."
|
||||
? "All clear, no open issues to review."
|
||||
: "No closed issues found.",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ struct MarkdownToolbar: View {
|
|||
|
||||
private func uploadTestImage() async {
|
||||
guard let onUploadImage else { return }
|
||||
// 1×1 transparent PNG — used in UI tests to skip the Photos picker.
|
||||
// 1×1 transparent PNG, used in UI tests to skip the Photos picker.
|
||||
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
guard let pngData = Data(base64Encoded: base64) else { return }
|
||||
await runUpload { try await onUploadImage(pngData, "test.png", "image/png") }
|
||||
|
|
@ -114,7 +114,7 @@ struct MarkdownToolbar: View {
|
|||
|
||||
private func uploadTestFile() async {
|
||||
guard let onUploadImage else { return }
|
||||
// Plain-text log file — used in UI tests to exercise the non-image
|
||||
// Plain-text log file, used in UI tests to exercise the non-image
|
||||
// attachment path (link markdown + file-row display).
|
||||
let logData = Data("line1 ERROR boom\nline2 WARN stuff\nline3 done\n".utf8)
|
||||
await runUpload { try await onUploadImage(logData, "server.log", "text/plain") }
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ struct PullRequestListView: View {
|
|||
} description: {
|
||||
Text(
|
||||
stateFilter == .open
|
||||
? "All clear — no open pull requests."
|
||||
? "All clear, no open pull requests."
|
||||
: "No closed pull requests found.",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ struct RepositoryDetailView: View {
|
|||
repo: repository.repoName,
|
||||
)
|
||||
} catch {
|
||||
// Non-critical — branch selector stays disabled
|
||||
// Non-critical, branch selector stays disabled
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
|
|
@ -504,7 +504,7 @@ struct RepositoryCodeView: View {
|
|||
)
|
||||
readmeContent = fileContent.decodedContent
|
||||
} catch {
|
||||
// README exists in listing but couldn't be fetched — ignore
|
||||
// README exists in listing but couldn't be fetched, ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
|
|||
return "No \(itemNoun) matching your search."
|
||||
}
|
||||
if stateFilter == .open {
|
||||
return "All clear — no open \(itemNoun) to review."
|
||||
return "All clear, no open \(itemNoun) to review."
|
||||
}
|
||||
let prefix = stateFilter == .all ? "" : stateFilter.rawValue + " "
|
||||
return "No \(prefix)\(itemNoun) found."
|
||||
|
|
@ -85,7 +85,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
|
|||
case .closed: "Closed"
|
||||
case .all: "All"
|
||||
}
|
||||
// Scope is ignored during search — only show state
|
||||
// Scope is ignored during search, only show state
|
||||
if !searchText.isEmpty || (stateFilter == .open && involvementScope == .involved) {
|
||||
return stateLabel
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ struct WorkflowJobView: View {
|
|||
Label("No Steps Recorded", systemImage: "tray")
|
||||
.foregroundStyle(.secondary)
|
||||
} description: {
|
||||
Text("This job has no recorded steps — it likely never started.")
|
||||
Text("This job has no recorded steps, it likely never started.")
|
||||
}
|
||||
} header: {
|
||||
experimentalHeader
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ struct WorkflowRunDetailView: View {
|
|||
|
||||
isLoading = false
|
||||
|
||||
// Best-effort experimental fetch — don't surface errors if it fails
|
||||
// Best-effort experimental fetch, don't surface errors if it fails
|
||||
// (e.g. older Forgejo versions without this route, or auth quirks).
|
||||
if let runIndex = run?.indexInRepo {
|
||||
await loadRunView(runIndex: runIndex)
|
||||
|
|
@ -214,7 +214,7 @@ struct WorkflowRunDetailView: View {
|
|||
logCursors: [],
|
||||
)
|
||||
} catch {
|
||||
// Silent — experimental endpoint failure shouldn't block run detail.
|
||||
// Silent, experimental endpoint failure shouldn't block run detail.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +222,7 @@ struct WorkflowRunDetailView: View {
|
|||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.hour, .minute, .second]
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.string(from: seconds) ?? "—"
|
||||
return formatter.string(from: seconds) ?? "-"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ struct KeychainManagerTests {
|
|||
_ = try await KeychainManager.shared.getPassword(for: raw, username: "user")
|
||||
Issue.record("Should not find password with non-normalized key")
|
||||
} catch is KeychainError {
|
||||
// Expected — keys differ
|
||||
// Expected, keys differ
|
||||
}
|
||||
|
||||
// Clean up
|
||||
|
|
@ -156,7 +156,7 @@ struct KeychainManagerTests {
|
|||
_ = try await KeychainManager.shared.getToken(for: server, username: username)
|
||||
Issue.record("Expected KeychainError.notFound for token after deleteCredentials")
|
||||
} catch is KeychainError {
|
||||
// Expected — this is the assertion that fails under the old leak
|
||||
// Expected, this is the assertion that fails under the old leak
|
||||
} catch {
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ struct MarkdownComponentsTests {
|
|||
}
|
||||
|
||||
@Test func repoRelativePathReturnsNilForBranchOnly() {
|
||||
// URL ends right after the ref — no file path follows
|
||||
// URL ends right after the ref, no file path follows
|
||||
let url = URL(string: "https://forgejo.example.com/owner/repo/src/branch/main")!
|
||||
#expect(repoRelativePath(from: url) == nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,12 +71,12 @@ struct NotificationPollingIntegrationTests {
|
|||
let ids = Set(notifications.map(\.id))
|
||||
#expect(!ids.isEmpty)
|
||||
|
||||
// Seed — should record all current IDs
|
||||
// Seed, should record all current IDs
|
||||
store.seed(ids: ids, for: normalizedURL, username: Self.username)
|
||||
#expect(store.isSeeded(for: normalizedURL, username: Self.username))
|
||||
#expect(store.seenIDs(for: normalizedURL, username: Self.username) == ids)
|
||||
|
||||
// Simulate a poll returning the same IDs — no new notifications
|
||||
// Simulate a poll returning the same IDs, no new notifications
|
||||
let newIDs = ids.subtracting(store.seenIDs(for: normalizedURL, username: Self.username))
|
||||
#expect(newIDs.isEmpty, "After seeding with current IDs, diff should be empty")
|
||||
}
|
||||
|
|
@ -93,11 +93,11 @@ struct NotificationPollingIntegrationTests {
|
|||
let allIDs = Set(notifications.map(\.id))
|
||||
#expect(allIDs.count >= 2, "Need at least 2 notifications for this test")
|
||||
|
||||
// Seed with only a subset — simulates having seen all but one
|
||||
// Seed with only a subset, simulates having seen all but one
|
||||
let partial = Set(allIDs.dropFirst())
|
||||
store.seed(ids: partial, for: normalizedURL, username: Self.username)
|
||||
|
||||
// Now diff — the first ID should show as "new"
|
||||
// Now diff, the first ID should show as "new"
|
||||
let seenIDs = store.seenIDs(for: normalizedURL, username: Self.username)
|
||||
let newIDs = allIDs.subtracting(seenIDs)
|
||||
#expect(newIDs.count == 1, "Expected exactly 1 new notification after partial seed")
|
||||
|
|
@ -123,7 +123,7 @@ struct NotificationPollingIntegrationTests {
|
|||
store.seed(ids: seeded, for: normalizedURL, username: Self.username)
|
||||
#expect(store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999))
|
||||
|
||||
// Prune — only keep IDs that the server still reports as unread
|
||||
// Prune, only keep IDs that the server still reports as unread
|
||||
store.prune(keeping: serverIDs, for: normalizedURL, username: Self.username)
|
||||
#expect(!store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999))
|
||||
#expect(store.seenIDs(for: normalizedURL, username: Self.username) == serverIDs)
|
||||
|
|
@ -150,7 +150,7 @@ struct NotificationPollingIntegrationTests {
|
|||
let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
|
||||
#expect(!store.isSeeded(for: normalizedURL, username: Self.username))
|
||||
|
||||
// Seed the store — first poll seeds without posting notifications
|
||||
// Seed the store, first poll seeds without posting notifications
|
||||
let firstPollIDs = Set(notifications.map(\.id))
|
||||
store.seed(ids: firstPollIDs, for: normalizedURL, username: Self.username)
|
||||
|
||||
|
|
@ -191,7 +191,7 @@ struct NotificationPollingIntegrationTests {
|
|||
// Save with current (post-migration) accessibility
|
||||
try await KeychainManager.shared.saveToken(token, for: server, username: user)
|
||||
|
||||
// Run migration — should read + delete + re-save without losing data
|
||||
// Run migration, should read + delete + re-save without losing data
|
||||
await KeychainManager.shared.migrateAccessibility(for: server, username: user)
|
||||
|
||||
// Verify token is still accessible
|
||||
|
|
@ -283,7 +283,7 @@ struct NotificationPollingIntegrationTests {
|
|||
let service = NotificationService(client: client)
|
||||
let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
|
||||
|
||||
// Initial poll — seed seen store
|
||||
// Initial poll, seed seen store
|
||||
let initial = try await service.fetchNotifications(
|
||||
statusTypes: ["unread"], page: 1, limit: 50
|
||||
)
|
||||
|
|
@ -295,14 +295,14 @@ struct NotificationPollingIntegrationTests {
|
|||
let markedID = initial[0].id
|
||||
try await service.markAsRead(id: markedID)
|
||||
|
||||
// Next poll — fewer unreads
|
||||
// Next poll, fewer unreads
|
||||
let afterMark = try await service.fetchNotifications(
|
||||
statusTypes: ["unread"], page: 1, limit: 50
|
||||
)
|
||||
let afterIDs = Set(afterMark.map(\.id))
|
||||
#expect(!afterIDs.contains(markedID), "Marked notification should no longer be unread")
|
||||
|
||||
// Prune — the marked ID should be removed from seen store
|
||||
// Prune, the marked ID should be removed from seen store
|
||||
store.prune(keeping: afterIDs, for: normalizedURL, username: Self.username)
|
||||
#expect(
|
||||
!store.seenIDs(for: normalizedURL, username: Self.username).contains(markedID),
|
||||
|
|
@ -319,7 +319,7 @@ struct NotificationPollingIntegrationTests {
|
|||
let server = "https://test.example.com"
|
||||
let user = "testuser"
|
||||
|
||||
// Seed with partial IDs — ID 1 is "seen", ID 2 is "new"
|
||||
// Seed with partial IDs, ID 1 is "seen", ID 2 is "new"
|
||||
store.seed(ids: [1], for: server, username: user)
|
||||
|
||||
// Simulate a poll returning IDs [1, 2]
|
||||
|
|
@ -460,12 +460,12 @@ struct NotificationPollingIntegrationTests {
|
|||
let allIDs = Set(notifications.map(\.id))
|
||||
#expect(allIDs.count >= 2, "Need at least 2 notifications")
|
||||
|
||||
// Seed with a subset — simulate having seen all but one
|
||||
// Seed with a subset, simulate having seen all but one
|
||||
let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
|
||||
let partial = Set(allIDs.dropFirst())
|
||||
store.seed(ids: partial, for: normalizedURL, username: Self.username)
|
||||
|
||||
// Diff — should detect the missing ID as new
|
||||
// Diff, should detect the missing ID as new
|
||||
let seenIDs = store.seenIDs(for: normalizedURL, username: Self.username)
|
||||
let newIDs = allIDs.subtracting(seenIDs)
|
||||
#expect(newIDs.count == 1, "Expected exactly 1 new notification")
|
||||
|
|
|
|||
|
|
@ -147,13 +147,13 @@ struct PaginationStateConcurrentTests {
|
|||
}
|
||||
await yieldUntil { fetcherA.isPending }
|
||||
|
||||
// Start reload B — cancels A's internal task
|
||||
// Start reload B, cancels A's internal task
|
||||
let taskB = pagination.reload { page, limit in
|
||||
try await fetcherB.fetch(page: page, limit: limit)
|
||||
}
|
||||
await yieldUntil { fetcherB.isPending }
|
||||
|
||||
// Complete both — only B's results should be applied
|
||||
// Complete both, only B's results should be applied
|
||||
fetcherA.complete(returning: ["stale"])
|
||||
fetcherB.complete(returning: ["fresh"])
|
||||
await taskB.value
|
||||
|
|
@ -177,7 +177,7 @@ struct PaginationStateConcurrentTests {
|
|||
}
|
||||
await yieldUntil { fetcherB.isPending }
|
||||
|
||||
// Complete A first (stale, its task was cancelled) — must be discarded
|
||||
// Complete A first (stale, its task was cancelled), must be discarded
|
||||
fetcherA.complete(returning: ["stale"])
|
||||
await yieldUntil { fetcherA.callCount == 1 }
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ struct PaginationStateConcurrentTests {
|
|||
}
|
||||
await yieldUntil { fetcherC.isPending }
|
||||
|
||||
// Complete in order A, B, C — only C should be applied
|
||||
// Complete in order A, B, C, only C should be applied
|
||||
fetcherA.complete(returning: ["a"])
|
||||
fetcherB.complete(returning: ["b"])
|
||||
fetcherC.complete(returning: ["c"])
|
||||
|
|
@ -238,7 +238,7 @@ struct PaginationStateConcurrentTests {
|
|||
}
|
||||
await yieldUntil { fetcherB.isPending }
|
||||
|
||||
// A fails with error — but its task was cancelled, so no alert
|
||||
// A fails with error, but its task was cancelled, so no alert
|
||||
fetcherA.complete(throwing: URLError(.badServerResponse))
|
||||
fetcherB.complete(returning: ["ok"])
|
||||
await taskB.value
|
||||
|
|
@ -264,7 +264,7 @@ struct PaginationStateConcurrentTests {
|
|||
await yieldUntil { fetcherB.isPending }
|
||||
#expect(pagination.isLoading)
|
||||
|
||||
// Complete stale A — isLoading must REMAIN true (B is still in flight)
|
||||
// Complete stale A, isLoading must REMAIN true (B is still in flight)
|
||||
fetcherA.complete(returning: ["stale"])
|
||||
await yieldUntil { fetcherA.callCount == 1 }
|
||||
#expect(pagination.isLoading, "isLoading must stay true while latest reload (B) is pending")
|
||||
|
|
@ -287,7 +287,7 @@ struct PaginationStateConcurrentTests {
|
|||
}
|
||||
await yieldUntil { initialFetcher.isPending }
|
||||
|
||||
// Simulate onChange: user changed filter — just call reload again
|
||||
// Simulate onChange: user changed filter, just call reload again
|
||||
let filterTask = pagination.reload(clearItems: true) { page, limit in
|
||||
try await filterFetcher.fetch(page: page, limit: limit)
|
||||
}
|
||||
|
|
@ -463,11 +463,11 @@ struct PaginationStateLoadMoreTests {
|
|||
}
|
||||
await yieldUntil { freshFetcher.isPending }
|
||||
|
||||
// loadMore completes — should be discarded (task was cancelled)
|
||||
// loadMore completes, should be discarded (task was cancelled)
|
||||
moreFetcher.complete(returning: ["stale-more"])
|
||||
await moreTask.value
|
||||
|
||||
// reload completes — should be applied
|
||||
// reload completes, should be applied
|
||||
freshFetcher.complete(returning: ["fresh"])
|
||||
await reloadTask.value
|
||||
|
||||
|
|
@ -485,7 +485,7 @@ struct PaginationStateDedupeTests {
|
|||
pagination.dedupeKey = { $0 }
|
||||
await pagination.reload { _, _ in ["a", "b"] }.value
|
||||
|
||||
// "b" resurfaces on page 2 — only "c" is new
|
||||
// "b" resurfaces on page 2, only "c" is new
|
||||
await pagination.loadMore { _, _ in ["b", "c"] }
|
||||
#expect(pagination.items == ["a", "b", "c"])
|
||||
#expect(pagination.hasMore) // the page contributed a new item
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ final class AttachmentUITests: ForgejoUITestBase {
|
|||
XCTAssertTrue(commentButton.waitForExistence(timeout: 5))
|
||||
commentButton.tap()
|
||||
|
||||
// Tap the image upload button twice — in test mode each tap uploads a
|
||||
// Tap the image upload button twice, in test mode each tap uploads a
|
||||
// hardcoded PNG instead of presenting the Photos picker. Two taps produce
|
||||
// two distinct attachments and two markdown references.
|
||||
let imageButton = app.buttons["markdown-toolbar-image"]
|
||||
|
|
@ -33,13 +33,13 @@ final class AttachmentUITests: ForgejoUITestBase {
|
|||
let textEditor = app.textViews["markdown-text-editor"].firstMatch
|
||||
XCTAssertTrue(textEditor.waitForExistence(timeout: 5))
|
||||
|
||||
// First upload — wait until one markdown reference is inserted before tapping
|
||||
// First upload, wait until one markdown reference is inserted before tapping
|
||||
// again, so the second upload appends rather than racing the first.
|
||||
imageButton.tap()
|
||||
expectation(for: NSPredicate(format: "value MATCHES '.*!\\\\[.*'"), evaluatedWith: textEditor)
|
||||
waitForExpectations(timeout: 15, handler: nil)
|
||||
|
||||
// Second upload — wait until two markdown references are present.
|
||||
// Second upload, wait until two markdown references are present.
|
||||
imageButton.tap()
|
||||
let twoRefs = NSPredicate(format: "value MATCHES '(.*!\\\\[.*){2,}'")
|
||||
expectation(for: twoRefs, evaluatedWith: textEditor)
|
||||
|
|
@ -120,7 +120,7 @@ final class AttachmentUITests: ForgejoUITestBase {
|
|||
XCTAssertTrue(commentButton.waitForExistence(timeout: 5))
|
||||
commentButton.tap()
|
||||
|
||||
// Tap the file upload button — in test mode this uploads a hardcoded server.log.
|
||||
// Tap the file upload button, in test mode this uploads a hardcoded server.log.
|
||||
let fileButton = app.buttons["markdown-toolbar-file"]
|
||||
XCTAssertTrue(fileButton.waitForExistence(timeout: 5))
|
||||
let textEditor = app.textViews["markdown-text-editor"].firstMatch
|
||||
|
|
@ -132,7 +132,7 @@ final class AttachmentUITests: ForgejoUITestBase {
|
|||
waitForExpectations(timeout: 15, handler: nil)
|
||||
|
||||
// A non-image attachment must use link syntax [name](url), NOT image-embed
|
||||
// syntax  — otherwise it would render as a broken inline image.
|
||||
// syntax , otherwise it would render as a broken inline image.
|
||||
let editorValue = textEditor.value as? String ?? ""
|
||||
XCTAssertTrue(
|
||||
editorValue.contains("[server.log]"),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class ForgejoReadOnlyUITestBase: XCTestCase, UITestNavigating {
|
|||
guard let url = ForgejoUITestBase.resolveTestServerURL(),
|
||||
!url.isEmpty
|
||||
else {
|
||||
// Can't XCTSkip from class setUp — individual tests will skip
|
||||
// Can't XCTSkip from class setUp, individual tests will skip
|
||||
return
|
||||
}
|
||||
sharedServerURL = url
|
||||
|
|
@ -60,7 +60,7 @@ class ForgejoReadOnlyUITestBase: XCTestCase, UITestNavigating {
|
|||
// Ensure we're at the home screen (Repositories tab as anchor)
|
||||
let reposTab = app.tabBars.buttons["Repositories"]
|
||||
if !reposTab.waitForExistence(timeout: 5) {
|
||||
// Recovery: app may have crashed or navigated away — relaunch
|
||||
// Recovery: app may have crashed or navigated away, relaunch
|
||||
let freshApp = XCUIApplication()
|
||||
freshApp.launchArguments += [
|
||||
"-dev_serverURL", Self.sharedServerURL,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ final class ForjiUITestsLaunchTests: XCTestCase {
|
|||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
|
||||
// Skip during integration test runs — these tests need manual credentials
|
||||
// Skip during integration test runs, these tests need manual credentials
|
||||
if FileManager.default.fileExists(atPath: "/tmp/forgejo_test_url.txt") {
|
||||
throw XCTSkip("Skipping launch tests during integration test run")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ final class IssueMutatingUITests: ForgejoUITestBase {
|
|||
openButton.tap()
|
||||
XCTAssertTrue(app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10))
|
||||
|
||||
// Issue detail — title, comments, label, milestone, assignee
|
||||
// Issue detail, title, comments, label, milestone, assignee
|
||||
let issueCell = app.staticTexts["Test issue 1 from integration tests"].firstMatch
|
||||
issueCell.tap()
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ final class MergedInstanceUITests: ForgejoUITestBase {
|
|||
XCTAssertTrue(addButton.waitForExistence(timeout: 15), "Instance list did not appear")
|
||||
addButton.tap()
|
||||
|
||||
// Form is pre-filled from dev launch args — scroll and tap login
|
||||
// Form is pre-filled from dev launch args, scroll and tap login
|
||||
app.swipeUp()
|
||||
|
||||
let loginButton = app.buttons["login-button"]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import XCTest
|
|||
|
||||
final class OverviewCreateMutatingUITests: ForgejoUITestBase {
|
||||
|
||||
// MARK: - Create Issue from Issues Overview (mutates — creates an issue)
|
||||
// MARK: - Create Issue from Issues Overview (mutates, creates an issue)
|
||||
|
||||
@MainActor
|
||||
func testCreateIssueFromOverview() throws {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ final class OverviewCreateUITests: ForgejoReadOnlyUITestBase {
|
|||
func testCreatePRFromOverview() throws {
|
||||
app.tabBars.buttons["Pull Requests"].tap()
|
||||
|
||||
// Default scope is "All involvement" — testadmin is assigned to PR #4
|
||||
// Default scope is "All involvement", testadmin is assigned to PR #4
|
||||
XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10))
|
||||
|
||||
// Tap floating create button
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ final class PaginationUITests: ForgejoReadOnlyUITestBase {
|
|||
XCTAssertTrue(closedButton.waitForExistence(timeout: 5))
|
||||
closedButton.tap()
|
||||
|
||||
// Only 1 closed issue (#3) — should not show load-more
|
||||
// Only 1 closed issue (#3), should not show load-more
|
||||
XCTAssertTrue(app.staticTexts["Test issue 3 from integration tests"].waitForExistence(timeout: 10))
|
||||
|
||||
let loadMore = app.activityIndicators["load-more-indicator"]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ final class PermissionUITests: ForgejoUITestBase {
|
|||
]
|
||||
}
|
||||
|
||||
// MARK: - Issue Detail — read-only user
|
||||
// MARK: - Issue Detail, read-only user
|
||||
|
||||
@MainActor
|
||||
func testIssueDetailHidesActionsForReadOnlyUser() throws {
|
||||
|
|
@ -43,7 +43,7 @@ final class PermissionUITests: ForgejoUITestBase {
|
|||
XCTAssertFalse(toggleStateButton.exists, "Close/Reopen button should be hidden for read-only user")
|
||||
}
|
||||
|
||||
// MARK: - PR Detail — read-only user
|
||||
// MARK: - PR Detail, read-only user
|
||||
|
||||
@MainActor
|
||||
func testPRDetailHidesActionsForReadOnlyUser() throws {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
|
|||
openButton.tap()
|
||||
XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10))
|
||||
|
||||
// PR detail — title, branches
|
||||
// PR detail, title, branches
|
||||
let prCell = app.staticTexts["Add feature file"].firstMatch
|
||||
prCell.tap()
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
|
|||
XCTAssertTrue(app.staticTexts["testbot"].waitForExistence(timeout: 10))
|
||||
}
|
||||
|
||||
// MARK: - Diff View — context line has no comment button
|
||||
// MARK: - Diff View, context line has no comment button
|
||||
|
||||
@MainActor
|
||||
func testDiffViewContextLineHasNoCommentButton() throws {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ final class RepositoryMutatingUITests: ForgejoUITestBase {
|
|||
toggleButton.tap()
|
||||
toggleButton.tap()
|
||||
|
||||
// File viewer — tap hello.py
|
||||
// File viewer, tap hello.py
|
||||
let fileCell = app.staticTexts["hello.py"].firstMatch
|
||||
XCTAssertTrue(fileCell.waitForExistence(timeout: 10))
|
||||
fileCell.tap()
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ final class RepositoryUITests: ForgejoReadOnlyUITestBase {
|
|||
searchField.buttons["Clear text"].tap()
|
||||
XCTAssertTrue(app.staticTexts["test-repo"].waitForExistence(timeout: 10))
|
||||
|
||||
// Star toggle (last — tap triggers NavigationLink, only tests tappability)
|
||||
// Star toggle (last, tap triggers NavigationLink, only tests tappability)
|
||||
let starButton = repoList.buttons["star-button"].firstMatch
|
||||
XCTAssertTrue(starButton.waitForExistence(timeout: 5), "No star button found in repo list")
|
||||
starButton.tap()
|
||||
|
|
@ -130,7 +130,7 @@ final class RepositoryUITests: ForgejoReadOnlyUITestBase {
|
|||
// Wait for repo detail to load
|
||||
XCTAssertTrue(app.staticTexts["html-readme-repo"].waitForExistence(timeout: 10))
|
||||
|
||||
// Raw HTML tags should NOT appear as plain text — they should be rendered
|
||||
// Raw HTML tags should NOT appear as plain text, they should be rendered
|
||||
XCTAssertFalse(
|
||||
app.staticTexts["<details>"].waitForExistence(timeout: 5),
|
||||
"Raw <details> tag should not be visible as plain text")
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -39,6 +39,7 @@ Forji is available on the Apple App Store for free: [Forji App Store](https://ap
|
|||
- Create, edit, and close/reopen issues
|
||||
- Manage labels, milestones, and assignees
|
||||
- Comment with Markdown support
|
||||
- Attach and view image and file attachments in descriptions and comments
|
||||
|
||||
### Pull Requests
|
||||
- View PRs across all repositories or per-repo
|
||||
|
|
@ -47,6 +48,7 @@ Forji is available on the Apple App Store for free: [Forji App Store](https://ap
|
|||
- Submit reviews (comment, approve, request changes)
|
||||
- Merge with merge commit, rebase, or squash
|
||||
- Close, reopen, and edit PRs
|
||||
- Attach and view image and file attachments in descriptions and comments
|
||||
|
||||
### Actions
|
||||
- Browse Forgejo Actions workflows defined in a repository
|
||||
|
|
@ -110,13 +112,13 @@ The Forji logo is based on the [Forgejo logo](https://codeberg.org/forgejo/forge
|
|||
|
||||
### Libraries
|
||||
|
||||
- [ForgejoKit](https://codeberg.org/secana/ForgejoKit) — Forgejo API client (MIT)
|
||||
- [Textual](https://github.com/gonzalezreal/textual) — Markdown rendering (MIT)
|
||||
- [HighlightSwift](https://github.com/appstefan/HighlightSwift) — Code syntax highlighting (MIT)
|
||||
- [mermaid](https://github.com/mermaid-js/mermaid) — Diagram rendering (MIT)
|
||||
- [marked](https://github.com/markedjs/marked) — Markdown parser (MIT)
|
||||
- [DOMPurify](https://github.com/cure53/DOMPurify) — HTML sanitizer (Apache 2.0 / MPL 2.0)
|
||||
- [github-markdown-css](https://github.com/sindresorhus/github-markdown-css) — GitHub-style Markdown styling (MIT)
|
||||
- [ForgejoKit](https://codeberg.org/secana/ForgejoKit), Forgejo API client (MIT)
|
||||
- [Textual](https://github.com/gonzalezreal/textual), Markdown rendering (MIT)
|
||||
- [HighlightSwift](https://github.com/appstefan/HighlightSwift), Code syntax highlighting (MIT)
|
||||
- [mermaid](https://github.com/mermaid-js/mermaid), Diagram rendering (MIT)
|
||||
- [marked](https://github.com/markedjs/marked), Markdown parser (MIT)
|
||||
- [DOMPurify](https://github.com/cure53/DOMPurify), HTML sanitizer (Apache 2.0 / MPL 2.0)
|
||||
- [github-markdown-css](https://github.com/sindresorhus/github-markdown-css), GitHub-style Markdown styling (MIT)
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue