Files
OmniRoute/tests/unit/firecrawl-search.test.ts
Alan Vb 6a59c09460 feat(search): add Firecrawl search provider (#8814)
* feat(search): add Firecrawl search provider

Introduce the Firecrawl search provider to handle `POST /v1/search` requests via Firecrawl /v2/search API (sources: web|news)

* chore(changelog): add changelog on firecrawl search provider support

* fix(search): satisfy file-size freeze and APIKEY count

---------

Co-authored-by: allanvb <allanvb@users.noreply.github.com>
2026-07-27 22:38:21 -03:00

302 lines
10 KiB
TypeScript

/**
* tests/unit/firecrawl-search.test.ts
*
* Firecrawl as a unified /v1/search + /v1/web/fetch provider id `firecrawl`:
* - registry entry id `firecrawl` (no firecrawl-search split, no credential fallback)
* - request builder uses Firecrawl POST /v2/search + sources web|news
* - FIRECRAWL_BASE_URL self-host root (same env as firecrawl-fetch)
* - response normalization for v2 buckets only (data.web / data.news)
* - handleSearch path with mocked fetch
*/
import test from "node:test";
import assert from "node:assert/strict";
const { SEARCH_PROVIDERS, SEARCH_CREDENTIAL_FALLBACKS, getSearchProvider, selectProvider } =
await import("../../open-sse/config/searchRegistry.ts");
const { handleSearch } = await import("../../open-sse/handlers/search.ts");
const { v1SearchSchema } = await import("../../src/shared/validation/schemas.ts");
test("firecrawl is registered and selects cleanly", () => {
const cfg = getSearchProvider("firecrawl");
assert.ok(cfg, "firecrawl must exist in SEARCH_PROVIDERS");
assert.equal(cfg!.id, "firecrawl");
assert.equal(cfg!.method, "POST");
assert.equal(cfg!.authType, "apikey");
assert.equal(cfg!.authHeader, "bearer");
assert.equal(cfg!.baseUrl, "https://api.firecrawl.dev/v2/search");
assert.equal(cfg!.costPerQuery, 0.002);
assert.equal(cfg!.freeMonthlyQuota, 1000);
assert.deepEqual(cfg!.searchTypes, ["web", "news"]);
assert.equal(SEARCH_CREDENTIAL_FALLBACKS["firecrawl"], undefined);
const selectedWeb = selectProvider("firecrawl", "web");
assert.equal(selectedWeb?.id, "firecrawl");
const selectedNews = selectProvider("firecrawl", "news");
assert.equal(selectedNews?.id, "firecrawl");
});
test("dashboard resolves firecrawl under search category", async () => {
const providerPageUtils =
await import("../../src/app/(dashboard)/dashboard/providers/providerPageUtils.ts");
const providers = await import("../../src/shared/constants/providers.ts");
const info = providerPageUtils.resolveDashboardProviderInfo("firecrawl");
assert.equal(info?.category, "search");
assert.equal(info?.name, providers.SEARCH_PROVIDERS.firecrawl.name);
});
test("v1SearchSchema accepts firecrawl for search (unified id)", () => {
const ok = v1SearchSchema.safeParse({ query: "q", provider: "firecrawl" });
assert.equal(ok.success, true);
const news = v1SearchSchema.safeParse({
query: "q",
provider: "firecrawl",
search_type: "news",
});
assert.equal(news.success, true);
const legacy = v1SearchSchema.safeParse({ query: "q", provider: "firecrawl-search" });
assert.equal(legacy.success, false, "legacy firecrawl-search id is not accepted");
});
test("handleSearch firecrawl hits /v2/search with sources web and normalizes data.web", async () => {
const originalFetch = globalThis.fetch;
let capturedUrl = "";
let capturedInit: RequestInit | undefined;
globalThis.fetch = async (url, init) => {
capturedUrl = String(url);
capturedInit = init;
return new Response(
JSON.stringify({
success: true,
data: {
web: [
{
url: "https://example.com/a",
title: "Alpha",
description: "First hit",
},
{
url: "https://example.com/b",
title: "Beta",
description: "Second hit",
},
],
news: [
{
url: "https://news.example/ignored-on-web",
title: "Ignored",
snippet: "news bucket must not leak into web",
},
],
},
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
try {
const result = await handleSearch({
query: "omniroute search",
provider: "firecrawl",
maxResults: 5,
searchType: "web",
credentials: { apiKey: "fc-test-key" },
log: null,
});
assert.equal(result.success, true, `expected success, got ${JSON.stringify(result)}`);
assert.equal(capturedUrl, "https://api.firecrawl.dev/v2/search");
const headers = capturedInit?.headers as Record<string, string>;
assert.equal(headers.Authorization, "Bearer fc-test-key");
const body = JSON.parse(String(capturedInit?.body || "{}"));
assert.equal(body.query, "omniroute search");
assert.equal(body.limit, 5);
assert.deepEqual(body.sources, ["web"]);
assert.ok(result.data?.results?.length === 2);
assert.equal(result.data!.results[0].title, "Alpha");
assert.equal(result.data!.results[0].url, "https://example.com/a");
assert.equal(result.data!.results[0].snippet, "First hit");
assert.equal(result.data!.provider, "firecrawl");
assert.equal(result.data!.usage.search_cost_usd, 0.002);
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleSearch firecrawl maps search_type news to sources news", async () => {
const originalFetch = globalThis.fetch;
let capturedInit: RequestInit | undefined;
globalThis.fetch = async (_url, init) => {
capturedInit = init;
return new Response(
JSON.stringify({
success: true,
data: {
news: [
{
title: "Breaking",
url: "https://news.example/story",
snippet: "Headline blurb",
date: "2026-07-01T12:00:00Z",
imageUrl: "https://news.example/img.png",
},
],
web: [
{
url: "https://web.example/ignored-on-news",
title: "Web only",
description: "must not leak into news",
},
],
},
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
try {
const result = await handleSearch({
query: "latest ai",
provider: "firecrawl",
maxResults: 3,
searchType: "news",
timeRange: "week",
country: "US",
language: "en",
domainFilter: ["reuters.com", "-example.com"],
credentials: { apiKey: "fc-news-key" },
log: null,
});
assert.equal(result.success, true, JSON.stringify(result));
const body = JSON.parse(String(capturedInit?.body || "{}"));
assert.deepEqual(body.sources, ["news"]);
assert.equal(body.tbs, "qdr:w");
assert.equal(body.country, "us");
assert.equal(body.lang, "en");
assert.deepEqual(body.includeDomains, ["reuters.com"]);
assert.deepEqual(body.excludeDomains, ["example.com"]);
assert.equal(result.data!.results.length, 1);
assert.equal(result.data!.results[0].title, "Breaking");
assert.equal(result.data!.results[0].url, "https://news.example/story");
assert.equal(result.data!.results[0].snippet, "Headline blurb");
assert.equal(result.data!.results[0].published_at, "2026-07-01T12:00:00Z");
assert.equal(result.data!.results[0].metadata?.image_url, "https://news.example/img.png");
assert.equal(result.data!.results[0].metadata?.source_type, "news");
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleSearch firecrawl honors FIRECRAWL_BASE_URL self-host root", async () => {
const originalFetch = globalThis.fetch;
const prev = process.env.FIRECRAWL_BASE_URL;
process.env.FIRECRAWL_BASE_URL = "http://127.0.0.1:3002";
let capturedUrl = "";
globalThis.fetch = async (url) => {
capturedUrl = String(url);
return new Response(
JSON.stringify({
data: { web: [{ url: "https://local.test", title: "Local", description: "ok" }] },
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
try {
const result = await handleSearch({
query: "selfhost",
provider: "firecrawl",
maxResults: 3,
searchType: "web",
credentials: { apiKey: "optional-selfhost" },
log: null,
});
assert.equal(result.success, true, JSON.stringify(result));
assert.equal(capturedUrl, "http://127.0.0.1:3002/v2/search");
assert.equal(result.data!.results[0].title, "Local");
} finally {
globalThis.fetch = originalFetch;
if (prev === undefined) delete process.env.FIRECRAWL_BASE_URL;
else process.env.FIRECRAWL_BASE_URL = prev;
}
});
test("handleSearch firecrawl ignores legacy v1 envelopes", async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = async () => {
call += 1;
if (call === 1) {
return new Response(
JSON.stringify({
data: [{ url: "https://d.example", title: "D", description: "from data array" }],
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
if (call === 2) {
return new Response(
JSON.stringify({
results: [{ url: "https://r.example", title: "R", snippet: "from results" }],
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
return new Response(
JSON.stringify({
web: [{ url: "https://w.example", title: "W", description: "from web" }],
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
try {
const fromData = await handleSearch({
query: "data-shape",
provider: "firecrawl",
maxResults: 1,
searchType: "web",
credentials: { apiKey: "fc-key" },
log: null,
});
assert.equal(fromData.success, true, JSON.stringify(fromData));
assert.equal(fromData.data!.results.length, 0);
const fromResults = await handleSearch({
query: "results-shape",
provider: "firecrawl",
maxResults: 1,
searchType: "web",
credentials: { apiKey: "fc-key" },
log: null,
});
assert.equal(fromResults.success, true, JSON.stringify(fromResults));
assert.equal(fromResults.data!.results.length, 0);
const fromWeb = await handleSearch({
query: "web-shape",
provider: "firecrawl",
maxResults: 1,
searchType: "web",
credentials: { apiKey: "fc-key" },
log: null,
});
assert.equal(fromWeb.success, true, JSON.stringify(fromWeb));
assert.equal(fromWeb.data!.results.length, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test("SEARCH_PROVIDERS still exposes duckduckgo-free as fallbackOnly", () => {
assert.equal(SEARCH_PROVIDERS["duckduckgo-free"].fallbackOnly, true);
});