{"version":3,"file":"search.test.d.ts","sourceRoot":"","sources":["../../../src/core/web-research/search.test.ts"],"names":[],"mappings":"","sourcesContent":["import { describe, expect, it, vi } from \"vitest\";\nimport {\n\tDuckDuckGoLiteProvider,\n\tparseDuckDuckGoLiteResults,\n\tSearxngProvider,\n\tWebSearchProviderRegistry,\n} from \"./search.js\";\nimport { WebResearchError, type WebSearchProvider } from \"./types.js\";\nimport { canonicalizeWebUrl } from \"./url.js\";\n\nconst PROVIDER_OPTIONS = { timeoutMs: 1000, maxResults: 10, userAgent: \"test\" };\n\nfunction searxng(fetchImpl: typeof fetch): SearxngProvider {\n\treturn new SearxngProvider({ ...PROVIDER_OPTIONS, baseUrl: \"http://127.0.0.1:18888\", fetch: fetchImpl });\n}\n\ndescribe(\"web search providers\", () => {\n\tit(\"serializes SearXNG query capabilities deterministically\", async () => {\n\t\tconst fetchImpl = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ results: [] })));\n\t\tawait searxng(fetchImpl).search({\n\t\t\tquery: \"cache stable\",\n\t\t\tlanguage: \"en\",\n\t\t\tfreshness: \"week\",\n\t\t\tcategories: [\"science\", \"general\"],\n\t\t\tsafeSearch: true,\n\t\t});\n\t\tconst url = new URL(String(fetchImpl.mock.calls[0][0]));\n\t\texpect(Object.fromEntries(url.searchParams)).toEqual({\n\t\t\tcategories: \"general,science\",\n\t\t\tformat: \"json\",\n\t\t\tlanguage: \"en\",\n\t\t\tq: \"cache stable\",\n\t\t\tsafesearch: \"1\",\n\t\t\ttime_range: \"week\",\n\t\t});\n\t});\n\n\tit(\"parses, canonicalizes, deduplicates, and preserves provider order\", async () => {\n\t\tconst fetchImpl: typeof fetch = async () =>\n\t\t\tnew Response(\n\t\t\t\tJSON.stringify({\n\t\t\t\t\tresults: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttitle: \" First \",\n\t\t\t\t\t\t\turl: \"https://example.com/a?utm_source=x&b=2&a=1#fragment\",\n\t\t\t\t\t\t\tcontent: \"one\",\n\t\t\t\t\t\t\tengine: \"bing\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ title: \"Duplicate\", url: \"https://example.com/a?a=1&b=2\", content: \"duplicate\" },\n\t\t\t\t\t\t{ title: \"Second\", url: \"https://example.org/b\", content: \"two\", score: 0.4 },\n\t\t\t\t\t],\n\t\t\t\t}),\n\t\t\t);\n\t\tconst response = await searxng(fetchImpl).search({ query: \"q\", maxResults: 10 });\n\t\texpect(response.rawResultCount).toBe(3);\n\t\texpect(response.deduplicatedCount).toBe(1);\n\t\texpect(response.results.map(({ title, url, rank, provider }) => ({ title, url, rank, provider }))).toEqual([\n\t\t\t{ title: \"First\", url: \"https://example.com/a?a=1&b=2\", rank: 1, provider: \"searxng\" },\n\t\t\t{ title: \"Second\", url: \"https://example.org/b\", rank: 2, provider: \"searxng\" },\n\t\t]);\n\t});\n\n\tit(\"distinguishes empty results from malformed and failed providers\", async () => {\n\t\tawait expect(\n\t\t\tsearxng(async () => new Response('{\"results\":[]}')).search({ query: \"none\" }),\n\t\t).resolves.toMatchObject({\n\t\t\tresults: [],\n\t\t});\n\t\tawait expect(searxng(async () => new Response(\"{}\")).search({ query: \"bad\" })).rejects.toMatchObject({\n\t\t\tcode: \"PROVIDER_RESPONSE_INVALID\",\n\t\t});\n\t\tawait expect(\n\t\t\tsearxng(async () => new Response(\"down\", { status: 503 })).search({ query: \"bad\" }),\n\t\t).rejects.toMatchObject({\n\t\t\tcode: \"PROVIDER_UNAVAILABLE\",\n\t\t});\n\t});\n\n\tit(\"keeps DuckDuckGo Lite compatibility\", async () => {\n\t\tconst html = `<table><a class=\"result-link\" href=\"//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fdocs\">Docs &amp; API</a><td class=\"result-snippet\">Official <b>docs</b>.</td></table>`;\n\t\texpect(parseDuckDuckGoLiteResults(html)).toEqual([\n\t\t\texpect.objectContaining({\n\t\t\t\ttitle: \"Docs & API\",\n\t\t\t\turl: \"https://example.com/docs\",\n\t\t\t\tsnippet: \"Official docs.\",\n\t\t\t\tprovider: \"duckduckgo-lite\",\n\t\t\t\trank: 1,\n\t\t\t}),\n\t\t]);\n\t});\n\n\tit(\"falls back only on operational failure, not zero results\", async () => {\n\t\tlet fallbackCalls = 0;\n\t\tconst emptyPrimary: WebSearchProvider = {\n\t\t\tid: \"searxng\",\n\t\t\tcapabilities: { freshness: true, language: true, region: false, categories: true, safeSearch: true },\n\t\t\tsearch: async (request) => ({\n\t\t\t\tprovider: \"searxng\",\n\t\t\t\tquery: request.query,\n\t\t\t\tresults: [],\n\t\t\t\trawResultCount: 0,\n\t\t\t\tdeduplicatedCount: 0,\n\t\t\t\tdurationMs: 1,\n\t\t\t}),\n\t\t\thealthCheck: async () => ({ provider: \"searxng\", status: \"healthy\" }),\n\t\t};\n\t\tconst fallback: WebSearchProvider = {\n\t\t\tid: \"duckduckgo-lite\",\n\t\t\tcapabilities: { freshness: false, language: false, region: true, categories: false, safeSearch: false },\n\t\t\tsearch: async (request) => {\n\t\t\t\tfallbackCalls++;\n\t\t\t\treturn {\n\t\t\t\t\tprovider: \"duckduckgo-lite\",\n\t\t\t\t\tquery: request.query,\n\t\t\t\t\tresults: [],\n\t\t\t\t\trawResultCount: 0,\n\t\t\t\t\tdeduplicatedCount: 0,\n\t\t\t\t\tdurationMs: 1,\n\t\t\t\t};\n\t\t\t},\n\t\t\thealthCheck: async () => ({ provider: \"duckduckgo-lite\", status: \"healthy\" }),\n\t\t};\n\t\tconst registry = new WebSearchProviderRegistry([emptyPrimary, fallback]);\n\t\tawait expect(registry.search(\"auto\", { query: \"none\" })).resolves.toMatchObject({ provider: \"searxng\" });\n\t\texpect(fallbackCalls).toBe(0);\n\t\temptyPrimary.search = async () => {\n\t\t\tthrow new WebResearchError(\"PROVIDER_UNAVAILABLE\", \"down\", { provider: \"searxng\" });\n\t\t};\n\t\tawait expect(registry.search(\"auto\", { query: \"fallback\" })).resolves.toMatchObject({\n\t\t\tprovider: \"duckduckgo-lite\",\n\t\t\tfallbackFrom: \"searxng\",\n\t\t});\n\t\texpect(fallbackCalls).toBe(1);\n\t});\n\n\tit(\"honors explicit provider selection without fallback\", async () => {\n\t\tconst failed = searxng(async () => new Response(\"down\", { status: 503 }));\n\t\tconst duck = new DuckDuckGoLiteProvider({\n\t\t\t...PROVIDER_OPTIONS,\n\t\t\tfetch: async () => new Response(\"<table></table>\"),\n\t\t});\n\t\tconst registry = new WebSearchProviderRegistry([failed, duck]);\n\t\tawait expect(registry.search(\"searxng\", { query: \"q\" })).rejects.toMatchObject({ provider: \"searxng\" });\n\t});\n\n\tit(\"propagates caller abort\", async () => {\n\t\tconst controller = new AbortController();\n\t\tcontroller.abort();\n\t\tawait expect(searxng(fetch).search({ query: \"q\", signal: controller.signal })).rejects.toMatchObject({\n\t\t\tcode: \"ABORTED\",\n\t\t});\n\t});\n\n\tit(\"canonical URL serialization removes trackers and sorts only unordered query parameters\", () => {\n\t\texpect(canonicalizeWebUrl(\"HTTPS://Example.COM:443/a/?z=2&utm_campaign=x&a=1#x\")).toBe(\n\t\t\t\"https://example.com/a?a=1&z=2\",\n\t\t);\n\t});\n});\n"]}