diff --git a/packages/pi/extensions/askweb.ts b/packages/pi/extensions/askweb.ts index 11658a3..dff6bf2 100644 --- a/packages/pi/extensions/askweb.ts +++ b/packages/pi/extensions/askweb.ts @@ -206,7 +206,7 @@ export default function askwebExtension(pi: ExtensionAPI) { const resolvedProvider = providerName ?? (await askweb.resolveDefaultProviderAsync()) - const provider = askweb.create(resolvedProvider) + const provider = askweb.createSearchProvider(resolvedProvider) const results = await provider.search(query, searchOptions) const header = buildHeader({ mode: "single", @@ -346,7 +346,7 @@ export default function askwebExtension(pi: ExtensionAPI) { let results: SearchResult[] try { results = await askweb - .create(providerName) + .createSearchProvider(providerName) .search(trimmed, { maxResults: DEFAULT_MAX_RESULTS }) } catch (err) { if (ctx.hasUI) { diff --git a/src/ai.ts b/src/ai.ts index a9e358b..214e12c 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -1,7 +1,7 @@ import { tool } from 'ai' import { z } from 'zod' import { builtinProviders } from './core/providers.ts' -import { create } from './core/registry.ts' +import { createSearchProvider } from './core/registry.ts' import { searchAll } from './core/all.ts' import { readProviderNames, readUrl } from './core/read.ts' import { EmptyQueryError, EmptyUrlError } from './core/errors.ts' @@ -34,7 +34,7 @@ export const searchTool = tool({ } const name = providerName ?? resolveDefaultProvider() - return create(name).search(query, searchOptions) + return createSearchProvider(name).search(query, searchOptions) }, }) diff --git a/src/commands/search.ts b/src/commands/search.ts index 391d520..15e0d5b 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -28,9 +28,9 @@ export default defineCommand({ }, }, async run({ args }) { - const { create } = await import('../core/registry.ts') + const { createSearchProvider } = await import('../core/registry.ts') const { resolveDefaultProvider } = await import('../core/resolve.ts') - const { AuthError, UnknownProviderError, NoProviderConfiguredError } = await import('../core/errors.ts') + const { AuthError, SearchNotSupportedError, UnknownProviderError, NoProviderConfiguredError } = await import('../core/errors.ts') let providerName = args.provider try { @@ -47,7 +47,7 @@ export default defineCommand({ providerName = args.provider || resolveDefaultProvider() await import('../providers/index.ts') - const provider = create(providerName, {}) + const provider = createSearchProvider(providerName, {}) const results = await provider.search(args.query, { maxResults: maxResults.value, }) @@ -81,6 +81,10 @@ export default defineCommand({ consola.info(`Set the ${authProvider.toUpperCase()}_API_KEY environment variable.`) process.exit(1) } + if (error instanceof SearchNotSupportedError) { + consola.error(`Provider "${error.provider}" does not support web search.`) + process.exit(1) + } if (error instanceof UnknownProviderError) { const { providers } = await import('../core/registry.ts') consola.error(`Unknown provider: ${providerName}`) diff --git a/src/core/all.ts b/src/core/all.ts index 75118a2..a69dd5f 100644 --- a/src/core/all.ts +++ b/src/core/all.ts @@ -1,6 +1,6 @@ import type { SearchResult, SearchOptions } from './types.ts' import { UnknownProviderError, NoProviderConfiguredError, NoProviderAvailableError, EmptyQueryError, validateDateFilters } from './errors.ts' -import { create, has } from './registry.ts' +import { createSearchProvider, has } from './registry.ts' import { detectAvailableProviders, detectAvailableProvidersAsync } from './resolve.ts' export interface SearchAllOptions extends SearchOptions { @@ -67,7 +67,7 @@ export async function searchAllDetailed(query: string, options?: SearchAllOption const settled = await Promise.allSettled( providerNames.map(async (name) => { - const provider = create(name) + const provider = createSearchProvider(name) const results = await provider.search(query, searchOptions) return results.map(result => ({ ...result, provider: name })) }), @@ -96,6 +96,7 @@ export async function searchAllDetailed(query: string, options?: SearchAllOption + function deduplicateByUrl(results: SearchAllResult[]): SearchAllResult[] { const seen = new Map() diff --git a/src/core/errors.ts b/src/core/errors.ts index b87e106..5bc2710 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -82,6 +82,27 @@ export class EmptyUrlError extends AskwebError { } } +export class InvalidProviderUrlError extends AskwebError { + readonly provider: string + + constructor(provider: string) { + super(`Invalid base URL for provider "${provider}": expected an absolute http or https URL`) + this.name = 'InvalidProviderUrlError' + this.provider = provider + } +} + +/** Thrown when a provider does not implement the search capability. */ +export class SearchNotSupportedError extends AskwebError { + readonly provider: string + + constructor(provider: string) { + super(`Provider does not support search: ${provider}`) + this.name = 'SearchNotSupportedError' + this.provider = provider + } +} + /** Thrown when a provider does not implement the read capability. */ export class ReadNotSupportedError extends AskwebError { readonly provider: string diff --git a/src/core/provider.ts b/src/core/provider.ts new file mode 100644 index 0000000..5c1dbe4 --- /dev/null +++ b/src/core/provider.ts @@ -0,0 +1,65 @@ +import { defaultClient, type Client } from './client.ts' +import type { ProviderConfig, ReadOptions, ReadResult, SearchOptions, SearchResult } from './types.ts' +import { InvalidProviderUrlError } from './errors.ts' + +export interface ProviderConstructor { + readonly providerName: string + readonly defaultBaseURL: string + new (config: ProviderConfig): Provider +} + +export abstract class Provider { + readonly #name: string + protected readonly client: Client + protected readonly baseURL: string + + get name(): string { + return this.#name + } + + protected constructor(config: ProviderConfig, provider: Pick) { + this.#name = provider.providerName + const baseURL = config.baseURL ?? provider.defaultBaseURL + assertProviderBaseURL(baseURL, this.name) + this.baseURL = baseURL.replace(/\/+$/, '') + this.client = defaultClient() + } +} + +export function assertProviderBaseURL(baseURL: string, providerName: string): void { + let protocol: string + try { + protocol = new URL(baseURL).protocol + } + catch { + throw new InvalidProviderUrlError(providerName) + } + + if (protocol !== 'http:' && protocol !== 'https:') { + throw new InvalidProviderUrlError(providerName) + } +} + +export interface SearchProvider { + search(query: string, options?: SearchOptions): Promise +} + +export interface ReadProvider { + read(url: string, options?: ReadOptions): Promise +} + +export interface AvailabilityProvider { + isAvailable(): Promise +} + +export function isSearchProvider(provider: Provider): provider is Provider & SearchProvider { + return 'search' in provider && typeof provider.search === 'function' +} + +export function isReadProvider(provider: Provider): provider is Provider & ReadProvider { + return 'read' in provider && typeof provider.read === 'function' +} + +export function isAvailabilityProvider(provider: Provider): provider is Provider & AvailabilityProvider { + return 'isAvailable' in provider && typeof provider.isAvailable === 'function' +} diff --git a/src/core/read.ts b/src/core/read.ts index 3e63195..17c5885 100644 --- a/src/core/read.ts +++ b/src/core/read.ts @@ -1,7 +1,7 @@ import type { ReadOptions, ReadResult } from './types.ts' import { builtinProviders } from './providers.ts' import { EmptyUrlError, ReadNotSupportedError } from './errors.ts' -import { create } from './registry.ts' +import { createReadProvider } from './registry.ts' export const readProviderNames = ['jina', 'firecrawl'] as const export type ReadProviderName = typeof readProviderNames[number] @@ -24,14 +24,11 @@ export async function readUrl(url: string, options?: ReadUrlOptions): Promise() -const defaultURLs = new Map() +const providerClasses = new Map() /** - * Register a provider factory with the registry. + * Register a provider class. * Called by providers on import to self-register. */ -export function register( - name: string, - defaultURL: string, - factory: ProviderFactory -): void { - factories.set(name, factory) - defaultURLs.set(name, defaultURL) +export function register(provider: ProviderConstructor): void { + providerClasses.set(provider.providerName, provider) } /** * Create a provider instance by name. * Resolves apiKey from config or environment variable (PROVIDER_NAME_API_KEY). */ -export function create(name: string, config?: ProviderConfig): SearchProvider { - const factory = factories.get(name) - if (!factory) { +export function create(name: string, config?: ProviderConfig): Provider { + const ProviderClass = providerClasses.get(name) + if (!ProviderClass) { throw new UnknownProviderError(name) } @@ -31,25 +37,33 @@ export function create(name: string, config?: ProviderConfig): SearchProvider { config?.apiKey || process.env[`${name.toUpperCase()}_API_KEY`] - const resolvedConfig: ProviderConfig = { + return new ProviderClass({ ...config, apiKey, - baseURL: config?.baseURL || defaultURLs.get(name), + baseURL: config?.baseURL || ProviderClass.defaultBaseURL, + }) +} + +export function createSearchProvider(name: string, config?: ProviderConfig): Provider & SearchProvider { + const provider = create(name, config) + if (!isSearchProvider(provider)) { + throw new SearchNotSupportedError(name) } + return provider +} - return factory(resolvedConfig) +export function createReadProvider(name: string, config?: ProviderConfig): Provider & ReadProvider { + const provider = create(name, config) + if (!isReadProvider(provider)) { + throw new ReadNotSupportedError(name) + } + return provider } -/** - * List all registered provider names. - */ export function providers(): string[] { - return Array.from(factories.keys()) + return Array.from(providerClasses.keys()) } -/** - * Check if a provider is registered. - */ export function has(name: string): boolean { - return factories.has(name) + return providerClasses.has(name) } diff --git a/src/core/resolve.ts b/src/core/resolve.ts index ff69574..76888df 100644 --- a/src/core/resolve.ts +++ b/src/core/resolve.ts @@ -1,6 +1,7 @@ import { builtinProviders, type WebSearchProviderName } from './providers.ts' import { create, has } from './registry.ts' import { NoProviderAvailableError, NoProviderConfiguredError } from './errors.ts' +import { isAvailabilityProvider } from './provider.ts' const envKeys: Record = { EXA_API_KEY: 'exa', @@ -52,9 +53,9 @@ export interface ProviderStatus { envVar: string | null /** * Set by {@link listProvidersAsync} when the provider implements - * {@link SearchProvider.isAvailable}. `true` = probe succeeded, `false` = - * probe failed (host down / unreachable / timeout), `undefined` = no probe - * was performed (sync caller) or provider has no probe (trust `configured`). + * {@link AvailabilityProvider.isAvailable}. `true` = probe succeeded, + * `false` = probe failed (host down / unreachable / timeout), `undefined` = + * no reachability probe was performed (trust `configured`). */ reachable?: boolean } @@ -126,7 +127,7 @@ export async function resolveDefaultProviderAsync(): Promise { try { const provider = create(name) - if (typeof provider.isAvailable !== 'function') return undefined + if (!isAvailabilityProvider(provider)) return undefined return await provider.isAvailable() } catch { diff --git a/src/core/types.ts b/src/core/types.ts index 3829f60..5dd77df 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -45,28 +45,12 @@ export interface ReadOptions { noCache?: boolean } -export interface SearchProvider { - name(): string - search(query: string, options?: SearchOptions): Promise - read?(url: string, options?: ReadOptions): Promise - /** - * Optional reachability probe. Used by {@link searchAll} and async detection - * helpers to skip self-hosted / optional providers whose endpoint is not - * responding, without failing the fan-out. Providers backed by paid APIs - * usually omit this and rely on env-var presence as the configured signal. - * Should resolve quickly (<= ~2s) and never throw. - */ - isAvailable?(): Promise -} - export interface ProviderConfig { apiKey?: string baseURL?: string readBaseURL?: string } -export type ProviderFactory = (config: ProviderConfig) => SearchProvider - export interface ClientOptions { maxRetries?: number baseDelay?: number diff --git a/src/index.ts b/src/index.ts index a501630..7d44a42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,13 +4,15 @@ export { version } from './version.ts' export { builtinProviders, type WebSearchProviderName } from './core/providers.ts' -export type { SearchResult, SearchOptions, ReadResult, ReadOptions, SearchProvider, ProviderConfig, ProviderFactory, ClientOptions } from './core/types.ts' +export type { SearchResult, SearchOptions, ReadResult, ReadOptions, ProviderConfig, ClientOptions } from './core/types.ts' +export { Provider, isSearchProvider, isReadProvider, isAvailabilityProvider } from './core/provider.ts' +export type { ProviderConstructor, SearchProvider, ReadProvider, AvailabilityProvider } from './core/provider.ts' -export { AskwebError, HTTPError, AuthError, RateLimitError, UnknownProviderError, NoProviderConfiguredError, NoProviderAvailableError, EmptyQueryError, EmptyUrlError, ReadNotSupportedError, InvalidDateFilterError, normalizeError, validateDateFilters } from './core/errors.ts' +export { AskwebError, HTTPError, AuthError, RateLimitError, UnknownProviderError, InvalidProviderUrlError, SearchNotSupportedError, NoProviderConfiguredError, NoProviderAvailableError, EmptyQueryError, EmptyUrlError, ReadNotSupportedError, InvalidDateFilterError, normalizeError, validateDateFilters } from './core/errors.ts' export { Client, defaultClient } from './core/client.ts' -export { register, create, providers, has } from './core/registry.ts' +export { register, create, createSearchProvider, createReadProvider, providers, has } from './core/registry.ts' export { searchAll, searchAllDetailed } from './core/all.ts' export type { SearchAllOptions, SearchAllResult, SearchAllResponse, ProviderError } from './core/all.ts' diff --git a/src/opencode.ts b/src/opencode.ts index dbb22c8..aada6af 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -2,7 +2,7 @@ import type { Plugin } from '@opencode-ai/plugin' import { tool } from '@opencode-ai/plugin' import { encode } from '@toon-format/toon' import { builtinProviders } from './core/providers.ts' -import { create } from './core/registry.ts' +import { createSearchProvider } from './core/registry.ts' import { searchAll } from './core/all.ts' import { readProviderNames, readUrl } from './core/read.ts' import { resolveDefaultProvider, listProviders } from './core/resolve.ts' @@ -28,7 +28,7 @@ const AskwebPlugin: Plugin = async () => ({ } const name = providerName ?? resolveDefaultProvider() - return encode(await create(name).search(query, { maxResults })) + return encode(await createSearchProvider(name).search(query, { maxResults })) }, }), askweb_read: tool({ diff --git a/src/providers/brave.ts b/src/providers/brave.ts index 5001d9d..98f17cc 100644 --- a/src/providers/brave.ts +++ b/src/providers/brave.ts @@ -1,6 +1,5 @@ -import type { SearchResult, SearchOptions, SearchProvider, ProviderConfig, ProviderFactory } from '../core/types.ts' -import { defaultClient } from '../core/client.ts' -import type { Client } from '../core/client.ts' +import type { SearchResult, SearchOptions, ProviderConfig } from '../core/types.ts' +import { Provider } from '../core/provider.ts' import { AuthError, normalizeError } from '../core/errors.ts' import { register } from '../core/registry.ts' @@ -23,25 +22,21 @@ interface BraveSearchResponse { } } -class BraveProvider implements SearchProvider { - private readonly client: Client - private readonly baseURL: string +class BraveProvider extends Provider { + static readonly providerName = 'brave' + static readonly defaultBaseURL = 'https://api.search.brave.com' + private readonly apiKey: string constructor(config: ProviderConfig) { + super(config, BraveProvider) if (!config.apiKey) { throw new AuthError('Missing API key for Brave Search. Set BRAVE_API_KEY', 'brave') } - this.client = defaultClient() - this.baseURL = config.baseURL ?? 'https://api.search.brave.com' this.apiKey = config.apiKey } - name(): string { - return 'brave' - } - async search(query: string, options?: SearchOptions): Promise { try { const url = `${this.baseURL}/res/v1/web/search?q=${encodeURIComponent(query)}&count=${options?.maxResults ?? 10}` @@ -65,6 +60,4 @@ function mapResult(result: BraveResult): SearchResult { } } -const factory: ProviderFactory = (config) => new BraveProvider(config) - -register('brave', 'https://api.search.brave.com', factory) +register(BraveProvider) diff --git a/src/providers/exa.ts b/src/providers/exa.ts index 857503f..0b85821 100644 --- a/src/providers/exa.ts +++ b/src/providers/exa.ts @@ -1,6 +1,5 @@ -import type { SearchResult, SearchOptions, SearchProvider, ProviderConfig, ProviderFactory } from '../core/types.ts' -import { defaultClient } from '../core/client.ts' -import type { Client } from '../core/client.ts' +import type { SearchResult, SearchOptions, ProviderConfig } from '../core/types.ts' +import { Provider } from '../core/provider.ts' import { AuthError, normalizeError } from '../core/errors.ts' import { register } from '../core/registry.ts' @@ -36,25 +35,21 @@ interface ExaSearchResponse { results: ExaResult[] } -class ExaProvider implements SearchProvider { - private readonly client: Client - private readonly baseURL: string +class ExaProvider extends Provider { + static readonly providerName = 'exa' + static readonly defaultBaseURL = 'https://api.exa.ai' + private readonly apiKey: string constructor(config: ProviderConfig) { + super(config, ExaProvider) if (!config.apiKey) { throw new AuthError('Missing API key for Exa. Set EXA_API_KEY', 'exa') } - this.client = defaultClient() - this.baseURL = config.baseURL ?? 'https://api.exa.ai' this.apiKey = config.apiKey } - name(): string { - return 'exa' - } - async search(query: string, options?: SearchOptions): Promise { const body = { query, @@ -97,6 +92,4 @@ function mapResult(result: ExaResult): SearchResult { } } -const factory: ProviderFactory = (config) => new ExaProvider(config) - -register('exa', 'https://api.exa.ai', factory) +register(ExaProvider) diff --git a/src/providers/firecrawl.ts b/src/providers/firecrawl.ts index 76b862e..34d091a 100644 --- a/src/providers/firecrawl.ts +++ b/src/providers/firecrawl.ts @@ -1,6 +1,5 @@ -import type { SearchResult, SearchOptions, ReadResult, ReadOptions, SearchProvider, ProviderConfig, ProviderFactory } from '../core/types.ts' -import { defaultClient } from '../core/client.ts' -import type { Client } from '../core/client.ts' +import type { SearchResult, SearchOptions, ReadResult, ReadOptions, ProviderConfig } from '../core/types.ts' +import { Provider } from '../core/provider.ts' import { AuthError, normalizeError } from '../core/errors.ts' import { register } from '../core/registry.ts' @@ -55,25 +54,21 @@ function clampMaxResults(max?: number): number { return Math.min(Math.max(max ?? 10, 1), FIRECRAWL_MAX_RESULTS) } -class FirecrawlProvider implements SearchProvider { - private readonly client: Client - private readonly baseURL: string +class FirecrawlProvider extends Provider { + static readonly providerName = 'firecrawl' + static readonly defaultBaseURL = 'https://api.firecrawl.dev' + private readonly apiKey: string constructor(config: ProviderConfig) { + super(config, FirecrawlProvider) if (!config.apiKey) { throw new AuthError('Missing API key for Firecrawl. Set FIRECRAWL_API_KEY', 'firecrawl') } - this.client = defaultClient() - this.baseURL = (config.baseURL ?? 'https://api.firecrawl.dev').replace(/\/+$/, '') this.apiKey = config.apiKey } - name(): string { - return 'firecrawl' - } - private authHeaders(): Record { return { 'Authorization': `Bearer ${this.apiKey}` } } @@ -163,6 +158,4 @@ function mapSearchResult(result: FirecrawlWebResult): SearchResult { } } -const factory: ProviderFactory = (config) => new FirecrawlProvider(config) - -register('firecrawl', 'https://api.firecrawl.dev', factory) +register(FirecrawlProvider) diff --git a/src/providers/jina.ts b/src/providers/jina.ts index 14cc894..4c46fca 100644 --- a/src/providers/jina.ts +++ b/src/providers/jina.ts @@ -1,6 +1,5 @@ -import type { SearchResult, SearchOptions, ReadResult, ReadOptions, SearchProvider, ProviderConfig, ProviderFactory } from '../core/types.ts' -import { defaultClient } from '../core/client.ts' -import type { Client } from '../core/client.ts' +import type { SearchResult, SearchOptions, ReadResult, ReadOptions, ProviderConfig } from '../core/types.ts' +import { Provider, assertProviderBaseURL } from '../core/provider.ts' import { AuthError, HTTPError, normalizeError } from '../core/errors.ts' import { register } from '../core/registry.ts' @@ -39,23 +38,22 @@ interface JinaReadResponse extends JinaEnvelope { const JINA_MAX_RESULTS = 20 -class JinaProvider implements SearchProvider { - private readonly client: Client +class JinaProvider extends Provider { + static readonly providerName = 'jina' + static readonly defaultBaseURL = 'https://s.jina.ai' + private readonly searchBaseURL: string private readonly readBaseURL: string private readonly apiKey?: string constructor(config: ProviderConfig) { - this.client = defaultClient() - this.searchBaseURL = (config.baseURL ?? 'https://s.jina.ai').replace(/\/+$/, '') + super(config, JinaProvider) + this.searchBaseURL = this.baseURL this.readBaseURL = (config.readBaseURL ?? deriveReadBaseURL(this.searchBaseURL)).replace(/\/+$/, '') + assertProviderBaseURL(this.readBaseURL, JinaProvider.providerName) this.apiKey = config.apiKey } - name(): string { - return 'jina' - } - async search(query: string, options?: SearchOptions): Promise { if (!this.apiKey) { throw new AuthError('Missing API key for Jina. Set JINA_API_KEY', 'jina') @@ -202,6 +200,4 @@ function resultMetadata(result: JinaResult): Record | undefined return Object.keys(metadata).length > 0 ? metadata : undefined } -const factory: ProviderFactory = (config) => new JinaProvider(config) - -register('jina', 'https://s.jina.ai', factory) +register(JinaProvider) diff --git a/src/providers/searxng.ts b/src/providers/searxng.ts index f1da224..4d314ef 100644 --- a/src/providers/searxng.ts +++ b/src/providers/searxng.ts @@ -1,6 +1,5 @@ -import type { SearchResult, SearchOptions, SearchProvider, ProviderConfig, ProviderFactory } from '../core/types.ts' -import { defaultClient } from '../core/client.ts' -import type { Client } from '../core/client.ts' +import type { SearchResult, SearchOptions, ProviderConfig } from '../core/types.ts' +import { Provider } from '../core/provider.ts' import { normalizeError } from '../core/errors.ts' import { register } from '../core/registry.ts' @@ -25,17 +24,12 @@ interface SearXNGSearchResponse { const SEARXNG_PROBE_TIMEOUT_MS = 2000 -class SearXNGProvider implements SearchProvider { - private readonly client: Client - private readonly baseURL: string +class SearXNGProvider extends Provider { + static readonly providerName = 'searxng' + static readonly defaultBaseURL = 'http://localhost:8080' constructor(config: ProviderConfig) { - this.client = defaultClient() - this.baseURL = config.baseURL ?? 'http://localhost:8080' - } - - name(): string { - return 'searxng' + super(config, SearXNGProvider) } /** @@ -108,6 +102,4 @@ function mapResult(result: SearXNGResult): SearchResult { } } -const factory: ProviderFactory = (config) => new SearXNGProvider(config) - -register('searxng', 'http://localhost:8080', factory) +register(SearXNGProvider) diff --git a/src/providers/serpapi.ts b/src/providers/serpapi.ts index 260355f..9b18190 100644 --- a/src/providers/serpapi.ts +++ b/src/providers/serpapi.ts @@ -1,6 +1,5 @@ -import type { SearchResult, SearchOptions, SearchProvider, ProviderConfig, ProviderFactory } from '../core/types.ts' -import { defaultClient } from '../core/client.ts' -import type { Client } from '../core/client.ts' +import type { SearchResult, SearchOptions, ProviderConfig } from '../core/types.ts' +import { Provider } from '../core/provider.ts' import { AuthError, normalizeError } from '../core/errors.ts' import { register } from '../core/registry.ts' @@ -24,25 +23,21 @@ interface SerpApiSearchResponse { organic_results?: SerpApiResult[] } -class SerpApiProvider implements SearchProvider { - private readonly client: Client - private readonly baseURL: string +class SerpApiProvider extends Provider { + static readonly providerName = 'serpapi' + static readonly defaultBaseURL = 'https://serpapi.com' + private readonly apiKey: string constructor(config: ProviderConfig) { + super(config, SerpApiProvider) if (!config.apiKey) { throw new AuthError('Missing API key for SerpAPI. Set SERPAPI_API_KEY', 'serpapi') } - this.client = defaultClient() - this.baseURL = config.baseURL ?? 'https://serpapi.com' this.apiKey = config.apiKey } - name(): string { - return 'serpapi' - } - async search(query: string, options?: SearchOptions): Promise { try { const url = `${this.baseURL}/search?engine=google&q=${encodeURIComponent(query)}&api_key=${this.apiKey}&num=${options?.maxResults ?? 10}` @@ -71,6 +66,4 @@ function mapResult(result: SerpApiResult): SearchResult { } } -const factory: ProviderFactory = (config) => new SerpApiProvider(config) - -register('serpapi', 'https://serpapi.com', factory) +register(SerpApiProvider) diff --git a/src/providers/serpbase.ts b/src/providers/serpbase.ts index 868eaab..104a5db 100644 --- a/src/providers/serpbase.ts +++ b/src/providers/serpbase.ts @@ -1,6 +1,5 @@ -import type { SearchResult, SearchOptions, SearchProvider, ProviderConfig, ProviderFactory } from '../core/types.ts' -import { defaultClient } from '../core/client.ts' -import type { Client } from '../core/client.ts' +import type { SearchResult, SearchOptions, ProviderConfig } from '../core/types.ts' +import { Provider } from '../core/provider.ts' import { AskwebError, AuthError, RateLimitError, normalizeError } from '../core/errors.ts' import { register } from '../core/registry.ts' @@ -49,25 +48,21 @@ interface SerpBaseSearchResponse { const SERPBASE_MAX_RESULTS = 20 -class SerpBaseProvider implements SearchProvider { - private readonly client: Client - private readonly baseURL: string +class SerpBaseProvider extends Provider { + static readonly providerName = 'serpbase' + static readonly defaultBaseURL = 'https://api.serpbase.dev' + private readonly apiKey: string constructor(config: ProviderConfig) { + super(config, SerpBaseProvider) if (!config.apiKey) { throw new AuthError('Missing API key for SerpBase. Set SERPBASE_API_KEY', 'serpbase') } - this.client = defaultClient() - this.baseURL = config.baseURL ?? 'https://api.serpbase.dev' this.apiKey = config.apiKey } - name(): string { - return 'serpbase' - } - async search(query: string, options?: SearchOptions): Promise { const endpoint = endpointForCategory(options?.category) const body = { @@ -166,6 +161,4 @@ function mapResult(result: SerpBaseResult, response: SerpBaseSearchResponse): Se } } -const factory: ProviderFactory = (config) => new SerpBaseProvider(config) - -register('serpbase', 'https://api.serpbase.dev', factory) +register(SerpBaseProvider) diff --git a/src/providers/tavily.ts b/src/providers/tavily.ts index 41cfc1e..546e892 100644 --- a/src/providers/tavily.ts +++ b/src/providers/tavily.ts @@ -1,6 +1,5 @@ -import type { SearchResult, SearchOptions, SearchProvider, ProviderConfig, ProviderFactory } from '../core/types.ts' -import { defaultClient } from '../core/client.ts' -import type { Client } from '../core/client.ts' +import type { SearchResult, SearchOptions, ProviderConfig } from '../core/types.ts' +import { Provider } from '../core/provider.ts' import { AuthError, normalizeError } from '../core/errors.ts' import { register } from '../core/registry.ts' @@ -30,25 +29,21 @@ interface TavilySearchResponse { query: string } -class TavilyProvider implements SearchProvider { - private readonly client: Client - private readonly baseURL: string +class TavilyProvider extends Provider { + static readonly providerName = 'tavily' + static readonly defaultBaseURL = 'https://api.tavily.com' + private readonly apiKey: string constructor(config: ProviderConfig) { + super(config, TavilyProvider) if (!config.apiKey) { throw new AuthError('Missing API key for Tavily. Set TAVILY_API_KEY', 'tavily') } - this.client = defaultClient() - this.baseURL = config.baseURL ?? 'https://api.tavily.com' this.apiKey = config.apiKey } - name(): string { - return 'tavily' - } - async search(query: string, options?: SearchOptions): Promise { const body = { api_key: this.apiKey, @@ -84,6 +79,4 @@ function mapResult(result: TavilyResult, answer: string | undefined, isFirst: bo } } -const factory: ProviderFactory = (config) => new TavilyProvider(config) - -register('tavily', 'https://api.tavily.com', factory) +register(TavilyProvider) diff --git a/test/index.test.ts b/test/index.test.ts index 39bb8d2..9f6d159 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { builtinProviders, create, readUrl, version } from '../src/index.ts' +import { Provider, builtinProviders, create, createReadProvider, createSearchProvider, readUrl, version, ReadNotSupportedError } from '../src/index.ts' describe('askweb', () => { it('should export version matching package.json', () => { @@ -17,6 +17,17 @@ describe('askweb', () => { } }) + it('should export the abstract Provider base class', () => { + expect(Provider).toBeTypeOf('function') + expect(create('searxng')).toBeInstanceOf(Provider) + }) + + it('should export capability-aware provider constructors', () => { + expect(createSearchProvider('searxng').name).toBe('searxng') + expect(createReadProvider('jina').name).toBe('jina') + expect(() => createReadProvider('searxng')).toThrow(ReadNotSupportedError) + }) + it('should export readUrl', () => { expect(readUrl).toBeTypeOf('function') }) diff --git a/test/unit/brave.test.ts b/test/unit/brave.test.ts index 6fa939f..140b837 100644 --- a/test/unit/brave.test.ts +++ b/test/unit/brave.test.ts @@ -14,7 +14,7 @@ vi.mock('../../src/core/client.ts', () => ({ })), })) -import { create, has } from '../../src/core/registry.ts' +import { createSearchProvider, has } from '../../src/core/registry.ts' import { AuthError } from '../../src/core/errors.ts' import type { SearchResult } from '../../src/core/types.ts' @@ -50,24 +50,24 @@ describe('brave provider', () => { describe('create', () => { it('creates provider with apiKey', () => { - expect(() => create('brave', { apiKey: 'test-key' })).not.toThrow() + expect(() => createSearchProvider('brave', { apiKey: 'test-key' })).not.toThrow() }) it('throws AuthError without apiKey and without env var', () => { - expect(() => create('brave', {})).toThrow(AuthError) + expect(() => createSearchProvider('brave', {})).toThrow(AuthError) }) }) - describe('name()', () => { + describe('name', () => { it('returns brave', () => { - const provider = create('brave', { apiKey: 'test-key' }) - expect(provider.name()).toBe('brave') + const provider = createSearchProvider('brave', { apiKey: 'test-key' }) + expect(provider.name).toBe('brave') }) }) describe('search()', () => { it('calls getJSON with correct url and headers', async () => { - const provider = create('brave', { apiKey: 'test-key' }) + const provider = createSearchProvider('brave', { apiKey: 'test-key' }) await provider.search('test query') expect(mockGetJSON).toHaveBeenCalledOnce() @@ -78,8 +78,19 @@ describe('brave provider', () => { expect(headers).toEqual({ 'X-Subscription-Token': 'test-key' }) }) + it('normalizes a trailing slash in custom baseURL', async () => { + const provider = createSearchProvider('brave', { + apiKey: 'test-key', + baseURL: 'https://custom.example.com/', + }) + await provider.search('test query') + + const [url] = mockGetJSON.mock.calls[0] + expect(url).toContain('https://custom.example.com/res/v1/web/search') + }) + it('maps result fields correctly', async () => { - const provider = create('brave', { apiKey: 'test-key' }) + const provider = createSearchProvider('brave', { apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results).toHaveLength(1) @@ -91,7 +102,7 @@ describe('brave provider', () => { }) it('maps maxResults to count query param', async () => { - const provider = create('brave', { apiKey: 'test-key' }) + const provider = createSearchProvider('brave', { apiKey: 'test-key' }) await provider.search('test query', { maxResults: 5 }) const [url] = mockGetJSON.mock.calls[0] @@ -103,7 +114,7 @@ describe('brave provider', () => { web: undefined, }) - const provider = create('brave', { apiKey: 'test-key' }) + const provider = createSearchProvider('brave', { apiKey: 'test-key' }) const results = await provider.search('query') expect(results).toEqual([]) @@ -124,7 +135,7 @@ describe('brave provider', () => { }, }) - const provider = create('brave', { apiKey: 'test-key' }) + const provider = createSearchProvider('brave', { apiKey: 'test-key' }) const results = await provider.search('query') expect(results[0].text).toBe('Snippet 1\nSnippet 2\nSnippet 3') diff --git a/test/unit/exa.test.ts b/test/unit/exa.test.ts index 246e95e..940806b 100644 --- a/test/unit/exa.test.ts +++ b/test/unit/exa.test.ts @@ -14,7 +14,7 @@ vi.mock('../../src/core/client.ts', () => ({ })), })) -import { create, has } from '../../src/core/registry.ts' +import { createSearchProvider, has } from '../../src/core/registry.ts' import { AuthError } from '../../src/core/errors.ts' import type { SearchResult } from '../../src/core/types.ts' @@ -53,24 +53,24 @@ describe('exa provider', () => { describe('create', () => { it('creates provider with apiKey', () => { - expect(() => create('exa', { apiKey: 'test-key' })).not.toThrow() + expect(() => createSearchProvider('exa', { apiKey: 'test-key' })).not.toThrow() }) it('throws AuthError without apiKey and without env var', () => { - expect(() => create('exa', {})).toThrow(AuthError) + expect(() => createSearchProvider('exa', {})).toThrow(AuthError) }) }) - describe('name()', () => { + describe('name', () => { it('returns exa', () => { - const provider = create('exa', { apiKey: 'test-key' }) - expect(provider.name()).toBe('exa') + const provider = createSearchProvider('exa', { apiKey: 'test-key' }) + expect(provider.name).toBe('exa') }) }) describe('search()', () => { it('calls postJSON with correct url, body, and headers', async () => { - const provider = create('exa', { apiKey: 'test-key' }) + const provider = createSearchProvider('exa', { apiKey: 'test-key' }) await provider.search('test query') expect(mockPostJSON).toHaveBeenCalledOnce() @@ -86,7 +86,7 @@ describe('exa provider', () => { }) it('maps result fields correctly', async () => { - const provider = create('exa', { apiKey: 'test-key' }) + const provider = createSearchProvider('exa', { apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results).toHaveLength(1) @@ -100,7 +100,7 @@ describe('exa provider', () => { }) it('maps maxResults option to numResults in body', async () => { - const provider = create('exa', { apiKey: 'test-key' }) + const provider = createSearchProvider('exa', { apiKey: 'test-key' }) await provider.search('test query', { maxResults: 5 }) const [, body] = mockPostJSON.mock.calls[0] @@ -113,7 +113,7 @@ describe('exa provider', () => { results: [{ ...exaResponse.results[0], title: null }], }) - const provider = create('exa', { apiKey: 'test-key' }) + const provider = createSearchProvider('exa', { apiKey: 'test-key' }) const results = await provider.search('query') expect(results[0].title).toBe('') @@ -130,7 +130,7 @@ describe('exa provider', () => { }], }) - const provider = create('exa', { apiKey: 'test-key' }) + const provider = createSearchProvider('exa', { apiKey: 'test-key' }) const results = await provider.search('query') expect(results[0].snippet).toBe(longText.slice(0, 200)) @@ -142,7 +142,7 @@ describe('exa provider', () => { results: [], }) - const provider = create('exa', { apiKey: 'test-key' }) + const provider = createSearchProvider('exa', { apiKey: 'test-key' }) const results = await provider.search('query') expect(results).toEqual([]) diff --git a/test/unit/firecrawl.test.ts b/test/unit/firecrawl.test.ts index ea02dae..de62f19 100644 --- a/test/unit/firecrawl.test.ts +++ b/test/unit/firecrawl.test.ts @@ -14,13 +14,22 @@ vi.mock('../../src/core/client.ts', () => ({ })), })) -import { create, has } from '../../src/core/registry.ts' +import { createSearchProvider, has } from '../../src/core/registry.ts' +import { isReadProvider } from '../../src/core/provider.ts' import { AuthError } from '../../src/core/errors.ts' -import type { SearchResult } from '../../src/core/types.ts' +import type { ProviderConfig, SearchResult } from '../../src/core/types.ts' // Triggers self-registration of firecrawl provider import '../../src/providers/index.ts' +function createFirecrawlProvider(config: ProviderConfig = {}) { + const provider = createSearchProvider('firecrawl', config) + if (!isReadProvider(provider)) { + throw new Error('Firecrawl provider must support URL reading') + } + return provider +} + const firecrawlSearchResponse = { success: true, data: { @@ -73,24 +82,24 @@ describe('firecrawl provider', () => { describe('create', () => { it('creates provider with apiKey', () => { - expect(() => create('firecrawl', { apiKey: 'test-key' })).not.toThrow() + expect(() => createFirecrawlProvider({ apiKey: 'test-key' })).not.toThrow() }) it('throws AuthError without apiKey and without env var', () => { - expect(() => create('firecrawl', {})).toThrow(AuthError) + expect(() => createFirecrawlProvider({})).toThrow(AuthError) }) }) - describe('name()', () => { + describe('name', () => { it('returns firecrawl', () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) - expect(provider.name()).toBe('firecrawl') + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) + expect(provider.name).toBe('firecrawl') }) }) describe('search()', () => { it('calls postJSON with correct url and Authorization header', async () => { - const provider = create('firecrawl', { apiKey: 'fc-test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'fc-test-key' }) await provider.search('test query') expect(mockPostJSON).toHaveBeenCalledOnce() @@ -107,7 +116,7 @@ describe('firecrawl provider', () => { }) it('maps result fields correctly', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results).toHaveLength(2) @@ -117,7 +126,7 @@ describe('firecrawl provider', () => { }) it('maps markdown content to text field', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results[1].text).toBe('# Firecrawl\n\nOpen source web scraper.') @@ -125,7 +134,7 @@ describe('firecrawl provider', () => { }) it('maps maxResults to limit in body', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await provider.search('test query', { maxResults: 5 }) const [, body] = mockPostJSON.mock.calls[0] @@ -133,7 +142,7 @@ describe('firecrawl provider', () => { }) it('passes includeDomains in body', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await provider.search('test query', { includeDomains: ['github.com'] }) const [, body] = mockPostJSON.mock.calls[0] @@ -141,7 +150,7 @@ describe('firecrawl provider', () => { }) it('passes excludeDomains in body', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await provider.search('test query', { excludeDomains: ['reddit.com'] }) const [, body] = mockPostJSON.mock.calls[0] @@ -149,7 +158,7 @@ describe('firecrawl provider', () => { }) it('sets sources to news when category is news', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await provider.search('test query', { category: 'news' }) const [, body] = mockPostJSON.mock.calls[0] @@ -165,7 +174,7 @@ describe('firecrawl provider', () => { }, }) - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) const results = await provider.search('test query', { category: 'news' }) expect(results).toHaveLength(2) @@ -182,14 +191,14 @@ describe('firecrawl provider', () => { }, }) - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) const results = await provider.search('test query', { category: 'news', maxResults: 5 }) expect(results).toHaveLength(5) }) it('does not set sources when category is not news', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await provider.search('test query', { category: 'general' }) const [, body] = mockPostJSON.mock.calls[0] @@ -199,7 +208,7 @@ describe('firecrawl provider', () => { it('returns empty array when web results are missing', async () => { mockPostJSON.mockResolvedValueOnce({ success: true, data: {} }) - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) const results = await provider.search('query') expect(results).toEqual([]) @@ -208,12 +217,12 @@ describe('firecrawl provider', () => { it('throws when success is false', async () => { mockPostJSON.mockResolvedValueOnce({ success: false }) - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await expect(provider.search('query')).rejects.toThrow() }) it('clamps maxResults to 100', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await provider.search('test query', { maxResults: 500 }) const [, body] = mockPostJSON.mock.calls[0] @@ -228,7 +237,7 @@ describe('firecrawl provider', () => { }) it('calls postJSON with scrape endpoint and url in body', async () => { - const provider = create('firecrawl', { apiKey: 'fc-test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'fc-test-key' }) await provider.read('https://example.com') expect(mockPostJSON).toHaveBeenCalledOnce() @@ -246,7 +255,7 @@ describe('firecrawl provider', () => { }) it('returns read result with content from markdown', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) const result = await provider.read('https://example.com') expect(result.url).toBe('https://example.com') @@ -259,7 +268,7 @@ describe('firecrawl provider', () => { }) it('passes format option to formats array', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await provider.read('https://example.com', { format: 'html' }) const [, body] = mockPostJSON.mock.calls[0] @@ -267,7 +276,7 @@ describe('firecrawl provider', () => { }) it('maps text format to markdown', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await provider.read('https://example.com', { format: 'text' }) const [, body] = mockPostJSON.mock.calls[0] @@ -283,14 +292,14 @@ describe('firecrawl provider', () => { }, }) - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) const result = await provider.read('https://example.com', { format: 'html' }) expect(result.content).toBe('

Only HTML

') }) it('converts timeout from seconds to milliseconds', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await provider.read('https://example.com', { timeout: 30 }) const [, body] = mockPostJSON.mock.calls[0] @@ -298,7 +307,7 @@ describe('firecrawl provider', () => { }) it('sets onlyMainContent to true by default', async () => { - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await provider.read('https://example.com') const [, body] = mockPostJSON.mock.calls[0] @@ -308,14 +317,14 @@ describe('firecrawl provider', () => { it('throws when success is false', async () => { mockPostJSON.mockResolvedValueOnce({ success: false }) - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) await expect(provider.read('https://example.com')).rejects.toThrow() }) it('handles missing data gracefully', async () => { mockPostJSON.mockResolvedValueOnce({ success: true, data: {} }) - const provider = create('firecrawl', { apiKey: 'test-key' }) + const provider = createFirecrawlProvider({ apiKey: 'test-key' }) const result = await provider.read('https://example.com') expect(result.content).toBe('') diff --git a/test/unit/jina.test.ts b/test/unit/jina.test.ts index aeedac5..bc16aaf 100644 --- a/test/unit/jina.test.ts +++ b/test/unit/jina.test.ts @@ -14,13 +14,22 @@ vi.mock('../../src/core/client.ts', () => ({ })), })) -import { create, has } from '../../src/core/registry.ts' -import { AuthError, HTTPError } from '../../src/core/errors.ts' -import type { SearchResult, ReadResult } from '../../src/core/types.ts' +import { createSearchProvider, has } from '../../src/core/registry.ts' +import { isReadProvider } from '../../src/core/provider.ts' +import { AuthError, HTTPError, InvalidProviderUrlError } from '../../src/core/errors.ts' +import type { ProviderConfig, SearchResult, ReadResult } from '../../src/core/types.ts' // Triggers self-registration of jina provider import '../../src/providers/index.ts' +function createJinaProvider(config: ProviderConfig = {}) { + const provider = createSearchProvider('jina', config) + if (!isReadProvider(provider)) { + throw new Error('Jina provider must support URL reading') + } + return provider +} + const jinaResponse = { code: 200, status: 20000, @@ -51,30 +60,34 @@ describe('jina provider', () => { describe('create', () => { it('creates provider with apiKey', () => { - expect(() => create('jina', { apiKey: 'test-key' })).not.toThrow() + expect(() => createJinaProvider({ apiKey: 'test-key' })).not.toThrow() }) it('creates provider without apiKey for read-only use', () => { - expect(() => create('jina', {})).not.toThrow() + expect(() => createJinaProvider({})).not.toThrow() + }) + + it('rejects non-HTTP reader base URLs', () => { + expect(() => createJinaProvider({ readBaseURL: 'file:///etc/passwd' })).toThrow(InvalidProviderUrlError) }) }) - describe('name()', () => { + describe('name', () => { it('returns jina', () => { - const provider = create('jina', { apiKey: 'test-key' }) - expect(provider.name()).toBe('jina') + const provider = createJinaProvider({ apiKey: 'test-key' }) + expect(provider.name).toBe('jina') }) }) describe('search()', () => { it('throws AuthError without apiKey and without env var', async () => { - const provider = create('jina', {}) + const provider = createJinaProvider({}) await expect(provider.search('test query')).rejects.toThrow(AuthError) expect(mockGetJSON).not.toHaveBeenCalled() }) it('calls getJSON with correct URL and bearer auth headers', async () => { - const provider = create('jina', { apiKey: 'test-key' }) + const provider = createJinaProvider({ apiKey: 'test-key' }) await provider.search('test query') expect(mockGetJSON).toHaveBeenCalledOnce() @@ -90,7 +103,7 @@ describe('jina provider', () => { }) it('maps result fields correctly', async () => { - const provider = create('jina', { apiKey: 'test-key' }) + const provider = createJinaProvider({ apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results).toHaveLength(1) @@ -105,7 +118,7 @@ describe('jina provider', () => { }) it('maps maxResults to count query param and clamps to Jina limit', async () => { - const provider = create('jina', { apiKey: 'test-key' }) + const provider = createJinaProvider({ apiKey: 'test-key' }) await provider.search('test query', { maxResults: 25 }) const [url] = mockGetJSON.mock.calls[0] @@ -113,7 +126,7 @@ describe('jina provider', () => { }) it('maps includeDomains and news category to Jina query params', async () => { - const provider = create('jina', { apiKey: 'test-key' }) + const provider = createJinaProvider({ apiKey: 'test-key' }) await provider.search('test query', { includeDomains: ['example.com'], category: 'news' }) const [url] = mockGetJSON.mock.calls[0] @@ -131,7 +144,7 @@ describe('jina provider', () => { }], }) - const provider = create('jina', { apiKey: 'test-key' }) + const provider = createJinaProvider({ apiKey: 'test-key' }) const results = await provider.search('query') expect(results[0].snippet).toBe('A'.repeat(200)) @@ -145,7 +158,7 @@ describe('jina provider', () => { data: undefined, }) - const provider = create('jina', { apiKey: 'test-key' }) + const provider = createJinaProvider({ apiKey: 'test-key' }) const results = await provider.search('query') expect(results).toEqual([]) @@ -160,8 +173,8 @@ describe('jina provider', () => { data: { url: 'https://example.com/', content: 'Read content' }, }) - const provider = create('jina', { baseURL: 'https://eu.s.jina.ai' }) - await provider.read!('https://example.com') + const provider = createJinaProvider({ baseURL: 'https://eu.s.jina.ai' }) + await provider.read('https://example.com') const [url] = mockGetJSON.mock.calls[0] expect(url).toBe('https://eu.r.jina.ai/https%3A%2F%2Fexample.com') @@ -179,8 +192,8 @@ describe('jina provider', () => { }, }) - const provider = create('jina', {}) - const result = await provider.read!('https://example.com/?a=1&b=2') + const provider = createJinaProvider({}) + const result = await provider.read('https://example.com/?a=1&b=2') expect(mockGetJSON).toHaveBeenCalledOnce() const [url, headers] = mockGetJSON.mock.calls[0] @@ -196,8 +209,8 @@ describe('jina provider', () => { data: { url: 'https://example.com/', content: 'Text content' }, }) - const provider = create('jina', { apiKey: 'test-key' }) - await provider.read!('https://example.com', { + const provider = createJinaProvider({ apiKey: 'test-key' }) + await provider.read('https://example.com', { format: 'text', maxTokens: 500, targetSelector: 'main', @@ -238,8 +251,8 @@ describe('jina provider', () => { }, }) - const provider = create('jina', {}) - const result: ReadResult = await provider.read!('https://example.com') + const provider = createJinaProvider({}) + const result: ReadResult = await provider.read('https://example.com') expect(result).toEqual({ url: 'https://example.com/', @@ -262,7 +275,7 @@ describe('jina provider', () => { message: 'invalid token', }) - const provider = create('jina', { apiKey: 'bad-key' }) + const provider = createJinaProvider({ apiKey: 'bad-key' }) await expect(provider.search('query')).rejects.toThrow(AuthError) }) @@ -274,9 +287,9 @@ describe('jina provider', () => { message: 'unsupported url', }) - const provider = create('jina', {}) + const provider = createJinaProvider({}) - await expect(provider.read!('ftp://example.com')).rejects.toMatchObject({ + await expect(provider.read('ftp://example.com')).rejects.toMatchObject({ statusCode: 422, body: 'unsupported url', } satisfies Partial) diff --git a/test/unit/read.test.ts b/test/unit/read.test.ts index 58a676c..3fa1da1 100644 --- a/test/unit/read.test.ts +++ b/test/unit/read.test.ts @@ -2,16 +2,26 @@ import { describe, it, expect, vi } from 'vitest' import { readUrl } from '../../src/core/read.ts' import { register } from '../../src/core/registry.ts' import { EmptyUrlError, ReadNotSupportedError } from '../../src/core/errors.ts' +import { Provider } from '../../src/core/provider.ts' +import type { ProviderConfig, ReadOptions, ReadResult } from '../../src/core/types.ts' describe('readUrl', () => { it('passes explicit provider and read options through', async () => { const providerName = `reader-${Math.random().toString(36).slice(2)}` const read = vi.fn().mockResolvedValue({ url: 'https://example.com', content: 'ok' }) - register(providerName, 'https://reader.example.com', () => ({ - name: () => providerName, - search: vi.fn().mockResolvedValue([]), - read, - })) + class ReaderProvider extends Provider { + static readonly providerName = providerName + static readonly defaultBaseURL = 'https://reader.example.com' + + constructor(config: ProviderConfig) { + super(config, ReaderProvider) + } + + async read(url: string, options?: ReadOptions): Promise { + return read(url, options) + } + } + register(ReaderProvider) await readUrl(' https://example.com ', { provider: providerName, format: 'text', maxTokens: 500 }) @@ -30,10 +40,15 @@ describe('readUrl', () => { it('throws ReadNotSupportedError when a custom provider has no read capability', async () => { const providerName = `search-only-${Math.random().toString(36).slice(2)}` - register(providerName, 'https://search.example.com', () => ({ - name: () => providerName, - search: vi.fn().mockResolvedValue([]), - })) + class SearchOnlyProvider extends Provider { + static readonly providerName = providerName + static readonly defaultBaseURL = 'https://search.example.com' + + constructor(config: ProviderConfig) { + super(config, SearchOnlyProvider) + } + } + register(SearchOnlyProvider) await expect(readUrl('https://example.com', { provider: providerName })).rejects.toThrow(ReadNotSupportedError) }) diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index f53cf6e..0d36e19 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -1,164 +1,191 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { register, has, providers, create } from '../../src/core/registry' -import { UnknownProviderError } from '../../src/core/errors' -import type { ProviderFactory, SearchProvider } from '../../src/core/types' +import { beforeEach, describe, expect, it } from 'vitest' +import { + create, + createSearchProvider, + has, + providers, + register, +} from '../../src/core/registry.ts' +import { + InvalidProviderUrlError, + SearchNotSupportedError, + UnknownProviderError, +} from '../../src/core/errors.ts' +import { Provider } from '../../src/core/provider.ts' +import type { ProviderConfig, SearchResult } from '../../src/core/types.ts' describe('registry', () => { - // Use unique names per test to avoid collisions with module-level Maps + // Use unique names per test suite to avoid collisions with module-level Maps. const testProviderName = `testprovider${Math.random().toString(36).slice(2)}` const testProviderName2 = `testprovider${Math.random().toString(36).slice(2)}` + const testProviderName3 = `testprovider${Math.random().toString(36).slice(2)}` const envVarName = `${testProviderName.toUpperCase()}_API_KEY` - const mockFactory: ProviderFactory = (config) => ({ - name: () => 'mock', - search: vi.fn().mockResolvedValue([]), - }) + class MockProvider extends Provider { + static readonly providerName = testProviderName + static readonly defaultBaseURL = 'https://api.example.com' + static readonly capturedConfigs: ProviderConfig[] = [] + + constructor(config: ProviderConfig) { + super(config, MockProvider) + MockProvider.capturedConfigs.push(config) + } + + async search(): Promise { + return [] + } + } + + class SecondMockProvider extends Provider { + static readonly providerName = testProviderName2 + static readonly defaultBaseURL = 'https://api2.example.com' + + constructor(config: ProviderConfig) { + super(config, SecondMockProvider) + } + } + + class ReadOnlyProvider extends Provider { + static readonly providerName = testProviderName3 + static readonly defaultBaseURL = 'https://reader.example.com' + + constructor(config: ProviderConfig) { + super(config, ReadOnlyProvider) + } + + async read(): Promise<{ url: string; content: string }> { + return { url: 'https://example.com', content: 'ok' } + } + } beforeEach(() => { - // Clean up environment variables before each test delete process.env[envVarName] + MockProvider.capturedConfigs.length = 0 }) describe('register() + has()', () => { - it('should register a provider and has() returns true', () => { - register(testProviderName, 'https://api.example.com', mockFactory) + it('registers a provider class', () => { + register(MockProvider) expect(has(testProviderName)).toBe(true) }) - it('should return false for unregistered providers', () => { + it('returns false for unregistered providers', () => { const unregisteredName = `nonexistent-${Math.random().toString(36).slice(2)}` expect(has(unregisteredName)).toBe(false) }) }) describe('providers()', () => { - it('should include registered provider names', () => { - register(testProviderName, 'https://api.example.com', mockFactory) - const allProviders = providers() - expect(allProviders).toContain(testProviderName) + it('includes registered provider class names', () => { + register(MockProvider) + expect(providers()).toContain(testProviderName) }) - it('should return array of all registered providers', () => { - register(testProviderName, 'https://api.example.com', mockFactory) - register(testProviderName2, 'https://api2.example.com', mockFactory) - const allProviders = providers() - expect(allProviders).toContain(testProviderName) - expect(allProviders).toContain(testProviderName2) - expect(Array.isArray(allProviders)).toBe(true) + it('returns every registered provider class name', () => { + register(MockProvider) + register(SecondMockProvider) + + const registeredProviders = providers() + expect(registeredProviders).toContain(testProviderName) + expect(registeredProviders).toContain(testProviderName2) + expect(Array.isArray(registeredProviders)).toBe(true) }) }) describe('create()', () => { - it('should create a provider instance with name() and search() methods', () => { - register(testProviderName, 'https://api.example.com', mockFactory) + it('creates an instance of the abstract provider base', () => { + register(MockProvider) + const provider = create(testProviderName) - expect(provider).toBeDefined() - expect(typeof provider.name).toBe('function') - expect(typeof provider.search).toBe('function') - expect(provider.name()).toBe('mock') - }) - it('should pass config.apiKey through to factory', () => { - const capturedConfigs: any[] = [] - const trackingFactory: ProviderFactory = (config) => { - capturedConfigs.push(config) - return { - name: () => 'tracking-mock', - search: vi.fn().mockResolvedValue([]), - } - } + expect(provider).toBeInstanceOf(Provider) + expect(provider.name).toBe(testProviderName) + }) - register(testProviderName, 'https://api.example.com', trackingFactory) - const testApiKey = 'test-api-key-12345' - create(testProviderName, { apiKey: testApiKey }) + it('keeps provider identity immutable at runtime', () => { + register(MockProvider) + const provider = create(testProviderName) - expect(capturedConfigs).toHaveLength(1) - expect(capturedConfigs[0].apiKey).toBe(testApiKey) + expect(() => Object.assign(provider, { name: 'changed' })).toThrow(TypeError) + expect(provider.name).toBe(testProviderName) }) - it('should read env var when no config.apiKey is provided', () => { - const capturedConfigs: any[] = [] - const trackingFactory: ProviderFactory = (config) => { - capturedConfigs.push(config) - return { - name: () => 'tracking-mock', - search: vi.fn().mockResolvedValue([]), - } - } + it('passes config.apiKey to the provider constructor', () => { + register(MockProvider) + const apiKey = 'test-api-key-12345' - register(testProviderName, 'https://api.example.com', trackingFactory) - const envApiKey = 'env-api-key-67890' - process.env[envVarName] = envApiKey + create(testProviderName, { apiKey }) + + expect(MockProvider.capturedConfigs).toHaveLength(1) + expect(MockProvider.capturedConfigs[0]?.apiKey).toBe(apiKey) + }) + + it('reads the API key from the environment', () => { + register(MockProvider) + const apiKey = 'env-api-key-67890' + process.env[envVarName] = apiKey create(testProviderName) - expect(capturedConfigs).toHaveLength(1) - expect(capturedConfigs[0].apiKey).toBe(envApiKey) + expect(MockProvider.capturedConfigs[0]?.apiKey).toBe(apiKey) }) - it('should prefer config.apiKey over environment variable', () => { - const capturedConfigs: any[] = [] - const trackingFactory: ProviderFactory = (config) => { - capturedConfigs.push(config) - return { - name: () => 'tracking-mock', - search: vi.fn().mockResolvedValue([]), - } - } - - register(testProviderName, 'https://api.example.com', trackingFactory) - const configApiKey = 'config-api-key' - const envApiKey = 'env-api-key' - process.env[envVarName] = envApiKey + it('prefers config.apiKey over the environment', () => { + register(MockProvider) + process.env[envVarName] = 'env-api-key' - create(testProviderName, { apiKey: configApiKey }) + create(testProviderName, { apiKey: 'config-api-key' }) - expect(capturedConfigs).toHaveLength(1) - expect(capturedConfigs[0].apiKey).toBe(configApiKey) + expect(MockProvider.capturedConfigs[0]?.apiKey).toBe('config-api-key') }) - it('should throw UnknownProviderError for unregistered provider names', () => { + it('throws for an unregistered provider name', () => { const unregisteredName = `unknown-${Math.random().toString(36).slice(2)}` expect(() => create(unregisteredName)).toThrow(UnknownProviderError) - expect(() => create(unregisteredName)).toThrow( - `Unknown provider: ${unregisteredName}` - ) + expect(() => create(unregisteredName)).toThrow(`Unknown provider: ${unregisteredName}`) }) - it('should pass baseURL from config to factory', () => { - const capturedConfigs: any[] = [] - const trackingFactory: ProviderFactory = (config) => { - capturedConfigs.push(config) - return { - name: () => 'tracking-mock', - search: vi.fn().mockResolvedValue([]), - } - } + it('passes a custom baseURL to the provider constructor', () => { + register(MockProvider) - register(testProviderName, 'https://default.example.com', trackingFactory) - const customBaseURL = 'https://custom.example.com' - create(testProviderName, { baseURL: customBaseURL }) + create(testProviderName, { baseURL: 'https://custom.example.com' }) - expect(capturedConfigs).toHaveLength(1) - expect(capturedConfigs[0].baseURL).toBe(customBaseURL) + expect(MockProvider.capturedConfigs[0]?.baseURL).toBe('https://custom.example.com') }) - it('should use default baseURL from registration when not in config', () => { - const capturedConfigs: any[] = [] - const trackingFactory: ProviderFactory = (config) => { - capturedConfigs.push(config) - return { - name: () => 'tracking-mock', - search: vi.fn().mockResolvedValue([]), - } + it('rejects provider base URLs that are not absolute HTTP(S) URLs', () => { + register(MockProvider) + + for (const baseURL of ['ftp://example.com', '/relative']) { + expect(() => create(testProviderName, { baseURL })).toThrow(InvalidProviderUrlError) } - const defaultURL = 'https://default.example.com' - register(testProviderName, defaultURL, trackingFactory) + expect(() => create(testProviderName, { baseURL: 'ftp://example.com' })) + .toThrow(`Invalid base URL for provider "${testProviderName}": expected an absolute http or https URL`) + }) + + it('uses class metadata as the default baseURL', () => { + register(MockProvider) + create(testProviderName) - expect(capturedConfigs).toHaveLength(1) - expect(capturedConfigs[0].baseURL).toBe(defaultURL) + expect(MockProvider.capturedConfigs[0]?.baseURL).toBe(MockProvider.defaultBaseURL) + }) + }) + + describe('capabilities', () => { + it('returns a search-capable provider when required', async () => { + register(MockProvider) + + const provider = createSearchProvider(testProviderName) + + await expect(provider.search('query')).resolves.toEqual([]) + }) + + it('rejects a read-only provider when search is required', () => { + register(ReadOnlyProvider) + + expect(() => createSearchProvider(testProviderName3)).toThrow(SearchNotSupportedError) }) }) }) diff --git a/test/unit/search-command.test.ts b/test/unit/search-command.test.ts index cff0415..3996d3e 100644 --- a/test/unit/search-command.test.ts +++ b/test/unit/search-command.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { NoProviderConfiguredError, UnknownProviderError } from '../../src/core/errors.ts' +import { NoProviderConfiguredError, SearchNotSupportedError, UnknownProviderError } from '../../src/core/errors.ts' const mockLog = vi.fn() const mockInfo = vi.fn() @@ -24,7 +24,7 @@ vi.mock('consola', () => ({ })) vi.mock('../../src/core/registry.ts', () => ({ - create: (name: string, config: Record) => mockCreate(name, config), + createSearchProvider: (name: string, config: Record) => mockCreate(name, config), providers: vi.fn(() => ['brave', 'exa']), })) @@ -150,6 +150,19 @@ describe('search command', () => { expect(exitSpy).toHaveBeenCalledWith(1) }) + it('reports providers without search capability', async () => { + mockCreate.mockImplementationOnce(() => { + throw new SearchNotSupportedError('reader') + }) + + await expect( + runSearch({ provider: 'reader' }), + ).rejects.toThrow('__EXIT__') + + expect(mockError).toHaveBeenCalledWith('Provider "reader" does not support web search.') + expect(exitSpy).toHaveBeenCalledWith(1) + }) + it('exits with error for empty query', async () => { await expect( runSearch({ query: '' }), diff --git a/test/unit/searxng.test.ts b/test/unit/searxng.test.ts index c778913..6eb58a2 100644 --- a/test/unit/searxng.test.ts +++ b/test/unit/searxng.test.ts @@ -14,7 +14,7 @@ vi.mock('../../src/core/client.ts', () => ({ })), })) -import { create, has } from '../../src/core/registry.ts' +import { createSearchProvider, has } from '../../src/core/registry.ts' import type { SearchResult } from '../../src/core/types.ts' // Triggers self-registration of searxng provider @@ -56,18 +56,18 @@ describe('searxng provider', () => { describe('create', () => { it('creates provider without apiKey', () => { - expect(() => create('searxng', {})).not.toThrow() + expect(() => createSearchProvider('searxng', {})).not.toThrow() }) it('creates provider with apiKey (ignores it)', () => { - expect(() => create('searxng', { apiKey: 'test-key' })).not.toThrow() + expect(() => createSearchProvider('searxng', { apiKey: 'test-key' })).not.toThrow() }) }) - describe('name()', () => { + describe('name', () => { it('returns searxng', () => { - const provider = create('searxng', {}) - expect(provider.name()).toBe('searxng') + const provider = createSearchProvider('searxng', {}) + expect(provider.name).toBe('searxng') }) }) @@ -81,7 +81,7 @@ describe('searxng provider', () => { }) vi.stubGlobal('fetch', fetchMock) - const provider = create('searxng', {}) + const provider = createSearchProvider('searxng', {}) const availability = provider.isAvailable?.() await vi.advanceTimersByTimeAsync(1999) @@ -93,7 +93,7 @@ describe('searxng provider', () => { describe('search()', () => { it('calls getJSON with correct URL containing q, format, and pageno', async () => { - const provider = create('searxng', {}) + const provider = createSearchProvider('searxng', {}) await provider.search('test query') expect(mockGetJSON).toHaveBeenCalledOnce() @@ -106,7 +106,7 @@ describe('searxng provider', () => { }) it('maps result fields correctly', async () => { - const provider = create('searxng', {}) + const provider = createSearchProvider('searxng', {}) const results: SearchResult[] = await provider.search('test query') expect(results).toHaveLength(1) @@ -120,7 +120,7 @@ describe('searxng provider', () => { }) it('maps metadata correctly', async () => { - const provider = create('searxng', {}) + const provider = createSearchProvider('searxng', {}) const results: SearchResult[] = await provider.search('test query') expect(results).toHaveLength(1) @@ -141,14 +141,14 @@ describe('searxng provider', () => { query: 'test query', }) - const provider = create('searxng', {}) + const provider = createSearchProvider('searxng', {}) const results = await provider.search('test query', { maxResults: 2 }) expect(results).toHaveLength(2) }) it('adds categories param when category option is provided', async () => { - const provider = create('searxng', {}) + const provider = createSearchProvider('searxng', {}) await provider.search('test query', { category: 'news' }) const [url] = mockGetJSON.mock.calls[0] @@ -162,7 +162,7 @@ describe('searxng provider', () => { query: 'test query', }) - const provider = create('searxng', {}) + const provider = createSearchProvider('searxng', {}) const results = await provider.search('query') expect(results).toEqual([]) @@ -179,7 +179,7 @@ describe('searxng provider', () => { query: 'test query', }) - const provider = create('searxng', {}) + const provider = createSearchProvider('searxng', {}) const results = await provider.search('query') expect(results[0].image).toBe('https://example.com/thumb.png') diff --git a/test/unit/serpapi.test.ts b/test/unit/serpapi.test.ts index c0ffad6..315b737 100644 --- a/test/unit/serpapi.test.ts +++ b/test/unit/serpapi.test.ts @@ -14,7 +14,7 @@ vi.mock('../../src/core/client.ts', () => ({ })), })) -import { create, has } from '../../src/core/registry.ts' +import { createSearchProvider, has } from '../../src/core/registry.ts' import { AuthError } from '../../src/core/errors.ts' import type { SearchResult } from '../../src/core/types.ts' @@ -54,24 +54,24 @@ describe('serpapi provider', () => { describe('create', () => { it('creates provider with apiKey', () => { - expect(() => create('serpapi', { apiKey: 'test-key' })).not.toThrow() + expect(() => createSearchProvider('serpapi', { apiKey: 'test-key' })).not.toThrow() }) it('throws AuthError without apiKey and without env var', () => { - expect(() => create('serpapi', {})).toThrow(AuthError) + expect(() => createSearchProvider('serpapi', {})).toThrow(AuthError) }) }) - describe('name()', () => { + describe('name', () => { it('returns serpapi', () => { - const provider = create('serpapi', { apiKey: 'test-key' }) - expect(provider.name()).toBe('serpapi') + const provider = createSearchProvider('serpapi', { apiKey: 'test-key' }) + expect(provider.name).toBe('serpapi') }) }) describe('search()', () => { it('calls getJSON with URL containing engine, q, api_key, and num parameters', async () => { - const provider = create('serpapi', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpapi', { apiKey: 'test-key' }) await provider.search('test query') expect(mockGetJSON).toHaveBeenCalledOnce() @@ -84,7 +84,7 @@ describe('serpapi provider', () => { }) it('maps result fields correctly', async () => { - const provider = create('serpapi', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpapi', { apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results).toHaveLength(1) @@ -98,7 +98,7 @@ describe('serpapi provider', () => { }) it('maps metadata fields correctly', async () => { - const provider = create('serpapi', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpapi', { apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results).toHaveLength(1) @@ -109,7 +109,7 @@ describe('serpapi provider', () => { }) it('maps maxResults option to num query parameter', async () => { - const provider = create('serpapi', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpapi', { apiKey: 'test-key' }) await provider.search('test query', { maxResults: 5 }) const [url] = mockGetJSON.mock.calls[0] @@ -125,7 +125,7 @@ describe('serpapi provider', () => { organic_results: undefined, }) - const provider = create('serpapi', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpapi', { apiKey: 'test-key' }) const results = await provider.search('query') expect(results).toEqual([]) diff --git a/test/unit/serpbase.test.ts b/test/unit/serpbase.test.ts index 18c4600..424e6e0 100644 --- a/test/unit/serpbase.test.ts +++ b/test/unit/serpbase.test.ts @@ -14,7 +14,7 @@ vi.mock('../../src/core/client.ts', () => ({ })), })) -import { create, has } from '../../src/core/registry.ts' +import { createSearchProvider, has } from '../../src/core/registry.ts' import { AskwebError, AuthError, RateLimitError } from '../../src/core/errors.ts' import type { SearchResult } from '../../src/core/types.ts' @@ -58,29 +58,29 @@ describe('serpbase provider', () => { describe('create', () => { it('creates provider with apiKey', () => { - expect(() => create('serpbase', { apiKey: 'test-key' })).not.toThrow() + expect(() => createSearchProvider('serpbase', { apiKey: 'test-key' })).not.toThrow() }) it('creates provider with env api key', () => { process.env.SERPBASE_API_KEY = 'env-key' - expect(() => create('serpbase')).not.toThrow() + expect(() => createSearchProvider('serpbase')).not.toThrow() }) it('throws AuthError without apiKey and without env var', () => { - expect(() => create('serpbase', {})).toThrow(AuthError) + expect(() => createSearchProvider('serpbase', {})).toThrow(AuthError) }) }) - describe('name()', () => { + describe('name', () => { it('returns serpbase', () => { - const provider = create('serpbase', { apiKey: 'test-key' }) - expect(provider.name()).toBe('serpbase') + const provider = createSearchProvider('serpbase', { apiKey: 'test-key' }) + expect(provider.name).toBe('serpbase') }) }) describe('search()', () => { it('calls postJSON with Google search endpoint, body, and X-API-Key header', async () => { - const provider = create('serpbase', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpbase', { apiKey: 'test-key' }) await provider.search('test query') expect(mockPostJSON).toHaveBeenCalledOnce() @@ -92,7 +92,7 @@ describe('serpbase provider', () => { }) it('maps organic result fields correctly', async () => { - const provider = create('serpbase', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpbase', { apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results).toHaveLength(1) @@ -116,7 +116,7 @@ describe('serpbase provider', () => { ], }) - const provider = create('serpbase', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpbase', { apiKey: 'test-key' }) const results = await provider.search('test query', { maxResults: 1 }) expect(results).toHaveLength(1) @@ -141,7 +141,7 @@ describe('serpbase provider', () => { }], }) - const provider = create('serpbase', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpbase', { apiKey: 'test-key' }) const results = await provider.search('image query', { category: 'images' }) const [url] = mockPostJSON.mock.calls[0] @@ -162,7 +162,7 @@ describe('serpbase provider', () => { search_type: 'search', }) - const provider = create('serpbase', { apiKey: 'bad-key' }) + const provider = createSearchProvider('serpbase', { apiKey: 'bad-key' }) await expect(provider.search('test query')).rejects.toThrow(AuthError) }) @@ -177,7 +177,7 @@ describe('serpbase provider', () => { search_type: 'search', }) - const provider = create('serpbase', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpbase', { apiKey: 'test-key' }) await expect(provider.search('test query')).rejects.toThrow(RateLimitError) }) @@ -192,7 +192,7 @@ describe('serpbase provider', () => { search_type: 'search', }) - const provider = create('serpbase', { apiKey: 'test-key' }) + const provider = createSearchProvider('serpbase', { apiKey: 'test-key' }) await expect(provider.search('test query')).rejects.toThrow(AskwebError) }) diff --git a/test/unit/tavily.test.ts b/test/unit/tavily.test.ts index b87b64a..91bb303 100644 --- a/test/unit/tavily.test.ts +++ b/test/unit/tavily.test.ts @@ -14,7 +14,7 @@ vi.mock('../../src/core/client.ts', () => ({ })), })) -import { create, has } from '../../src/core/registry.ts' +import { createSearchProvider, has } from '../../src/core/registry.ts' import { AuthError } from '../../src/core/errors.ts' import type { SearchResult } from '../../src/core/types.ts' @@ -49,24 +49,24 @@ describe('tavily provider', () => { describe('create', () => { it('creates provider with apiKey', () => { - expect(() => create('tavily', { apiKey: 'test-key' })).not.toThrow() + expect(() => createSearchProvider('tavily', { apiKey: 'test-key' })).not.toThrow() }) it('throws AuthError without apiKey and without env var', () => { - expect(() => create('tavily', {})).toThrow(AuthError) + expect(() => createSearchProvider('tavily', {})).toThrow(AuthError) }) }) - describe('name()', () => { + describe('name', () => { it('returns tavily', () => { - const provider = create('tavily', { apiKey: 'test-key' }) - expect(provider.name()).toBe('tavily') + const provider = createSearchProvider('tavily', { apiKey: 'test-key' }) + expect(provider.name).toBe('tavily') }) }) describe('search()', () => { it('calls postJSON with correct url and body containing api_key', async () => { - const provider = create('tavily', { apiKey: 'test-key' }) + const provider = createSearchProvider('tavily', { apiKey: 'test-key' }) await provider.search('test query') expect(mockPostJSON).toHaveBeenCalledOnce() @@ -82,7 +82,7 @@ describe('tavily provider', () => { }) it('maps result fields correctly', async () => { - const provider = create('tavily', { apiKey: 'test-key' }) + const provider = createSearchProvider('tavily', { apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results).toHaveLength(1) @@ -96,7 +96,7 @@ describe('tavily provider', () => { }) it('puts response.answer into first result summary field', async () => { - const provider = create('tavily', { apiKey: 'test-key' }) + const provider = createSearchProvider('tavily', { apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results[0].summary).toBe('A direct answer from Tavily') @@ -122,7 +122,7 @@ describe('tavily provider', () => { query: 'test query', }) - const provider = create('tavily', { apiKey: 'test-key' }) + const provider = createSearchProvider('tavily', { apiKey: 'test-key' }) const results: SearchResult[] = await provider.search('test query') expect(results[0].summary).toBe('A direct answer from Tavily') @@ -130,7 +130,7 @@ describe('tavily provider', () => { }) it('maps maxResults to max_results in body', async () => { - const provider = create('tavily', { apiKey: 'test-key' }) + const provider = createSearchProvider('tavily', { apiKey: 'test-key' }) await provider.search('test query', { maxResults: 5 }) const [, body] = mockPostJSON.mock.calls[0] @@ -138,7 +138,7 @@ describe('tavily provider', () => { }) it('passes includeDomains to include_domains in body', async () => { - const provider = create('tavily', { apiKey: 'test-key' }) + const provider = createSearchProvider('tavily', { apiKey: 'test-key' }) await provider.search('test query', { includeDomains: ['github.com', 'stackoverflow.com'] }) const [, body] = mockPostJSON.mock.calls[0] @@ -146,7 +146,7 @@ describe('tavily provider', () => { }) it('passes excludeDomains to exclude_domains in body', async () => { - const provider = create('tavily', { apiKey: 'test-key' }) + const provider = createSearchProvider('tavily', { apiKey: 'test-key' }) await provider.search('test query', { excludeDomains: ['reddit.com'] }) const [, body] = mockPostJSON.mock.calls[0] @@ -159,7 +159,7 @@ describe('tavily provider', () => { query: 'test query', }) - const provider = create('tavily', { apiKey: 'test-key' }) + const provider = createSearchProvider('tavily', { apiKey: 'test-key' }) const results = await provider.search('query') expect(results).toEqual([])