mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
fix(cliproxy): refresh non-default gemini quota tokens
This commit is contained in:
@@ -16,12 +16,35 @@ 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;
|
||||
}
|
||||
|
||||
try {
|
||||
const expiredDate = new Date(expiredStr);
|
||||
return expiredDate.getTime() < Date.now();
|
||||
if (typeof expiredValue === 'number') {
|
||||
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 {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenExpired(expiredValue?: string | number | null): boolean {
|
||||
const expiredAt = getTokenExpiryTimestamp(expiredValue);
|
||||
return expiredAt !== null ? expiredAt < Date.now() : false;
|
||||
}
|
||||
|
||||
@@ -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, unknown>): string | null {
|
||||
* Extract expiry from Gemini auth file data
|
||||
* 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: "..." }
|
||||
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<string, unknown>;
|
||||
if (typeof token.expiry === 'string') {
|
||||
return token.expiry;
|
||||
}
|
||||
if (typeof token.expiry === 'number') {
|
||||
return token.expiry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -736,19 +742,20 @@ 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 attemptedRefresh = false;
|
||||
|
||||
if (shouldRefresh) {
|
||||
attemptedRefresh = true;
|
||||
if (verbose)
|
||||
console.error(
|
||||
authData.isExpired
|
||||
? '[i] Token expired, refreshing...'
|
||||
: '[i] Token expiring soon, proactive refresh...'
|
||||
);
|
||||
const refreshResult = await refreshGeminiToken();
|
||||
const refreshResult = await refreshGeminiToken(accountId);
|
||||
|
||||
if (refreshResult.success) {
|
||||
if (verbose) console.error('[i] Token refreshed successfully');
|
||||
@@ -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 && !attemptedRefresh) {
|
||||
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) {
|
||||
|
||||
@@ -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,21 @@ 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 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 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');
|
||||
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,74 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('direct Gemini error helper coverage', () => {
|
||||
|
||||
@@ -249,7 +249,7 @@ export function AccountCard({
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -250,7 +250,7 @@ export function AccountQuotaPanel({
|
||||
</div>
|
||||
)}
|
||||
</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} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -306,23 +306,8 @@ export function AccountQuotaPanel({
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-[260px]">
|
||||
<div className="space-y-1 text-xs">
|
||||
<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 side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-sm">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -86,22 +86,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 (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium text-destructive">
|
||||
{failureInfo?.label || quota.error || 'Failed to load quota'}
|
||||
</p>
|
||||
<p className="text-destructive/90">{failureInfo?.summary || quota.error}</p>
|
||||
<div className="min-w-[16rem] space-y-2 text-xs">
|
||||
<div className="space-y-1">
|
||||
<p className={cn('font-semibold tracking-tight', failureToneClass)}>
|
||||
{failureInfo?.label || quota.error || 'Failed to load quota'}
|
||||
</p>
|
||||
<p className="leading-relaxed text-foreground/90">
|
||||
{failureInfo?.summary || quota.error}
|
||||
</p>
|
||||
</div>
|
||||
{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 && (
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
</pre>
|
||||
)}
|
||||
@@ -116,7 +129,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-xs space-y-1.5">
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{tierOrder.map((tier, idx) => {
|
||||
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);
|
||||
return (
|
||||
<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) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||
@@ -159,7 +172,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-xs space-y-1.5">
|
||||
<p className="font-medium">Rate Limits:</p>
|
||||
{quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>}
|
||||
{orderedWindows.map((w, index) => (
|
||||
@@ -228,7 +241,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
null;
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-xs space-y-1.5">
|
||||
<p className="font-medium">Rate Limits:</p>
|
||||
{orderedWindows.map((window, index) => (
|
||||
<div
|
||||
@@ -255,7 +268,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-xs space-y-1.5">
|
||||
{quota.tierLabel && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">Tier</span>
|
||||
@@ -306,7 +319,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
const planLabel = formatPlanLabel(quota.planType);
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="text-xs space-y-1.5">
|
||||
<p className="font-medium">Quota Snapshots:</p>
|
||||
{planLabel && <p className="text-muted-foreground">Plan: {planLabel}</p>}
|
||||
{snapshotRows.map(({ label, snapshot }) => {
|
||||
@@ -344,9 +357,11 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) {
|
||||
if (!resetTime) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">Resets {formatResetTime(resetTime)}</span>
|
||||
<div className="flex items-center gap-1.5 border-t border-border/60 pt-1">
|
||||
<Clock className="h-3 w-3 text-sky-600 dark:text-sky-300" />
|
||||
<span className="font-medium text-sky-600 dark:text-sky-300">
|
||||
Resets {formatResetTime(resetTime)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -364,19 +379,19 @@ function CodexResetIndicators({
|
||||
if (!hasSpecificReset && !fallbackResetTime) return null;
|
||||
|
||||
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 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
<Clock className="h-3 w-3 text-sky-600 dark:text-sky-300" />
|
||||
<span className="font-medium text-sky-600 dark:text-sky-300">
|
||||
5h resets {formatResetTime(fiveHourResetTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{weeklyResetTime && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3 text-indigo-400" />
|
||||
<span className="text-indigo-400 font-medium">
|
||||
<Clock className="h-3 w-3 text-indigo-600 dark:text-indigo-300" />
|
||||
<span className="font-medium text-indigo-600 dark:text-indigo-300">
|
||||
Weekly resets {formatResetTime(weeklyResetTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -32,7 +32,7 @@ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimiti
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
sideOffset = 6,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
@@ -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 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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{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.Portal>
|
||||
);
|
||||
|
||||
@@ -133,6 +133,9 @@ describe('AccountCard grouped quota tooltip', () => {
|
||||
await userEvent.hover(screen.getByText('Business'));
|
||||
expect((await screen.findAllByText('Plan: team')).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'));
|
||||
expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0);
|
||||
|
||||
@@ -90,4 +90,28 @@ 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,
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user