diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index a5fa0ae16..c4926164c 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1036,6 +1036,7 @@ final class CodexAccountSwitcherView: NSView { private let accounts: [CodexVisibleAccount] private let onSelect: (CodexVisibleAccount) -> Void private var selectedAccountID: String + private var pressedAccountID: String? private var buttons: [NSButton] = [] private let preferredSize: NSSize private let rowSpacing: CGFloat = 4 @@ -1278,10 +1279,48 @@ final class CodexAccountSwitcherView: NSView { } } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let descendant = super.hitTest(point) + if descendant != nil, descendant !== self { + return self + } + return descendant + } + + override func mouseDown(with event: NSEvent) { + let location = self.convert(event.locationInWindow, from: nil) + self.pressedAccountID = self.accountID(at: location) + } + + override func mouseUp(with event: NSEvent) { + defer { self.pressedAccountID = nil } + guard let pressedAccountID = self.pressedAccountID else { return } + let location = self.convert(event.locationInWindow, from: nil) + guard let releasedAccountID = self.accountID(at: location), + releasedAccountID == pressedAccountID, + let account = self.accounts.first(where: { $0.id == pressedAccountID }) + else { + return + } + self.applySelection(account) + } + + private func accountID(at pointInSelf: NSPoint) -> String? { + self.buttons.first(where: { self.convert($0.bounds, from: $0).contains(pointInSelf) })?.identifier?.rawValue + } + @objc private func handleSelect(_ sender: NSButton) { - guard let accountID = sender.identifier?.rawValue else { return } - guard let account = self.accounts.first(where: { $0.id == accountID }) else { return } - self.selectedAccountID = accountID + guard let accountID = sender.identifier?.rawValue, + let account = self.accounts.first(where: { $0.id == accountID }) else { return } + self.applySelection(account) + } + + private func applySelection(_ account: CodexVisibleAccount) { + self.selectedAccountID = account.id self.updateButtonStyles() self.onSelect(account) } @@ -1296,8 +1335,30 @@ final class CodexAccountSwitcherView: NSView { } func _test_selectAccount(id: String) { - guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return } - self.handleSelect(button) + guard let account = self.accounts.first(where: { $0.id == id }) else { return } + self.applySelection(account) + } + + func _test_simulateRuntimeClick(id: String) -> Bool { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + self.pressedAccountID = self.accountID(at: point) + defer { self.pressedAccountID = nil } + guard let pressedAccountID = self.pressedAccountID, + let releasedAccountID = self.accountID(at: point), + releasedAccountID == pressedAccountID, + let account = self.accounts.first(where: { $0.id == pressedAccountID }) + else { + return false + } + self.applySelection(account) + return true + } + + func _test_hitTestSwallowsChildButton(id: String) -> Bool { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + return self.hitTest(point) === self } #endif } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index db0ec82fd..5a22cdda9 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -151,6 +151,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var providerSwitcherUpdateToken = 0 var lastAppliedMergedIconRenderSignature: String? var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:] + var lastObservedStoreIconWorkSignature: String? var lastKnownScreenCount: Int var pendingScreenChangePreviousCount: Int? var screenChangeVisibilityTask: Task? @@ -358,11 +359,59 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin Task { @MainActor [weak self] in guard let self else { return } self.observeStoreIconChanges() + let signature = self.storeIconObservationSignature() + guard signature != self.lastObservedStoreIconWorkSignature else { return } + self.lastObservedStoreIconWorkSignature = signature self.updateIcons() } } } + func storeIconObservationSignature() -> String { + let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent + let mergeIcons = self.shouldMergeIcons + let needsAnimation = self.needsMenuBarIconAnimation() + let providerSignatures = UsageProvider.allCases.map { + self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent) + }.joined(separator: "||") + let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",") + return [ + "merge=\(mergeIcons ? "1" : "0")", + "visible=\(visibleProviders)", + "iconStyle=\(String(describing: self.store.iconStyle))", + "brandPercent=\(showBrandPercent ? "1" : "0")", + "needsAnimation=\(needsAnimation ? "1" : "0")", + providerSignatures, + ].joined(separator: "|") + } + + private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String { + let snapshot = self.store.snapshot(for: provider) + let stale = self.store.isStale(provider: provider) + let status = self.store.statusIndicator(for: provider).rawValue + let isVisibleForAnimation = self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider) + let isAnimating = isVisibleForAnimation && !stale && snapshot == nil + let isRefreshingWarpPlaceholder = self.store.refreshingProviders.contains(provider) + let creditsRemaining = provider == .codex + ? self.store.codexMenuBarCreditsRemaining( + snapshotOverride: snapshot, + now: snapshot?.updatedAt ?? Date()) + : nil + let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil + + return [ + provider.rawValue, + "style=\(String(describing: self.store.style(for: provider)))", + "snapshot=\(String(describing: snapshot))", + "stale=\(stale ? "1" : "0")", + "status=\(status)", + "anim=\(isAnimating ? "1" : "0")", + "refreshing=\(isRefreshingWarpPlaceholder ? "1" : "0")", + "credits=\(String(describing: creditsRemaining))", + "text=\(displayText ?? "nil")", + ].joined(separator: "|") + } + private func observeDebugForceAnimation() { withObservationTracking { _ = self.store.debugForceAnimation @@ -542,6 +591,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #if DEBUG guard !self.isReleasedForTesting else { return } #endif + self.lastObservedStoreIconWorkSignature = self.storeIconObservationSignature() // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d798152d4..dc7fffb7d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -43,7 +43,6 @@ extension UsageStore { _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin - _ = self.isRefreshing _ = self.refreshingProviders _ = self.statuses _ = self.historicalPaceRevision @@ -555,7 +554,15 @@ final class UsageStore { group.addTask { await self.refreshStatus(provider) } } } - group.addTask { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } + if forceTokenUsage { + group.addTask { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } + } + } + + if !forceTokenUsage { + Task { @MainActor [weak self] in + await self?.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) + } } if forceTokenUsage { @@ -587,9 +594,17 @@ final class UsageStore { ]) if shouldRefreshOpenAIWeb { let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() - await self.refreshOpenAIDashboardIfNeeded( - force: forceTokenUsage, - expectedGuard: codexDashboardGuard) + if forceTokenUsage { + await self.refreshOpenAIDashboardIfNeeded( + force: true, + expectedGuard: codexDashboardGuard) + } else { + Task { @MainActor [weak self] in + await self?.refreshOpenAIDashboardIfNeeded( + force: false, + expectedGuard: codexDashboardGuard) + } + } } if forceTokenUsage, self.openAIDashboardRequiresLogin { diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index 36f3fe902..6c7a7be79 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -6,6 +6,129 @@ import Testing @Suite(.serialized) @MainActor struct CodexManagedOpenAIWebRefreshTests { + @Test + func `regular refresh does not await OpenAI web scrape`() async { + let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-regular-refresh-nonblocking") + settings.statusChecksEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingManagedOpenAIDashboardLoader() + let completion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + store._test_openAIDashboardLoaderOverride = { _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + await completion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(200)) + + #expect(await blocker.startedCount() == 1) + #expect(await completion.isCompleted == true) + + await blocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + + await refreshTask.value + } + + @Test + func `regular refresh does not await Codex credits fetch`() async { + let settings = self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-regular-refresh-nonblocking-credits") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingCreditsLoader() + let completion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + await completion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(200)) + + #expect(await blocker.startedCount() == 1) + #expect(await completion.isCompleted == true) + + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + + await refreshTask.value + } + @Test func `manual cookie import bypasses same account refresh coalescing`() async { let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-manual-import-bypass-coalesce") @@ -305,6 +428,44 @@ struct CodexManagedOpenAIWebRefreshTests { settings.codexCookieSource = .auto return settings } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String, accountId: String? = nil) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + var tokens: [String: Any] = [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan, accountId: accountId), + ] + if let accountId { + tokens["accountId"] = accountId + } + let data = try JSONSerialization.data(withJSONObject: ["tokens": tokens], options: [.sortedKeys]) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String, accountId: String? = nil) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + var authClaims: [String: Any] = [ + "chatgpt_plan_type": plan, + ] + if let accountId { + authClaims["chatgpt_account_id"] = accountId + } + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": authClaims, + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } } private enum ManagedDashboardTestError: LocalizedError { @@ -318,6 +479,14 @@ private enum ManagedDashboardTestError: LocalizedError { } } +private actor RefreshCompletionProbe { + private(set) var isCompleted = false + + func markCompleted() { + self.isCompleted = true + } +} + private actor BlockingManagedOpenAIDashboardLoader { private var continuations: [CheckedContinuation, Never>] = [] private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] @@ -362,6 +531,29 @@ private actor BlockingManagedOpenAIDashboardLoader { } } +private actor BlockingCreditsLoader { + private var continuations: [CheckedContinuation, Never>] = [] + private var started = 0 + + func awaitResult() async throws -> CreditsSnapshot { + self.started += 1 + let result = await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + return try result.get() + } + + func startedCount() -> Int { + self.started + } + + func resumeNext(with result: Result) { + guard !self.continuations.isEmpty else { return } + let continuation = self.continuations.removeFirst() + continuation.resume(returning: result) + } +} + private actor OpenAIDashboardImportCallTracker { private var calls: Int = 0 private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift new file mode 100644 index 000000000..6660c264e --- /dev/null +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -0,0 +1,89 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemIconObservationSignatureTests { + private func makeController(suiteName: String) -> (SettingsStore, UsageStore, StatusItemController) { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = true + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = false + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(Self.makeSnapshot(provider: .codex, email: "icon@example.com"), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + return (settings, store, controller) + } + + @Test + func `store icon observation signature ignores refresh and status metadata churn`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-refresh-metadata") + defer { controller.releaseStatusItemsForTesting() } + + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "initial", + updatedAt: Date(timeIntervalSince1970: 10)) + let baseline = controller.storeIconObservationSignature() + + store.isRefreshing = true + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "same indicator, newer timestamp", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `store icon observation signature changes when status indicator changes`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-status-indicator") + defer { controller.releaseStatusItemsForTesting() } + + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "initial", + updatedAt: Date(timeIntervalSince1970: 10)) + let baseline = controller.storeIconObservationSignature() + + store.statuses[.codex] = ProviderStatus( + indicator: .major, + description: "major outage", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 100), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "plus")) + } +} diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index a312dceaf..755060f3c 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -872,6 +872,128 @@ struct StatusMenuCodexSwitcherTests { } } +extension StatusMenuCodexSwitcherTests { + @Test + func `codex account switcher swallows child button hit testing for first click`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { _ in }) + + #expect(view.acceptsFirstMouse(for: nil) == true) + #expect(view._test_hitTestSwallowsChildButton(id: "managed@example.com") == true) + } + + @Test + func `codex account switcher routes runtime click path to selected account`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + #expect(view._test_simulateRuntimeClick(id: "managed@example.com") == true) + #expect(selectedAccount == accounts[1]) + } + + @Test + func `codex account switcher runtime click resolves second row buttons`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let secondManagedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + CodexVisibleAccount( + id: "team@example.com", + email: "team@example.com", + storedAccountID: secondManagedID, + selectionSource: .managedAccount(id: secondManagedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + CodexVisibleAccount( + id: "second-row@example.com", + email: "second-row@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: true, + canReauthenticate: true, + canRemove: false), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + #expect(view._test_simulateRuntimeClick(id: "second-row@example.com") == true) + #expect(selectedAccount == accounts[3]) + } +} + @MainActor extension StatusMenuCodexSwitcherTests { @Test