mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
fix(review): harden quota retry and tooltip edge cases
This commit is contained in:
@@ -21,9 +21,18 @@ export function getTokenExpiryTimestamp(expiredValue?: string | number | null):
|
|||||||
return null;
|
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 {
|
try {
|
||||||
if (typeof expiredValue === 'number') {
|
if (typeof expiredValue === 'number') {
|
||||||
return Number.isFinite(expiredValue) ? expiredValue : null;
|
return normalizeNumericTimestamp(expiredValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = expiredValue.trim();
|
const trimmed = expiredValue.trim();
|
||||||
@@ -33,7 +42,7 @@ export function getTokenExpiryTimestamp(expiredValue?: string | number | null):
|
|||||||
|
|
||||||
if (/^\d+$/.test(trimmed)) {
|
if (/^\d+$/.test(trimmed)) {
|
||||||
const numericTimestamp = Number(trimmed);
|
const numericTimestamp = Number(trimmed);
|
||||||
return Number.isFinite(numericTimestamp) ? numericTimestamp : null;
|
return normalizeNumericTimestamp(numericTimestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiredDate = new Date(trimmed);
|
const expiredDate = new Date(trimmed);
|
||||||
|
|||||||
@@ -745,10 +745,9 @@ export async function fetchGeminiCliQuota(
|
|||||||
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
|
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
|
||||||
const shouldRefresh =
|
const shouldRefresh =
|
||||||
authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS;
|
authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS;
|
||||||
let attemptedRefresh = false;
|
let refreshedBeforeQuotaFetch = false;
|
||||||
|
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
attemptedRefresh = true;
|
|
||||||
if (verbose)
|
if (verbose)
|
||||||
console.error(
|
console.error(
|
||||||
authData.isExpired
|
authData.isExpired
|
||||||
@@ -758,6 +757,7 @@ export async function fetchGeminiCliQuota(
|
|||||||
const refreshResult = await refreshGeminiToken(accountId);
|
const refreshResult = await refreshGeminiToken(accountId);
|
||||||
|
|
||||||
if (refreshResult.success) {
|
if (refreshResult.success) {
|
||||||
|
refreshedBeforeQuotaFetch = true;
|
||||||
if (verbose) console.error('[i] Token refreshed successfully');
|
if (verbose) console.error('[i] Token refreshed successfully');
|
||||||
// Re-read auth data after successful refresh
|
// Re-read auth data after successful refresh
|
||||||
const refreshedAuthData = readGeminiCliAuthData(accountId);
|
const refreshedAuthData = readGeminiCliAuthData(accountId);
|
||||||
@@ -784,7 +784,7 @@ export async function fetchGeminiCliQuota(
|
|||||||
const result = await fetchWithAuthData(authData, accountId, verbose);
|
const result = await fetchWithAuthData(authData, accountId, verbose);
|
||||||
|
|
||||||
// Retry once with an account-scoped refresh when the quota endpoint rejects auth.
|
// Retry once with an account-scoped refresh when the quota endpoint rejects auth.
|
||||||
if (result.needsReauth && !attemptedRefresh) {
|
if (result.needsReauth && !refreshedBeforeQuotaFetch) {
|
||||||
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(accountId);
|
const refreshResult = await refreshGeminiToken(accountId);
|
||||||
if (refreshResult.success) {
|
if (refreshResult.success) {
|
||||||
|
|||||||
@@ -89,10 +89,19 @@ describe('Auth Utilities', () => {
|
|||||||
expect(isTokenExpired(futureTimestamp)).toBe(false);
|
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', () => {
|
it('should expose normalized expiry timestamps for string and numeric inputs', () => {
|
||||||
const futureTimestamp = Date.now() + 60000;
|
const futureTimestamp = Date.now() + 60000;
|
||||||
expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp);
|
expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp);
|
||||||
expect(getTokenExpiryTimestamp(String(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();
|
expect(getTokenExpiryTimestamp('not-a-date')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -717,6 +717,111 @@ describe('Gemini CLI Quota Fetcher', () => {
|
|||||||
expect(refreshRequest.body).not.toContain('default-refresh-token');
|
expect(refreshRequest.body).not.toContain('default-refresh-token');
|
||||||
expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-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', () => {
|
describe('direct Gemini error helper coverage', () => {
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ export function AccountCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="max-w-sm">
|
<TooltipContent side="top" className="sm: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-sm">
|
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="sm:max-w-sm">
|
||||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -306,7 +306,7 @@ export function AccountQuotaPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-sm">
|
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="sm:max-w-sm">
|
||||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface QuotaTooltipContentProps {
|
|||||||
resetTime: string | null;
|
resetTime: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lowQuotaTextClass = 'text-red-700 dark:text-red-400';
|
||||||
|
|
||||||
function formatPlanLabel(planType: string | null | undefined): string | null {
|
function formatPlanLabel(planType: string | null | undefined): string | null {
|
||||||
if (!planType) return null;
|
if (!planType) return null;
|
||||||
const normalized = planType
|
const normalized = planType
|
||||||
@@ -94,7 +96,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
|||||||
: 'text-foreground';
|
: 'text-foreground';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[16rem] space-y-2 text-xs">
|
<div className="max-w-sm space-y-2 text-xs">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className={cn('font-semibold tracking-tight', failureToneClass)}>
|
<p className={cn('font-semibold tracking-tight', failureToneClass)}>
|
||||||
{failureInfo?.label || quota.error || 'Failed to load quota'}
|
{failureInfo?.label || quota.error || 'Failed to load quota'}
|
||||||
@@ -140,10 +142,10 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
|||||||
{!isFirst && <div className="my-1 border-t border-border/40" />}
|
{!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 && lowQuotaTextClass)}>
|
||||||
{m.displayName}
|
{m.displayName}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
<span className={cn('font-mono', m.exhausted && lowQuotaTextClass)}>
|
||||||
{m.percentage}%
|
{m.percentage}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +182,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
|||||||
key={`${w.label}-${w.resetAt ?? 'no-reset'}-${index}`}
|
key={`${w.label}-${w.resetAt ?? 'no-reset'}-${index}`}
|
||||||
className="flex justify-between gap-4"
|
className="flex justify-between gap-4"
|
||||||
>
|
>
|
||||||
<span className={cn(w.remainingPercent < 20 && 'text-red-500')}>
|
<span className={cn(w.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||||
{getCodexWindowDisplayLabel(w, orderedWindows)}
|
{getCodexWindowDisplayLabel(w, orderedWindows)}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono">{w.remainingPercent}%</span>
|
<span className="font-mono">{w.remainingPercent}%</span>
|
||||||
@@ -248,7 +250,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
|||||||
key={`${window.rateLimitType}-${window.resetAt ?? 'no-reset'}-${window.status}-${index}`}
|
key={`${window.rateLimitType}-${window.resetAt ?? 'no-reset'}-${window.status}-${index}`}
|
||||||
className="flex justify-between gap-4"
|
className="flex justify-between gap-4"
|
||||||
>
|
>
|
||||||
<span className={cn(window.remainingPercent < 20 && 'text-red-500')}>
|
<span className={cn(window.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||||
{getClaudeWindowDisplayLabel(window.rateLimitType, window.label)}
|
{getClaudeWindowDisplayLabel(window.rateLimitType, window.label)}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono">{window.remainingPercent}%</span>
|
<span className="font-mono">{window.remainingPercent}%</span>
|
||||||
@@ -285,7 +287,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
|||||||
{quota.buckets.map((b) => (
|
{quota.buckets.map((b) => (
|
||||||
<div key={b.id} className="space-y-0.5">
|
<div key={b.id} className="space-y-0.5">
|
||||||
<div className="flex justify-between gap-4">
|
<div className="flex justify-between gap-4">
|
||||||
<span className={cn(b.remainingPercent < 20 && 'text-red-500')}>
|
<span className={cn(b.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||||
{b.label}
|
{b.label}
|
||||||
{b.tokenType ? ` (${b.tokenType})` : ''}
|
{b.tokenType ? ` (${b.tokenType})` : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -327,8 +329,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
|||||||
return (
|
return (
|
||||||
<div key={label} className="space-y-0.5">
|
<div key={label} className="space-y-0.5">
|
||||||
<div className="flex justify-between gap-4">
|
<div className="flex justify-between gap-4">
|
||||||
<span className={cn(isLow && 'text-red-500')}>{label}</span>
|
<span className={cn(isLow && lowQuotaTextClass)}>{label}</span>
|
||||||
<span className={cn('font-mono', isLow && 'text-red-500')}>
|
<span className={cn('font-mono', isLow && lowQuotaTextClass)}>
|
||||||
{snapshot.unlimited
|
{snapshot.unlimited
|
||||||
? 'Unlimited'
|
? 'Unlimited'
|
||||||
: `${formatQuotaPercent(snapshot.percentRemaining)}%`}
|
: `${formatQuotaPercent(snapshot.percentRemaining)}%`}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function TooltipContent({
|
|||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'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',
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -131,14 +131,21 @@ 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);
|
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);
|
expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0);
|
||||||
const tooltipContent = document.querySelector('[data-slot="tooltip-content"]');
|
const tooltipContent = businessPlan.closest('[data-slot="tooltip-content"]');
|
||||||
expect(tooltipContent?.className).toContain('bg-popover');
|
expect(tooltipContent?.className).toContain('bg-popover');
|
||||||
expect(tooltipContent?.className).toContain('text-popover-foreground');
|
expect(tooltipContent?.className).toContain('text-popover-foreground');
|
||||||
|
expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]');
|
||||||
|
|
||||||
await userEvent.hover(screen.getByText('Personal'));
|
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);
|
expect(screen.getAllByText('Weekly usage limit').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<AccountQuotaPanel
|
||||||
|
provider="gemini"
|
||||||
|
quota={createGeminiFailureQuota()}
|
||||||
|
quotaLoading={false}
|
||||||
|
mode="detailed"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,7 +104,7 @@ describe('QuotaTooltipContent', () => {
|
|||||||
needsReauth: true,
|
needsReauth: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<QuotaTooltipContent quota={quota} resetTime={null} />);
|
const { container } = render(<QuotaTooltipContent quota={quota} resetTime={null} />);
|
||||||
|
|
||||||
expect(screen.getByText('Reauth')).toBeInTheDocument();
|
expect(screen.getByText('Reauth')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Request had invalid authentication credentials.')).toBeInTheDocument();
|
expect(screen.getByText('Request had invalid authentication credentials.')).toBeInTheDocument();
|
||||||
@@ -113,5 +113,6 @@ describe('QuotaTooltipContent', () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText('HTTP 401 | UNAUTHENTICATED')).toBeInTheDocument();
|
expect(screen.getByText('HTTP 401 | UNAUTHENTICATED')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/"status":"UNAUTHENTICATED"/)).toBeInTheDocument();
|
expect(screen.getByText(/"status":"UNAUTHENTICATED"/)).toBeInTheDocument();
|
||||||
|
expect(container.firstChild).not.toHaveClass('min-w-[16rem]');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user