From 4e15ec5387ada5f24a29d57bb70e1bc004e5c6fe Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 13 Apr 2026 22:19:16 -0400 Subject: [PATCH] fix(cliproxy): degrade gracefully on GitHub release 403s - fall back to stale CLIProxy version caches instead of surfacing 500s - remove duplicate dashboard update-check fetches and disable retry/focus refetch - add backend and UI regressions for the degraded-path behavior --- src/cliproxy/binary/version-cache.ts | 42 +++++- src/cliproxy/binary/version-checker.ts | 126 +++++++++++++----- .../routes/cliproxy-stats-routes.ts | 96 +++++++++++-- .../version-checker-stale-cache.test.ts | 89 +++++++++++++ ...roxy-stats-routes-version-fallback.test.ts | 46 +++++++ .../components/cliproxy/cliproxy-header.tsx | 43 ++---- ui/src/hooks/use-cliproxy.ts | 6 +- .../cliproxy/cliproxy-header.test.tsx | 56 ++++++++ 8 files changed, 418 insertions(+), 86 deletions(-) create mode 100644 tests/unit/cliproxy/version-checker-stale-cache.test.ts create mode 100644 tests/unit/web-server/cliproxy-stats-routes-version-fallback.test.ts create mode 100644 ui/tests/unit/components/cliproxy/cliproxy-header.test.tsx diff --git a/src/cliproxy/binary/version-cache.ts b/src/cliproxy/binary/version-cache.ts index 2527fd76..cf39c211 100644 --- a/src/cliproxy/binary/version-cache.ts +++ b/src/cliproxy/binary/version-cache.ts @@ -33,6 +33,23 @@ export function getVersionPinPath(backend: CLIProxyBackend = DEFAULT_BACKEND): s * Read version cache if still valid (backend-specific) */ export function readVersionCache(backend: CLIProxyBackend = DEFAULT_BACKEND): VersionCache | null { + return readVersionCacheInternal(backend, false); +} + +/** + * Read version cache even if expired. + * Used as a resilience fallback when GitHub release lookups fail. + */ +export function readStaleVersionCache( + backend: CLIProxyBackend = DEFAULT_BACKEND +): VersionCache | null { + return readVersionCacheInternal(backend, true); +} + +function readVersionCacheInternal( + backend: CLIProxyBackend, + allowExpired: boolean +): VersionCache | null { const cachePath = getVersionCachePath(backend); if (!fs.existsSync(cachePath)) { return null; @@ -42,8 +59,8 @@ export function readVersionCache(backend: CLIProxyBackend = DEFAULT_BACKEND): Ve const content = fs.readFileSync(cachePath, 'utf8'); const cache: VersionCache = JSON.parse(content); - // Check if cache is still valid - if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { + // Check if cache is still valid, unless caller explicitly allows stale fallback. + if (allowExpired || Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { return cache; } @@ -192,6 +209,23 @@ export function getVersionListCachePath(backend: CLIProxyBackend = DEFAULT_BACKE */ export function readVersionListCache( backend: CLIProxyBackend = DEFAULT_BACKEND +): VersionListCache | null { + return readVersionListCacheInternal(backend, false); +} + +/** + * Read version list cache even if expired. + * Used as a resilience fallback when GitHub release lookups fail. + */ +export function readStaleVersionListCache( + backend: CLIProxyBackend = DEFAULT_BACKEND +): VersionListCache | null { + return readVersionListCacheInternal(backend, true); +} + +function readVersionListCacheInternal( + backend: CLIProxyBackend, + allowExpired: boolean ): VersionListCache | null { const cachePath = getVersionListCachePath(backend); if (!fs.existsSync(cachePath)) { @@ -202,8 +236,8 @@ export function readVersionListCache( const content = fs.readFileSync(cachePath, 'utf8'); const cache: VersionListCache = JSON.parse(content); - // Check if cache is still valid (1 hour) - if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { + // Check if cache is still valid, unless caller explicitly allows stale fallback. + if (allowExpired || Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { return cache; } diff --git a/src/cliproxy/binary/version-checker.ts b/src/cliproxy/binary/version-checker.ts index a76d4996..7c11a0b2 100644 --- a/src/cliproxy/binary/version-checker.ts +++ b/src/cliproxy/binary/version-checker.ts @@ -6,9 +6,11 @@ import { fetchJson } from './downloader'; import { readVersionCache, + readStaleVersionCache, writeVersionCache, readInstalledVersion, readVersionListCache, + readStaleVersionListCache, writeVersionListCache, } from './version-cache'; import { UpdateCheckResult, VersionListResult, getGitHubApiUrls } from './types'; @@ -19,6 +21,18 @@ import { } from '../platform-detector'; import type { CLIProxyBackend } from '../types'; +interface FetchLatestVersionDeps { + fetchJsonFn?: typeof fetchJson; +} + +interface CheckForUpdatesDeps { + fetchLatestVersionFn?: (verbose: boolean, backend: CLIProxyBackend) => Promise; +} + +interface FetchAllVersionsDeps { + fetchJsonFn?: typeof fetchJson; +} + /** * Compare semver versions (true if latest > current) * Handles CLIProxyAPIPlus versioning: strips -0 suffix before comparison @@ -61,10 +75,11 @@ export function isVersionFaulty(version: string): boolean { */ export async function fetchLatestVersion( verbose = false, - backend: CLIProxyBackend = DEFAULT_BACKEND + backend: CLIProxyBackend = DEFAULT_BACKEND, + deps: FetchLatestVersionDeps = {} ): Promise { const urls = getGitHubApiUrls(backend); - const response = await fetchJson(urls.latestRelease, verbose); + const response = await (deps.fetchJsonFn ?? fetchJson)(urls.latestRelease, verbose); // Extract version from tag_name (format: "v6.5.27" or "6.5.27") const tagName = response.tag_name as string; @@ -84,7 +99,8 @@ export async function checkForUpdates( binPath: string, configVersion: string, verbose = false, - backend: CLIProxyBackend = DEFAULT_BACKEND + backend: CLIProxyBackend = DEFAULT_BACKEND, + deps: CheckForUpdatesDeps = {} ): Promise { const currentVersion = readInstalledVersion(binPath, configVersion); @@ -103,18 +119,38 @@ export async function checkForUpdates( }; } - // Fetch from GitHub API (backend-specific repo) - const latestVersion = await fetchLatestVersion(verbose, backend); - const now = Date.now(); - writeVersionCache(latestVersion, backend); + try { + // Fetch from GitHub API (backend-specific repo) + const latestVersion = await (deps.fetchLatestVersionFn ?? fetchLatestVersion)(verbose, backend); + const now = Date.now(); + writeVersionCache(latestVersion, backend); - return { - hasUpdate: isNewerVersion(latestVersion, currentVersion), - currentVersion, - latestVersion, - fromCache: false, - checkedAt: now, - }; + return { + hasUpdate: isNewerVersion(latestVersion, currentVersion), + currentVersion, + latestVersion, + fromCache: false, + checkedAt: now, + }; + } catch (error) { + const staleCache = readStaleVersionCache(backend); + if (staleCache) { + if (verbose) { + console.error( + `[cliproxy] GitHub latest release lookup failed, using stale cache: ${(error as Error).message}` + ); + } + return { + hasUpdate: isNewerVersion(staleCache.latestVersion, currentVersion), + currentVersion, + latestVersion: staleCache.latestVersion, + fromCache: true, + checkedAt: staleCache.checkedAt, + }; + } + + throw error; + } } /** @@ -125,7 +161,8 @@ export async function checkForUpdates( */ export async function fetchAllVersions( verbose = false, - backend: CLIProxyBackend = DEFAULT_BACKEND + backend: CLIProxyBackend = DEFAULT_BACKEND, + deps: FetchAllVersionsDeps = {} ): Promise { // Try cache first (backend-specific) const cache = readVersionListCache(backend); @@ -136,31 +173,46 @@ export async function fetchAllVersions( return { ...cache, fromCache: true }; } - // Fetch from GitHub API (backend-specific repo) - const urls = getGitHubApiUrls(backend); - const response = await fetchJson(urls.allReleases, verbose); + try { + // Fetch from GitHub API (backend-specific repo) + const urls = getGitHubApiUrls(backend); + const response = await (deps.fetchJsonFn ?? fetchJson)(urls.allReleases, verbose); - // Extract and normalize versions - const releases = response as unknown as Array<{ tag_name: string }>; - const versions = releases - .map((r) => r.tag_name.replace(/^v/, '')) - .filter((v) => /^\d+\.\d+\.\d+(-\d+)?$/.test(v)); // Valid semver only + // Extract and normalize versions + const releases = response as unknown as Array<{ tag_name: string }>; + const versions = releases + .map((r) => r.tag_name.replace(/^v/, '')) + .filter((v) => /^\d+\.\d+\.\d+(-\d+)?$/.test(v)); // Valid semver only - const latest = versions[0] || ''; + const latest = versions[0] || ''; - // Find latest stable (not newer than max stable AND not in faulty range) - const latestStable = - versions.find((v) => !isNewerVersion(v, CLIPROXY_MAX_STABLE_VERSION) && !isVersionFaulty(v)) || - CLIPROXY_MAX_STABLE_VERSION; + // Find latest stable (not newer than max stable AND not in faulty range) + const latestStable = + versions.find( + (v) => !isNewerVersion(v, CLIPROXY_MAX_STABLE_VERSION) && !isVersionFaulty(v) + ) || CLIPROXY_MAX_STABLE_VERSION; - const result: VersionListResult = { - versions, - latestStable, - latest, - fromCache: false, - checkedAt: Date.now(), - }; + const result: VersionListResult = { + versions, + latestStable, + latest, + fromCache: false, + checkedAt: Date.now(), + }; - writeVersionListCache(result, backend); - return result; + writeVersionListCache(result, backend); + return result; + } catch (error) { + const staleCache = readStaleVersionListCache(backend); + if (staleCache) { + if (verbose) { + console.error( + `[cliproxy] GitHub release list lookup failed, using stale cache: ${(error as Error).message}` + ); + } + return { ...staleCache, fromCache: true }; + } + + throw error; + } } diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index a3fc3b05..e580d993 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -153,6 +153,89 @@ function getConfiguredBackend() { } } +function buildUpdateCheckFallback( + backend: ReturnType, + getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion +) { + const currentVersion = getInstalledVersionFn(backend); + const isStable = !isNewerVersion(currentVersion, CLIPROXY_MAX_STABLE_VERSION); + const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + + return { + hasUpdate: false, + currentVersion, + latestVersion: currentVersion, + fromCache: true, + checkedAt: Date.now(), + backend, + backendLabel, + isStable, + maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, + stabilityMessage: isStable + ? undefined + : `v${currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`, + }; +} + +function buildVersionsFallback( + backend: ReturnType, + getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion +) { + const currentVersion = getInstalledVersionFn(backend); + + return { + versions: currentVersion ? [currentVersion] : [], + latestStable: currentVersion || CLIPROXY_MAX_STABLE_VERSION, + latest: currentVersion || CLIPROXY_MAX_STABLE_VERSION, + fromCache: true, + checkedAt: Date.now(), + currentVersion, + maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, + faultyRange: CLIPROXY_FAULTY_RANGE, + }; +} + +interface ResolveUpdateCheckDeps { + checkCliproxyUpdateFn?: typeof checkCliproxyUpdate; + getInstalledVersionFn?: typeof getInstalledCliproxyVersion; +} + +interface ResolveVersionsDeps { + fetchAllVersionsFn?: typeof fetchAllVersions; + getInstalledVersionFn?: typeof getInstalledCliproxyVersion; +} + +export async function resolveCliproxyUpdateCheckPayload( + backend: ReturnType, + deps: ResolveUpdateCheckDeps = {} +) { + const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate; + const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion; + + return checkCliproxyUpdateFn(backend).catch(() => + buildUpdateCheckFallback(backend, getInstalledVersionFn) + ); +} + +export async function resolveCliproxyVersionsPayload( + backend: ReturnType, + deps: ResolveVersionsDeps = {} +) { + const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions; + const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion; + const result = await fetchAllVersionsFn(false, backend).catch(() => null); + if (!result) { + return buildVersionsFallback(backend, getInstalledVersionFn); + } + + return { + ...result, + currentVersion: getInstalledVersionFn(backend), + maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, + faultyRange: CLIPROXY_FAULTY_RANGE, + }; +} + /** * Extract status code and model from error log file (lightweight parsing). * Reads first 4KB for model, last 2KB for status code. Async to avoid blocking event loop. @@ -327,7 +410,8 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise = router.get('/update-check', async (_req: Request, res: Response): Promise => { try { const backend = getConfiguredBackend(); - const result = await checkCliproxyUpdate(backend); + const result = await resolveCliproxyUpdateCheckPayload(backend); + res.json(result); } catch (error) { console.error(`[cliproxy-stats] ${(error as Error).message}`); @@ -936,15 +1020,7 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P router.get('/versions', async (_req: Request, res: Response): Promise => { try { const backend = getConfiguredBackend(); - const result = await fetchAllVersions(false, backend); - const currentVersion = getInstalledCliproxyVersion(backend); - - res.json({ - ...result, - currentVersion, - maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, - faultyRange: CLIPROXY_FAULTY_RANGE, - }); + res.json(await resolveCliproxyVersionsPayload(backend)); } catch (error) { console.error(`[cliproxy-stats] ${(error as Error).message}`); res.status(500).json({ error: 'Internal server error' }); diff --git a/tests/unit/cliproxy/version-checker-stale-cache.test.ts b/tests/unit/cliproxy/version-checker-stale-cache.test.ts new file mode 100644 index 00000000..27c03871 --- /dev/null +++ b/tests/unit/cliproxy/version-checker-stale-cache.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +describe('version-checker stale cache fallback', () => { + let originalCcsHome: string | undefined; + let tempHome = ''; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-version-checker-')); + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('uses a stale latest-version cache when GitHub lookup fails', async () => { + const { + getVersionCachePath, + writeInstalledVersion, + } = await import('../../../src/cliproxy/binary/version-cache'); + const { VERSION_CACHE_DURATION_MS } = await import('../../../src/cliproxy/binary/types'); + const { checkForUpdates } = await import('../../../src/cliproxy/binary/version-checker'); + const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus'); + + fs.mkdirSync(plusBinDir, { recursive: true }); + writeInstalledVersion(plusBinDir, '6.6.80'); + fs.writeFileSync( + getVersionCachePath('plus'), + JSON.stringify({ + latestVersion: '6.9.23-0', + checkedAt: Date.now() - VERSION_CACHE_DURATION_MS - 1_000, + }), + 'utf8' + ); + + const result = await checkForUpdates(plusBinDir, '6.6.80', false, 'plus', { + fetchLatestVersionFn: async () => { + throw new Error('GitHub API error: HTTP 403'); + }, + }); + + expect(result.latestVersion).toBe('6.9.23-0'); + expect(result.currentVersion).toBe('6.6.80'); + expect(result.hasUpdate).toBe(true); + expect(result.fromCache).toBe(true); + }); + + it('uses a stale release-list cache when GitHub list lookup fails', async () => { + const { getVersionListCachePath } = await import('../../../src/cliproxy/binary/version-cache'); + const { VERSION_CACHE_DURATION_MS } = await import('../../../src/cliproxy/binary/types'); + const { fetchAllVersions } = await import('../../../src/cliproxy/binary/version-checker'); + const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus'); + + fs.mkdirSync(plusBinDir, { recursive: true }); + fs.writeFileSync( + getVersionListCachePath('plus'), + JSON.stringify({ + versions: ['6.9.23-0', '6.9.22-0', '6.9.19-0'], + latestStable: '6.9.23-0', + latest: '6.9.23-0', + checkedAt: Date.now() - VERSION_CACHE_DURATION_MS - 1_000, + }), + 'utf8' + ); + + const result = await fetchAllVersions(false, 'plus', { + fetchJsonFn: async () => { + throw new Error('GitHub API error: HTTP 403'); + }, + }); + + expect(result.versions).toEqual(['6.9.23-0', '6.9.22-0', '6.9.19-0']); + expect(result.latestStable).toBe('6.9.23-0'); + expect(result.latest).toBe('6.9.23-0'); + expect(result.fromCache).toBe(true); + }); +}); diff --git a/tests/unit/web-server/cliproxy-stats-routes-version-fallback.test.ts b/tests/unit/web-server/cliproxy-stats-routes-version-fallback.test.ts new file mode 100644 index 00000000..74865452 --- /dev/null +++ b/tests/unit/web-server/cliproxy-stats-routes-version-fallback.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'bun:test'; + +describe('cliproxy-stats-routes version fallback', () => { + it('returns a degraded update-check payload instead of propagating a 500', async () => { + const { resolveCliproxyUpdateCheckPayload } = await import( + '../../../src/web-server/routes/cliproxy-stats-routes' + ); + + const body = await resolveCliproxyUpdateCheckPayload('plus', { + checkCliproxyUpdateFn: async () => { + throw new Error('GitHub API error: HTTP 403'); + }, + getInstalledVersionFn: () => '6.6.80', + }); + + expect(body).toMatchObject({ + hasUpdate: false, + currentVersion: '6.6.80', + latestVersion: '6.6.80', + backend: 'plus', + backendLabel: 'CLIProxy Plus', + fromCache: true, + }); + }); + + it('returns a degraded versions payload instead of propagating a 500', async () => { + const { resolveCliproxyVersionsPayload } = await import( + '../../../src/web-server/routes/cliproxy-stats-routes' + ); + + const body = await resolveCliproxyVersionsPayload('plus', { + fetchAllVersionsFn: async () => { + throw new Error('GitHub API error: HTTP 403'); + }, + getInstalledVersionFn: () => '6.6.80', + }); + + expect(body).toMatchObject({ + versions: ['6.6.80'], + latestStable: '6.6.80', + latest: '6.6.80', + currentVersion: '6.6.80', + fromCache: true, + }); + }); +}); diff --git a/ui/src/components/cliproxy/cliproxy-header.tsx b/ui/src/components/cliproxy/cliproxy-header.tsx index a2bcf5e5..118dbcb6 100644 --- a/ui/src/components/cliproxy/cliproxy-header.tsx +++ b/ui/src/components/cliproxy/cliproxy-header.tsx @@ -3,22 +3,15 @@ * Fixed header with OAuth login buttons, status indicator, and refresh */ -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { RefreshCw, Loader2, AlertTriangle } from 'lucide-react'; -import { useCliproxyAuth } from '@/hooks/use-cliproxy'; +import { useCliproxyAuth, useCliproxyUpdateCheck } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { cn } from '@/lib/utils'; import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; -interface VersionInfo { - currentVersion: string; - isStable: boolean; - stabilityMessage?: string; - backendLabel?: string; -} - interface LoginButtonProps { provider: string; displayName: string; @@ -116,25 +109,9 @@ export function CliproxyHeader({ isRunning = true, }: CliproxyHeaderProps) { const { data: authData } = useCliproxyAuth(); + const { data: updateCheck } = useCliproxyUpdateCheck(); const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow(); const lastUpdatedText = useRelativeTime(lastUpdated); - const [versionInfo, setVersionInfo] = useState(null); - - useEffect(() => { - fetch('/api/cliproxy/update-check') - .then((res) => (res.ok ? res.json() : null)) - .then((data) => { - if (data) { - setVersionInfo({ - currentVersion: data.currentVersion, - isStable: data.isStable, - stabilityMessage: data.stabilityMessage, - backendLabel: data.backendLabel, - }); - } - }) - .catch(() => {}); // Silently fail - }, []); const providers = CLIPROXY_PROVIDERS.map((id) => ({ id, @@ -155,7 +132,7 @@ export function CliproxyHeader({

- {versionInfo?.backendLabel ?? 'CLIProxy'} + {updateCheck?.backendLabel ?? 'CLIProxy'}

CCS-level account management

@@ -191,18 +168,18 @@ export function CliproxyHeader({ {isRunning ? 'Running' : 'Offline'} - {versionInfo && ( + {updateCheck && ( - {!versionInfo.isStable && }v - {versionInfo.currentVersion} + {!updateCheck.isStable && }v + {updateCheck.currentVersion} )} diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 46d48a4e..e8154e62 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -447,7 +447,8 @@ export function useCliproxyUpdateCheck() { queryFn: () => api.cliproxy.updateCheck(), staleTime: 5 * 60 * 1000, // 5 minutes (reduced from 1 hour for faster backend switch response) refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes - refetchOnWindowFocus: true, // Refetch on window focus to catch backend changes + refetchOnWindowFocus: false, // Avoid refetch bursts for non-critical release metadata + retry: false, }); } @@ -492,7 +493,8 @@ export function useCliproxyVersions() { queryKey: ['cliproxy-versions'], queryFn: () => api.cliproxy.versions(), staleTime: 5 * 60 * 1000, // 5 minutes (reduced for faster backend switch response) - refetchOnWindowFocus: true, // Refetch on focus to catch backend changes + refetchOnWindowFocus: false, // Avoid repeated release lookups while browsing the dashboard + retry: false, }); } diff --git a/ui/tests/unit/components/cliproxy/cliproxy-header.test.tsx b/ui/tests/unit/components/cliproxy/cliproxy-header.test.tsx new file mode 100644 index 00000000..3416b382 --- /dev/null +++ b/ui/tests/unit/components/cliproxy/cliproxy-header.test.tsx @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '../../../setup/test-utils'; + +const hookMocks = vi.hoisted(() => ({ + startAuth: vi.fn(), +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useCliproxyAuth: () => ({ + data: { + authStatus: [], + }, + }), + useCliproxyUpdateCheck: () => ({ + data: { + backendLabel: 'CLIProxy Plus', + currentVersion: '6.9.23-0', + isStable: true, + stabilityMessage: undefined, + }, + }), +})); + +vi.mock('@/hooks/use-cliproxy-auth-flow', () => ({ + useCliproxyAuthFlow: () => ({ + provider: null, + isAuthenticating: false, + startAuth: hookMocks.startAuth, + }), +})); + +import { CliproxyHeader } from '@/components/cliproxy/cliproxy-header'; + +describe('CliproxyHeader', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders version data from the shared update query without issuing a direct fetch', () => { + render( + + ); + + expect(screen.getByText('CLIProxy Plus')).toBeInTheDocument(); + expect(screen.getByText('v6.9.23-0')).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});