From 3ea9d2fc346ca7c3cd64785b067aeeefbe2145b5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Mar 2026 10:02:46 -0400 Subject: [PATCH] fix(settings): stabilize websearch and quota checks --- src/utils/websearch/status.ts | 22 ++- .../unit/cliproxy/quota-fetcher-codex.test.ts | 33 +++- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 65 ++++--- tests/unit/utils/websearch/status.test.ts | 161 +++++++----------- .../settings/sections/websearch/index.tsx | 31 ++-- 5 files changed, 149 insertions(+), 163 deletions(-) diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 4594b4ad..4021d178 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -180,14 +180,11 @@ export function getCliInstallHints(): string[] { ]; } -/** - * Get WebSearch readiness status for display. - */ -export function getWebSearchReadiness(): WebSearchStatus { - const wsConfig = getWebSearchConfig(); - const providers = getWebSearchCliProviders(); - - if (!wsConfig.enabled) { +export function buildWebSearchReadiness( + enabled: boolean, + providers: WebSearchCliInfo[] +): WebSearchStatus { + if (!enabled) { return { readiness: 'unavailable', message: 'Disabled in config', @@ -223,6 +220,15 @@ export function getWebSearchReadiness(): WebSearchStatus { }; } +/** + * Get WebSearch readiness status for display. + */ +export function getWebSearchReadiness(): WebSearchStatus { + const wsConfig = getWebSearchConfig(); + const providers = getWebSearchCliProviders(); + return buildWebSearchReadiness(wsConfig.enabled, providers); +} + /** * Display WebSearch status (single line, equilibrium UX). */ diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index fc089c84..23473109 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -8,16 +8,15 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { - buildCodexQuotaWindows, - buildCodexCoreUsageSummary, - fetchCodexQuota, - getUnknownCodexWindowLabels, -} from '../../../src/cliproxy/quota-fetcher-codex'; let tmpDir: string; let originalCcsHome: string | undefined; let originalFetch: typeof fetch; +let moduleVersion = 0; +let buildCodexQuotaWindows: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexQuotaWindows; +let buildCodexCoreUsageSummary: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexCoreUsageSummary; +let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota; +let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels; function createCodexAccount( accountId: string, @@ -29,7 +28,26 @@ function createCodexAccount( fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload)); } -beforeEach(() => { +beforeEach(async () => { + moduleVersion += 1; + mock.restore(); + + const configGenerator = await import( + `../../../src/cliproxy/config-generator?codex-config-generator=${moduleVersion}` + ); + const accountManager = await import( + `../../../src/cliproxy/account-manager?codex-account-manager=${moduleVersion}` + ); + mock.module('../../../src/cliproxy/config-generator', () => configGenerator); + mock.module('../../../src/cliproxy/account-manager', () => accountManager); + + ({ + buildCodexQuotaWindows, + buildCodexCoreUsageSummary, + fetchCodexQuota, + getUnknownCodexWindowLabels, + } = await import(`../../../src/cliproxy/quota-fetcher-codex?codex-fetcher=${moduleVersion}`)); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-quota-test-')); originalCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tmpDir; @@ -37,6 +55,7 @@ beforeEach(() => { }); afterEach(() => { + mock.restore(); global.fetch = originalFetch; if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index f3dfd82d..58d9f741 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -7,21 +7,18 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { - buildGeminiCliBuckets, - resolveGeminiCliProjectId, -} from '../../../src/cliproxy/quota-fetcher-gemini-cli'; -import { refreshGeminiToken } from '../../../src/cliproxy/auth/gemini-token-refresh'; -import { getProviderAuthDir } from '../../../src/cliproxy/config-generator'; +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; describe('Gemini CLI Quota Fetcher', () => { let tempHome: string; - let originalHome: string | undefined; - let originalCcsHome: string | undefined; let originalGeminiClientId: string | undefined; let originalGeminiClientSecret: string | undefined; + let moduleVersion = 0; + let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets; + let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId; + 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 { const authDir = getProviderAuthDir('gemini'); @@ -31,35 +28,51 @@ describe('Gemini CLI Quota Fetcher', () => { return tokenPath; } - beforeEach(() => { + beforeEach(async () => { + moduleVersion += 1; + mock.restore(); + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-')); - originalHome = process.env.HOME; - originalCcsHome = process.env.CCS_HOME; originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID; originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; - process.env.HOME = tempHome; - process.env.CCS_HOME = tempHome; delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; + + const authDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + const actualConfigGenerator = await import( + `../../../src/cliproxy/config-generator?gemini-config-generator-actual=${moduleVersion}` + ); + const actualAccountManager = await import( + `../../../src/cliproxy/account-manager?gemini-account-manager-actual=${moduleVersion}` + ); + mock.module('../../../src/cliproxy/config-generator', () => ({ + ...actualConfigGenerator, + getProviderAuthDir: () => authDir, + })); + mock.module('../../../src/cliproxy/account-manager', () => ({ + ...actualAccountManager, + getDefaultAccount: () => null, + getProviderAccounts: () => [], + })); + + const configGenerator = await import( + `../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}` + ); + ({ buildGeminiCliBuckets, resolveGeminiCliProjectId } = await import( + `../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}` + )); + ({ refreshGeminiToken } = await import( + `../../../src/cliproxy/auth/gemini-token-refresh?gemini-refresh=${moduleVersion}` + )); + ({ getProviderAuthDir } = configGenerator); }); afterEach(() => { + mock.restore(); restoreFetch(); fs.rmSync(tempHome, { recursive: true, force: true }); - if (originalHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = originalHome; - } - - if (originalCcsHome === undefined) { - delete process.env.CCS_HOME; - } else { - process.env.CCS_HOME = originalCcsHome; - } - if (originalGeminiClientId === undefined) { delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; } else { diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index 4c79f83b..3bdd3c23 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -1,126 +1,83 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { getWebSearchReadiness } from '../../../../src/utils/websearch/status'; +import { describe, expect, it } from 'bun:test'; +import { buildWebSearchReadiness } from '../../../../src/utils/websearch/status'; +import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types'; -function writeWebSearchConfig(tempRoot: string, lines: string[]): void { - fs.writeFileSync(path.join(tempRoot, '.ccs', 'config.yaml'), lines.join('\n'), 'utf8'); +function provider(overrides: Partial & Pick): WebSearchCliInfo { + return { + id: overrides.id, + kind: overrides.kind ?? 'backend', + name: overrides.name, + enabled: overrides.enabled ?? false, + available: overrides.available ?? false, + version: overrides.version ?? null, + requiresApiKey: overrides.requiresApiKey ?? false, + description: overrides.description ?? '', + detail: overrides.detail ?? '', + ...overrides, + }; } describe('websearch readiness', () => { - const originalCcsHome = process.env.CCS_HOME; - const originalBraveKey = process.env.BRAVE_API_KEY; - const originalExaKey = process.env.EXA_API_KEY; - const originalTavilyKey = process.env.TAVILY_API_KEY; - let tempRoot = ''; - - beforeEach(() => { - tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-status-')); - process.env.CCS_HOME = tempRoot; - delete process.env.BRAVE_API_KEY; - delete process.env.EXA_API_KEY; - delete process.env.TAVILY_API_KEY; - fs.mkdirSync(path.join(tempRoot, '.ccs'), { recursive: true }); - }); - - afterEach(() => { - if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; - else delete process.env.CCS_HOME; - - if (originalBraveKey !== undefined) process.env.BRAVE_API_KEY = originalBraveKey; - else delete process.env.BRAVE_API_KEY; - - if (originalExaKey !== undefined) process.env.EXA_API_KEY = originalExaKey; - else delete process.env.EXA_API_KEY; - - if (originalTavilyKey !== undefined) process.env.TAVILY_API_KEY = originalTavilyKey; - else delete process.env.TAVILY_API_KEY; - - fs.rmSync(tempRoot, { recursive: true, force: true }); - }); - it('is ready by default because DuckDuckGo is enabled', () => { - const readiness = getWebSearchReadiness(); + const readiness = buildWebSearchReadiness(true, [ + provider({ + id: 'duckduckgo', + name: 'DuckDuckGo', + enabled: true, + available: true, + detail: 'Built-in (5 results)', + }), + ]); expect(readiness.readiness).toBe('ready'); expect(readiness.message).toContain('DuckDuckGo'); }); it('reports setup required when only Tavily is enabled without an API key', () => { - writeWebSearchConfig(tempRoot, [ - 'version: 10', - 'websearch:', - ' enabled: true', - ' providers:', - ' exa:', - ' enabled: false', - ' max_results: 5', - ' tavily:', - ' enabled: true', - ' max_results: 5', - ' duckduckgo:', - ' enabled: false', - ' max_results: 5', - ' brave:', - ' enabled: false', - ' max_results: 5', - ' gemini:', - ' enabled: false', - ' model: "gemini-2.5-flash"', - ' timeout: 55', - ' opencode:', - ' enabled: false', - ' model: "opencode/grok-code"', - ' timeout: 90', - ' grok:', - ' enabled: false', - ' timeout: 55', - '', + const readiness = buildWebSearchReadiness(true, [ + provider({ + id: 'tavily', + name: 'Tavily', + enabled: true, + available: false, + requiresApiKey: true, + apiKeyEnvVar: 'TAVILY_API_KEY', + detail: 'Set TAVILY_API_KEY', + }), + provider({ + id: 'duckduckgo', + name: 'DuckDuckGo', + enabled: false, + available: false, + detail: 'Built-in (5 results)', + }), ]); - const readiness = getWebSearchReadiness(); - expect(readiness.readiness).toBe('needs_setup'); expect(readiness.message).toContain('Tavily'); expect(readiness.message).toContain('TAVILY_API_KEY'); }); it('prefers API-backed readiness when Exa is enabled and configured', () => { - process.env.EXA_API_KEY = 'exa-test-key'; - writeWebSearchConfig(tempRoot, [ - 'version: 10', - 'websearch:', - ' enabled: true', - ' providers:', - ' exa:', - ' enabled: true', - ' max_results: 5', - ' tavily:', - ' enabled: false', - ' max_results: 5', - ' duckduckgo:', - ' enabled: false', - ' max_results: 5', - ' brave:', - ' enabled: false', - ' max_results: 5', - ' gemini:', - ' enabled: false', - ' model: "gemini-2.5-flash"', - ' timeout: 55', - ' opencode:', - ' enabled: false', - ' model: "opencode/grok-code"', - ' timeout: 90', - ' grok:', - ' enabled: false', - ' timeout: 55', - '', + const readiness = buildWebSearchReadiness(true, [ + provider({ + id: 'exa', + name: 'Exa', + enabled: true, + available: true, + requiresApiKey: true, + apiKeyEnvVar: 'EXA_API_KEY', + detail: 'API key detected (5 results)', + }), + provider({ + id: 'duckduckgo', + name: 'DuckDuckGo', + enabled: false, + available: false, + detail: 'Built-in (5 results)', + }), ]); - const readiness = getWebSearchReadiness(); - expect(readiness.readiness).toBe('ready'); expect(readiness.message).toContain('Exa'); }); diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index f8a92803..2cc1eb7d 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -350,24 +350,6 @@ export default function WebSearchSection() { fetchRawConfig(); }, [fetchConfig, fetchStatus, fetchRawConfig]); - useEffect(() => { - if (!config?.providers) { - return; - } - - const nextDrafts: Record = {}; - for (const provider of [...BACKEND_PROVIDERS, ...LEGACY_PROVIDERS]) { - for (const field of provider.fields ?? []) { - nextDrafts[`${provider.id}.${field.key}`] = getConfiguredValue( - config.providers, - provider.id, - field - ); - } - } - setFieldDrafts(nextDrafts); - }, [config]); - const providerStatus = useMemo( () => new Map((status?.providers ?? []).map((provider) => [provider.id, provider])), [status?.providers] @@ -410,7 +392,10 @@ export default function WebSearchSection() { const currentProviders = (config.providers ?? {}) as WebSearchProvidersConfig; const currentProviderConfig = currentProviders[providerId] ?? {}; const currentValue = currentProviderConfig[field.key] ?? field.defaultValue; - const normalized = normalizeFieldValue(field, fieldDrafts[fieldId] ?? String(currentValue)); + const normalized = normalizeFieldValue( + field, + fieldDrafts[fieldId] ?? getConfiguredValue(currentProviders, providerId, field) + ); setFieldDrafts((current) => ({ ...current, [fieldId]: String(normalized) })); @@ -443,7 +428,13 @@ export default function WebSearchSection() { return { id: fieldId, label: field.label, - value: fieldDrafts[fieldId] ?? String(field.defaultValue), + value: + fieldDrafts[fieldId] ?? + getConfiguredValue( + (config?.providers ?? {}) as WebSearchProvidersConfig, + provider.id, + field + ), placeholder: field.placeholder, type: field.type, helpText: field.helpText,