fix(cliproxy): refresh non-default gemini quota tokens

This commit is contained in:
Tam Nhu Tran
2026-04-08 23:55:55 -04:00
parent 04878f0864
commit b6fb443f57
11 changed files with 207 additions and 71 deletions
+28 -5
View File
@@ -16,12 +16,35 @@ export function sanitizeEmail(email: string): string {
* Check if token is expired based on the expired timestamp. * Check if token is expired based on the expired timestamp.
* Returns false if timestamp is missing or invalid (fail-open for quota display). * Returns false if timestamp is missing or invalid (fail-open for quota display).
*/ */
export function isTokenExpired(expiredStr?: string): boolean { export function getTokenExpiryTimestamp(expiredValue?: string | number | null): number | null {
if (!expiredStr) return false; if (expiredValue === undefined || expiredValue === null || expiredValue === '') {
return null;
}
try { try {
const expiredDate = new Date(expiredStr); if (typeof expiredValue === 'number') {
return expiredDate.getTime() < Date.now(); return Number.isFinite(expiredValue) ? expiredValue : null;
}
const trimmed = expiredValue.trim();
if (!trimmed) {
return null;
}
if (/^\d+$/.test(trimmed)) {
const numericTimestamp = Number(trimmed);
return Number.isFinite(numericTimestamp) ? numericTimestamp : null;
}
const expiredDate = new Date(trimmed);
const expiredAt = expiredDate.getTime();
return Number.isNaN(expiredAt) ? null : expiredAt;
} catch { } catch {
return false; return null;
} }
} }
export function isTokenExpired(expiredValue?: string | number | null): boolean {
const expiredAt = getTokenExpiryTimestamp(expiredValue);
return expiredAt !== null ? expiredAt < Date.now() : false;
}
+17 -10
View File
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { getAuthDir } from './config-generator'; import { getAuthDir } from './config-generator';
import { getProviderAccounts, getPausedDir } from './account-manager'; 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 { refreshGeminiToken } from './auth/gemini-token-refresh';
import { import {
buildGeminiCliBucketsFromParsedBuckets, buildGeminiCliBucketsFromParsedBuckets,
@@ -38,7 +38,7 @@ interface GeminiCliAuthData {
accessToken: string; accessToken: string;
projectId: string | null; projectId: string | null;
isExpired: boolean; isExpired: boolean;
expiresAt: string | null; expiresAt: string | number | null;
} }
/** Raw bucket from API response */ /** Raw bucket from API response */
@@ -130,17 +130,23 @@ function extractAccessToken(data: Record<string, unknown>): string | null {
* Extract expiry from Gemini auth file data * Extract expiry from Gemini auth file data
* Handles both flat (expired) and nested (token.expiry) structures * Handles both flat (expired) and nested (token.expiry) structures
*/ */
function extractExpiry(data: Record<string, unknown>): string | null { function extractExpiry(data: Record<string, unknown>): string | number | null {
// Flat structure: { expired: "..." } // Flat structure: { expired: "..." }
if (typeof data.expired === 'string') { if (typeof data.expired === 'string') {
return data.expired; return data.expired;
} }
if (typeof data.expired === 'number') {
return data.expired;
}
// Nested structure: { token: { expiry: "..." } } // Nested structure: { token: { expiry: "..." } }
if (data.token && typeof data.token === 'object') { if (data.token && typeof data.token === 'object') {
const token = data.token as Record<string, unknown>; const token = data.token as Record<string, unknown>;
if (typeof token.expiry === 'string') { if (typeof token.expiry === 'string') {
return token.expiry; return token.expiry;
} }
if (typeof token.expiry === 'number') {
return token.expiry;
}
} }
return null; return null;
} }
@@ -736,19 +742,20 @@ export async function fetchGeminiCliQuota(
// Proactive refresh: refresh if expired OR expiring within 5 minutes // Proactive refresh: refresh if expired OR expiring within 5 minutes
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000;
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
const shouldRefresh = const shouldRefresh =
authData.isExpired || authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS;
!authData.expiresAt || let attemptedRefresh = false;
new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS;
if (shouldRefresh) { if (shouldRefresh) {
attemptedRefresh = true;
if (verbose) if (verbose)
console.error( console.error(
authData.isExpired authData.isExpired
? '[i] Token expired, refreshing...' ? '[i] Token expired, refreshing...'
: '[i] Token expiring soon, proactive refresh...' : '[i] Token expiring soon, proactive refresh...'
); );
const refreshResult = await refreshGeminiToken(); const refreshResult = await refreshGeminiToken(accountId);
if (refreshResult.success) { if (refreshResult.success) {
if (verbose) console.error('[i] Token refreshed successfully'); if (verbose) console.error('[i] Token refreshed successfully');
@@ -776,10 +783,10 @@ export async function fetchGeminiCliQuota(
// First attempt with current token // First attempt with current token
const result = await fetchWithAuthData(authData, accountId, verbose); const result = await fetchWithAuthData(authData, accountId, verbose);
// If 401 error and we haven't refreshed yet, try refresh and retry // Retry once with an account-scoped refresh when the quota endpoint rejects auth.
if (result.needsReauth && result.error?.includes('expired')) { if (result.needsReauth && !attemptedRefresh) {
if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); if (verbose) console.error('[i] Got 401, attempting refresh and retry...');
const refreshResult = await refreshGeminiToken(); const refreshResult = await refreshGeminiToken(accountId);
if (refreshResult.success) { if (refreshResult.success) {
const refreshedAuthData = readGeminiCliAuthData(accountId); const refreshedAuthData = readGeminiCliAuthData(accountId);
if (refreshedAuthData) { if (refreshedAuthData) {
+19 -8
View File
@@ -5,7 +5,11 @@
*/ */
import { describe, it, expect } from 'bun:test'; 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('Auth Utilities', () => {
describe('sanitizeEmail', () => { describe('sanitizeEmail', () => {
@@ -75,14 +79,21 @@ describe('Auth Utilities', () => {
expect(isTokenExpired(futureISO)).toBe(false); expect(isTokenExpired(futureISO)).toBe(false);
}); });
it('should handle Unix timestamp strings', () => { it('should handle Unix timestamp strings deterministically', () => {
// JavaScript Date can parse numeric strings as timestamps
const pastTimestamp = String(Date.now() - 86400000); // Yesterday const pastTimestamp = String(Date.now() - 86400000); // Yesterday
// Note: Date parsing of pure numbers as strings is inconsistent expect(isTokenExpired(pastTimestamp)).toBe(true);
// This test documents the actual behavior });
const result = isTokenExpired(pastTimestamp);
// The behavior depends on how Date parses the string it('should handle Unix timestamps provided as numbers', () => {
expect(typeof result).toBe('boolean'); const futureTimestamp = Date.now() + 86400000;
expect(isTokenExpired(futureTimestamp)).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);
expect(getTokenExpiryTimestamp('not-a-date')).toBeNull();
}); });
}); });
}); });
@@ -27,10 +27,10 @@ describe('Gemini CLI Quota Fetcher', () => {
let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken;
let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir;
function writeGeminiToken(token: Record<string, unknown>): string { function writeGeminiToken(token: Record<string, unknown>, filename = 'gemini-test.json'): string {
const authDir = getProviderAuthDir('gemini'); const authDir = getProviderAuthDir('gemini');
fs.mkdirSync(authDir, { recursive: true }); 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)); fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2));
return tokenPath; return tokenPath;
} }
@@ -649,6 +649,74 @@ describe('Gemini CLI Quota Fetcher', () => {
expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)'); expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)');
expect(result.errorDetail).toBe('[HTML error response omitted]'); 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');
});
}); });
describe('direct Gemini error helper coverage', () => { describe('direct Gemini error helper coverage', () => {
@@ -249,7 +249,7 @@ export function AccountCard({
)} )}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-xs"> <TooltipContent side="top" className="max-w-sm">
<QuotaTooltipContent quota={quota} resetTime={resetTime} /> <QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -250,7 +250,7 @@ export function AccountQuotaPanel({
</div> </div>
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-xs"> <TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-sm">
<QuotaTooltipContent quota={quota} resetTime={resetTime} /> <QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -306,23 +306,8 @@ export function AccountQuotaPanel({
</div> </div>
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-[260px]"> <TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-sm">
<div className="space-y-1 text-xs"> <QuotaTooltipContent quota={quota} resetTime={resetTime} />
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -86,22 +86,35 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
if (!quota.success) { if (!quota.success) {
const failureInfo = getQuotaFailureInfo(quota); const failureInfo = getQuotaFailureInfo(quota);
const failureToneClass =
failureInfo?.tone === 'destructive'
? 'text-destructive'
: failureInfo?.tone === 'warning'
? 'text-amber-700 dark:text-amber-300'
: 'text-foreground';
return ( return (
<div className="text-xs space-y-1"> <div className="min-w-[16rem] space-y-2 text-xs">
<p className="font-medium text-destructive"> <div className="space-y-1">
{failureInfo?.label || quota.error || 'Failed to load quota'} <p className={cn('font-semibold tracking-tight', failureToneClass)}>
</p> {failureInfo?.label || quota.error || 'Failed to load quota'}
<p className="text-destructive/90">{failureInfo?.summary || quota.error}</p> </p>
<p className="leading-relaxed text-foreground/90">
{failureInfo?.summary || quota.error}
</p>
</div>
{failureInfo?.actionHint && ( {failureInfo?.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p> <div className="rounded-md border border-border/70 bg-muted/35 px-2.5 py-2 text-foreground/80">
{failureInfo.actionHint}
</div>
)} )}
{failureInfo?.technicalDetail && ( {failureInfo?.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground"> <div className="rounded-md border border-border/60 bg-muted/25 px-2 py-1.5 font-mono text-[11px] text-foreground/75">
{failureInfo.technicalDetail} {failureInfo.technicalDetail}
</p> </div>
)} )}
{failureInfo?.rawDetail && ( {failureInfo?.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground"> <pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border/70 bg-muted/55 px-2.5 py-2 font-mono text-[11px] leading-relaxed text-foreground/85">
{failureInfo.rawDetail} {failureInfo.rawDetail}
</pre> </pre>
)} )}
@@ -116,7 +129,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
return ( return (
<div className="text-xs space-y-1"> <div className="text-xs space-y-1.5">
<p className="font-medium">Model Quotas:</p> <p className="font-medium">Model Quotas:</p>
{tierOrder.map((tier, idx) => { {tierOrder.map((tier, idx) => {
const models = groups.get(tier); const models = groups.get(tier);
@@ -124,7 +137,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length);
return ( return (
<div key={tier}> <div key={tier}>
{!isFirst && <div className="border-t border-border/40 my-1" />} {!isFirst && <div className="my-1 border-t border-border/40" />}
{models.map((m) => ( {models.map((m) => (
<div key={m.name} className="flex justify-between gap-4"> <div key={m.name} className="flex justify-between gap-4">
<span className={cn('truncate', m.exhausted && 'text-red-500')}> <span className={cn('truncate', m.exhausted && 'text-red-500')}>
@@ -159,7 +172,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
); );
return ( return (
<div className="text-xs space-y-1"> <div className="text-xs space-y-1.5">
<p className="font-medium">Rate Limits:</p> <p className="font-medium">Rate Limits:</p>
{quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>} {quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>}
{orderedWindows.map((w, index) => ( {orderedWindows.map((w, index) => (
@@ -228,7 +241,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
null; null;
return ( return (
<div className="text-xs space-y-1"> <div className="text-xs space-y-1.5">
<p className="font-medium">Rate Limits:</p> <p className="font-medium">Rate Limits:</p>
{orderedWindows.map((window, index) => ( {orderedWindows.map((window, index) => (
<div <div
@@ -255,7 +268,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime); const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
return ( return (
<div className="text-xs space-y-1"> <div className="text-xs space-y-1.5">
{quota.tierLabel && ( {quota.tierLabel && (
<div className="flex justify-between gap-4"> <div className="flex justify-between gap-4">
<span className="text-muted-foreground">Tier</span> <span className="text-muted-foreground">Tier</span>
@@ -306,7 +319,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
const planLabel = formatPlanLabel(quota.planType); const planLabel = formatPlanLabel(quota.planType);
return ( return (
<div className="text-xs space-y-1"> <div className="text-xs space-y-1.5">
<p className="font-medium">Quota Snapshots:</p> <p className="font-medium">Quota Snapshots:</p>
{planLabel && <p className="text-muted-foreground">Plan: {planLabel}</p>} {planLabel && <p className="text-muted-foreground">Plan: {planLabel}</p>}
{snapshotRows.map(({ label, snapshot }) => { {snapshotRows.map(({ label, snapshot }) => {
@@ -344,9 +357,11 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) {
if (!resetTime) return null; if (!resetTime) return null;
return ( return (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50"> <div className="flex items-center gap-1.5 border-t border-border/60 pt-1">
<Clock className="w-3 h-3 text-blue-400" /> <Clock className="h-3 w-3 text-sky-600 dark:text-sky-300" />
<span className="text-blue-400 font-medium">Resets {formatResetTime(resetTime)}</span> <span className="font-medium text-sky-600 dark:text-sky-300">
Resets {formatResetTime(resetTime)}
</span>
</div> </div>
); );
} }
@@ -364,19 +379,19 @@ function CodexResetIndicators({
if (!hasSpecificReset && !fallbackResetTime) return null; if (!hasSpecificReset && !fallbackResetTime) return null;
return ( return (
<div className="pt-1 border-t border-border/50 space-y-1"> <div className="space-y-1 border-t border-border/60 pt-1">
{fiveHourResetTime && ( {fiveHourResetTime && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock className="w-3 h-3 text-blue-400" /> <Clock className="h-3 w-3 text-sky-600 dark:text-sky-300" />
<span className="text-blue-400 font-medium"> <span className="font-medium text-sky-600 dark:text-sky-300">
5h resets {formatResetTime(fiveHourResetTime)} 5h resets {formatResetTime(fiveHourResetTime)}
</span> </span>
</div> </div>
)} )}
{weeklyResetTime && ( {weeklyResetTime && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock className="w-3 h-3 text-indigo-400" /> <Clock className="h-3 w-3 text-indigo-600 dark:text-indigo-300" />
<span className="text-indigo-400 font-medium"> <span className="font-medium text-indigo-600 dark:text-indigo-300">
Weekly resets {formatResetTime(weeklyResetTime)} Weekly resets {formatResetTime(weeklyResetTime)}
</span> </span>
</div> </div>
+1 -1
View File
@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 className
)} )}
{...props} {...props}
+3 -3
View File
@@ -32,7 +32,7 @@ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimiti
function TooltipContent({ function TooltipContent({
className, className,
sideOffset = 0, sideOffset = 6,
children, children,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) { }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
@@ -42,13 +42,13 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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 max-w-sm 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 className
)} )}
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 fill-popover stroke-border stroke-[0.75px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
); );
@@ -133,6 +133,9 @@ describe('AccountCard grouped quota tooltip', () => {
await userEvent.hover(screen.getByText('Business')); await userEvent.hover(screen.getByText('Business'));
expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0); expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0);
expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0); expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0);
const tooltipContent = document.querySelector('[data-slot="tooltip-content"]');
expect(tooltipContent?.className).toContain('bg-popover');
expect(tooltipContent?.className).toContain('text-popover-foreground');
await userEvent.hover(screen.getByText('Personal')); await userEvent.hover(screen.getByText('Personal'));
expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0); expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0);
@@ -90,4 +90,28 @@ describe('QuotaTooltipContent', () => {
vi.useRealTimers(); 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,
});
render(<QuotaTooltipContent quota={quota} resetTime={null} />);
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();
});
}); });