diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cbafbe..a5cc69d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,3 +17,10 @@ jobs: -destination 'generic/platform=macOS' \ CODE_SIGNING_ALLOWED=NO \ build + + - name: Test + run: | + xcodebuild -project ActionBar.xcodeproj -scheme ActionBar -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGNING_ALLOWED=NO \ + test diff --git a/ActionBar.xcodeproj/project.pbxproj b/ActionBar.xcodeproj/project.pbxproj index 8cb5b95..af6f986 100644 --- a/ActionBar.xcodeproj/project.pbxproj +++ b/ActionBar.xcodeproj/project.pbxproj @@ -29,6 +29,18 @@ A10001000000000000000019 /* UpdaterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000019 /* UpdaterService.swift */; }; A10001000000000000000021 /* GitHubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000021 /* GitHubModels.swift */; }; A10001000000000000000022 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000022 /* Components.swift */; }; + A10001000000000000000023 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000023 /* SettingsStore.swift */; }; + A10001000000000000000030 /* TestRuns.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000030 /* TestRuns.swift */; }; + A10001000000000000000036 /* FetchFailureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000036 /* FetchFailureTests.swift */; }; + A10001000000000000000031 /* RunStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000031 /* RunStatusTests.swift */; }; + A10001000000000000000032 /* WorkflowRunListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000032 /* WorkflowRunListTests.swift */; }; + A10001000000000000000033 /* RelativeTimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000033 /* RelativeTimeTests.swift */; }; + A10001000000000000000034 /* ModelDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000034 /* ModelDecodingTests.swift */; }; + A10001000000000000000035 /* SettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000035 /* SettingsStoreTests.swift */; }; + A10001000000000000000040 /* WorkflowRun.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000003 /* WorkflowRun.swift */; }; + A10001000000000000000041 /* GitHubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000021 /* GitHubModels.swift */; }; + A10001000000000000000042 /* RelativeTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000014 /* RelativeTime.swift */; }; + A10001000000000000000043 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10002000000000000000023 /* SettingsStore.swift */; }; /* End PBXBuildFile section */ @@ -56,7 +68,16 @@ A10002000000000000000019 /* UpdaterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterService.swift; sourceTree = ""; }; A10002000000000000000021 /* GitHubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubModels.swift; sourceTree = ""; }; A10002000000000000000022 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; + A10002000000000000000023 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = ""; }; A10002000000000000000020 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A10002000000000000000030 /* TestRuns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestRuns.swift; sourceTree = ""; }; + A10002000000000000000036 /* FetchFailureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchFailureTests.swift; sourceTree = ""; }; + A10002000000000000000031 /* RunStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunStatusTests.swift; sourceTree = ""; }; + A10002000000000000000032 /* WorkflowRunListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkflowRunListTests.swift; sourceTree = ""; }; + A10002000000000000000033 /* RelativeTimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelativeTimeTests.swift; sourceTree = ""; }; + A10002000000000000000034 /* ModelDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelDecodingTests.swift; sourceTree = ""; }; + A10002000000000000000035 /* SettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStoreTests.swift; sourceTree = ""; }; + A10003000000000000000002 /* ActionBarTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ActionBarTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -68,6 +89,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10004000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -77,6 +105,7 @@ A10002000000000000000018 /* Base.xcconfig */, A10005000000000000000003 /* Sources */, A10005000000000000000008 /* Resources */, + A10005000000000000000010 /* ActionBarTests */, A10005000000000000000002 /* Products */, ); sourceTree = ""; @@ -85,6 +114,7 @@ isa = PBXGroup; children = ( A10003000000000000000001 /* ActionBar.app */, + A10003000000000000000002 /* ActionBarTests.xctest */, ); name = Products; sourceTree = ""; @@ -117,6 +147,7 @@ A10002000000000000000003 /* WorkflowRun.swift */, A10002000000000000000004 /* AppState.swift */, A10002000000000000000021 /* GitHubModels.swift */, + A10002000000000000000023 /* SettingsStore.swift */, ); path = Models; sourceTree = ""; @@ -166,6 +197,20 @@ path = Utilities; sourceTree = ""; }; + A10005000000000000000010 /* ActionBarTests */ = { + isa = PBXGroup; + children = ( + A10002000000000000000030 /* TestRuns.swift */, + A10002000000000000000036 /* FetchFailureTests.swift */, + A10002000000000000000031 /* RunStatusTests.swift */, + A10002000000000000000032 /* WorkflowRunListTests.swift */, + A10002000000000000000033 /* RelativeTimeTests.swift */, + A10002000000000000000034 /* ModelDecodingTests.swift */, + A10002000000000000000035 /* SettingsStoreTests.swift */, + ); + path = Tests/ActionBarTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -189,6 +234,22 @@ productReference = A10003000000000000000001 /* ActionBar.app */; productType = "com.apple.product-type.application"; }; + A10006000000000000000002 /* ActionBarTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10008000000000000000004 /* Build configuration list for PBXNativeTarget "ActionBarTests" */; + buildPhases = ( + A10007000000000000000003 /* Sources */, + A10004000000000000000002 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ActionBarTests; + productName = ActionBarTests; + productReference = A10003000000000000000002 /* ActionBarTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -216,6 +277,7 @@ projectRoot = ""; targets = ( A10006000000000000000001 /* ActionBar */, + A10006000000000000000002 /* ActionBarTests */, ); }; /* End PBXProject section */ @@ -256,6 +318,25 @@ A10001000000000000000017 /* SettingsView.swift in Sources */, A10001000000000000000021 /* GitHubModels.swift in Sources */, A10001000000000000000022 /* Components.swift in Sources */, + A10001000000000000000023 /* SettingsStore.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A10007000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10001000000000000000030 /* TestRuns.swift in Sources */, + A10001000000000000000036 /* FetchFailureTests.swift in Sources */, + A10001000000000000000031 /* RunStatusTests.swift in Sources */, + A10001000000000000000032 /* WorkflowRunListTests.swift in Sources */, + A10001000000000000000033 /* RelativeTimeTests.swift in Sources */, + A10001000000000000000034 /* ModelDecodingTests.swift in Sources */, + A10001000000000000000035 /* SettingsStoreTests.swift in Sources */, + A10001000000000000000040 /* WorkflowRun.swift in Sources */, + A10001000000000000000041 /* GitHubModels.swift in Sources */, + A10001000000000000000042 /* RelativeTime.swift in Sources */, + A10001000000000000000043 /* SettingsStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -389,7 +470,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -419,7 +500,31 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + A1000A000000000000000005 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.ptrchm.actionbar.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + A1000A000000000000000006 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.ptrchm.actionbar.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -444,6 +549,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A10008000000000000000004 /* Build configuration list for PBXNativeTarget "ActionBarTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1000A000000000000000005 /* Debug */, + A1000A000000000000000006 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/ActionBar.xcodeproj/xcshareddata/xcschemes/ActionBar.xcscheme b/ActionBar.xcodeproj/xcshareddata/xcschemes/ActionBar.xcscheme index 8c13473..406acb5 100644 --- a/ActionBar.xcodeproj/xcshareddata/xcschemes/ActionBar.xcscheme +++ b/ActionBar.xcodeproj/xcshareddata/xcschemes/ActionBar.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:ActionBar.xcodeproj"> + + + + + + + + + + /dev/null | grep -m1 '^\s*BUILT_PRODUCTS_DIR' | sed 's/.*= //') APP_BUNDLE = $(BUILD_DIR)/$(APP_NAME).app -.PHONY: build run clean +.PHONY: build run test clean build: xcodebuild -project $(PROJECT) -scheme $(SCHEME) -configuration Release build @@ -14,6 +14,10 @@ run: build sleep 0.5 open "$(APP_BUNDLE)" +# The tests don't launch the app, so they never touch the Keychain. +test: + xcodebuild -project $(PROJECT) -scheme $(SCHEME) -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO test + clean: xcodebuild -project $(PROJECT) -scheme $(SCHEME) clean rm -rf ~/Library/Developer/Xcode/DerivedData/$(APP_NAME)-* diff --git a/README.md b/README.md index eced5a9..930c5f3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Requirements: macOS 14+ and a recent version of Xcode. 2. Copy `Config/Local.xcconfig.example` to `Config/Local.xcconfig` and set your Apple Developer Team ID and a bundle identifier registered to your team. 3. Open `ActionBar.xcodeproj` and run the `ActionBar` scheme, or use `make run`. +Run the unit tests with `make test` or the scheme's Test action. They cover the app's model logic and don't launch the app. + The app authenticates through the official ActionBar GitHub App. If you're shipping your own fork, register your own [GitHub App](https://docs.github.com/en/apps/creating-github-apps) with device flow enabled and **Actions (read)** permission, then update `GitHubApp.clientID` and `GitHubApp.installationURL` in `Sources/ActionBar/Services/OAuthService.swift`. You'll also need your own Sparkle key pair and feed URL in `Resources/Info.plist` (see below). ## Releasing diff --git a/Sources/ActionBar/App/ActionBarApp.swift b/Sources/ActionBar/App/ActionBarApp.swift index 4aac3e8..fc50e7b 100644 --- a/Sources/ActionBar/App/ActionBarApp.swift +++ b/Sources/ActionBar/App/ActionBarApp.swift @@ -3,16 +3,17 @@ import SwiftUI @main struct ActionBarApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @StateObject private var appState = AppState() + @State private var appState = AppState() @StateObject private var updater = UpdaterService() var body: some Scene { MenuBarExtra { MenuContentView() - .environmentObject(appState) + .environment(appState) .environmentObject(updater) } label: { MenuBarLabel(appState: appState) + // onAppear can fire more than once; start() only runs the first time. .onAppear { appState.start() } } .menuBarExtraStyle(.window) @@ -20,7 +21,7 @@ struct ActionBarApp: App { } private struct MenuBarLabel: View { - @ObservedObject var appState: AppState + let appState: AppState var body: some View { Image(nsImage: MenuBarIcon.image(statusDotColor: statusDotColor)) diff --git a/Sources/ActionBar/App/AppDelegate.swift b/Sources/ActionBar/App/AppDelegate.swift index 9413904..dd51228 100644 --- a/Sources/ActionBar/App/AppDelegate.swift +++ b/Sources/ActionBar/App/AppDelegate.swift @@ -1,28 +1,34 @@ import AppKit import UserNotifications +@MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { - private var windowObserver: NSObjectProtocol? - func applicationDidFinishLaunching(_ notification: Notification) { UNUserNotificationCenter.current().delegate = self NotificationService.requestPermission() // Make the MenuBarExtra panel immune to "Show Desktop" / Exposé - windowObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didBecomeKeyNotification, - object: nil, - queue: .main - ) { notification in - let behavior: NSWindow.CollectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] - guard let panel = notification.object as? NSPanel, !(panel is NSOpenPanel), - !panel.collectionBehavior.contains(behavior) else { return } - panel.collectionBehavior.insert(behavior) - } + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidBecomeKey(_:)), + name: NSWindow.didBecomeKeyNotification, + object: nil + ) } + // Windows become key on the main thread, so this is called there. + @objc private func windowDidBecomeKey(_ notification: Notification) { + let behavior: NSWindow.CollectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + guard let panel = notification.object as? NSPanel, !(panel is NSOpenPanel), + !panel.collectionBehavior.contains(behavior) else { return } + panel.collectionBehavior.insert(behavior) + } + + // UserNotifications doesn't promise to call its delegate on the main thread, so these are + // nonisolated rather than inheriting the app delegate's main actor isolation. + // Show notifications even when the app is in the foreground - func userNotificationCenter( + nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void @@ -31,7 +37,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Open the workflow run in the browser when the notification is clicked - func userNotificationCenter( + nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void diff --git a/Sources/ActionBar/Models/AppState.swift b/Sources/ActionBar/Models/AppState.swift index 17df531..98f7517 100644 --- a/Sources/ActionBar/Models/AppState.swift +++ b/Sources/ActionBar/Models/AppState.swift @@ -1,62 +1,61 @@ import AppKit -import Combine +import Observation @MainActor -final class AppState: ObservableObject { - static let defaultPollingInterval: TimeInterval = 30 - static let defaultMaxRunsPerRepo = 5 - - @Published var isAuthenticated = KeychainService.loadToken() != nil - @Published var runs: [WorkflowRun] = [] - @Published var isLoading = false - @Published var errorMessage: String? - @Published var username: String? - @Published var deviceCode: DeviceCodeResponse? - @Published var isLoggingIn = false - @Published var selectedRepoNames: Set = [] - @Published var repoSelectionSnapshot: Set = [] - @Published var availableRepos: [Repository] = [] - @Published var isLoadingRepos = false - @Published var repoListIsCapped = false - @Published var showingSettings = false - @Published var needsInstallation = false - @Published var isCheckingInstallation = false - @Published var pollingInterval = AppState.defaultPollingInterval - @Published var workflowFilterMode: WorkflowFilterMode = .off - @Published var workflowFilterText = "" - @Published var maxRunsPerRepo = AppState.defaultMaxRunsPerRepo - @Published var includeRunsFromOtherUsers = false - @Published private(set) var overallStatus: RunStatus = .unknown +@Observable +final class AppState { + var isAuthenticated = KeychainService.loadToken() != nil + var runs: [WorkflowRun] = [] + /// Repositories whose runs failed to load on the last fetch, while others loaded fine. + var failedRepos: [String] = [] + var isLoading = false + var errorMessage: String? + var username: String? + var deviceCode: DeviceCodeResponse? + var isLoggingIn = false + var availableRepos: [Repository] = [] + var isLoadingRepos = false + var repoListIsCapped = false + var repoListError: String? + private(set) var showingSettings = false + /// The run query from when Settings was opened. Polls keep using it until Settings closes, + /// so half-finished edits (like an empty selection) don't reach the menu or notifications. + private(set) var runQueryAtSettingsOpen: SettingsStore.RunQuery? + var needsInstallation = false + var isCheckingInstallation = false + private(set) var overallStatus: RunStatus = .unknown + + let settings: SettingsStore private let apiClient = GitHubAPIClient() private let oauthService = OAuthService() - private let defaults = UserDefaults.standard - private var pollTask: Task? - private var installCheckTask: Task? - private var oauthTask: Task? - private var previousRunStatuses: [Int: String] = [:] // run.id -> run.status - private var hasLoadedOnce = false - private var isFetchingRuns = false - - private enum DefaultsKey { - static let selectedRepos = "selectedRepositories" - static let pollingInterval = "pollingInterval" - static let workflowFilterMode = "workflowFilterMode" - static let workflowFilterText = "workflowFilterText" - static let maxRunsPerRepo = "maxRunsPerRepo" - static let includeRunsFromOtherUsers = "includeRunsFromOtherUsers" - } - - private var hasRepoConfiguration: Bool { - defaults.object(forKey: DefaultsKey.selectedRepos) != nil + @ObservationIgnored private var pollTask: Task? + @ObservationIgnored private var installCheckTask: Task? + @ObservationIgnored private var oauthTask: Task? + @ObservationIgnored private var runHistory = RunHistory() + @ObservationIgnored private var hasStarted = false + @ObservationIgnored private var hasLoadedOnce = false + @ObservationIgnored private var isFetchingRuns = false + @ObservationIgnored private var refetchRequested = false + /// Changes on sign-out, so a fetch that was in flight doesn't apply its results afterwards. + @ObservationIgnored private var sessionID = 0 + @ObservationIgnored private var repoListTask: Task? + + init(settings: SettingsStore? = nil) { + self.settings = settings ?? SettingsStore() } + /// Resumes the saved session. Safe to call more than once; only the first call does anything. func start() { - loadSettings() + guard !hasStarted else { return } + hasStarted = true guard let token = KeychainService.loadToken() else { return } isAuthenticated = true + let sessionID = self.sessionID Task { - if let user = try? await apiClient.validateToken(token) { + let user = try? await apiClient.validateToken(token) + guard sessionID == self.sessionID else { return } + if let user { username = user.login } await startMonitoring(token: token) @@ -116,80 +115,86 @@ final class AppState: ObservableObject { cancelLogin() stopPolling() stopInstallCheckTimer() + repoListTask?.cancel() + repoListTask = nil + isLoadingRepos = false Task { await apiClient.clearConditionalCache() } KeychainService.deleteToken() + sessionID += 1 isAuthenticated = false needsInstallation = false overallStatus = .unknown runs = [] + failedRepos = [] username = nil - previousRunStatuses = [:] + runHistory = RunHistory() hasLoadedOnce = false errorMessage = nil - selectedRepoNames = [] availableRepos = [] repoListIsCapped = false + repoListError = nil showingSettings = false - workflowFilterMode = .off - workflowFilterText = "" - maxRunsPerRepo = Self.defaultMaxRunsPerRepo - includeRunsFromOtherUsers = false - // The polling interval is a device preference, so it survives signing out. - for key in [ - DefaultsKey.selectedRepos, - DefaultsKey.workflowFilterMode, - DefaultsKey.workflowFilterText, - DefaultsKey.maxRunsPerRepo, - DefaultsKey.includeRunsFromOtherUsers, - ] { - defaults.removeObject(forKey: key) - } + runQueryAtSettingsOpen = nil + settings.resetAccountSettings() } // MARK: - GitHub App Installation /// Starts polling if the GitHub App is installed, otherwise waits for it to be installed. private func startMonitoring(token: String) async { + let sessionID = self.sessionID + let hasInstallations: Bool do { - if try await apiClient.hasInstallations(token: token) { - startPolling() - } else { - needsInstallation = true - startInstallCheckTimer() - } + hasInstallations = try await apiClient.hasInstallations(token: token) } catch { // Don't assume there are no installations; fetchRuns will surface real errors. + hasInstallations = true + } + guard sessionID == self.sessionID else { return } + + if hasInstallations { startPolling() + } else { + stopPolling() + needsInstallation = true + startInstallCheckTimer() } } func checkInstallationAndStart() { guard let token = KeychainService.loadToken() else { return } isCheckingInstallation = true + let sessionID = self.sessionID Task { + defer { isCheckingInstallation = false } do { - if try await apiClient.hasInstallations(token: token) { + let hasInstallations = try await apiClient.hasInstallations(token: token) + guard sessionID == self.sessionID else { return } + if hasInstallations { installationFound() } else { errorMessage = "No installation found yet. Please install the app on GitHub first. If you did, wait a bit." } } catch { + guard sessionID == self.sessionID else { return } errorMessage = error.localizedDescription } - isCheckingInstallation = false } } private func startInstallCheckTimer() { stopInstallCheckTimer() + let sessionID = self.sessionID installCheckTask = Task { while !Task.isCancelled { try? await Task.sleep(for: .seconds(10)) guard !Task.isCancelled else { break } guard let token = KeychainService.loadToken() else { continue } - if (try? await apiClient.hasInstallations(token: token)) == true { + let hasInstallations = (try? await apiClient.hasInstallations(token: token)) == true + guard !Task.isCancelled, sessionID == self.sessionID else { break } + if hasInstallations { installationFound() break } @@ -209,6 +214,20 @@ final class AppState: ObservableObject { startPolling() } + // MARK: - Settings + + func openSettings() { + runQueryAtSettingsOpen = settings.runQuery + showingSettings = true + fetchAvailableRepos() + } + + func closeSettings() { + showingSettings = false + runQueryAtSettingsOpen = nil + refreshNow() + } + // MARK: - Polling func refreshNow() { @@ -223,19 +242,22 @@ final class AppState: ObservableObject { } func updatePollingInterval(_ interval: TimeInterval) { - pollingInterval = interval - defaults.set(pollingInterval, forKey: DefaultsKey.pollingInterval) + settings.pollingInterval = interval startPolling() } private func startPolling() { + let previousPollTask = pollTask stopPolling() pollTask = Task { + // Let a cancelled poll wind down first. Otherwise its fetch would still be in flight, + // this fetch would be skipped, and nothing would refresh until the next interval. + await previousPollTask?.value await fetchRuns() while !Task.isCancelled { // Wait in short steps against a wall-clock deadline, so a refresh happens // promptly after the Mac wakes from sleep. - let deadline = Date().addingTimeInterval(pollingInterval) + let deadline = Date().addingTimeInterval(settings.pollingInterval) while Date() < deadline, !Task.isCancelled { try? await Task.sleep(for: .seconds(1)) } @@ -251,10 +273,21 @@ final class AppState: ObservableObject { } private func fetchRuns(manageLoading: Bool = true) async { - guard !isFetchingRuns else { return } + guard !isFetchingRuns else { + // Fetch again once the current fetch finishes, so this refresh isn't lost. + refetchRequested = true + return + } isFetchingRuns = true defer { isFetchingRuns = false } + repeat { + refetchRequested = false + await fetchRunsOnce(manageLoading: manageLoading) + } while refetchRequested && !Task.isCancelled + } + + private func fetchRunsOnce(manageLoading: Bool) async { guard let token = KeychainService.loadToken() else { isAuthenticated = false return @@ -264,123 +297,78 @@ final class AppState: ObservableObject { isLoading = true } errorMessage = nil + defer { if manageLoading { isLoading = false } } + let sessionID = self.sessionID do { - let usingFallback = !hasRepoConfiguration || selectedRepoNames.isEmpty - let repos = usingFallback ? nil : Array(selectedRepoNames) - let actor = includeRunsFromOtherUsers ? nil : username - let newRuns = try await apiClient.fetchRecentRuns(token: token, actor: actor, repos: repos) + let query = runQueryAtSettingsOpen ?? settings.runQuery + let usingFallback = query.repoNames.isEmpty + let repos = usingFallback ? nil : Array(query.repoNames) + let actor = query.includeRunsFromOtherUsers ? nil : username + let recentRuns = try await apiClient.fetchRecentRuns(token: token, actor: actor, repos: repos) + let newRuns = recentRuns.runs + + guard sessionID == self.sessionID else { return } + // Settings closed with changes while this was in flight; these results are for the old ones. + guard query == runQueryAtSettingsOpen ?? settings.runQuery else { + refetchRequested = true + return + } // With nothing selected yet, adopt the repos that have recent runs as the selection. - if usingFallback && !newRuns.isEmpty { - selectedRepoNames = Set(newRuns.map(\.repositoryFullName)) - saveSelectedRepos() + // Not while Settings is open, where it would overwrite the user's edits. + if usingFallback && runQueryAtSettingsOpen == nil && !newRuns.isEmpty { + settings.selectedRepoNames = Set(newRuns.map(\.repositoryFullName)) } - let workflowFilteredRuns = newRuns.filtered(by: workflowFilterMode, patterns: workflowFilterText) - let visibleRuns = workflowFilteredRuns.limited(toRunsPerRepo: maxRunsPerRepo) + let workflowFilteredRuns = newRuns.filtered(by: query.workflowFilterMode, patterns: query.workflowFilterText) + let visibleRuns = workflowFilteredRuns.limited(toRunsPerRepo: query.maxRunsPerRepo) if hasLoadedOnce { - notifyCompletedRuns(in: visibleRuns) - } - previousRunStatuses = [:] - for run in visibleRuns { - previousRunStatuses[run.id] = run.status + for run in runHistory.newlyCompleted(in: visibleRuns) { + NotificationService.sendCompletionNotification(run: run) + } } + // Track every fetched run, not just the visible ones, so a run hidden by the filter or + // the per-repo limit for one fetch still notifies if it shows up completed later. + runHistory.record(newRuns, keepingRunsFrom: recentRuns.failedRepos) overallStatus = workflowFilteredRuns.overallStatus runs = visibleRuns + failedRepos = recentRuns.failedRepos hasLoadedOnce = true + } catch let error where error is CancellationError || Task.isCancelled { + // Task was cancelled (e.g. polling restart) — don't surface to UI. Requests cut off + // by the cancellation can surface as network errors, hence the `Task.isCancelled` check. } catch GitHubAPIError.invalidToken { + guard sessionID == self.sessionID else { return } signOut() - } catch is CancellationError { - // Task was cancelled (e.g. polling restart) — don't surface to UI. } catch { + guard sessionID == self.sessionID else { return } errorMessage = error.localizedDescription } - - if manageLoading { isLoading = false } } - /// Notifies about runs that were queued or in progress on the previous fetch and have now completed. - private func notifyCompletedRuns(in newRuns: [WorkflowRun]) { - for run in newRuns where run.status == "completed" { - if let previousStatus = previousRunStatuses[run.id], ["in_progress", "queued"].contains(previousStatus) { - NotificationService.sendCompletionNotification(run: run) - } - } - } - - // MARK: - Settings - - private func loadSettings() { - if let saved = defaults.stringArray(forKey: DefaultsKey.selectedRepos) { - selectedRepoNames = Set(saved) - } - - let savedInterval = defaults.double(forKey: DefaultsKey.pollingInterval) - pollingInterval = savedInterval > 0 ? savedInterval : Self.defaultPollingInterval - - if let modeRaw = defaults.string(forKey: DefaultsKey.workflowFilterMode), - let mode = WorkflowFilterMode(rawValue: modeRaw) { - workflowFilterMode = mode - } - workflowFilterText = defaults.string(forKey: DefaultsKey.workflowFilterText) ?? "" - - let savedMaxRuns = defaults.integer(forKey: DefaultsKey.maxRunsPerRepo) - maxRunsPerRepo = savedMaxRuns > 0 ? savedMaxRuns : Self.defaultMaxRunsPerRepo - - includeRunsFromOtherUsers = defaults.bool(forKey: DefaultsKey.includeRunsFromOtherUsers) - } - - /// Persists the settings edited in the settings panel. The polling interval is saved as soon as it changes. - func saveSettings() { - saveSelectedRepos() - defaults.set(workflowFilterMode.rawValue, forKey: DefaultsKey.workflowFilterMode) - defaults.set(workflowFilterText, forKey: DefaultsKey.workflowFilterText) - defaults.set(maxRunsPerRepo, forKey: DefaultsKey.maxRunsPerRepo) - defaults.set(includeRunsFromOtherUsers, forKey: DefaultsKey.includeRunsFromOtherUsers) - } - - private func saveSelectedRepos() { - defaults.set(Array(selectedRepoNames), forKey: DefaultsKey.selectedRepos) - } - - // MARK: - Repository Selection - - func snapshotSelectedRepos() { - repoSelectionSnapshot = Set(defaults.stringArray(forKey: DefaultsKey.selectedRepos) ?? []) - } - - func resetReposToSnapshot() { - selectedRepoNames = repoSelectionSnapshot - } - - func toggleRepo(_ repoName: String) { - if selectedRepoNames.contains(repoName) { - selectedRepoNames.remove(repoName) - } else { - selectedRepoNames.insert(repoName) - } - } - - func selectAllRepos(_ repoNames: [String]) { - selectedRepoNames = Set(repoNames) - } - - func deselectAllRepos() { - selectedRepoNames = [] - } + // MARK: - Repositories func fetchAvailableRepos(force: Bool = false) { guard let token = KeychainService.loadToken() else { return } if !force && !availableRepos.isEmpty { return } isLoadingRepos = true - Task { - // On failure, keep whatever list we had before. - if let repos = try? await apiClient.fetchAllRepos(token: token) { + repoListError = nil + let sessionID = self.sessionID + // Only the latest load applies its result, so an older one finishing later can't overwrite it. + repoListTask?.cancel() + repoListTask = Task { + do { + let repos = try await apiClient.fetchAllRepos(token: token) + guard !Task.isCancelled, sessionID == self.sessionID else { return } availableRepos = repos repoListIsCapped = repos.count >= GitHubAPIClient.maxRepositories + } catch { + guard !Task.isCancelled, sessionID == self.sessionID else { return } + // Keep whatever list we had before. + repoListError = error.localizedDescription } isLoadingRepos = false } diff --git a/Sources/ActionBar/Models/GitHubModels.swift b/Sources/ActionBar/Models/GitHubModels.swift index add7c04..68f6902 100644 --- a/Sources/ActionBar/Models/GitHubModels.swift +++ b/Sources/ActionBar/Models/GitHubModels.swift @@ -3,6 +3,64 @@ import Foundation // Responses from the GitHub REST API and OAuth device flow. // Decoded with `.convertFromSnakeCase`, so property names mirror the JSON keys. +extension JSONDecoder { + /// A decoder for GitHub REST API responses. + static func gitHubAPI() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} + +enum GitHubAPIError: Error, LocalizedError { + case invalidToken + case rateLimited + case networkError(Error) + case invalidResponse(Int) + case decodingError(Error) + + var errorDescription: String? { + switch self { + case .invalidToken: + return "Invalid or expired token" + case .rateLimited: + return "GitHub API rate limit reached. Please wait and try again." + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .invalidResponse(let code): + return "GitHub API returned status \(code)" + case .decodingError(let error): + return "Failed to parse response: \(error.localizedDescription)" + } + } +} + +/// Recent runs across repositories, plus the repositories whose runs couldn't be loaded. +struct RecentRuns { + let runs: [WorkflowRun] + let failedRepos: [String] +} + +extension GitHubAPIError { + /// The error that should fail a fetch across several repos, or nil to return partial results. + /// An invalid token or a rate limit on any repo fails the fetch, and so does every repo failing. + /// A repo that loaded with no runs (or with Actions disabled) still counts as loaded. + static func failingWholeFetch(_ errors: [Error], requestedRepoCount: Int) -> Error? { + if errors.contains(where: { if case GitHubAPIError.invalidToken = $0 { true } else { false } }) { + return GitHubAPIError.invalidToken + } + if errors.contains(where: { if case GitHubAPIError.rateLimited = $0 { true } else { false } }) { + return GitHubAPIError.rateLimited + } + if let firstError = errors.first, errors.count >= requestedRepoCount { + return firstError + } + return nil + } +} + + struct GitHubUser: Decodable { let login: String } diff --git a/Sources/ActionBar/Models/SettingsStore.swift b/Sources/ActionBar/Models/SettingsStore.swift new file mode 100644 index 0000000..86db36c --- /dev/null +++ b/Sources/ActionBar/Models/SettingsStore.swift @@ -0,0 +1,126 @@ +import Foundation +import Observation + +/// The user's preferences, saved to `UserDefaults` as soon as they change. +@MainActor +@Observable +final class SettingsStore { + static let defaultPollingInterval: TimeInterval = 30 + static let defaultMaxRunsPerRepo = 5 + + /// The settings that decide which runs are fetched and shown. + struct RunQuery: Equatable { + var repoNames: Set + var workflowFilterMode: WorkflowFilterMode + var workflowFilterText: String + var maxRunsPerRepo: Int + var includeRunsFromOtherUsers: Bool + } + + var selectedRepoNames: Set = [] { + didSet { save(Array(selectedRepoNames), forKey: Key.selectedRepos) } + } + var pollingInterval = SettingsStore.defaultPollingInterval { + didSet { save(pollingInterval, forKey: Key.pollingInterval) } + } + var workflowFilterMode: WorkflowFilterMode = .off { + didSet { save(workflowFilterMode.rawValue, forKey: Key.workflowFilterMode) } + } + var workflowFilterText = "" { + didSet { save(workflowFilterText, forKey: Key.workflowFilterText) } + } + var maxRunsPerRepo = SettingsStore.defaultMaxRunsPerRepo { + didSet { save(maxRunsPerRepo, forKey: Key.maxRunsPerRepo) } + } + var includeRunsFromOtherUsers = false { + didSet { save(includeRunsFromOtherUsers, forKey: Key.includeRunsFromOtherUsers) } + } + + var runQuery: RunQuery { + RunQuery( + repoNames: selectedRepoNames, + workflowFilterMode: workflowFilterMode, + workflowFilterText: workflowFilterText, + maxRunsPerRepo: maxRunsPerRepo, + includeRunsFromOtherUsers: includeRunsFromOtherUsers + ) + } + + @ObservationIgnored private let defaults: UserDefaults + /// `@Observable` runs `didSet` even for assignments in `init`; this keeps loading from writing back. + @ObservationIgnored private var isLoading = true + + private enum Key { + static let selectedRepos = "selectedRepositories" + static let pollingInterval = "pollingInterval" + static let workflowFilterMode = "workflowFilterMode" + static let workflowFilterText = "workflowFilterText" + static let maxRunsPerRepo = "maxRunsPerRepo" + static let includeRunsFromOtherUsers = "includeRunsFromOtherUsers" + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + + if let saved = defaults.stringArray(forKey: Key.selectedRepos) { + selectedRepoNames = Set(saved) + } + + let savedInterval = defaults.double(forKey: Key.pollingInterval) + pollingInterval = savedInterval > 0 ? savedInterval : Self.defaultPollingInterval + + if let modeRaw = defaults.string(forKey: Key.workflowFilterMode), + let mode = WorkflowFilterMode(rawValue: modeRaw) { + workflowFilterMode = mode + } + workflowFilterText = defaults.string(forKey: Key.workflowFilterText) ?? "" + + let savedMaxRuns = defaults.integer(forKey: Key.maxRunsPerRepo) + maxRunsPerRepo = savedMaxRuns > 0 ? savedMaxRuns : Self.defaultMaxRunsPerRepo + + includeRunsFromOtherUsers = defaults.bool(forKey: Key.includeRunsFromOtherUsers) + isLoading = false + } + + private func save(_ value: Any, forKey key: String) { + guard !isLoading else { return } + defaults.set(value, forKey: key) + } + + /// Forgets everything tied to the signed-in account. + /// The polling interval is a device preference, so it survives signing out. + func resetAccountSettings() { + selectedRepoNames = [] + workflowFilterMode = .off + workflowFilterText = "" + maxRunsPerRepo = Self.defaultMaxRunsPerRepo + includeRunsFromOtherUsers = false + for key in [ + Key.selectedRepos, + Key.workflowFilterMode, + Key.workflowFilterText, + Key.maxRunsPerRepo, + Key.includeRunsFromOtherUsers, + ] { + defaults.removeObject(forKey: key) + } + } + + // MARK: - Repository Selection + + func toggleRepo(_ repoName: String) { + if selectedRepoNames.contains(repoName) { + selectedRepoNames.remove(repoName) + } else { + selectedRepoNames.insert(repoName) + } + } + + func selectAllRepos(_ repoNames: [String]) { + selectedRepoNames = Set(repoNames) + } + + func deselectAllRepos() { + selectedRepoNames = [] + } +} diff --git a/Sources/ActionBar/Models/WorkflowRun.swift b/Sources/ActionBar/Models/WorkflowRun.swift index 7d0736c..46ca59f 100644 --- a/Sources/ActionBar/Models/WorkflowRun.swift +++ b/Sources/ActionBar/Models/WorkflowRun.swift @@ -167,3 +167,33 @@ extension Array where Element == WorkflowRun { return RunStatus(from: lastFinishedRun) } } + +// MARK: - Run History + +/// The last status seen for each run, to tell which runs finished since the previous fetch. +struct RunHistory { + private struct Entry { + let repo: String + let status: RunStatus + } + + private var entries: [Int: Entry] = [:] // run.id -> last seen + + /// Runs that have completed since they were last seen queued or in progress. + func newlyCompleted(in runs: [WorkflowRun]) -> [WorkflowRun] { + runs.filter { run in + run.status == "completed" && entries[run.id]?.status.isActive == true + } + } + + /// Replaces the history with `runs`. Repos in `failedRepos` weren't loaded this time, + /// so their runs keep their last status until the repo loads again. + mutating func record(_ runs: [WorkflowRun], keepingRunsFrom failedRepos: [String]) { + let failedRepos = Set(failedRepos) + var newEntries = entries.filter { failedRepos.contains($0.value.repo) } + for run in runs { + newEntries[run.id] = Entry(repo: run.repositoryFullName, status: RunStatus(from: run)) + } + entries = newEntries + } +} diff --git a/Sources/ActionBar/Services/GitHubAPIClient.swift b/Sources/ActionBar/Services/GitHubAPIClient.swift index 781db91..a4a3476 100644 --- a/Sources/ActionBar/Services/GitHubAPIClient.swift +++ b/Sources/ActionBar/Services/GitHubAPIClient.swift @@ -1,28 +1,5 @@ import Foundation -enum GitHubAPIError: Error, LocalizedError { - case invalidToken - case rateLimited - case networkError(Error) - case invalidResponse(Int) - case decodingError(Error) - - var errorDescription: String? { - switch self { - case .invalidToken: - return "Invalid or expired token" - case .rateLimited: - return "GitHub API rate limit reached. Please wait and try again." - case .networkError(let error): - return "Network error: \(error.localizedDescription)" - case .invalidResponse(let code): - return "GitHub API returned status \(code)" - case .decodingError(let error): - return "Failed to parse response: \(error.localizedDescription)" - } - } -} - actor GitHubAPIClient { /// Upper bound on repositories listed for selection (and polled when nothing is selected). static let maxRepositories = 200 @@ -53,10 +30,7 @@ actor GitHubAPIClient { "X-GitHub-Api-Version": "2022-11-28", ] self.session = URLSession(configuration: config) - - self.decoder = JSONDecoder() - self.decoder.keyDecodingStrategy = .convertFromSnakeCase - self.decoder.dateDecodingStrategy = .iso8601 + self.decoder = .gitHubAPI() } func clearConditionalCache() { @@ -71,7 +45,7 @@ actor GitHubAPIClient { // MARK: - Workflow Runs - func fetchRecentRuns(token: String, actor: String? = nil, repos repoNames: [String]? = nil) async throws -> [WorkflowRun] { + func fetchRecentRuns(token: String, actor: String? = nil, repos repoNames: [String]? = nil) async throws -> RecentRuns { let repoFullNames: [String] if let repoNames { repoFullNames = repoNames @@ -79,12 +53,12 @@ actor GitHubAPIClient { repoFullNames = try await fetchAllRepos(token: token).map(\.fullName) } - let (allRuns, errors) = await withTaskGroup(of: Result<[WorkflowRun], Error>.self) { group in + let (allRuns, errors) = await withTaskGroup(of: (String, Result<[WorkflowRun], Error>).self) { group in var pendingRepos = repoFullNames.makeIterator() func enqueueNextRepo() { guard let repo = pendingRepos.next() else { return } - group.addTask { await self.fetchRunsResult(repo: repo, token: token, actor: actor) } + group.addTask { (repo, await self.fetchRunsResult(repo: repo, token: token, actor: actor)) } } for _ in 0.. $1.createdAt } - return Array(recentRuns.prefix(Self.maxRecentRuns)) + return RecentRuns( + runs: Array(recentRuns.prefix(Self.maxRecentRuns)), + failedRepos: errors.map(\.repo).sorted() + ) } private func fetchRunsResult(repo: String, token: String, actor: String?) async -> Result<[WorkflowRun], Error> { @@ -158,8 +130,11 @@ actor GitHubAPIClient { throw GitHubAPIError.invalidResponse(httpResponse.statusCode) } - guard let runsResponse = try? decoder.decode(WorkflowRunsResponse.self, from: data) else { - return [] + let runsResponse: WorkflowRunsResponse + do { + runsResponse = try decoder.decode(WorkflowRunsResponse.self, from: data) + } catch { + throw GitHubAPIError.decodingError(error) } if let cacheKey { diff --git a/Sources/ActionBar/Views/MenuContentView.swift b/Sources/ActionBar/Views/MenuContentView.swift index f8d79ce..32376d8 100644 --- a/Sources/ActionBar/Views/MenuContentView.swift +++ b/Sources/ActionBar/Views/MenuContentView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MenuContentView: View { - @EnvironmentObject private var appState: AppState + @Environment(AppState.self) private var appState @EnvironmentObject private var updater: UpdaterService @State private var now = Date() @State private var showAbout = false @@ -17,11 +17,15 @@ struct MenuContentView: View { private var authenticatedView: some View { VStack(spacing: 0) { if appState.showingSettings { - SettingsView() + SettingsView(settings: appState.settings) .frame(minHeight: 520) } else { header Divider() + if !appState.failedRepos.isEmpty && appState.errorMessage == nil { + failedReposWarning + Divider() + } content } @@ -56,8 +60,7 @@ struct MenuContentView: View { } .help("Refresh now") Button("Settings", systemImage: "gearshape") { - appState.showingSettings = true - appState.fetchAvailableRepos() + appState.openSettings() } .help("Settings") } @@ -110,6 +113,25 @@ struct MenuContentView: View { .padding(.vertical, 8) } + private var failedReposWarning: some View { + let repos = appState.failedRepos + return HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(repos.count == 1 + ? "Couldn't load runs for \(repos[0])" + : "Couldn't load runs for \(repos.count) repositories") + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + .font(.caption2) + .foregroundStyle(.secondary) + .help(repos.joined(separator: "\n")) + .padding(.horizontal, 16) + .padding(.vertical, 6) + } + private var runsList: some View { ScrollView { LazyVStack(spacing: 0) { @@ -117,7 +139,7 @@ struct MenuContentView: View { WorkflowRowView( run: run, now: now, - showsTriggeringActor: appState.includeRunsFromOtherUsers + showsTriggeringActor: appState.settings.includeRunsFromOtherUsers ) if run.id != appState.runs.last?.id { @@ -201,7 +223,7 @@ struct MenuContentView: View { ), ] return MenuContentView() - .environmentObject(state) + .environment(state) .environmentObject(UpdaterService()) } @@ -210,7 +232,7 @@ struct MenuContentView: View { state.isAuthenticated = true state.username = "octocat" return MenuContentView() - .environmentObject(state) + .environment(state) .environmentObject(UpdaterService()) } @@ -220,6 +242,6 @@ struct MenuContentView: View { state.username = "octocat" state.isLoading = true return MenuContentView() - .environmentObject(state) + .environment(state) .environmentObject(UpdaterService()) } diff --git a/Sources/ActionBar/Views/SettingsView.swift b/Sources/ActionBar/Views/SettingsView.swift index 3ddbeae..acfe405 100644 --- a/Sources/ActionBar/Views/SettingsView.swift +++ b/Sources/ActionBar/Views/SettingsView.swift @@ -1,7 +1,9 @@ import SwiftUI struct SettingsView: View { - @EnvironmentObject private var appState: AppState + @Bindable var settings: SettingsStore + + @Environment(AppState.self) private var appState @Environment(\.openURL) private var openURL @State private var searchText = "" @State private var showSelectAllWarning = false @@ -16,6 +18,11 @@ struct SettingsView: View { ("5 min", 300), ] + /// What the Reset button restores. + private var repoSelectionAtOpen: Set { + appState.runQueryAtSettingsOpen?.repoNames ?? settings.selectedRepoNames + } + private var filteredRepos: [Repository] { if searchText.isEmpty { return appState.availableRepos @@ -43,9 +50,6 @@ struct SettingsView: View { Divider() repositoryList } - .onAppear { - appState.snapshotSelectedRepos() - } } private var header: some View { @@ -54,9 +58,7 @@ struct SettingsView: View { .font(.headline) Spacer() Button("Done") { - appState.saveSettings() - appState.showingSettings = false - appState.refreshNow() + appState.closeSettings() } .buttonStyle(.plain) .foregroundStyle(Color.accentColor) @@ -71,7 +73,7 @@ struct SettingsView: View { .font(.subheadline) Spacer() Picker("Polling interval", selection: Binding( - get: { appState.pollingInterval }, + get: { settings.pollingInterval }, set: { appState.updatePollingInterval($0) } )) { ForEach(Self.intervalOptions, id: \.value) { option in @@ -91,7 +93,7 @@ struct SettingsView: View { Text("Max runs per repo") .font(.subheadline) Spacer() - Picker("Max runs per repo", selection: $appState.maxRunsPerRepo) { + Picker("Max runs per repo", selection: $settings.maxRunsPerRepo) { ForEach(1...10, id: \.self) { n in Text("\(n)").tag(n) } @@ -109,7 +111,7 @@ struct SettingsView: View { private var otherUsersSection: some View { VStack(alignment: .leading, spacing: 4) { - Toggle("Include runs from other users", isOn: $appState.includeRunsFromOtherUsers) + Toggle("Include runs from other users", isOn: $settings.includeRunsFromOtherUsers) .toggleStyle(.checkbox) .font(.subheadline) Text("Show recent workflow runs from anyone in the selected repositories.") @@ -126,7 +128,7 @@ struct SettingsView: View { Text("Workflow filter") .font(.subheadline) Spacer() - Picker("Workflow filter", selection: $appState.workflowFilterMode) { + Picker("Workflow filter", selection: $settings.workflowFilterMode) { ForEach(WorkflowFilterMode.allCases, id: \.self) { mode in Text(mode.rawValue.capitalized).tag(mode) } @@ -137,13 +139,13 @@ struct SettingsView: View { .fixedSize() } - if appState.workflowFilterMode != .off { - TextField("e.g. deploy, CI, build", text: $appState.workflowFilterText) + if settings.workflowFilterMode != .off { + TextField("e.g. deploy, CI, build", text: $settings.workflowFilterText) .textFieldStyle(.roundedBorder) .font(.caption) VStack(alignment: .leading, spacing: 1) { - Text(appState.workflowFilterMode == .include + Text(settings.workflowFilterMode == .include ? "Only show workflows matching these names." : "Hide workflows matching these names.") Text("Comma-separated, case-insensitive substring match.") @@ -195,7 +197,7 @@ struct SettingsView: View { .font(.caption) HStack { - Text("\(appState.selectedRepoNames.count) selected") + Text("\(settings.selectedRepoNames.count) selected") .font(.caption) .foregroundStyle(.secondary) Spacer() @@ -203,21 +205,21 @@ struct SettingsView: View { if filteredRepos.count > Self.selectAllWarningThreshold { showSelectAllWarning = true } else { - appState.selectAllRepos(filteredRepos.map(\.fullName)) + settings.selectAllRepos(filteredRepos.map(\.fullName)) } } .textButtonStyle(.accentColor) Button("None") { showSelectAllWarning = false - appState.deselectAllRepos() + settings.deselectAllRepos() } .textButtonStyle(.accentColor) Button("Reset") { showSelectAllWarning = false - appState.resetReposToSnapshot() + settings.selectedRepoNames = repoSelectionAtOpen } .textButtonStyle(.accentColor) - .disabled(appState.selectedRepoNames == appState.repoSelectionSnapshot) + .disabled(settings.selectedRepoNames == repoSelectionAtOpen) } if appState.repoListIsCapped { @@ -227,6 +229,14 @@ struct SettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) } + if let error = appState.repoListError { + Text("Couldn't load repositories: \(error)") + .font(.caption2) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + if showSelectAllWarning { selectAllWarning } @@ -245,7 +255,7 @@ struct SettingsView: View { .fixedSize(horizontal: false, vertical: true) HStack(spacing: 12) { Button("Select All Anyway") { - appState.selectAllRepos(filteredRepos.map(\.fullName)) + settings.selectAllRepos(filteredRepos.map(\.fullName)) showSelectAllWarning = false } .textButtonStyle(.accentColor, font: .caption2.bold()) @@ -265,7 +275,7 @@ struct SettingsView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(filteredRepos) { repo in - repositoryRow(repo, isSelected: appState.selectedRepoNames.contains(repo.fullName)) + repositoryRow(repo, isSelected: settings.selectedRepoNames.contains(repo.fullName)) } } } @@ -275,7 +285,7 @@ struct SettingsView: View { private func repositoryRow(_ repo: Repository, isSelected: Bool) -> some View { Button { - appState.toggleRepo(repo.fullName) + settings.toggleRepo(repo.fullName) } label: { HStack(spacing: 8) { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") @@ -311,7 +321,7 @@ private extension View { Repository(id: 2, fullName: "owner/second-repo"), Repository(id: 3, fullName: "owner/third-repo"), ] - state.selectedRepoNames = ["owner/first-repo", "owner/third-repo"] - return SettingsView() - .environmentObject(state) + state.settings.selectedRepoNames = ["owner/first-repo", "owner/third-repo"] + return SettingsView(settings: state.settings) + .environment(state) } diff --git a/Sources/ActionBar/Views/SetupView.swift b/Sources/ActionBar/Views/SetupView.swift index a54297d..2d9a076 100644 --- a/Sources/ActionBar/Views/SetupView.swift +++ b/Sources/ActionBar/Views/SetupView.swift @@ -1,7 +1,7 @@ import SwiftUI struct SetupView: View { - @EnvironmentObject private var appState: AppState + @Environment(AppState.self) private var appState @Environment(\.openURL) private var openURL var body: some View { @@ -163,7 +163,7 @@ struct SetupView: View { #Preview("Login") { SetupView() - .environmentObject(AppState()) + .environment(AppState()) } #Preview("Device Code") { @@ -175,12 +175,12 @@ struct SetupView: View { interval: 5 ) return SetupView() - .environmentObject(state) + .environment(state) } #Preview("Installation Required") { let state = AppState() state.needsInstallation = true return SetupView() - .environmentObject(state) + .environment(state) } diff --git a/Tests/ActionBarTests/FetchFailureTests.swift b/Tests/ActionBarTests/FetchFailureTests.swift new file mode 100644 index 0000000..b9e588c --- /dev/null +++ b/Tests/ActionBarTests/FetchFailureTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +struct FetchFailureTests { + private let serverError = GitHubAPIError.invalidResponse(500) + + @Test func noErrorsReturnsResults() { + #expect(GitHubAPIError.failingWholeFetch([], requestedRepoCount: 2) == nil) + } + + @Test func someReposFailingReturnsPartialResults() { + // One repo failed; the other loaded, even if it had no runs. + #expect(GitHubAPIError.failingWholeFetch([serverError], requestedRepoCount: 2) == nil) + } + + @Test func everyRepoFailingFailsTheFetch() throws { + let error = try #require(GitHubAPIError.failingWholeFetch([serverError, serverError], requestedRepoCount: 2)) + guard case GitHubAPIError.invalidResponse(500) = error else { + Issue.record("Expected the first repo's error, got \(error)") + return + } + } + + @Test func invalidTokenOnAnyRepoFailsTheFetch() throws { + let error = try #require(GitHubAPIError.failingWholeFetch( + [serverError, GitHubAPIError.invalidToken], + requestedRepoCount: 5 + )) + guard case GitHubAPIError.invalidToken = error else { + Issue.record("Expected invalidToken, got \(error)") + return + } + } + + @Test func rateLimitOnAnyRepoFailsTheFetch() throws { + let error = try #require(GitHubAPIError.failingWholeFetch([GitHubAPIError.rateLimited], requestedRepoCount: 5)) + guard case GitHubAPIError.rateLimited = error else { + Issue.record("Expected rateLimited, got \(error)") + return + } + } +} diff --git a/Tests/ActionBarTests/ModelDecodingTests.swift b/Tests/ActionBarTests/ModelDecodingTests.swift new file mode 100644 index 0000000..34c85d2 --- /dev/null +++ b/Tests/ActionBarTests/ModelDecodingTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing + +struct ModelDecodingTests { + private let decoder = JSONDecoder.gitHubAPI() + + private func decode(_ type: T.Type, _ json: String) throws -> T { + try decoder.decode(type, from: Data(json.utf8)) + } + + @Test func workflowRunsResponse() throws { + let response = try decode(WorkflowRunsResponse.self, """ + { + "total_count": 1, + "workflow_runs": [{ + "id": 42, + "name": "CI", + "display_title": "Fix login bug", + "status": "completed", + "conclusion": "success", + "event": "push", + "html_url": "https://github.com/owner/repo/actions/runs/42", + "head_branch": "main", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-01-02T03:14:05Z", + "repository": { "full_name": "owner/repo" }, + "actor": { "login": "octocat" }, + "triggering_actor": { "login": "hubot" } + }] + } + """) + + let run = try #require(response.workflowRuns.first) + #expect(run.id == 42) + #expect(run.name == "CI") + #expect(run.title == "Fix login bug") + #expect(run.status == "completed") + #expect(run.conclusion == "success") + #expect(run.event == "push") + #expect(run.htmlUrl == "https://github.com/owner/repo/actions/runs/42") + #expect(run.headBranch == "main") + #expect(run.createdAt == Date(timeIntervalSince1970: 1_735_787_045)) + #expect(run.updatedAt == Date(timeIntervalSince1970: 1_735_787_645)) + #expect(run.repositoryFullName == "owner/repo") + #expect(run.triggeringActorLogin == "hubot") + } + + @Test func workflowRunWithOptionalFieldsMissing() throws { + let run = try decode(WorkflowRun.self, """ + { + "id": 7, + "html_url": "https://github.com/owner/repo/actions/runs/7", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-01-02T03:04:05Z", + "repository": { "full_name": "owner/repo" }, + "actor": { "login": "octocat" } + } + """) + + #expect(run.name == nil) + #expect(run.status == nil) + #expect(run.headBranch == nil) + #expect(run.title == "Workflow") + #expect(run.triggeringActorLogin == "octocat") + } + + @Test func workflowRunWithoutRepositoryFails() { + #expect(throws: DecodingError.self) { + try decode(WorkflowRun.self, """ + { + "id": 7, + "html_url": "https://github.com/owner/repo/actions/runs/7", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-01-02T03:04:05Z" + } + """) + } + } + + @Test func installationRepositoriesResponse() throws { + let response = try decode(InstallationRepositoriesResponse.self, """ + { + "total_count": 2, + "repositories": [ + { "id": 1, "full_name": "owner/first", "private": false }, + { "id": 2, "full_name": "owner/second", "private": true } + ] + } + """) + + #expect(response.totalCount == 2) + #expect(response.items.map(\.fullName) == ["owner/first", "owner/second"]) + } + + @Test func installationsResponse() throws { + let response = try decode(InstallationsResponse.self, """ + { "total_count": 1, "installations": [{ "id": 99, "app_id": 1 }] } + """) + + #expect(response.totalCount == 1) + #expect(response.items.map(\.id) == [99]) + } + + @Test func deviceCodeResponse() throws { + let response = try decode(DeviceCodeResponse.self, """ + { + "device_code": "abc123", + "user_code": "ABCD-1234", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5 + } + """) + + #expect(response.deviceCode == "abc123") + #expect(response.userCode == "ABCD-1234") + #expect(response.verificationUri == "https://github.com/login/device") + #expect(response.interval == 5) + } + + @Test func oauthTokenResponses() throws { + let success = try decode(OAuthTokenResponse.self, """ + { "access_token": "token", "token_type": "bearer", "scope": "" } + """) + #expect(success.accessToken == "token") + #expect(success.error == nil) + + let pending = try decode(OAuthTokenResponse.self, """ + { "error": "slow_down", "error_description": "Too many requests.", "interval": 10 } + """) + #expect(pending.accessToken == nil) + #expect(pending.error == "slow_down") + #expect(pending.errorDescription == "Too many requests.") + #expect(pending.interval == 10) + } +} diff --git a/Tests/ActionBarTests/RelativeTimeTests.swift b/Tests/ActionBarTests/RelativeTimeTests.swift new file mode 100644 index 0000000..3f4f5ff --- /dev/null +++ b/Tests/ActionBarTests/RelativeTimeTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing + +struct RelativeTimeTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func format(secondsAgo: TimeInterval) -> String { + RelativeTime.format(now.addingTimeInterval(-secondsAgo), relativeTo: now) + } + + @Test func underAMinuteIsJustNow() { + #expect(format(secondsAgo: 0) == "just now") + #expect(format(secondsAgo: 59) == "just now") + } + + @Test func futureDatesAreJustNow() { + #expect(format(secondsAgo: -120) == "just now") + } + + @Test func minutes() { + #expect(format(secondsAgo: 60) == "1m ago") + #expect(format(secondsAgo: 59 * 60 + 59) == "59m ago") + } + + @Test func hours() { + #expect(format(secondsAgo: 60 * 60) == "1h ago") + #expect(format(secondsAgo: 23 * 60 * 60 + 59 * 60) == "23h ago") + } + + @Test func days() { + #expect(format(secondsAgo: 24 * 60 * 60) == "1d ago") + #expect(format(secondsAgo: 29 * 24 * 60 * 60) == "29d ago") + } + + @Test func thirtyDaysOrMoreShowsTheDate() { + let date = now.addingTimeInterval(-30 * 24 * 60 * 60) + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + #expect(RelativeTime.format(date, relativeTo: now) == formatter.string(from: date)) + } +} diff --git a/Tests/ActionBarTests/RunStatusTests.swift b/Tests/ActionBarTests/RunStatusTests.swift new file mode 100644 index 0000000..28cc3e5 --- /dev/null +++ b/Tests/ActionBarTests/RunStatusTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing + +struct RunStatusTests { + @Test(arguments: [ + ("in_progress", RunStatus.running), + ("queued", .queued), + ("waiting", .queued), + ("pending", .queued), + ("requested", .queued), + ]) + func activeStatuses(status: String, expected: RunStatus) { + let status = RunStatus(from: .make(status: status, conclusion: nil)) + #expect(status == expected) + #expect(status.isActive) + } + + @Test(arguments: [ + ("success", RunStatus.success), + ("failure", .failure), + ("timed_out", .failure), + ("startup_failure", .failure), + ("cancelled", .cancelled), + ("skipped", .cancelled), + ("neutral", .unknown), + ]) + func completedConclusions(conclusion: String, expected: RunStatus) { + let status = RunStatus(from: .make(status: "completed", conclusion: conclusion)) + #expect(status == expected) + #expect(!status.isActive) + } + + @Test func completedWithoutConclusionIsUnknown() { + #expect(RunStatus(from: .make(status: "completed", conclusion: nil)) == .unknown) + } + + @Test func unrecognizedOrMissingStatusIsUnknown() { + #expect(RunStatus(from: .make(status: "something_new", conclusion: nil)) == .unknown) + #expect(RunStatus(from: .make(status: nil, conclusion: nil)) == .unknown) + } + + // MARK: - Overall Status + + @Test func overallStatusOfNoRunsIsUnknown() { + #expect([WorkflowRun]().overallStatus == .unknown) + } + + @Test func overallStatusIsRunningWhileAnythingIsActive() { + let runs: [WorkflowRun] = [ + .make(id: 1, conclusion: "failure", updatedAt: Date(timeIntervalSince1970: 200)), + .make(id: 2, status: "waiting", conclusion: nil, updatedAt: Date(timeIntervalSince1970: 100)), + ] + #expect(runs.overallStatus == .running) + } + + @Test func overallStatusFollowsMostRecentlyFinishedRun() { + let runs: [WorkflowRun] = [ + .make(id: 1, conclusion: "success", updatedAt: Date(timeIntervalSince1970: 100)), + .make(id: 2, conclusion: "failure", updatedAt: Date(timeIntervalSince1970: 300)), + .make(id: 3, conclusion: "cancelled", updatedAt: Date(timeIntervalSince1970: 200)), + ] + #expect(runs.overallStatus == .failure) + } + + @Test func overallStatusIgnoresRunsThatAreNeitherActiveNorCompleted() { + let runs: [WorkflowRun] = [ + .make(id: 1, conclusion: "success", updatedAt: Date(timeIntervalSince1970: 100)), + .make(id: 2, status: "something_new", conclusion: nil, updatedAt: Date(timeIntervalSince1970: 300)), + ] + #expect(runs.overallStatus == .success) + } +} diff --git a/Tests/ActionBarTests/SettingsStoreTests.swift b/Tests/ActionBarTests/SettingsStoreTests.swift new file mode 100644 index 0000000..10e9914 --- /dev/null +++ b/Tests/ActionBarTests/SettingsStoreTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing + +/// Each test starts from an empty defaults suite. They share one suite, so they run one at a time. +@MainActor +@Suite(.serialized) +final class SettingsStoreTests { + private let suiteName = "com.ptrchm.actionbar.tests" + private let defaults: UserDefaults + + init() throws { + defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + } + + deinit { + UserDefaults().removePersistentDomain(forName: suiteName) + } + + @Test func defaultsWhenNothingIsSaved() { + let store = SettingsStore(defaults: defaults) + #expect(store.selectedRepoNames.isEmpty) + #expect(store.pollingInterval == SettingsStore.defaultPollingInterval) + #expect(store.workflowFilterMode == .off) + #expect(store.workflowFilterText == "") + #expect(store.maxRunsPerRepo == SettingsStore.defaultMaxRunsPerRepo) + #expect(!store.includeRunsFromOtherUsers) + } + + @Test func loadingDoesNotWriteAnything() { + _ = SettingsStore(defaults: defaults) + #expect(defaults.persistentDomain(forName: suiteName)?.isEmpty ?? true) + } + + @Test func changesAreSavedImmediately() { + let store = SettingsStore(defaults: defaults) + store.selectedRepoNames = ["owner/a", "owner/b"] + store.pollingInterval = 60 + store.workflowFilterMode = .exclude + store.workflowFilterText = "deploy" + store.maxRunsPerRepo = 3 + store.includeRunsFromOtherUsers = true + + let reloaded = SettingsStore(defaults: defaults) + #expect(reloaded.selectedRepoNames == ["owner/a", "owner/b"]) + #expect(reloaded.pollingInterval == 60) + #expect(reloaded.workflowFilterMode == .exclude) + #expect(reloaded.workflowFilterText == "deploy") + #expect(reloaded.maxRunsPerRepo == 3) + #expect(reloaded.includeRunsFromOtherUsers) + } + + @Test func repoSelectionHelpers() { + let store = SettingsStore(defaults: defaults) + store.toggleRepo("owner/a") + store.toggleRepo("owner/b") + store.toggleRepo("owner/a") + #expect(SettingsStore(defaults: defaults).selectedRepoNames == ["owner/b"]) + + store.selectAllRepos(["owner/a", "owner/c"]) + #expect(SettingsStore(defaults: defaults).selectedRepoNames == ["owner/a", "owner/c"]) + + store.deselectAllRepos() + #expect(SettingsStore(defaults: defaults).selectedRepoNames.isEmpty) + } + + @Test func runQueryReflectsCurrentSettings() { + let store = SettingsStore(defaults: defaults) + store.selectedRepoNames = ["owner/a"] + store.workflowFilterMode = .include + store.workflowFilterText = "ci" + store.maxRunsPerRepo = 2 + store.includeRunsFromOtherUsers = true + + #expect(store.runQuery == SettingsStore.RunQuery( + repoNames: ["owner/a"], + workflowFilterMode: .include, + workflowFilterText: "ci", + maxRunsPerRepo: 2, + includeRunsFromOtherUsers: true + )) + } + + @Test func invalidSavedValuesFallBackToDefaults() { + defaults.set(0, forKey: "pollingInterval") + defaults.set(-1, forKey: "maxRunsPerRepo") + defaults.set("bogus", forKey: "workflowFilterMode") + + let store = SettingsStore(defaults: defaults) + #expect(store.pollingInterval == SettingsStore.defaultPollingInterval) + #expect(store.maxRunsPerRepo == SettingsStore.defaultMaxRunsPerRepo) + #expect(store.workflowFilterMode == .off) + } + + @Test func resetAccountSettingsKeepsPollingInterval() { + let store = SettingsStore(defaults: defaults) + store.selectedRepoNames = ["owner/a"] + store.pollingInterval = 300 + store.workflowFilterMode = .include + store.workflowFilterText = "ci" + store.maxRunsPerRepo = 2 + store.includeRunsFromOtherUsers = true + + store.resetAccountSettings() + + #expect(store.selectedRepoNames.isEmpty) + #expect(store.pollingInterval == 300) + #expect(store.workflowFilterMode == .off) + #expect(store.workflowFilterText == "") + #expect(store.maxRunsPerRepo == SettingsStore.defaultMaxRunsPerRepo) + #expect(!store.includeRunsFromOtherUsers) + #expect(defaults.persistentDomain(forName: suiteName)?.keys.sorted() == ["pollingInterval"]) + } +} diff --git a/Tests/ActionBarTests/TestRuns.swift b/Tests/ActionBarTests/TestRuns.swift new file mode 100644 index 0000000..0e6762e --- /dev/null +++ b/Tests/ActionBarTests/TestRuns.swift @@ -0,0 +1,24 @@ +import Foundation + +extension WorkflowRun { + static func make( + id: Int = 1, + name: String? = "CI", + status: String? = "completed", + conclusion: String? = "success", + updatedAt: Date = Date(timeIntervalSince1970: 0), + repo: String = "owner/repo" + ) -> WorkflowRun { + WorkflowRun( + id: id, + name: name, + displayTitle: nil, + status: status, + conclusion: conclusion, + htmlUrl: "https://github.com/\(repo)/actions/runs/\(id)", + headBranch: "main", + updatedAt: updatedAt, + repositoryFullName: repo + ) + } +} diff --git a/Tests/ActionBarTests/WorkflowRunListTests.swift b/Tests/ActionBarTests/WorkflowRunListTests.swift new file mode 100644 index 0000000..946cb44 --- /dev/null +++ b/Tests/ActionBarTests/WorkflowRunListTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing + +struct WorkflowRunListTests { + private let runs: [WorkflowRun] = [ + .make(id: 1, name: "CI"), + .make(id: 2, name: "Deploy to production"), + .make(id: 3, name: "Build docs"), + .make(id: 4, name: nil), + ] + + // MARK: - Workflow Filter + + @Test func filterOffKeepsEverything() { + #expect(runs.filtered(by: .off, patterns: "ci").map(\.id) == [1, 2, 3, 4]) + } + + @Test func includeKeepsMatchingNames() { + #expect(runs.filtered(by: .include, patterns: "ci, DEPLOY").map(\.id) == [1, 2]) + } + + @Test func excludeDropsMatchingNames() { + #expect(runs.filtered(by: .exclude, patterns: "ci,deploy").map(\.id) == [3, 4]) + } + + @Test func matchesSubstrings() { + #expect(runs.filtered(by: .include, patterns: "prod").map(\.id) == [2]) + } + + @Test(arguments: [WorkflowFilterMode.include, .exclude]) + func blankPatternsKeepEverything(mode: WorkflowFilterMode) { + #expect(runs.filtered(by: mode, patterns: "").map(\.id) == [1, 2, 3, 4]) + #expect(runs.filtered(by: mode, patterns: " , ,").map(\.id) == [1, 2, 3, 4]) + } + + // MARK: - Runs per Repo + + @Test func limitsRunsPerRepoPreservingOrder() { + let runs: [WorkflowRun] = [ + .make(id: 1, repo: "owner/a"), + .make(id: 2, repo: "owner/b"), + .make(id: 3, repo: "owner/a"), + .make(id: 4, repo: "owner/a"), + .make(id: 5, repo: "owner/b"), + .make(id: 6, repo: "owner/b"), + ] + #expect(runs.limited(toRunsPerRepo: 2).map(\.id) == [1, 2, 3, 5]) + #expect(runs.limited(toRunsPerRepo: 1).map(\.id) == [1, 2]) + #expect(runs.limited(toRunsPerRepo: 10).map(\.id) == [1, 2, 3, 4, 5, 6]) + } + + // MARK: - Completion Notifications + + private func history(_ runs: [WorkflowRun], failedRepos: [String] = []) -> RunHistory { + var history = RunHistory() + history.record(runs, keepingRunsFrom: failedRepos) + return history + } + + @Test(arguments: ["in_progress", "queued", "waiting", "pending", "requested"]) + func notifiesWhenActiveRunCompletes(previousStatus: String) { + let history = history([.make(id: 1, status: previousStatus, conclusion: nil)]) + let completed = [WorkflowRun.make(id: 1, conclusion: "failure")] + #expect(history.newlyCompleted(in: completed).map(\.id) == [1]) + } + + @Test func doesNotNotifyForRunsAlreadyFinished() { + let history = history([.make(id: 1)]) + #expect(history.newlyCompleted(in: [.make(id: 1)]).isEmpty) + } + + @Test func doesNotNotifyForRunsNotSeenBefore() { + #expect(RunHistory().newlyCompleted(in: [.make(id: 1)]).isEmpty) + } + + @Test func doesNotNotifyForRunsStillActive() { + let history = history([.make(id: 1, status: "queued", conclusion: nil)]) + let runs = [WorkflowRun.make(id: 1, status: "in_progress", conclusion: nil)] + #expect(history.newlyCompleted(in: runs).isEmpty) + } + + @Test func forgetsRunsThatAreNoLongerFetched() { + var history = history([.make(id: 1, status: "in_progress", conclusion: nil)]) + history.record([], keepingRunsFrom: []) + #expect(history.newlyCompleted(in: [.make(id: 1)]).isEmpty) + } + + @Test func keepsRunsFromReposThatFailedToLoad() { + var history = history([ + .make(id: 1, status: "in_progress", conclusion: nil, repo: "owner/a"), + .make(id: 2, status: "in_progress", conclusion: nil, repo: "owner/b"), + ]) + history.record([], keepingRunsFrom: ["owner/a"]) + + let completed: [WorkflowRun] = [.make(id: 1, repo: "owner/a"), .make(id: 2, repo: "owner/b")] + #expect(history.newlyCompleted(in: completed).map(\.id) == [1]) + } + + @Test func freshResultsReplaceKeptRuns() { + var history = history([.make(id: 1, status: "in_progress", conclusion: nil, repo: "owner/a")]) + history.record([.make(id: 1, repo: "owner/a")], keepingRunsFrom: ["owner/a"]) + #expect(history.newlyCompleted(in: [.make(id: 1, repo: "owner/a")]).isEmpty) + } +}