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