diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index c8e113267..c7e4dcc88 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -88,6 +88,8 @@ struct MenuContent: View { self.actions.openStatusPage() case .addCodexAccount: self.actions.addCodexAccount() + case let .addProviderAccount(provider): + self.actions.switchAccount(provider) case let .switchAccount(provider): self.actions.switchAccount(provider) case let .openTerminal(command): diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 7b9f168ce..f74852128 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -40,6 +40,7 @@ struct MenuDescriptor { case dashboard case statusPage case addCodexAccount + case addProviderAccount(UsageProvider) case switchAccount(UsageProvider) case openTerminal(command: String) case loginToProvider(url: String) @@ -466,7 +467,7 @@ extension MenuDescriptor.MenuAction { case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue - case .addCodexAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue + case .addCodexAccount, .addProviderAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 414f41c55..4d7c8afe7 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -207,9 +207,23 @@ struct ProviderSettingsTokenAccountsRowView: View { @State private var newToken: String = "" var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(self.descriptor.title) - .font(.subheadline.weight(.semibold)) + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 12) { + Text(self.descriptor.title) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + if let title = self.descriptor.primaryAddActionTitle, + let action = self.descriptor.primaryAddAction + { + Button(title) { + Task { @MainActor in + await action() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(self.descriptor.subtitle) @@ -224,46 +238,64 @@ struct ProviderSettingsTokenAccountsRowView: View { .font(.footnote) .foregroundStyle(.secondary) } else { - let selectedIndex = min(self.descriptor.activeIndex(), max(0, accounts.count - 1)) - Picker("", selection: Binding( - get: { selectedIndex }, - set: { index in self.descriptor.setActiveIndex(index) })) - { - ForEach(Array(accounts.enumerated()), id: \.offset) { index, account in - Text(account.displayName).tag(index) - } - } - .labelsHidden() - .pickerStyle(.menu) - .controlSize(.small) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in + HStack(alignment: .center, spacing: 10) { + Button { + self.descriptor.setActiveIndex(index) + } label: { + HStack(alignment: .center, spacing: 8) { + Image(systemName: self.isActive(index: index, accountCount: accounts.count) ? + "checkmark.circle.fill" : "circle") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(self.isActive(index: index, accountCount: accounts.count) ? + Color.accentColor : Color.secondary) + Text(account.displayName) + .font( + .footnote.weight( + self.isActive(index: index, accountCount: accounts.count) ? + .semibold : .regular)) + .foregroundStyle(.primary) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) - Button("Remove selected account") { - let account = accounts[selectedIndex] - self.descriptor.removeAccount(account.id) + Button("Remove") { + self.descriptor.removeAccount(account.id) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + if index < accounts.count - 1 { + Divider() + } + } } - .buttonStyle(.bordered) - .controlSize(.small) } - HStack(spacing: 8) { - TextField("Label", text: self.$newLabel) - .textFieldStyle(.roundedBorder) - .font(.footnote) - SecureField(self.descriptor.placeholder, text: self.$newToken) - .textFieldStyle(.roundedBorder) - .font(.footnote) - Button("Add") { - let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) - let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) - guard !label.isEmpty, !token.isEmpty else { return } - self.descriptor.addAccount(label, token) - self.newLabel = "" - self.newToken = "" + if self.descriptor.primaryAddAction == nil { + HStack(spacing: 8) { + TextField("Label", text: self.$newLabel) + .textFieldStyle(.roundedBorder) + .font(.footnote) + SecureField(self.descriptor.placeholder, text: self.$newToken) + .textFieldStyle(.roundedBorder) + .font(.footnote) + Button("Add") { + let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) + let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !label.isEmpty, !token.isEmpty else { return } + self.descriptor.addAccount(label, token) + self.newLabel = "" + self.newToken = "" + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } HStack(spacing: 10) { @@ -280,6 +312,12 @@ struct ProviderSettingsTokenAccountsRowView: View { } } } + + private func isActive(index: Int, accountCount: Int) -> Bool { + guard accountCount > 0 else { return false } + let selectedIndex = min(self.descriptor.activeIndex(), max(0, accountCount - 1)) + return selectedIndex == index + } } extension View { diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index 437cb6a93..b814be6a3 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -199,6 +199,8 @@ enum ProvidersPaneTestHarness { setActiveIndex: { _ in }, addAccount: { _, _ in }, removeAccount: { _ in }, + primaryAddActionTitle: nil, + primaryAddAction: nil, openConfigFile: {}, reloadFromDisk: {}) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 53943cf53..136f08fb1 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -353,6 +353,13 @@ struct ProvidersPane: View { } } }, + primaryAddActionTitle: provider == .copilot ? "Add Account" : nil, + primaryAddAction: provider == .copilot ? { + await CopilotLoginFlow.run(settings: self.settings) + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refreshProvider(provider, allowDisabled: true) + } + } : nil, openConfigFile: { self.settings.openTokenAccountsFile() }, diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift index 55275ae61..1cfaccdc4 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift @@ -80,14 +80,52 @@ struct CopilotLoginFlow { switch tokenResult { case let .success(token): - settings.copilotAPIToken = token + // Fetch username for account label + var label: String + do { + let username = try await CopilotUsageFetcher.fetchGitHubUsername(token: token) + let planSuffix: String + do { + let fetcher = CopilotUsageFetcher(token: token) + let usage = try await fetcher.fetch() + let plan = usage.identity(for: .copilot)?.loginMethod ?? "" + planSuffix = plan.isEmpty ? "" : " (\(plan))" + } catch { + planSuffix = "" + } + label = "\(username)\(planSuffix)" + } catch { + let count = settings.tokenAccounts(for: .copilot).count + label = "Account \(count + 1)" + } + + // Check for duplicate — same username means same GitHub user + let existingAccounts = settings.tokenAccounts(for: .copilot) + let usernamePrefix = label.components(separatedBy: " (").first ?? label + let wasRefresh = existingAccounts.contains(where: { + let existingPrefix = $0.label.components(separatedBy: " (").first ?? $0.label + return existingPrefix == usernamePrefix + }) + if let existing = existingAccounts.first(where: { + let existingPrefix = $0.label.components(separatedBy: " (").first ?? $0.label + return existingPrefix == usernamePrefix + }) { + settings.updateTokenAccount( + provider: .copilot, + accountID: existing.id, + label: label, + token: token) + } else { + settings.addTokenAccount(provider: .copilot, label: label, token: token) + } settings.setProviderEnabled( provider: .copilot, metadata: ProviderRegistry.shared.metadata[.copilot]!, enabled: true) let success = NSAlert() - success.messageText = "Login Successful" + success.messageText = wasRefresh ? "Token Refreshed" : "Account Added" + success.informativeText = label success.runModal() case let .failure(error): guard !(error is CancellationError) else { return } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 986d81f2f..a3f73d069 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -20,40 +20,38 @@ struct CopilotProviderImplementation: ProviderImplementation { @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { - _ = context - return .copilot(context.settings.copilotSettingsSnapshot()) + .copilot(context.settings.copilotSettingsSnapshot()) + } + + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) + -> (label: String, action: MenuDescriptor.MenuAction)? + { + ("Add Account...", .addProviderAccount(.copilot)) } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( - id: "copilot-api-token", + id: "copilot-add-account", title: "GitHub Login", - subtitle: "Requires authentication via GitHub Device Flow.", - kind: .secure, - placeholder: "Sign in via button below", - binding: context.stringBinding(\.copilotAPIToken), + subtitle: "Add accounts via GitHub OAuth Device Flow.", + kind: .plain, + placeholder: nil, + binding: .constant(""), actions: [ ProviderSettingsActionDescriptor( - id: "copilot-login", - title: "Sign in with GitHub", + id: "copilot-add-account-action", + title: "Add Account", style: .bordered, - isVisible: { context.settings.copilotAPIToken.isEmpty }, - perform: { - await CopilotLoginFlow.run(settings: context.settings) - }), - ProviderSettingsActionDescriptor( - id: "copilot-relogin", - title: "Sign in again", - style: .link, - isVisible: { !context.settings.copilotAPIToken.isEmpty }, + isVisible: { true }, perform: { await CopilotLoginFlow.run(settings: context.settings) }), ], isVisible: nil, - onActivate: { context.settings.ensureCopilotAPITokenLoaded() }), + onActivate: nil), ] } diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index d5a85b8f7..d04c93ed3 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -98,6 +98,8 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let setActiveIndex: (Int) -> Void let addAccount: (_ label: String, _ token: String) -> Void let removeAccount: (_ accountID: UUID) -> Void + let primaryAddActionTitle: String? + let primaryAddAction: (() async -> Void)? let openConfigFile: () -> Void let reloadFromDisk: () -> Void } diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 1f8a0277b..bc47946c5 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -66,18 +66,65 @@ extension SettingsStore { ]) } + func updateTokenAccount( + provider: UsageProvider, + accountID: UUID, + label: String? = nil, + token: String? = nil) + { + guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } + guard let index = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } + + let trimmedLabel = label?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedToken, trimmedToken.isEmpty { return } + + let existing = data.accounts[index] + let updatedAccount = ProviderTokenAccount( + id: existing.id, + label: (trimmedLabel?.isEmpty == false) ? trimmedLabel! : existing.label, + token: trimmedToken ?? existing.token, + addedAt: existing.addedAt, + lastUsed: existing.lastUsed) + + var accounts = data.accounts + accounts[index] = updatedAccount + let updated = ProviderTokenAccountData( + version: data.version, + accounts: accounts, + activeIndex: data.clampedActiveIndex()) + self.updateProviderConfig(provider: provider) { entry in + entry.tokenAccounts = updated + } + self.applyTokenAccountCookieSourceIfNeeded(provider: provider) + CodexBarLog.logger(LogCategories.tokenAccounts).info( + "Token account updated", + metadata: [ + "provider": provider.rawValue, + "count": "\(updated.accounts.count)", + ]) + } + func removeTokenAccount(provider: UsageProvider, accountID: UUID) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } + let activeAccountID = data.accounts[data.clampedActiveIndex()].id + guard let removedIndex = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } let filtered = data.accounts.filter { $0.id != accountID } self.updateProviderConfig(provider: provider) { entry in if filtered.isEmpty { entry.tokenAccounts = nil } else { - let clamped = min(max(data.activeIndex, 0), filtered.count - 1) + let nextActiveIndex = if activeAccountID != accountID, + let preservedIndex = filtered.firstIndex(where: { $0.id == activeAccountID }) + { + preservedIndex + } else { + min(removedIndex, filtered.count - 1) + } entry.tokenAccounts = ProviderTokenAccountData( version: data.version, accounts: filtered, - activeIndex: clamped) + activeIndex: nextActiveIndex) } } CodexBarLog.logger(LogCategories.tokenAccounts).info( diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 3e353c2cc..8e7eb8a2e 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -674,14 +674,19 @@ extension StatusItemController { onSelect: { [weak self, weak menu] index in guard let self, let menu else { return } self.settings.setActiveTokenAccountIndex(index, for: display.provider) - Task { @MainActor in + // Immediately rebuild to show the new selection, then refresh data + // and rebuild again once fresh data arrives. + self.populateMenu(menu, provider: display.provider) + self.markMenuFresh(menu) + self.applyIcon(phase: nil) + Task { @MainActor [weak self, weak menu] in + guard let self else { return } await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh() } + guard let menu else { return } + self.rebuildOpenMenuIfStillVisible(menu, provider: display.provider) } - self.populateMenu(menu, provider: display.provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) }) let item = NSMenuItem() item.view = view @@ -762,7 +767,7 @@ extension StatusItemController { let accounts = self.settings.tokenAccounts(for: provider) guard accounts.count > 1 else { return nil } let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 - let showAll = self.settings.showAllTokenAccountsInMenu + let showAll = provider == .copilot || self.settings.showAllTokenAccountsInMenu let snapshots = showAll ? (self.store.accountSnapshots[provider] ?? []) : [] return TokenAccountMenuDisplay( provider: provider, @@ -1150,6 +1155,7 @@ extension StatusItemController { case .dashboard: (#selector(self.openDashboard), nil) case .statusPage: (#selector(self.openStatusPage), nil) case .addCodexAccount: (#selector(self.addManagedCodexAccountFromMenu(_:)), nil) + case let .addProviderAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) case let .switchAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) case let .openTerminal(command): (#selector(self.openTerminalCommand(_:)), command) case let .loginToProvider(url): (#selector(self.openLoginToProvider(_:)), url) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 4940eeec5..ba11bbe44 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -25,6 +25,9 @@ extension UsageStore { func shouldFetchAllTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) -> Bool { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return false } + if provider == .copilot { + return accounts.count > 1 + } return self.settings.showAllTokenAccountsInMenu && accounts.count > 1 } @@ -130,6 +133,12 @@ extension UsageStore { let usage: UsageSnapshot? } + func tokenAccountErrorMessage(_ error: any Error) -> String? { + guard !(error is CancellationError) else { return nil } + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? nil : message + } + func recordFetchedTokenAccountPlanUtilizationHistory( provider: UsageProvider, samples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)], @@ -164,7 +173,7 @@ extension UsageStore { let snapshot = TokenAccountUsageSnapshot( account: account, snapshot: nil, - error: error.localizedDescription, + error: self.tokenAccountErrorMessage(error), sourceLabel: nil) return ResolvedAccountOutcome(snapshot: snapshot, usage: nil) } @@ -200,11 +209,15 @@ extension UsageStore { account: account) case let .failure(error): await MainActor.run { + guard let message = self.tokenAccountErrorMessage(error) else { + self.errors[provider] = nil + return + } let hadPriorData = self.snapshots[provider] != nil || fallbackSnapshot != nil let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { - self.errors[provider] = error.localizedDescription + self.errors[provider] = message self.snapshots.removeValue(forKey: provider) } else { self.errors[provider] = nil diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index ddf41a10a..6ef8e51cf 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -66,6 +66,32 @@ public struct CopilotUsageFetcher: Sendable { identity: identity) } + public static func fetchGitHubUsername(token: String) async throws -> String { + guard let url = URL(string: "https://api.github.com/user") else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw URLError(.userAuthenticationRequired) + } + guard httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + struct GitHubUser: Decodable { + let login: String + } + let user = try JSONDecoder().decode(GitHubUser.self, from: data) + return user.login + } + private func addCommonHeaders(to request: inout URLRequest) { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("vscode/1.96.2", forHTTPHeaderField: "Editor-Version") diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 2a1d0f1d4..645fd9247 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -58,5 +58,12 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .copilot: TokenAccountSupport( + title: "GitHub accounts", + subtitle: "Sign in with multiple GitHub accounts via OAuth.", + placeholder: "Paste GitHub token…", + injection: .environment(key: "COPILOT_API_TOKEN"), + requiresManualCookieSource: false, + cookieName: nil), ] } diff --git a/Tests/CodexBarTests/CopilotMultiAccountTests.swift b/Tests/CodexBarTests/CopilotMultiAccountTests.swift new file mode 100644 index 000000000..450854f36 --- /dev/null +++ b/Tests/CodexBarTests/CopilotMultiAccountTests.swift @@ -0,0 +1,157 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +// MARK: - Catalog + +@Test +func `copilot catalog entry exists`() { + let support = TokenAccountSupportCatalog.support(for: .copilot) + #expect(support != nil) + #expect(support?.requiresManualCookieSource == false) + #expect(support?.cookieName == nil) +} + +@Test +func `copilot catalog entry uses environment injection`() { + let support = TokenAccountSupportCatalog.support(for: .copilot) + guard let support else { + Issue.record("Copilot catalog entry missing") + return + } + if case let .environment(key) = support.injection { + #expect(key == "COPILOT_API_TOKEN") + } else { + Issue.record("Expected .environment injection, got cookieHeader") + } +} + +@Test +func `copilot env override uses correct key`() { + let override = TokenAccountSupportCatalog.envOverride(for: .copilot, token: "gh_abc") + #expect(override == ["COPILOT_API_TOKEN": "gh_abc"]) +} + +// MARK: - Username Fetch (parsing only) + +@Test +func `GitHub user response parses login`() throws { + let json = #"{"login": "testuser", "id": 123, "name": "Test User"}"# + struct GitHubUser: Decodable { let login: String } + let user = try JSONDecoder().decode(GitHubUser.self, from: Data(json.utf8)) + #expect(user.login == "testuser") +} + +@Test +func `GitHub user response parses login with minimal fields`() throws { + let json = #"{"login": "minimaluser"}"# + struct GitHubUser: Decodable { let login: String } + let user = try JSONDecoder().decode(GitHubUser.self, from: Data(json.utf8)) + #expect(user.login == "minimaluser") +} + +// MARK: - API Key Fallback + +@MainActor +struct CopilotAPIKeyFallbackTests { + @Test + func `ensure loader preserves config token`() { + let settings = Self.makeSettingsStore(suite: "copilot-api-key-loader") + settings.copilotAPIToken = "gh_token_123" + + settings.ensureCopilotAPITokenLoaded() + + #expect(settings.copilotAPIToken == "gh_token_123") + #expect(settings.tokenAccounts(for: .copilot).isEmpty) + } + + @Test + func `config token remains when token accounts already exist`() { + let settings = Self.makeSettingsStore(suite: "copilot-api-key-with-accounts") + settings.copilotAPIToken = "gh_token_old" + settings.addTokenAccount(provider: .copilot, label: "existing", token: "gh_token_existing") + + settings.ensureCopilotAPITokenLoaded() + + #expect(settings.tokenAccounts(for: .copilot).count == 1) + #expect(settings.copilotAPIToken == "gh_token_old") + #expect(settings.tokenAccounts(for: .copilot).first?.label == "existing") + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} + +// MARK: - Environment Precedence + +@MainActor +struct CopilotEnvironmentPrecedenceTests { + @Test + func `token account overrides config API key`() { + let settings = Self.makeSettingsStore(suite: "copilot-env-override") + settings.copilotAPIToken = "old_config_token" + settings.addTokenAccount(provider: .copilot, label: "new", token: "new_account_token") + + let account = try #require(settings.selectedTokenAccount(for: .copilot)) + let override = TokenAccountOverride(provider: .copilot, account: account) + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .copilot, + settings: settings, + tokenOverride: override) + + #expect(env["COPILOT_API_TOKEN"] == "new_account_token") + } + + @Test + func `config API key used when no token accounts`() { + let settings = Self.makeSettingsStore(suite: "copilot-env-config-only") + settings.copilotAPIToken = "config_token" + + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .copilot, + settings: settings, + tokenOverride: nil) + + #expect(env["COPILOT_API_TOKEN"] == "config_token") + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 7fedb5e7d..2b67dd8a3 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -81,6 +81,47 @@ struct SettingsStoreCoverageTests { settings.reloadTokenAccounts() } + @Test + func `token account update preserves identity and selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "token-2") + settings.setActiveTokenAccountIndex(0, for: .copilot) + + let original = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.updateTokenAccount( + provider: .copilot, + accountID: original.id, + label: "Primary (Pro)", + token: "token-1b") + + let updated = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(updated.id == original.id) + #expect(updated.label == "Primary (Pro)") + #expect(updated.token == "token-1b") + #expect(settings.tokenAccounts(for: .copilot).count == 2) + } + + @Test + func `removing another token account preserves active selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "A", token: "token-a") + settings.addTokenAccount(provider: .copilot, label: "B", token: "token-b") + settings.addTokenAccount(provider: .copilot, label: "C", token: "token-c") + settings.setActiveTokenAccountIndex(1, for: .copilot) + + let activeBefore = try #require(settings.selectedTokenAccount(for: .copilot)) + let accountToRemove = try #require(settings.tokenAccounts(for: .copilot).first) + settings.removeTokenAccount(provider: .copilot, accountID: accountToRemove.id) + + let activeAfter = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(activeAfter.id == activeBefore.id) + #expect(activeAfter.label == "B") + #expect(settings.tokenAccounts(for: .copilot).map(\.label) == ["B", "C"]) + } + @Test func `claude snapshot uses OAuth routing for OAuth token accounts`() { let settings = Self.makeSettingsStore() diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 67b0a323a..417406399 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -151,6 +151,15 @@ struct UsageStoreCoverageTests { #expect(gate.streak == 0) } + @Test + func `token account error message ignores cancellation`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-token-account-cancel") + let store = Self.makeUsageStore(settings: settings) + + #expect(store.tokenAccountErrorMessage(CancellationError()) == nil) + #expect(store.tokenAccountErrorMessage(ProviderFetchError.noAvailableStrategy(.copilot)) != nil) + } + private static func makeSettingsStore( suite: String, zaiTokenStore: any ZaiTokenStoring = NoopZaiTokenStore(),