mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
* 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>
302 lines
10 KiB
TypeScript
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);
|
|
});
|