import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { deduplicateResults, formatSearchResults, resetCachedInstance, searxngSearch } from "../src/agent/tools.ts"; const FAKE_RESULTS = [ { title: "Bun Runtime", url: "https://bun.sh", content: "A fast JavaScript runtime", engine: "google" }, { title: "Bun Docs", url: "https://bun.sh/docs", content: "Official documentation for Bun", engine: "duckduckgo" }, { title: "Bun on GitHub", url: "https://github.com/oven-sh/bun", content: "Source code", engine: "google" }, ]; type MockFetch = ReturnType>; function mockFetch(impl: (...args: unknown[]) => Promise): MockFetch { const fn = mock(impl) as unknown as MockFetch; globalThis.fetch = fn as unknown as typeof fetch; return fn; } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); } function htmlResponse(body: string, status = 200): Response { return new Response(body, { status, headers: { "content-type": "text/html" }, }); } describe("deduplicateResults", () => { it("removes duplicate URLs", () => { const input = [ { title: "A", url: "https://a.com", content: "first", engine: "google" }, { title: "A copy", url: "https://a.com", content: "duplicate", engine: "bing" }, { title: "B", url: "https://b.com", content: "second", engine: "google" }, ]; const result = deduplicateResults(input); expect(result).toHaveLength(2); expect(result[0]!.title).toBe("A"); expect(result[1]!.title).toBe("B"); }); it("filters out results with empty URLs", () => { const input = [ { title: "No URL", url: "", content: "oops", engine: "google" }, { title: "Has URL", url: "https://x.com", content: "ok", engine: "google" }, ]; const result = deduplicateResults(input); expect(result).toHaveLength(1); expect(result[0]!.title).toBe("Has URL"); }); it("preserves order (first occurrence wins)", () => { const input = [ { title: "First", url: "https://dup.com", content: "1st", engine: "google" }, { title: "Second", url: "https://dup.com", content: "2nd", engine: "bing" }, { title: "Third", url: "https://other.com", content: "3rd", engine: "google" }, ]; const result = deduplicateResults(input); expect(result).toHaveLength(2); expect(result[0]!.content).toBe("1st"); }); it("handles empty input", () => { expect(deduplicateResults([])).toEqual([]); }); }); describe("formatSearchResults", () => { it("formats results as numbered list with URL and snippet", () => { const output = formatSearchResults(FAKE_RESULTS, 3); expect(output).toContain("1. Bun Runtime"); expect(output).toContain("URL: https://bun.sh"); expect(output).toContain("A fast JavaScript runtime"); expect(output).toContain("2. Bun Docs"); expect(output).toContain("3. Bun on GitHub"); }); it("caps results at num_results", () => { const output = formatSearchResults(FAKE_RESULTS, 2); expect(output).toContain("1. Bun Runtime"); expect(output).toContain("2. Bun Docs"); expect(output).not.toContain("Bun on GitHub"); }); it("truncates long snippets", () => { const longContent = "x".repeat(300); const results = [{ title: "Long", url: "https://long.com", content: longContent, engine: "google" }]; const output = formatSearchResults(results, 1); expect(output).toContain("..."); expect(output.length).toBeLessThan(longContent.length); }); it("handles results with no content", () => { const results = [{ title: "Bare", url: "https://bare.com", content: "", engine: "google" }]; const output = formatSearchResults(results, 1); expect(output).toContain("1. Bare"); expect(output).toContain("URL: https://bare.com"); }); it("handles untitled results", () => { const results = [{ title: "", url: "https://x.com", content: "desc", engine: "google" }]; const output = formatSearchResults(results, 1); expect(output).toContain("(untitled)"); }); }); describe("searxngSearch", () => { const originalFetch = globalThis.fetch; beforeEach(() => { resetCachedInstance(); }); afterEach(() => { globalThis.fetch = originalFetch; delete process.env.SEARXNG_INSTANCES; }); it("returns results from a successful instance", async () => { process.env.SEARXNG_INSTANCES = "https://fake-searx.test"; const fn = mockFetch(() => Promise.resolve(jsonResponse({ results: FAKE_RESULTS }))); const results = await searxngSearch("bun runtime", 5); expect(results).toHaveLength(3); expect(results[0]!.title).toBe("Bun Runtime"); const calledUrl = fn.mock.calls[0]![0] as string; expect(calledUrl).toContain("fake-searx.test"); expect(calledUrl).toContain("q=bun%20runtime"); expect(calledUrl).toContain("format=json"); }); it("falls back to next instance when first fails", async () => { process.env.SEARXNG_INSTANCES = "https://dead.test,https://alive.test"; const calls: string[] = []; mockFetch((url) => { calls.push(url as string); if ((url as string).includes("dead.test")) { return Promise.resolve(new Response("Server Error", { status: 500 })); } return Promise.resolve(jsonResponse({ results: FAKE_RESULTS })); }); const results = await searxngSearch("test query", 5); expect(results).toHaveLength(3); expect(calls).toHaveLength(2); expect(calls[0]).toContain("dead.test"); expect(calls[1]).toContain("alive.test"); }); it("skips instances that return non-JSON (format disabled)", async () => { process.env.SEARXNG_INSTANCES = "https://html-only.test,https://json-ok.test"; mockFetch((url) => { if ((url as string).includes("html-only.test")) { return Promise.resolve(htmlResponse("Forbidden", 403)); } return Promise.resolve(jsonResponse({ results: FAKE_RESULTS })); }); const results = await searxngSearch("query", 5); expect(results).toHaveLength(3); }); it("throws when all instances fail", async () => { process.env.SEARXNG_INSTANCES = "https://dead1.test,https://dead2.test"; mockFetch(() => Promise.resolve(new Response("Error", { status: 500 }))); await expect(searxngSearch("query", 5)).rejects.toThrow("all SearXNG instances failed"); }); it("caches successful instance for subsequent calls", async () => { process.env.SEARXNG_INSTANCES = "https://slow.test,https://fast.test"; const calls: string[] = []; mockFetch((url) => { calls.push(url as string); if ((url as string).includes("slow.test")) { return Promise.resolve(new Response("Error", { status: 500 })); } return Promise.resolve(jsonResponse({ results: FAKE_RESULTS })); }); await searxngSearch("first", 5); expect(calls).toHaveLength(2); calls.length = 0; await searxngSearch("second", 5); // Should go directly to cached instance (fast.test), not try slow.test first expect(calls).toHaveLength(1); expect(calls[0]).toContain("fast.test"); }); it("resets cache when cached instance fails", async () => { process.env.SEARXNG_INSTANCES = "https://flaky.test,https://backup.test"; let flakyCallCount = 0; mockFetch((url) => { if ((url as string).includes("flaky.test")) { flakyCallCount++; if (flakyCallCount === 1) return Promise.resolve(jsonResponse({ results: FAKE_RESULTS })); return Promise.resolve(new Response("Error", { status: 500 })); } return Promise.resolve(jsonResponse({ results: FAKE_RESULTS })); }); // First call: flaky.test works, gets cached await searxngSearch("first", 5); // Second call: flaky.test is cached but now fails, should fall back to backup.test const results = await searxngSearch("second", 5); expect(results).toHaveLength(3); }); it("skips instances returning unexpected response shape", async () => { process.env.SEARXNG_INSTANCES = "https://weird.test,https://good.test"; mockFetch((url) => { if ((url as string).includes("weird.test")) { return Promise.resolve(jsonResponse({ error: "something went wrong" })); } return Promise.resolve(jsonResponse({ results: FAKE_RESULTS })); }); const results = await searxngSearch("query", 5); expect(results).toHaveLength(3); }); it("respects SEARXNG_INSTANCES env var", async () => { process.env.SEARXNG_INSTANCES = "https://custom.test"; const fn = mockFetch(() => Promise.resolve(jsonResponse({ results: FAKE_RESULTS }))); await searxngSearch("query", 5); const calledUrl = fn.mock.calls[0]![0] as string; expect(calledUrl).toContain("custom.test"); }); });