import { describe, expect, it, spyOn } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import * as geminiCli from '../../../../src/utils/websearch/gemini-cli'; import * as grokCli from '../../../../src/utils/websearch/grok-cli'; import * as opencodeCli from '../../../../src/utils/websearch/opencode-cli'; import * as providerSecrets from '../../../../src/utils/websearch/provider-secrets'; import * as unifiedConfigLoader from '../../../../src/config/unified-config-loader'; import { buildWebSearchReadiness, getWebSearchCliProviders, } from '../../../../src/utils/websearch/status'; import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types'; 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', () => { it('is ready by default because DuckDuckGo is enabled', () => { 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', () => { 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)', }), ]); 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', () => { 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)', }), ]); expect(readiness.readiness).toBe('ready'); expect(readiness.message).toContain('Exa'); }); it('treats SearXNG as ready when enabled with a valid URL', () => { const readiness = buildWebSearchReadiness(true, [ provider({ id: 'searxng', name: 'SearXNG', enabled: true, available: true, detail: 'Configured (5 results)', }), provider({ id: 'duckduckgo', name: 'DuckDuckGo', enabled: false, available: false, detail: 'Built-in (5 results)', }), ]); expect(readiness.readiness).toBe('ready'); expect(readiness.message).toContain('SearXNG'); }); it('marks SearXNG as unavailable when config uses a query-bearing endpoint URL', () => { const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ enabled: true, providers: { exa: { enabled: false, max_results: 5 }, tavily: { enabled: false, max_results: 5 }, brave: { enabled: false, max_results: 5 }, searxng: { enabled: true, url: 'https://search.example.com/search?format=json', max_results: 5, }, duckduckgo: { enabled: false, max_results: 5 }, gemini: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, }, } as any); const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, }); const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ installed: false, version: null, } as any); const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ installed: false, version: null, } as any); const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ installed: false, version: null, } as any); try { const providers = getWebSearchCliProviders(); const searxng = providers.find((entry) => entry.id === 'searxng'); expect(searxng?.enabled).toBe(true); expect(searxng?.available).toBe(false); expect(searxng?.detail).toContain('Set a valid SearXNG base URL'); } finally { getConfigSpy.mockRestore(); apiKeySpy.mockRestore(); geminiStatusSpy.mockRestore(); geminiAuthSpy.mockRestore(); grokStatusSpy.mockRestore(); opencodeStatusSpy.mockRestore(); } }); it('marks SearXNG as unavailable when enabled with a blank URL', () => { const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ enabled: true, providers: { exa: { enabled: false, max_results: 5 }, tavily: { enabled: false, max_results: 5 }, brave: { enabled: false, max_results: 5 }, searxng: { enabled: true, url: '', max_results: 5, }, duckduckgo: { enabled: false, max_results: 5 }, gemini: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, }, } as any); const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, }); const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ installed: false, version: null, } as any); const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ installed: false, version: null, } as any); const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ installed: false, version: null, } as any); try { const providers = getWebSearchCliProviders(); const searxng = providers.find((entry) => entry.id === 'searxng'); expect(searxng?.enabled).toBe(true); expect(searxng?.available).toBe(false); expect(searxng?.detail).toContain('Set a valid SearXNG base URL'); } finally { getConfigSpy.mockRestore(); apiKeySpy.mockRestore(); geminiStatusSpy.mockRestore(); geminiAuthSpy.mockRestore(); grokStatusSpy.mockRestore(); opencodeStatusSpy.mockRestore(); } }); it('treats cooled-down providers as temporarily unavailable in readiness status', () => { const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-')); const statePath = join(tempHome, '.ccs', 'cache', 'websearch-provider-state.json'); const originalCcsHome = process.env.CCS_HOME; mkdirSync(join(tempHome, '.ccs', 'cache'), { recursive: true }); writeFileSync( statePath, JSON.stringify( { cooldowns: { exa: { until: Date.now() + 10 * 60 * 1000, reason: 'quota_exhausted', }, }, }, null, 2 ), 'utf8' ); process.env.CCS_HOME = tempHome; const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ enabled: true, providers: { exa: { enabled: true, max_results: 5 }, tavily: { enabled: false, max_results: 5 }, brave: { enabled: false, max_results: 5 }, searxng: { enabled: false, url: '', max_results: 5 }, duckduckgo: { enabled: false, max_results: 5 }, gemini: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, }, } as any); const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ exa: { envVar: 'EXA_API_KEY', configured: true, available: true, source: 'process_env', }, tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none', }, brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none', }, }); const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ installed: false, version: null, } as any); const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ installed: false, version: null, } as any); const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ installed: false, version: null, } as any); try { const providers = getWebSearchCliProviders(); const exa = providers.find((provider) => provider.id === 'exa'); expect(exa?.enabled).toBe(true); expect(exa?.available).toBe(false); expect(exa?.detail).toContain('Cooling down'); expect(exa?.detail).toContain('quota exhaustion'); const readiness = buildWebSearchReadiness(true, providers); expect(readiness.readiness).toBe('needs_setup'); expect(readiness.message).toContain('Cooling down'); } finally { getConfigSpy.mockRestore(); apiKeySpy.mockRestore(); geminiStatusSpy.mockRestore(); geminiAuthSpy.mockRestore(); grokStatusSpy.mockRestore(); opencodeStatusSpy.mockRestore(); if (originalCcsHome === undefined) { delete process.env.CCS_HOME; } else { process.env.CCS_HOME = originalCcsHome; } rmSync(tempHome, { recursive: true, force: true }); } }); });