From cd1134e75194d4fb8e83806b1f08f33194e50f22 Mon Sep 17 00:00:00 2001 From: Niels Kootstra <545768+nkootstra@users.noreply.github.com> Date: Sat, 2 May 2026 23:16:56 +0200 Subject: [PATCH 1/2] fix: prevent refresh crash from user defaults notifications --- Package.swift | 2 +- .../ClaudeUsageApp/StatusBarController.swift | 1 + .../StatusBarControllerTests.swift | 28 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 Tests/ClaudeUsageTests/StatusBarControllerTests.swift diff --git a/Package.swift b/Package.swift index ef516d2..bf68e88 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( ), .testTarget( name: "ClaudeUsageTests", - dependencies: ["ClaudeUsageCore"], + dependencies: ["ClaudeUsageCore", "ClaudeUsageApp"], path: "Tests/ClaudeUsageTests" ), ] diff --git a/Sources/ClaudeUsageApp/StatusBarController.swift b/Sources/ClaudeUsageApp/StatusBarController.swift index df26a1d..b589b57 100644 --- a/Sources/ClaudeUsageApp/StatusBarController.swift +++ b/Sources/ClaudeUsageApp/StatusBarController.swift @@ -68,6 +68,7 @@ final class StatusBarController: NSObject { NotificationCenter.default .publisher(for: UserDefaults.didChangeNotification) + .receive(on: DispatchQueue.main) .sink { [weak self] _ in DispatchQueue.main.async { [weak self] in self?.updateStatusItemLength() diff --git a/Tests/ClaudeUsageTests/StatusBarControllerTests.swift b/Tests/ClaudeUsageTests/StatusBarControllerTests.swift new file mode 100644 index 0000000..845ceee --- /dev/null +++ b/Tests/ClaudeUsageTests/StatusBarControllerTests.swift @@ -0,0 +1,28 @@ +import AppKit +import Foundation +import Testing +@testable import ClaudeUsageApp +@testable import ClaudeUsageCore + +@Suite("StatusBarController") +@MainActor +struct StatusBarControllerTests { + @Test("UserDefaults changes posted off main actor do not crash label observer") + func backgroundUserDefaultsNotification() async throws { + let viewModel = UsageViewModel( + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: nil + ) + let controller = StatusBarController( + viewModel: viewModel, + widgetController: FloatingWidgetController(viewModel: viewModel) + ) + + _ = controller + await Task.detached { + NotificationCenter.default.post(name: UserDefaults.didChangeNotification, object: nil) + }.value + + try await Task.sleep(for: .milliseconds(100)) + } +} From be18415a3addfb7d994989b835abf75f0d7ead2f Mon Sep 17 00:00:00 2001 From: Niels Kootstra <545768+nkootstra@users.noreply.github.com> Date: Sat, 2 May 2026 23:19:03 +0200 Subject: [PATCH 2/2] test: avoid AppKit status bar construction in CI --- .../ClaudeUsageApp/StatusBarController.swift | 8 ++- .../MainQueuePublisherTests.swift | 63 +++++++++++++++++++ .../StatusBarControllerTests.swift | 28 --------- 3 files changed, 70 insertions(+), 29 deletions(-) create mode 100644 Tests/ClaudeUsageTests/MainQueuePublisherTests.swift delete mode 100644 Tests/ClaudeUsageTests/StatusBarControllerTests.swift diff --git a/Sources/ClaudeUsageApp/StatusBarController.swift b/Sources/ClaudeUsageApp/StatusBarController.swift index b589b57..66a1e2b 100644 --- a/Sources/ClaudeUsageApp/StatusBarController.swift +++ b/Sources/ClaudeUsageApp/StatusBarController.swift @@ -68,7 +68,7 @@ final class StatusBarController: NSObject { NotificationCenter.default .publisher(for: UserDefaults.didChangeNotification) - .receive(on: DispatchQueue.main) + .receiveOnMainQueue() .sink { [weak self] _ in DispatchQueue.main.async { [weak self] in self?.updateStatusItemLength() @@ -103,3 +103,9 @@ final class StatusBarController: NSObject { popover.contentViewController?.view.window?.makeFirstResponder(nil) } } + +extension Publisher { + func receiveOnMainQueue() -> Publishers.ReceiveOn { + receive(on: DispatchQueue.main) + } +} diff --git a/Tests/ClaudeUsageTests/MainQueuePublisherTests.swift b/Tests/ClaudeUsageTests/MainQueuePublisherTests.swift new file mode 100644 index 0000000..cdc1c4a --- /dev/null +++ b/Tests/ClaudeUsageTests/MainQueuePublisherTests.swift @@ -0,0 +1,63 @@ +import Combine +import Foundation +import Testing +@testable import ClaudeUsageApp + +@Suite("Main queue publishers") +struct MainQueuePublisherTests { + @Test("receiveOnMainQueue delivers background notifications on main thread") + func receiveOnMainQueueDeliversOnMainThread() async throws { + let deliveredOnMainThread = ThreadFlag() + var cancellables = Set() + + NotificationCenter.default + .publisher(for: UserDefaults.didChangeNotification) + .receiveOnMainQueue() + .sink { _ in + deliveredOnMainThread.set(Thread.isMainThread) + } + .store(in: &cancellables) + + await Task.detached { + NotificationCenter.default.post(name: UserDefaults.didChangeNotification, object: nil) + }.value + + try await waitUntil(deliveredOnMainThread.hasValue) + #expect(deliveredOnMainThread.value == true) + } +} + +private final class ThreadFlag: @unchecked Sendable { + private let lock = NSLock() + private var storedValue: Bool? + + var value: Bool? { + lock.lock() + defer { lock.unlock() } + return storedValue + } + + func set(_ value: Bool) { + lock.lock() + defer { lock.unlock() } + storedValue = value + } + + func hasValue() -> Bool { + value != nil + } +} + +private func waitUntil( + _ condition: @escaping @Sendable () -> Bool, + timeout: Duration = .seconds(1) +) async throws { + let deadline = ContinuousClock.now + timeout + while !condition() { + if ContinuousClock.now >= deadline { + Issue.record("Timed out waiting for condition") + return + } + try await Task.sleep(for: .milliseconds(10)) + } +} diff --git a/Tests/ClaudeUsageTests/StatusBarControllerTests.swift b/Tests/ClaudeUsageTests/StatusBarControllerTests.swift deleted file mode 100644 index 845ceee..0000000 --- a/Tests/ClaudeUsageTests/StatusBarControllerTests.swift +++ /dev/null @@ -1,28 +0,0 @@ -import AppKit -import Foundation -import Testing -@testable import ClaudeUsageApp -@testable import ClaudeUsageCore - -@Suite("StatusBarController") -@MainActor -struct StatusBarControllerTests { - @Test("UserDefaults changes posted off main actor do not crash label observer") - func backgroundUserDefaultsNotification() async throws { - let viewModel = UsageViewModel( - credentialProvider: { OAuthCredential.mock(accessToken: "token") }, - cache: nil - ) - let controller = StatusBarController( - viewModel: viewModel, - widgetController: FloatingWidgetController(viewModel: viewModel) - ) - - _ = controller - await Task.detached { - NotificationCenter.default.post(name: UserDefaults.didChangeNotification, object: nil) - }.value - - try await Task.sleep(for: .milliseconds(100)) - } -}