fix: show Claude Fable usage in account switcher

Show Fable usage in inactive Claude account switcher previews by supplementing OAuth usage with scoped Claude CLI /usage data, with macOS scoped Keychain safety checks and compact preview layout coverage.

Validation:
- pnpm exec vitest run --config config/vitest.config.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/inline-usage-bars.test.tsx src/renderer/src/components/status-bar/tooltip.test.ts
- pnpm run typecheck
- pnpm exec oxlint src/main/rate-limits/claude-fetcher.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/StatusBar.tsx src/renderer/src/components/status-bar/inline-usage-bars.test.tsx
- git diff --check HEAD~1..HEAD
This commit is contained in:
Neil 2026-07-02 22:18:12 -07:00 committed by GitHub
parent 4caea00f16
commit b523c5311f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 508 additions and 62 deletions

View File

@ -1,14 +1,17 @@
/* eslint-disable max-lines -- Why: Claude rate-limit fallback tests share account/keychain/PTY mocks that would be noisier split apart. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
import { fetchViaPty } from './claude-pty'
import {
deleteActiveClaudeKeychainCredentialsStrict,
readActiveClaudeKeychainCredentials,
readActiveClaudeKeychainCredentialsStrict,
readManagedClaudeKeychainCredentials
readManagedClaudeKeychainCredentials,
writeActiveClaudeKeychainCredentials,
writeManagedClaudeKeychainCredentials
} from '../claude-accounts/keychain'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
@ -46,9 +49,12 @@ vi.mock('./claude-pty', () => ({
}))
vi.mock('../claude-accounts/keychain', () => ({
deleteActiveClaudeKeychainCredentialsStrict: vi.fn(),
readActiveClaudeKeychainCredentials: vi.fn(),
readActiveClaudeKeychainCredentialsStrict: vi.fn(),
readManagedClaudeKeychainCredentials: vi.fn()
readManagedClaudeKeychainCredentials: vi.fn(),
writeActiveClaudeKeychainCredentials: vi.fn(),
writeManagedClaudeKeychainCredentials: vi.fn()
}))
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
@ -71,6 +77,9 @@ describe('fetchClaudeRateLimits', () => {
vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null)
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null)
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null)
vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue()
vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockResolvedValue()
vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue()
appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test')
resolveProxyMock.mockResolvedValue('DIRECT')
netFetchMock.mockResolvedValue(
@ -948,6 +957,239 @@ describe('fetchClaudeRateLimits', () => {
expect(readFileMock).not.toHaveBeenCalled()
})
it('supplements inactive managed account OAuth usage with Fable from its usage panel', async () => {
setPlatform('linux')
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
appGetPathMock.mockReturnValue(tempDir)
const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
mkdirSync(ownedAuthPath, { recursive: true })
writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
const canonicalAuthPath = realpathSync(ownedAuthPath)
writeFileSync(
join(ownedAuthPath, '.credentials.json'),
JSON.stringify({
claudeAiOauth: {
accessToken: 'inactive-token',
expiresAt: Date.now() + 60_000
}
}),
'utf-8'
)
vi.mocked(fetchViaPty).mockResolvedValueOnce({
provider: 'claude',
session: null,
weekly: null,
fableWeekly: {
usedPercent: 42,
windowMinutes: 10080,
resetsAt: null,
resetDescription: '2d'
},
updatedAt: 1,
error: null,
status: 'ok'
})
await expect(
fetchManagedAccountUsage(
{ id: 'account-1', managedAuthPath: ownedAuthPath },
{ allowUsagePanelSupplement: true }
)
).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
session: { usedPercent: 12 },
weekly: { usedPercent: 34 },
fableWeekly: { usedPercent: 42, resetDescription: '2d' }
})
expect(fetchViaPty).toHaveBeenCalledWith({
authPreparation: expect.objectContaining({
configDir: canonicalAuthPath,
envPatch: { CLAUDE_CONFIG_DIR: canonicalAuthPath },
provenance: 'managed:account-1:inactive-preview',
stripAuthEnv: true
})
})
})
it('stages macOS inactive account credentials in a scoped Keychain for Fable preview', async () => {
setPlatform('darwin')
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
appGetPathMock.mockReturnValue(tempDir)
const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
const credentialsJson = JSON.stringify({
claudeAiOauth: {
accessToken: 'managed-keychain-token',
expiresAt: Date.now() + 60_000
}
})
mkdirSync(ownedAuthPath, { recursive: true })
writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
const canonicalAuthPath = realpathSync(ownedAuthPath)
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(credentialsJson)
vi.mocked(fetchViaPty).mockResolvedValueOnce({
provider: 'claude',
session: {
usedPercent: 12,
windowMinutes: 300,
resetsAt: null,
resetDescription: null
},
weekly: {
usedPercent: 34,
windowMinutes: 10080,
resetsAt: null,
resetDescription: null
},
fableWeekly: {
usedPercent: 58,
windowMinutes: 10080,
resetsAt: null,
resetDescription: '3d'
},
updatedAt: 1,
error: null,
status: 'ok'
})
const result = await fetchManagedAccountUsage(
{ id: 'account-1', managedAuthPath: ownedAuthPath },
{ allowUsagePanelSupplement: true }
)
expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' })
expect(writeActiveClaudeKeychainCredentials).toHaveBeenCalledWith(
credentialsJson,
canonicalAuthPath
)
expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath)
})
it('stages refreshed macOS inactive account credentials before Fable preview', async () => {
setPlatform('darwin')
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
appGetPathMock.mockReturnValue(tempDir)
const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
const staleCredentialsJson = JSON.stringify({
claudeAiOauth: {
accessToken: 'stale-access',
refreshToken: 'stale-refresh',
expiresAt: Date.now() - 60_000
}
})
mkdirSync(ownedAuthPath, { recursive: true })
writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(staleCredentialsJson)
netFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'fresh-access',
expires_in: 3600,
refresh_token: 'fresh-refresh'
}),
{ status: 200 }
)
)
netFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }),
{
status: 200
}
)
)
vi.mocked(fetchViaPty).mockResolvedValueOnce({
provider: 'claude',
session: {
usedPercent: 12,
windowMinutes: 300,
resetsAt: null,
resetDescription: null
},
weekly: {
usedPercent: 34,
windowMinutes: 10080,
resetsAt: null,
resetDescription: null
},
fableWeekly: {
usedPercent: 58,
windowMinutes: 10080,
resetsAt: null,
resetDescription: '3d'
},
updatedAt: 1,
error: null,
status: 'ok'
})
const result = await fetchManagedAccountUsage(
{ id: 'account-1', managedAuthPath: ownedAuthPath },
{ allowUsagePanelSupplement: true }
)
const stagedCredentialsJson = vi.mocked(writeActiveClaudeKeychainCredentials).mock.calls[0]?.[0]
expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' })
expect(JSON.parse(stagedCredentialsJson ?? '{}')).toMatchObject({
claudeAiOauth: {
accessToken: 'fresh-access',
refreshToken: 'fresh-refresh'
}
})
expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith(
'account-1',
stagedCredentialsJson
)
})
it('does not merge macOS inactive Fable preview when usage windows belong to another account', async () => {
setPlatform('darwin')
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))
appGetPathMock.mockReturnValue(tempDir)
const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth')
mkdirSync(ownedAuthPath, { recursive: true })
writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8')
vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(
JSON.stringify({
claudeAiOauth: {
accessToken: 'managed-keychain-token',
expiresAt: Date.now() + 60_000
}
})
)
vi.mocked(fetchViaPty).mockResolvedValueOnce({
provider: 'claude',
session: {
usedPercent: 91,
windowMinutes: 300,
resetsAt: null,
resetDescription: null
},
weekly: {
usedPercent: 3,
windowMinutes: 10080,
resetsAt: null,
resetDescription: null
},
fableWeekly: {
usedPercent: 58,
windowMinutes: 10080,
resetsAt: null,
resetDescription: '3d'
},
updatedAt: 1,
error: null,
status: 'ok'
})
const result = await fetchManagedAccountUsage(
{ id: 'account-1', managedAuthPath: ownedAuthPath },
{ allowUsagePanelSupplement: true }
)
expect(result.fableWeekly).toBeNull()
})
it('refreshes and persists an expiring inactive account before fetching usage', async () => {
setPlatform('linux')
tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-'))

View File

@ -17,9 +17,11 @@ import { parseWslUncPath } from '../../shared/wsl-paths'
import { fetchViaPty } from './claude-pty'
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
import {
deleteActiveClaudeKeychainCredentialsStrict,
readActiveClaudeKeychainCredentials,
readActiveClaudeKeychainCredentialsStrict,
readManagedClaudeKeychainCredentials
readManagedClaudeKeychainCredentials,
writeActiveClaudeKeychainCredentials
} from '../claude-accounts/keychain'
import {
readClaudeManagedAuthFile,
@ -885,7 +887,7 @@ export type InactiveClaudeAccountInfo = {
}
type ManagedCredentialsLocation =
| { kind: 'keychain'; accountId: string }
| { kind: 'keychain'; accountId: string; managedAuthPath: string }
| { kind: 'file'; managedAuthPath: string }
// Why: resolves where an inactive account's credentials live without
@ -907,7 +909,7 @@ function resolveManagedCredentialsLocation(
// macOS stores host managed credentials in the Keychain; everything else
// (and WSL, handled above) stores them as a file under the managed dir.
if (process.platform === 'darwin') {
return { kind: 'keychain', accountId: account.id }
return { kind: 'keychain', accountId: account.id, managedAuthPath }
}
return { kind: 'file', managedAuthPath }
}
@ -966,12 +968,127 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccountInfo
}
}
export async function fetchManagedAccountUsage(
function getManagedUsagePanelAuthPreparation(
account: InactiveClaudeAccountInfo,
location: ManagedCredentialsLocation
): ClaudeRuntimeAuthPreparation | null {
if (process.platform === 'win32') {
return null
}
if (account.managedAuthRuntime === 'wsl') {
if (!account.wslLinuxAuthPath || !account.wslDistro) {
return null
}
return {
configDir: location.managedAuthPath,
runtime: 'wsl',
wslDistro: account.wslDistro,
wslLinuxConfigDir: account.wslLinuxAuthPath,
envPatch: { CLAUDE_CONFIG_DIR: account.wslLinuxAuthPath },
stripAuthEnv: true,
provenance: `managed:${account.id}:inactive-preview`
}
}
return {
configDir: location.managedAuthPath,
runtime: 'host',
wslDistro: null,
wslLinuxConfigDir: null,
envPatch: { CLAUDE_CONFIG_DIR: location.managedAuthPath },
stripAuthEnv: true,
provenance: `managed:${account.id}:inactive-preview`
}
}
function windowsAgree(left: RateLimitWindow | null, right: RateLimitWindow | null): boolean {
return Boolean(left && right && Math.abs(left.usedPercent - right.usedPercent) <= 1)
}
function canTrustManagedUsagePanelSupplement(
oauthLimits: ProviderRateLimits,
cliLimits: ProviderRateLimits,
options: { requireMatchingOAuthWindow: boolean }
): boolean {
if (!options.requireMatchingOAuthWindow) {
return true
}
const sharedWindowMatches = [
oauthLimits.session && cliLimits.session
? windowsAgree(oauthLimits.session, cliLimits.session)
: null,
oauthLimits.weekly && cliLimits.weekly
? windowsAgree(oauthLimits.weekly, cliLimits.weekly)
: null
].filter((match): match is boolean => match !== null)
// Why: macOS inactive previews temporarily stage managed credentials in a
// scoped Keychain item. If an older Claude build ignores scoped Keychains,
// matching OAuth windows prevent active-account Fable data from leaking in.
return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean)
}
async function withManagedPreviewKeychainCredentials<T>(
location: ManagedCredentialsLocation,
credentialsJson: string,
fn: () => Promise<T>
): Promise<T> {
if (location.kind !== 'keychain') {
return fn()
}
await writeActiveClaudeKeychainCredentials(credentialsJson, location.managedAuthPath)
try {
return await fn()
} finally {
await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {})
}
}
async function readStagedManagedPreviewCredentials(
location: ManagedCredentialsLocation
): Promise<string | null> {
if (location.kind !== 'keychain') {
return null
}
try {
return await readActiveClaudeKeychainCredentialsStrict(location.managedAuthPath)
} catch {
return null
}
}
async function fetchManagedUsagePanelSupplement(input: {
account: InactiveClaudeAccountInfo
location: ManagedCredentialsLocation
credentialsJson: string
oauthLimits: ProviderRateLimits
}): Promise<ProviderRateLimits | null> {
const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location)
if (!authPreparation) {
return null
}
return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => {
const cliLimits = await fetchViaPty({ authPreparation })
if (
!canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, {
requireMatchingOAuthWindow: input.location.kind === 'keychain'
})
) {
return null
}
const refreshedCredentials = await readStagedManagedPreviewCredentials(input.location)
if (refreshedCredentials && refreshedCredentials !== input.credentialsJson) {
await writeManagedCredentialsJson(input.location, refreshedCredentials)
}
return cliLimits
})
}
export async function fetchManagedAccountUsage(
account: InactiveClaudeAccountInfo,
options: { allowUsagePanelSupplement?: boolean } = {}
): Promise<ProviderRateLimits> {
const location = resolveManagedCredentialsLocation(account)
const credentialsJson = location ? await readManagedCredentialsJson(location) : null
if (!credentialsJson) {
let credentialsJson = location ? await readManagedCredentialsJson(location) : null
if (!location || !credentialsJson) {
return {
provider: 'claude',
session: null,
@ -988,7 +1105,7 @@ export async function fetchManagedAccountUsage(
// single-use refresh tokens fresh so a later switch-in never materializes a
// stale token. Persistence failure is non-fatal: we still try the fetch.
let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token
if (location && isOauthTokenExpiring(credentialsJson)) {
if (isOauthTokenExpiring(credentialsJson)) {
const refreshed = await refreshClaudeOauthCredentials(credentialsJson)
if (refreshed) {
try {
@ -997,6 +1114,7 @@ export async function fetchManagedAccountUsage(
// Keep going with the refreshed token in memory even if the write
// failed; worst case the next poll refreshes again.
}
credentialsJson = refreshed
token = parseOAuthCredentialsJson(refreshed, 'credentials-file').token
}
}
@ -1013,7 +1131,32 @@ export async function fetchManagedAccountUsage(
}
// Why: PTY fallback is intentionally omitted for inactive accounts. The PTY
// path materializes credentials via ClaudeRuntimeAuthService, which would
// interfere with the active account's auth state.
return fetchViaOAuth(token)
// path is used only as a supplement after OAuth succeeds, and it points
// directly at the managed account's isolated config so selection is unchanged.
const oauthLimits = await fetchViaOAuth(token)
if (
!canSupplementOAuthUsageFromCli({
oauthLimits,
authPreparation: undefined,
allowUsagePanelSupplement: options.allowUsagePanelSupplement === true
})
) {
return oauthLimits
}
try {
const cliLimits = await fetchManagedUsagePanelSupplement({
account,
location,
credentialsJson,
oauthLimits
})
return mergeClaudeUsageWindows(oauthLimits, cliLimits)
} catch (err) {
warnClaudeUsageFetchFailure(
undefined,
parseOAuthCredentialsJson(credentialsJson, 'credentials-file'),
err
)
return oauthLimits
}
}

View File

@ -621,6 +621,19 @@ describe('RateLimitService', () => {
])
})
it('allows usage-panel Fable supplements for inactive Claude account previews', async () => {
const service = new RateLimitService()
const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
service.setInactiveClaudeAccountsResolver(() => [account])
vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now()))
await service.fetchInactiveClaudeAccountsOnOpen()
expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, {
allowUsagePanelSupplement: true
})
})
it('does not start overlapping inactive Codex preview fetches', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()

View File

@ -382,7 +382,9 @@ export class RateLimitService {
continue
}
try {
const fresh = await fetchManagedAccountUsage(account)
const fresh = await fetchManagedAccountUsage(account, {
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement()
})
if (
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
!this.isCurrentInactiveClaudeAccount(account.id)

View File

@ -901,70 +901,63 @@ function MiniBar({ leftPct }: { leftPct: number }): React.JSX.Element {
// Inline usage bars (compact bars for inactive accounts in the switcher)
// ---------------------------------------------------------------------------
function InlineUsageBars({
export function InlineUsageBars({
limits,
isFetching
}: {
limits: ProviderRateLimits
isFetching: boolean
}): React.JSX.Element {
const sessionLeft = limits.session
? Math.max(0, Math.round(100 - limits.session.usedPercent))
: null
const weeklyLeft = limits.weekly ? Math.max(0, Math.round(100 - limits.weekly.usedPercent)) : null
const fableLeft = limits.fableWeekly
? Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent))
: null
const usageWindows = [
limits.session
? {
key: 'session',
left: Math.max(0, Math.round(100 - limits.session.usedPercent)),
label: translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')
}
: null,
limits.weekly
? {
key: 'weekly',
left: Math.max(0, Math.round(100 - limits.weekly.usedPercent)),
label: translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')
}
: null,
limits.fableWeekly
? {
key: 'fableWeekly',
left: Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent)),
label: translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')
}
: null
].filter((window): window is { key: string; left: number; label: string } => window !== null)
return (
<div className={`flex w-full items-center gap-2 ${isFetching ? 'animate-pulse' : ''}`}>
{sessionLeft !== null && (
<div className="flex flex-1 items-center gap-1">
<div className="h-[4px] flex-1 overflow-hidden rounded-full bg-muted">
<div
className={`grid w-full items-center gap-1.5 ${isFetching ? 'animate-pulse' : ''}`}
style={{
gridTemplateColumns: `repeat(${Math.max(1, usageWindows.length)}, minmax(0, 1fr))`
}}
>
{usageWindows.map((window) => (
<div key={window.key} className="flex min-w-0 items-center gap-1">
<div className="h-[4px] min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${barColor(sessionLeft)}`}
style={{ width: `${sessionLeft}%` }}
className={`h-full rounded-full ${barColor(window.left)}`}
style={{ width: `${window.left}%` }}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{sessionLeft}
{translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')}
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{window.left}
{window.label}
</span>
</div>
)}
{weeklyLeft !== null && (
<div className="flex flex-1 items-center gap-1">
<div className="h-[4px] flex-1 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${barColor(weeklyLeft)}`}
style={{ width: `${weeklyLeft}%` }}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{weeklyLeft}
{translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')}
</span>
</div>
)}
{fableLeft !== null && (
<div className="flex flex-1 items-center gap-1">
<div className="h-[4px] flex-1 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${barColor(fableLeft)}`}
style={{ width: `${fableLeft}%` }}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground shrink-0">
{fableLeft}
{translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')}
</span>
</div>
)}
{limits.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly && (
))}
{usageWindows.length === 0 && limits.status === 'error' ? (
<span className="text-[10px] text-muted-foreground">
{translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')}
</span>
)}
) : null}
</div>
)
}

View File

@ -0,0 +1,53 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
vi.mock('@/i18n/i18n', () => ({
i18n: { language: 'en' },
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: () => null
}))
function claudeLimits(): ProviderRateLimits {
return {
provider: 'claude',
session: {
usedPercent: 32,
windowMinutes: 300,
resetsAt: null,
resetDescription: null
},
weekly: {
usedPercent: 16,
windowMinutes: 10080,
resetsAt: null,
resetDescription: null
},
fableWeekly: {
usedPercent: 42,
windowMinutes: 10080,
resetsAt: null,
resetDescription: null
},
updatedAt: Date.now(),
error: null,
status: 'ok'
}
}
describe('InlineUsageBars', () => {
it('renders Claude Fable usage in inactive account preview rows', async () => {
const { InlineUsageBars } = await import('./StatusBar')
const markup = renderToStaticMarkup(
<InlineUsageBars limits={claudeLimits()} isFetching={false} />
)
expect(markup).toContain('68% 5h')
expect(markup).toContain('84% wk')
expect(markup).toContain('58% Fable')
})
})