diff --git a/src/cliproxy/auth-utils.ts b/src/cliproxy/auth-utils.ts index 77e371b1..d7f19c2a 100644 --- a/src/cliproxy/auth-utils.ts +++ b/src/cliproxy/auth-utils.ts @@ -16,12 +16,44 @@ export function sanitizeEmail(email: string): string { * Check if token is expired based on the expired timestamp. * Returns false if timestamp is missing or invalid (fail-open for quota display). */ -export function isTokenExpired(expiredStr?: string): boolean { - if (!expiredStr) return false; +export function getTokenExpiryTimestamp(expiredValue?: string | number | null): number | null { + if (expiredValue === undefined || expiredValue === null || expiredValue === '') { + return null; + } + + const normalizeNumericTimestamp = (value: number): number | null => { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + + // Support Unix seconds from older token stores while preserving millisecond timestamps. + return value < 1_000_000_000_000 ? value * 1000 : value; + }; + try { - const expiredDate = new Date(expiredStr); - return expiredDate.getTime() < Date.now(); + if (typeof expiredValue === 'number') { + return normalizeNumericTimestamp(expiredValue); + } + + const trimmed = expiredValue.trim(); + if (!trimmed) { + return null; + } + + if (/^\d+$/.test(trimmed)) { + const numericTimestamp = Number(trimmed); + return normalizeNumericTimestamp(numericTimestamp); + } + + const expiredDate = new Date(trimmed); + const expiredAt = expiredDate.getTime(); + return Number.isNaN(expiredAt) ? null : expiredAt; } catch { - return false; + return null; } } + +export function isTokenExpired(expiredValue?: string | number | null): boolean { + const expiredAt = getTokenExpiryTimestamp(expiredValue); + return expiredAt !== null ? expiredAt < Date.now() : false; +} diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 0ec6afef..1dc8fe7a 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { getAuthDir } from './config-generator'; import { getProviderAccounts, getPausedDir } from './account-manager'; -import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils'; import { refreshGeminiToken } from './auth/gemini-token-refresh'; import { buildGeminiCliBucketsFromParsedBuckets, @@ -38,7 +38,7 @@ interface GeminiCliAuthData { accessToken: string; projectId: string | null; isExpired: boolean; - expiresAt: string | null; + expiresAt: string | number | null; } /** Raw bucket from API response */ @@ -130,17 +130,23 @@ function extractAccessToken(data: Record): string | null { * Extract expiry from Gemini auth file data * Handles both flat (expired) and nested (token.expiry) structures */ -function extractExpiry(data: Record): string | null { +function extractExpiry(data: Record): string | number | null { // Flat structure: { expired: "..." } if (typeof data.expired === 'string') { return data.expired; } + if (typeof data.expired === 'number') { + return data.expired; + } // Nested structure: { token: { expiry: "..." } } if (data.token && typeof data.token === 'object') { const token = data.token as Record; if (typeof token.expiry === 'string') { return token.expiry; } + if (typeof token.expiry === 'number') { + return token.expiry; + } } return null; } @@ -736,10 +742,10 @@ export async function fetchGeminiCliQuota( // Proactive refresh: refresh if expired OR expiring within 5 minutes const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; + const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); const shouldRefresh = - authData.isExpired || - !authData.expiresAt || - new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; + authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS; + let refreshedBeforeQuotaFetch = false; if (shouldRefresh) { if (verbose) @@ -748,9 +754,10 @@ export async function fetchGeminiCliQuota( ? '[i] Token expired, refreshing...' : '[i] Token expiring soon, proactive refresh...' ); - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { + refreshedBeforeQuotaFetch = true; if (verbose) console.error('[i] Token refreshed successfully'); // Re-read auth data after successful refresh const refreshedAuthData = readGeminiCliAuthData(accountId); @@ -776,10 +783,10 @@ export async function fetchGeminiCliQuota( // First attempt with current token const result = await fetchWithAuthData(authData, accountId, verbose); - // If 401 error and we haven't refreshed yet, try refresh and retry - if (result.needsReauth && result.error?.includes('expired')) { + // Retry once with an account-scoped refresh when the quota endpoint rejects auth. + if (result.needsReauth && !refreshedBeforeQuotaFetch) { if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { const refreshedAuthData = readGeminiCliAuthData(accountId); if (refreshedAuthData) { diff --git a/tests/unit/cliproxy/auth-utils.test.ts b/tests/unit/cliproxy/auth-utils.test.ts index a6003b80..f6944882 100644 --- a/tests/unit/cliproxy/auth-utils.test.ts +++ b/tests/unit/cliproxy/auth-utils.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'bun:test'; -import { sanitizeEmail, isTokenExpired } from '../../../src/cliproxy/auth-utils'; +import { + getTokenExpiryTimestamp, + isTokenExpired, + sanitizeEmail, +} from '../../../src/cliproxy/auth-utils'; describe('Auth Utilities', () => { describe('sanitizeEmail', () => { @@ -75,14 +79,30 @@ describe('Auth Utilities', () => { expect(isTokenExpired(futureISO)).toBe(false); }); - it('should handle Unix timestamp strings', () => { - // JavaScript Date can parse numeric strings as timestamps + it('should handle Unix timestamp strings deterministically', () => { const pastTimestamp = String(Date.now() - 86400000); // Yesterday - // Note: Date parsing of pure numbers as strings is inconsistent - // This test documents the actual behavior - const result = isTokenExpired(pastTimestamp); - // The behavior depends on how Date parses the string - expect(typeof result).toBe('boolean'); + expect(isTokenExpired(pastTimestamp)).toBe(true); + }); + + it('should handle Unix timestamps provided as numbers', () => { + const futureTimestamp = Date.now() + 86400000; + expect(isTokenExpired(futureTimestamp)).toBe(false); + }); + + it('should treat Unix-seconds values as seconds, not milliseconds', () => { + const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000); + expect(isTokenExpired(futureUnixSeconds)).toBe(false); + expect(isTokenExpired(String(futureUnixSeconds))).toBe(false); + }); + + it('should expose normalized expiry timestamps for string and numeric inputs', () => { + const futureTimestamp = Date.now() + 60000; + expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp); + expect(getTokenExpiryTimestamp(String(futureTimestamp))).toBe(futureTimestamp); + const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000); + expect(getTokenExpiryTimestamp(futureUnixSeconds)).toBe(futureUnixSeconds * 1000); + expect(getTokenExpiryTimestamp(String(futureUnixSeconds))).toBe(futureUnixSeconds * 1000); + expect(getTokenExpiryTimestamp('not-a-date')).toBeNull(); }); }); }); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 8c2d1b18..1966c6d5 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -27,10 +27,10 @@ describe('Gemini CLI Quota Fetcher', () => { let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; - function writeGeminiToken(token: Record): string { + function writeGeminiToken(token: Record, filename = 'gemini-test.json'): string { const authDir = getProviderAuthDir('gemini'); fs.mkdirSync(authDir, { recursive: true }); - const tokenPath = path.join(authDir, 'gemini-test.json'); + const tokenPath = path.join(authDir, filename); fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2)); return tokenPath; } @@ -649,6 +649,179 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)'); expect(result.errorDetail).toBe('[HTML error response omitted]'); }); + + it('refreshes the requested Gemini account instead of the default account', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'default@example.com', + project_id: 'default-project', + token: { + access_token: 'default-access-token', + refresh_token: 'default-refresh-token', + expiry: Date.now() + 60 * 60 * 1000, + client_id: 'default-client-id', + client_secret: 'default-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-default.json' + ); + + writeGeminiToken( + { + type: 'gemini', + email: 'target@example.com', + project_id: 'target-project', + token: { + access_token: 'target-stale-token', + refresh_token: 'target-refresh-token', + expiry: Date.now() - 1000, + client_id: 'target-client-id', + client_secret: 'target-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-target.json' + ); + + mockFetch([ + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + response: { access_token: 'target-fresh-token', expires_in: 1800 }, + }, + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 200, + response: { + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + }, + }, + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const result = await fetchGeminiCliQuota('target@example.com'); + + expect(result.success).toBe(true); + + const [refreshRequest, quotaRequest] = getCapturedFetchRequests(); + expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL); + expect(refreshRequest.body).toContain('refresh_token=target-refresh-token'); + expect(refreshRequest.body).not.toContain('default-refresh-token'); + expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token'); + }); + + it('retries a 401 quota failure after a transient proactive refresh failure', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'retry@example.com', + project_id: 'retry-project', + token: { + access_token: 'retry-stale-token', + refresh_token: 'retry-refresh-token', + expiry: Date.now() + 60 * 1000, + client_id: 'retry-client-id', + client_secret: 'retry-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-retry.json' + ); + + mockFetch([ + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + response: { access_token: 'unused-default', expires_in: 1800 }, + }, + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 200, + response: { + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }, + }, + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const originalFetch = globalThis.fetch; + let refreshAttempt = 0; + let quotaAttempt = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + if (url === GOOGLE_TOKEN_URL) { + refreshAttempt += 1; + return refreshAttempt === 1 + ? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + : new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url === GEMINI_QUOTA_URL) { + quotaAttempt += 1; + return quotaAttempt === 1 + ? new Response( + JSON.stringify({ + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ) + : new Response( + JSON.stringify({ + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return originalFetch(input, init); + }) as typeof fetch; + + try { + const result = await fetchGeminiCliQuota('retry@example.com'); + + expect(result.success).toBe(true); + expect(refreshAttempt).toBe(2); + expect(quotaAttempt).toBe(2); + + const storedToken = JSON.parse( + fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8') + ) as { token?: { access_token?: string } }; + expect(storedToken.token?.access_token).toBe('retry-fresh-token'); + } finally { + globalThis.fetch = originalFetch; + } + }); }); describe('direct Gemini error helper coverage', () => { diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index b4f65156..debb9d8b 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -249,7 +249,7 @@ export function AccountCard({ )} - + diff --git a/ui/src/components/account/shared/account-quota-panel.tsx b/ui/src/components/account/shared/account-quota-panel.tsx index 95c4fb57..fc52019c 100644 --- a/ui/src/components/account/shared/account-quota-panel.tsx +++ b/ui/src/components/account/shared/account-quota-panel.tsx @@ -250,7 +250,7 @@ export function AccountQuotaPanel({ )} - + @@ -306,23 +306,8 @@ export function AccountQuotaPanel({ )} - -
-

{failureInfo.summary}

- {failureInfo.actionHint && ( -

{failureInfo.actionHint}

- )} - {failureInfo.technicalDetail && ( -

- {failureInfo.technicalDetail} -

- )} - {failureInfo.rawDetail && ( -
-                {failureInfo.rawDetail}
-              
- )} -
+ + diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 25edae1b..5dcda088 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -27,6 +27,8 @@ interface QuotaTooltipContentProps { resetTime: string | null; } +const lowQuotaTextClass = 'text-red-700 dark:text-red-400'; + function formatPlanLabel(planType: string | null | undefined): string | null { if (!planType) return null; const normalized = planType @@ -86,22 +88,35 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro if (!quota.success) { const failureInfo = getQuotaFailureInfo(quota); + const failureToneClass = + failureInfo?.tone === 'destructive' + ? 'text-destructive' + : failureInfo?.tone === 'warning' + ? 'text-amber-700 dark:text-amber-300' + : 'text-foreground'; + return ( -
-

- {failureInfo?.label || quota.error || 'Failed to load quota'} -

-

{failureInfo?.summary || quota.error}

+
+
+

+ {failureInfo?.label || quota.error || 'Failed to load quota'} +

+

+ {failureInfo?.summary || quota.error} +

+
{failureInfo?.actionHint && ( -

{failureInfo.actionHint}

+
+ {failureInfo.actionHint} +
)} {failureInfo?.technicalDetail && ( -

+

{failureInfo.technicalDetail} -

+
)} {failureInfo?.rawDetail && ( -
+          
             {failureInfo.rawDetail}
           
)} @@ -116,7 +131,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; return ( -
+

Model Quotas:

{tierOrder.map((tier, idx) => { const models = groups.get(tier); @@ -124,13 +139,13 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); return (
- {!isFirst &&
} + {!isFirst &&
} {models.map((m) => (
- + {m.displayName} - + {m.percentage}%
@@ -159,7 +174,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro ); return ( -
+

Rate Limits:

{quota.planType &&

Plan: {quota.planType}

} {orderedWindows.map((w, index) => ( @@ -167,7 +182,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro key={`${w.label}-${w.resetAt ?? 'no-reset'}-${index}`} className="flex justify-between gap-4" > - + {getCodexWindowDisplayLabel(w, orderedWindows)} {w.remainingPercent}% @@ -228,14 +243,14 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro null; return ( -
+

Rate Limits:

{orderedWindows.map((window, index) => (
- + {getClaudeWindowDisplayLabel(window.rateLimitType, window.label)} {window.remainingPercent}% @@ -255,7 +270,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime); return ( -
+
{quota.tierLabel && (
Tier @@ -272,7 +287,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro {quota.buckets.map((b) => (
- + {b.label} {b.tokenType ? ` (${b.tokenType})` : ''} @@ -306,7 +321,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const planLabel = formatPlanLabel(quota.planType); return ( -
+

Quota Snapshots:

{planLabel &&

Plan: {planLabel}

} {snapshotRows.map(({ label, snapshot }) => { @@ -314,8 +329,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro return (
- {label} - + {label} + {snapshot.unlimited ? 'Unlimited' : `${formatQuotaPercent(snapshot.percentRemaining)}%`} @@ -344,9 +359,11 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) { if (!resetTime) return null; return ( -
- - Resets {formatResetTime(resetTime)} +
+ + + Resets {formatResetTime(resetTime)} +
); } @@ -364,19 +381,19 @@ function CodexResetIndicators({ if (!hasSpecificReset && !fallbackResetTime) return null; return ( -
+
{fiveHourResetTime && (
- - + + 5h resets {formatResetTime(fiveHourResetTime)}
)} {weeklyResetTime && (
- - + + Weekly resets {formatResetTime(weeklyResetTime)}
diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx index 07b74e1a..b78a6417 100644 --- a/ui/src/components/ui/popover.tsx +++ b/ui/src/components/ui/popover.tsx @@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 w-80 max-w-[calc(100vw-2rem)] rounded-lg border border-border/70 bg-popover p-4 text-popover-foreground shadow-xl shadow-black/10 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...props} diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx index d4011c57..71e1ed7a 100644 --- a/ui/src/components/ui/tooltip.tsx +++ b/ui/src/components/ui/tooltip.tsx @@ -32,7 +32,7 @@ function TooltipTrigger({ ...props }: React.ComponentProps) { @@ -42,13 +42,13 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - 'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance', + 'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit max-w-[calc(100vw-2rem)] origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10', className )} {...props} > {children} - + ); diff --git a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx index 8086b0a9..539e48e3 100644 --- a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx @@ -131,11 +131,21 @@ describe('AccountCard grouped quota tooltip', () => { ); await userEvent.hover(screen.getByText('Business')); - expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0); + const businessPlan = (await screen.findAllByText('Plan: team')).find((node) => + node.closest('[data-slot="tooltip-content"]') + ); + expect(businessPlan).toBeInTheDocument(); expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0); + const tooltipContent = businessPlan.closest('[data-slot="tooltip-content"]'); + expect(tooltipContent?.className).toContain('bg-popover'); + expect(tooltipContent?.className).toContain('text-popover-foreground'); + expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]'); await userEvent.hover(screen.getByText('Personal')); - expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0); + const personalPlan = (await screen.findAllByText('Plan: plus')).find((node) => + node.closest('[data-slot="tooltip-content"]') + ); + expect(personalPlan).toBeInTheDocument(); expect(screen.getAllByText('Weekly usage limit').length).toBeGreaterThan(0); }); }); diff --git a/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx b/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx new file mode 100644 index 00000000..4263945f --- /dev/null +++ b/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, userEvent } from '@tests/setup/test-utils'; +import { describe, expect, it } from 'vitest'; +import { AccountQuotaPanel } from '@/components/account/shared/account-quota-panel'; +import type { GeminiCliQuotaResult } from '@/lib/api-client'; + +function createGeminiFailureQuota(): GeminiCliQuotaResult { + return { + success: false, + buckets: [], + projectId: 'test-project', + lastUpdated: Date.now(), + error: 'Request had invalid authentication credentials.', + httpStatus: 401, + errorCode: 'UNAUTHENTICATED', + errorDetail: + '{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}', + actionHint: 'Run ccs gemini --auth to reconnect this account.', + needsReauth: true, + }; +} + +describe('AccountQuotaPanel failure tooltip', () => { + it('renders the shared failure tooltip content with viewport-safe shell classes', async () => { + render( + + ); + + await userEvent.hover(screen.getByText('Reauth')); + + const summary = ( + await screen.findAllByText('Request had invalid authentication credentials.') + ).find((node) => node.closest('[data-slot="tooltip-content"]')); + expect(summary).toBeInTheDocument(); + const tooltipContent = summary.closest('[data-slot="tooltip-content"]'); + const actionHint = screen + .getAllByText('Run ccs gemini --auth to reconnect this account.') + .find((node) => node.closest('[data-slot="tooltip-content"]') === tooltipContent); + const technicalDetail = screen + .getAllByText('HTTP 401 | UNAUTHENTICATED') + .find((node) => node.closest('[data-slot="tooltip-content"]') === tooltipContent); + expect(actionHint).toBeInTheDocument(); + expect(technicalDetail).toBeInTheDocument(); + expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]'); + expect(tooltipContent?.className).toContain('bg-popover'); + expect(tooltipContent?.className).toContain('text-popover-foreground'); + }); +}); diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index 73ab0923..bada72b1 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -90,4 +90,29 @@ describe('QuotaTooltipContent', () => { vi.useRealTimers(); }); + + it('renders failure summaries, action hints, and raw details with readable structure', () => { + const quota = createGeminiQuotaResult({ + success: false, + buckets: [], + error: 'Request had invalid authentication credentials.', + httpStatus: 401, + errorCode: 'UNAUTHENTICATED', + errorDetail: + '{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}', + actionHint: 'Run ccs gemini --auth to reconnect this account.', + needsReauth: true, + }); + + const { container } = render(); + + expect(screen.getByText('Reauth')).toBeInTheDocument(); + expect(screen.getByText('Request had invalid authentication credentials.')).toBeInTheDocument(); + expect( + screen.getByText('Run ccs gemini --auth to reconnect this account.') + ).toBeInTheDocument(); + expect(screen.getByText('HTTP 401 | UNAUTHENTICATED')).toBeInTheDocument(); + expect(screen.getByText(/"status":"UNAUTHENTICATED"/)).toBeInTheDocument(); + expect(container.firstChild).not.toHaveClass('min-w-[16rem]'); + }); });