import { describe, expect, it } from "vitest"; import { createFirecrawlClient, type FetchLike } from "./client.js"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } interface CapturedCall { url: string; method?: string | undefined; body?: string | undefined; } /** Builds a fake fetch that returns scripted responses in order, capturing each call. */ function makeFetch(responses: Response[]): { fetchFn: FetchLike; calls: CapturedCall[] } { const calls: CapturedCall[] = []; let i = 0; const fetchFn: FetchLike = (async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; calls.push({ url, method: init?.method, body: typeof init?.body === "string" ? init.body : undefined, }); return responses[i++] ?? jsonResponse({}); }) as unknown as FetchLike; return { fetchFn, calls }; } const BASE = "http://test-firecrawl.local/v1"; const signal = (): AbortSignal => new AbortController().signal; describe("createFirecrawlClient.search", () => { it("sends POST /search with correct body", async () => { const { fetchFn, calls } = makeFetch([jsonResponse({ success: true, data: [] })]); const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); await client.search({ query: "hello", limit: 7 }, signal()); const call = calls[0]; if (!call) throw new Error("no call captured"); expect(call.url).toBe(`${BASE}/search`); expect(call.method).toBe("POST"); expect(JSON.parse(call.body ?? "{}")).toEqual({ query: "hello", limit: 7 }); }); it("returns parsed data on success", async () => { const data = [{ title: "T", url: "http://x", description: "d" }]; const { fetchFn } = makeFetch([jsonResponse({ success: true, data })]); const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); const result = await client.search({ query: "hello", limit: 7 }, signal()); expect(result).toEqual(data); }); it("throws on !success", async () => { const { fetchFn } = makeFetch([jsonResponse({ success: false, error: "boom" })]); const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); await expect(client.search({ query: "x", limit: 7 }, signal())).rejects.toThrow("boom"); }); }); describe("createFirecrawlClient.scrape", () => { it("sends POST /scrape with correct body", async () => { const { fetchFn, calls } = makeFetch([ jsonResponse({ success: true, data: { markdown: "md", metadata: { title: "T" } } }), ]); const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); await client.scrape({ url: "http://x", formats: ["markdown"] }, signal()); const call = calls[0]; if (!call) throw new Error("no call captured"); expect(call.url).toBe(`${BASE}/scrape`); expect(call.method).toBe("POST"); expect(JSON.parse(call.body ?? "{}")).toEqual({ url: "http://x", formats: ["markdown"], onlyMainContent: true, }); }); }); describe("createFirecrawlClient.crawl", () => { it("polls status URL until completed", async () => { const { fetchFn, calls } = makeFetch([ jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), jsonResponse({ status: "scraping" }), jsonResponse({ status: "completed", data: [{ markdown: "p1", metadata: { title: "P1", sourceURL: "http://p1" } }], }), ]); const client = createFirecrawlClient({ baseUrl: BASE, fetchFn, sleep: async () => {}, }); const pages = await client.crawl( { url: "http://site", limit: 3, formats: ["markdown"] }, signal(), ); expect(pages).toEqual([{ markdown: "p1", metadata: { title: "P1", sourceURL: "http://p1" } }]); expect(calls.length).toBe(3); }); it("returns data when completed", async () => { const { fetchFn } = makeFetch([ jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), jsonResponse({ status: "completed", data: [{ markdown: "page", metadata: { title: "T" } }], }), ]); const client = createFirecrawlClient({ baseUrl: BASE, fetchFn, sleep: async () => {}, }); const pages = await client.crawl( { url: "http://site", limit: 3, formats: ["markdown"] }, signal(), ); expect(pages.length).toBe(1); expect(pages[0]?.markdown).toBe("page"); }); it("throws when status is failed", async () => { const { fetchFn } = makeFetch([ jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), jsonResponse({ status: "failed", error: "boom" }), ]); const client = createFirecrawlClient({ baseUrl: BASE, fetchFn, sleep: async () => {}, }); await expect( client.crawl({ url: "http://site", limit: 3, formats: ["markdown"] }, signal()), ).rejects.toThrow("failed"); }); it("respects abort signal (stops polling)", async () => { const controller = new AbortController(); const { fetchFn, calls } = makeFetch([ jsonResponse({ success: true, url: `${BASE}/crawl/status/123` }), ]); const client = createFirecrawlClient({ baseUrl: BASE, fetchFn, sleep: async (_ms, sig) => { controller.abort(); if (sig.aborted) throw new Error("Request aborted."); }, }); await expect( client.crawl({ url: "http://site", limit: 3, formats: ["markdown"] }, controller.signal), ).rejects.toThrow(); expect(calls.length).toBe(1); }); }); describe("createFirecrawlClient.map", () => { it("sends POST /map and returns links", async () => { const { fetchFn, calls } = makeFetch([ jsonResponse({ success: true, links: ["http://a", "http://b"] }), ]); const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); const links = await client.map("http://site", signal()); expect(links).toEqual(["http://a", "http://b"]); const call = calls[0]; if (!call) throw new Error("no call captured"); expect(call.url).toBe(`${BASE}/map`); expect(call.method).toBe("POST"); expect(JSON.parse(call.body ?? "{}")).toEqual({ url: "http://site" }); }); }); describe("createFirecrawlClient.request (error paths)", () => { it("throws on HTTP error", async () => { const { fetchFn } = makeFetch([ new Response("not found", { status: 404, statusText: "Not Found" }), ]); const client = createFirecrawlClient({ baseUrl: BASE, fetchFn }); await expect(client.search({ query: "x", limit: 7 }, signal())).rejects.toThrow("HTTP 404"); }); it("throws on timeout", async () => { const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => new Promise((_resolve, reject) => { const sig = init?.signal; if (!sig) return; sig.addEventListener("abort", () => { const err = new Error("aborted"); err.name = "AbortError"; reject(err); }); })) as unknown as FetchLike; const client = createFirecrawlClient({ baseUrl: BASE, fetchFn, timeoutMs: 10, }); await expect(client.search({ query: "x", limit: 7 }, signal())).rejects.toThrow("timed out"); }); });