feat(FN-2993): merge fusion/fn-2993
Commits merged: - fix(FN-2993): resolve lint and build verification issues - fix(FN-2993): address provider registry review feedback - feat(FN-2993): complete Step 7 — add provider registry - feat(FN-2993): complete Step 6 — add LLM synthesis provider - feat(FN-2993): complete Step 5 — add local docs provider - feat(FN-2993): complete Step 4 — add GitHub provider - fix(FN-2993): align provider config types with step runner contract - fix(FN-2993): resolve page fetch test module ordering - test(FN-2993): expand page fetch coverage for timeout and truncation - feat(FN-2993): complete Step 3 — add page fetch provider - fix(FN-2993): align web search provider interface and abort assertions - feat(FN-2993): complete Step 2 — add web search provider - feat(FN-2993): complete Step 1 — define provider types and settings fields Files changed: packages/core/src/index.ts | 2 +- packages/core/src/settings-schema.ts | 11 + packages/core/src/types.ts | 25 ++ .../app/components/CustomProvidersSection.tsx | 1 - packages/engine/src/index.ts | 19 ++ .../research/__tests__/provider-registry.test.ts | 49 +++ packages/engine/src/research/provider-registry.ts | 110 +++++++ .../providers/__tests__/github-provider.test.ts | 143 +++++++++ .../__tests__/llm-synthesis-provider.test.ts | 107 +++++++ .../__tests__/local-docs-provider.test.ts | 79 +++++ .../__tests__/page-fetch-provider.test.ts | 92 ++++++ .../__tests__/web-search-provider.test.ts | 101 +++++++ .../src/research/providers/github-provider.ts | 327 +++++++++++++++++++++ packages/engine/src/research/providers/index.ts | 5 + .../research/providers/llm-synthesis-provider.ts | 216 ++++++++++++++ .../src/research/providers/local-docs-provider.ts | 238 +++++++++++++++ .../src/research/providers/page-fetch-provider.ts | 123 ++++++++ .../src/research/providers/web-search-provider.ts | 269 +++++++++++++++++ packages/engine/src/research/types.ts | 50 ++++ 19 files changed, 1965 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-2993
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { runGhAsyncMock, runGhJsonAsyncMock, isGhAvailableMock, isGhAuthenticatedMock } = vi.hoisted(() => ({
|
||||
runGhAsyncMock: vi.fn(),
|
||||
runGhJsonAsyncMock: vi.fn(),
|
||||
isGhAvailableMock: vi.fn(),
|
||||
isGhAuthenticatedMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
runGhAsync: runGhAsyncMock,
|
||||
runGhJsonAsync: runGhJsonAsyncMock,
|
||||
isGhAvailable: isGhAvailableMock,
|
||||
isGhAuthenticated: isGhAuthenticatedMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { GitHubProvider } from "../github-provider.js";
|
||||
|
||||
describe("GitHubProvider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isGhAvailableMock.mockReturnValue(true);
|
||||
isGhAuthenticatedMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("searches repositories", async () => {
|
||||
runGhJsonAsyncMock.mockResolvedValueOnce([
|
||||
{ fullName: "org/repo", description: "desc", htmlUrl: "https://github.com/org/repo", stargazersCount: 12, language: "ts", updatedAt: "2026" },
|
||||
]);
|
||||
const provider = new GitHubProvider();
|
||||
|
||||
const results = await provider.search("query", { metadata: { searchType: "repos" } });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({ type: "github", title: "org/repo", reference: "https://github.com/org/repo" });
|
||||
});
|
||||
|
||||
it("searches issues", async () => {
|
||||
runGhJsonAsyncMock.mockResolvedValueOnce([
|
||||
{ title: "Issue", body: "body", htmlUrl: "https://github.com/org/repo/issues/1", state: "open", labels: [{ name: "bug" }] },
|
||||
]);
|
||||
|
||||
const provider = new GitHubProvider();
|
||||
const results = await provider.search("query", { metadata: { searchType: "issues" } });
|
||||
|
||||
expect(results[0]?.metadata).toMatchObject({ resultType: "issue", state: "open", labels: ["bug"] });
|
||||
});
|
||||
|
||||
it("supports combined search", async () => {
|
||||
runGhJsonAsyncMock
|
||||
.mockResolvedValueOnce([{ fullName: "org/repo", htmlUrl: "https://github.com/org/repo" }])
|
||||
.mockResolvedValueOnce([{ title: "Issue", htmlUrl: "https://github.com/org/repo/issues/1" }]);
|
||||
|
||||
const provider = new GitHubProvider();
|
||||
const results = await provider.search("query", {});
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("fetches repo README", async () => {
|
||||
runGhJsonAsyncMock.mockResolvedValueOnce({
|
||||
content: Buffer.from("# Hello").toString("base64"),
|
||||
encoding: "base64",
|
||||
name: "README.md",
|
||||
});
|
||||
|
||||
const provider = new GitHubProvider();
|
||||
const result = await provider.fetchContent("https://github.com/org/repo", {});
|
||||
|
||||
expect(result.content).toContain("# Hello");
|
||||
expect(result.metadata).toMatchObject({ kind: "repo-readme" });
|
||||
});
|
||||
|
||||
it("fetches issue content", async () => {
|
||||
runGhAsyncMock.mockResolvedValueOnce("Issue body\nComments");
|
||||
|
||||
const provider = new GitHubProvider();
|
||||
const result = await provider.fetchContent("https://github.com/org/repo/issues/123", {});
|
||||
|
||||
expect(result.content).toContain("Issue body");
|
||||
expect(result.metadata).toMatchObject({ kind: "issue", number: "123" });
|
||||
});
|
||||
|
||||
it("fetches pr content", async () => {
|
||||
runGhAsyncMock.mockResolvedValueOnce("PR body\nComments");
|
||||
|
||||
const provider = new GitHubProvider();
|
||||
const result = await provider.fetchContent("https://github.com/org/repo/pull/9", {});
|
||||
expect(result.metadata).toMatchObject({ kind: "pr", number: "9" });
|
||||
});
|
||||
|
||||
it("fetches file content from blob url", async () => {
|
||||
runGhJsonAsyncMock.mockResolvedValueOnce({
|
||||
content: Buffer.from("file content").toString("base64"),
|
||||
encoding: "base64",
|
||||
name: "index.ts",
|
||||
});
|
||||
|
||||
const provider = new GitHubProvider();
|
||||
const result = await provider.fetchContent("https://github.com/org/repo/blob/main/src/index.ts", {});
|
||||
expect(result.content).toContain("file content");
|
||||
expect(result.metadata).toMatchObject({ kind: "file", path: "src/index.ts" });
|
||||
});
|
||||
|
||||
it("reports configuration state", () => {
|
||||
const provider = new GitHubProvider();
|
||||
expect(provider.isConfigured()).toBe(true);
|
||||
|
||||
isGhAvailableMock.mockReturnValue(false);
|
||||
expect(provider.isConfigured()).toBe(false);
|
||||
|
||||
isGhAvailableMock.mockReturnValue(true);
|
||||
isGhAuthenticatedMock.mockReturnValue(false);
|
||||
expect(provider.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it("maps abort and timeout errors", async () => {
|
||||
runGhJsonAsyncMock.mockRejectedValueOnce(Object.assign(new Error("gh command aborted"), { code: "ABORT_ERR", stderr: "", stdout: "" }));
|
||||
const provider = new GitHubProvider();
|
||||
await expect(provider.search("q", {})).rejects.toMatchObject({ code: "abort" });
|
||||
|
||||
runGhJsonAsyncMock.mockRejectedValueOnce(Object.assign(new Error("gh command timed out after 30000ms"), { code: null, stderr: "", stdout: "" }));
|
||||
await expect(provider.search("q", {})).rejects.toMatchObject({ code: "timeout" });
|
||||
});
|
||||
|
||||
it("maps rate limit and auth failures", async () => {
|
||||
const provider = new GitHubProvider();
|
||||
|
||||
runGhJsonAsyncMock.mockRejectedValueOnce(Object.assign(new Error("API rate limit exceeded"), { code: 403, stderr: "", stdout: "" }));
|
||||
await expect(provider.search("q", {})).rejects.toMatchObject({ code: "rate-limited", retryable: true });
|
||||
|
||||
runGhJsonAsyncMock.mockRejectedValueOnce(Object.assign(new Error("authentication required"), { code: 401, stderr: "", stdout: "" }));
|
||||
await expect(provider.search("q", {})).rejects.toMatchObject({ code: "auth-failed" });
|
||||
});
|
||||
|
||||
it("errors on unsupported urls", async () => {
|
||||
const provider = new GitHubProvider();
|
||||
await expect(provider.fetchContent("https://example.com/a", {})).rejects.toMatchObject({ code: "provider-unavailable" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createFnAgentMock, promptWithFallbackMock, disposeMock } = vi.hoisted(() => ({
|
||||
disposeMock: vi.fn(),
|
||||
createFnAgentMock: vi.fn(),
|
||||
promptWithFallbackMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../pi.js", () => ({
|
||||
createFnAgent: createFnAgentMock,
|
||||
promptWithFallback: promptWithFallbackMock,
|
||||
}));
|
||||
|
||||
import { LLMSynthesisProvider, buildSynthesisPrompt, extractCitations } from "../llm-synthesis-provider.js";
|
||||
|
||||
describe("LLMSynthesisProvider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const session = { state: { messages: [] as Array<{ role: string; content: string }> }, dispose: disposeMock };
|
||||
createFnAgentMock.mockResolvedValue({ session });
|
||||
promptWithFallbackMock.mockImplementation(async (s: typeof session, _prompt: string) => {
|
||||
s.state.messages.push({ role: "assistant", content: '```json\n{"summary":"ok","confidence":0.8}\n```\n[1]' });
|
||||
});
|
||||
});
|
||||
|
||||
it("builds synthesis output with citations", async () => {
|
||||
const provider = new LLMSynthesisProvider({ projectRoot: "/tmp" });
|
||||
const result = await provider.synthesize(
|
||||
{
|
||||
query: "q",
|
||||
round: 1,
|
||||
sources: [{ id: "1", type: "web", reference: "https://a", status: "completed", title: "A", content: "hello" }],
|
||||
},
|
||||
{ provider: "openai", modelId: "gpt-5" },
|
||||
);
|
||||
|
||||
expect(promptWithFallbackMock).toHaveBeenCalled();
|
||||
expect(result.citations).toEqual(["https://a"]);
|
||||
expect(result.confidence).toBe(0.8);
|
||||
expect(disposeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes query and sources in prompt", () => {
|
||||
const prompt = buildSynthesisPrompt(
|
||||
{ query: "query", round: 2, sources: [{ id: "s", type: "web", reference: "https://x", status: "completed", title: "Title", content: "Body" }] },
|
||||
[{ id: "s", type: "web", reference: "https://x", status: "completed", title: "Title", content: "Body" }],
|
||||
);
|
||||
expect(prompt).toContain("Query: query");
|
||||
expect(prompt).toContain("Reference: https://x");
|
||||
expect(prompt).toContain("Return valid JSON");
|
||||
});
|
||||
|
||||
it("extracts citations by index", () => {
|
||||
const citations = extractCitations("Findings [1] and [2]", [
|
||||
{ id: "1", type: "web", reference: "a", status: "completed" },
|
||||
{ id: "2", type: "web", reference: "b", status: "completed" },
|
||||
]);
|
||||
expect(citations).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("maps abort and timeout", async () => {
|
||||
const provider = new LLMSynthesisProvider({ projectRoot: "/tmp", timeoutMs: 20 });
|
||||
promptWithFallbackMock.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
await expect(
|
||||
provider.synthesize({ query: "q", round: 1, sources: [] }, { provider: "openai", modelId: "gpt-4o" }),
|
||||
).rejects.toMatchObject({ code: "timeout" });
|
||||
|
||||
promptWithFallbackMock.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = provider.synthesize({ query: "q", round: 1, sources: [] }, { provider: "openai", modelId: "gpt-4o" }, controller.signal);
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ code: "abort" });
|
||||
});
|
||||
|
||||
it("reports provider availability", () => {
|
||||
const provider = new LLMSynthesisProvider({ projectRoot: "/tmp" });
|
||||
expect(provider.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it("truncates sources to character budget", async () => {
|
||||
const provider = new LLMSynthesisProvider({ projectRoot: "/tmp" });
|
||||
const manySources = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: String(i),
|
||||
type: "web" as const,
|
||||
reference: `https://src/${i}`,
|
||||
status: "completed" as const,
|
||||
content: "x".repeat(8000),
|
||||
metadata: { confidence: i },
|
||||
}));
|
||||
|
||||
await provider.synthesize({ query: "q", round: 1, sources: manySources }, { provider: "openai", modelId: "small-model" });
|
||||
const promptArg = promptWithFallbackMock.mock.calls[0]?.[1] as string;
|
||||
expect(promptArg.length).toBeLessThan(35_000);
|
||||
});
|
||||
|
||||
it("maps model failures", async () => {
|
||||
const provider = new LLMSynthesisProvider({ projectRoot: "/tmp" });
|
||||
promptWithFallbackMock.mockRejectedValueOnce(new Error("model down"));
|
||||
await expect(provider.synthesize({ query: "q", round: 1, sources: [] }, { provider: "openai", modelId: "gpt-4o" })).rejects.toMatchObject({
|
||||
code: "provider-unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { LocalDocsProvider } from "../local-docs-provider.js";
|
||||
|
||||
describe("LocalDocsProvider", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
function makeProject() {
|
||||
const root = mkdtempSync(join(os.tmpdir(), "fn-test-research-"));
|
||||
tempDirs.push(root);
|
||||
mkdirSync(join(root, "docs"), { recursive: true });
|
||||
writeFileSync(join(root, "README.md"), "Fusion research provider docs");
|
||||
writeFileSync(join(root, "docs", "guide.md"), "This guide covers provider architecture and confidence scoring");
|
||||
return root;
|
||||
}
|
||||
|
||||
it("searches keywords across docs", async () => {
|
||||
const root = makeProject();
|
||||
const provider = new LocalDocsProvider({ projectRoot: root });
|
||||
|
||||
const results = await provider.search("provider confidence", {});
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.reference).toContain("docs/guide.md");
|
||||
});
|
||||
|
||||
it("fetches local file content", async () => {
|
||||
const root = makeProject();
|
||||
const provider = new LocalDocsProvider({ projectRoot: root });
|
||||
|
||||
const result = await provider.fetchContent("README.md", {});
|
||||
expect(result.content).toContain("Fusion research");
|
||||
expect(result.metadata).toMatchObject({ extension: ".md" });
|
||||
});
|
||||
|
||||
it("prevents path traversal", async () => {
|
||||
const root = makeProject();
|
||||
const provider = new LocalDocsProvider({ projectRoot: root });
|
||||
await expect(provider.fetchContent("../../etc/passwd", {})).rejects.toMatchObject({ code: "provider-unavailable" });
|
||||
});
|
||||
|
||||
it("skips binary files during search", async () => {
|
||||
const root = makeProject();
|
||||
writeFileSync(join(root, "docs", "binary.dat"), Buffer.from([0, 1, 2, 3, 4]));
|
||||
const provider = new LocalDocsProvider({ projectRoot: root });
|
||||
|
||||
const results = await provider.search("binary", {});
|
||||
expect(results.find((entry) => String(entry.reference).includes("binary.dat"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips oversized files during search", async () => {
|
||||
const root = makeProject();
|
||||
writeFileSync(join(root, "docs", "large.md"), "x".repeat(1024 * 1024 + 128));
|
||||
const provider = new LocalDocsProvider({ projectRoot: root });
|
||||
|
||||
const results = await provider.search("xxxx", {});
|
||||
expect(results.find((entry) => String(entry.reference).includes("large.md"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports abort signal", async () => {
|
||||
const root = makeProject();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
writeFileSync(join(root, "docs", `doc-${i}.md`), `line ${i}`);
|
||||
}
|
||||
|
||||
const provider = new LocalDocsProvider({ projectRoot: root });
|
||||
const controller = new AbortController();
|
||||
const promise = provider.search("line", {}, controller.signal);
|
||||
controller.abort();
|
||||
|
||||
await expect(promise).rejects.toMatchObject({ code: "abort" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { MAX_CONTENT_CHARS, PageFetchProvider } from "../page-fetch-provider.js";
|
||||
|
||||
describe("PageFetchProvider", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("extracts readable html content", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "text/html" }),
|
||||
text: async () =>
|
||||
"<html><head><title>Hello</title><meta name='description' content='Desc'></head><body><main><h1>Title</h1><p>Body</p></main></body></html>",
|
||||
} as Response);
|
||||
|
||||
const provider = new PageFetchProvider();
|
||||
const result = await provider.fetchContent("https://example.com", {});
|
||||
|
||||
expect(result.content).toContain("Title Body");
|
||||
expect(result.metadata).toMatchObject({ title: "Hello", description: "Desc", contentType: "text/html" });
|
||||
expect(result.mimeType).toBe("text/html");
|
||||
});
|
||||
|
||||
it("pretty prints json", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
text: async () => '{"a":1}',
|
||||
} as Response);
|
||||
|
||||
const provider = new PageFetchProvider();
|
||||
const result = await provider.fetchContent("https://example.com", {});
|
||||
expect(result.content).toContain('"a": 1');
|
||||
});
|
||||
|
||||
it("passes through text", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "text/plain" }),
|
||||
text: async () => "hello world",
|
||||
} as Response);
|
||||
|
||||
const provider = new PageFetchProvider();
|
||||
const result = await provider.fetchContent("https://example.com", {});
|
||||
expect(result.content).toBe("hello world");
|
||||
});
|
||||
|
||||
it("maps status errors", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 } as Response);
|
||||
const provider = new PageFetchProvider();
|
||||
await expect(provider.fetchContent("https://example.com", {})).rejects.toMatchObject({ code: "network-error" });
|
||||
});
|
||||
|
||||
it("maps 500 as provider unavailable", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 } as Response);
|
||||
const provider = new PageFetchProvider();
|
||||
await expect(provider.fetchContent("https://example.com", {})).rejects.toMatchObject({ code: "provider-unavailable" });
|
||||
});
|
||||
|
||||
it("truncates large content", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "text/plain" }),
|
||||
text: async () => "x".repeat(600 * 1024),
|
||||
} as Response);
|
||||
const provider = new PageFetchProvider();
|
||||
const result = await provider.fetchContent("https://example.com", {});
|
||||
expect(result.content.length).toBe(MAX_CONTENT_CHARS);
|
||||
});
|
||||
|
||||
it("maps timeout", async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(Object.assign(new Error("Timed out"), { name: "TimeoutError" }));
|
||||
const provider = new PageFetchProvider({ timeoutMs: 50 });
|
||||
await expect(provider.fetchContent("https://example.com", {})).rejects.toMatchObject({ code: "timeout" });
|
||||
});
|
||||
|
||||
it("maps abort", async () => {
|
||||
global.fetch = vi.fn().mockImplementation(async (_url, init: RequestInit) => {
|
||||
await new Promise((_, reject) => init.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))));
|
||||
return { ok: true, headers: new Headers(), text: async () => "" } as Response;
|
||||
});
|
||||
const provider = new PageFetchProvider();
|
||||
const controller = new AbortController();
|
||||
const pending = provider.fetchContent("https://example.com", {}, controller.signal);
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ code: "abort" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ResearchProviderError } from "../../types.js";
|
||||
import { WebSearchProvider } from "../web-search-provider.js";
|
||||
|
||||
describe("WebSearchProvider", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("validates configuration for each backend", () => {
|
||||
expect(new WebSearchProvider({ backend: "none" }).isConfigured()).toBe(false);
|
||||
expect(new WebSearchProvider({ backend: "searxng", searxngUrl: "https://sx" }).isConfigured()).toBe(true);
|
||||
expect(new WebSearchProvider({ backend: "brave", braveApiKey: "k" }).isConfigured()).toBe(true);
|
||||
expect(new WebSearchProvider({ backend: "google", googleApiKey: "k", googleCx: "cx" }).isConfigured()).toBe(true);
|
||||
expect(new WebSearchProvider({ backend: "tavily", tavilyApiKey: "k" }).isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes searxng results", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ results: [{ url: "https://a", title: "A", content: "Snippet" }] }),
|
||||
} as Response);
|
||||
|
||||
const provider = new WebSearchProvider({ backend: "searxng", searxngUrl: "https://sx" });
|
||||
const results = await provider.search("fusion", {});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
title: "A",
|
||||
reference: "https://a",
|
||||
excerpt: "Snippet",
|
||||
metadata: { backend: "searxng", rank: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("limits max results", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
web: {
|
||||
results: [
|
||||
{ url: "https://a", title: "A", description: "1" },
|
||||
{ url: "https://b", title: "B", description: "2" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const provider = new WebSearchProvider({ backend: "brave", braveApiKey: "k" });
|
||||
const results = await provider.search("fusion", { maxResults: 1 });
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retries 429 and succeeds", async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: false, status: 429 } as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ items: [{ link: "https://x", title: "X", snippet: "S" }] }),
|
||||
} as Response);
|
||||
|
||||
const provider = new WebSearchProvider({ backend: "google", googleApiKey: "k", googleCx: "cx" });
|
||||
const promise = provider.search("fusion", {});
|
||||
await vi.runAllTimersAsync();
|
||||
const results = await promise;
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(results[0]?.reference).toBe("https://x");
|
||||
});
|
||||
|
||||
it("classifies auth failures", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401 } as Response);
|
||||
const provider = new WebSearchProvider({ backend: "tavily", tavilyApiKey: "k" });
|
||||
await expect(provider.search("fusion", {})).rejects.toMatchObject({ code: "auth-failed" });
|
||||
});
|
||||
|
||||
it("propagates abort signal", async () => {
|
||||
global.fetch = vi.fn().mockImplementation(async (_url, init: RequestInit) => {
|
||||
await new Promise((_, reject) => {
|
||||
init.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
|
||||
});
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const provider = new WebSearchProvider({ backend: "brave", braveApiKey: "k" });
|
||||
const pending = provider.search("fusion", {}, controller.signal);
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ code: "abort", providerType: "web-search" });
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user