diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 13:11:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 13:11:29 +0900 |
| commit | 8a4a624d16422467a8e85434c674bb591877e8ea (patch) | |
| tree | 54052da00bbc580742913e5c031b7cc1b160db19 /packages/tool-web-search/src/client.test.ts | |
| parent | d23de3254374d4d63c8e15c6ab9311c3c6f4da5b (diff) | |
| download | dispatch-8a4a624d16422467a8e85434c674bb591877e8ea.tar.gz dispatch-8a4a624d16422467a8e85434c674bb591877e8ea.zip | |
feat(tool-web-search): Firecrawl-backed web search tool
New standard tool extension with one tool web_search supporting 4 modes
(search, scrape, crawl, map) against a self-hosted Firecrawl instance.
Pure core: validateArgs (discriminated union by mode) + format* functions
+ truncateOutput. Injected edge: FirecrawlClient (injectable fetchFn/sleep/now,
AbortSignal.any for per-request timeout + caller cancellation). concurrencySafe
true, capabilities network. 38 tests, zero vi.mock.
Live-verified: umans-glm-5.2 called web_search → real Firecrawl results (also
the first live Umans API call).
Diffstat (limited to 'packages/tool-web-search/src/client.test.ts')
| -rw-r--r-- | packages/tool-web-search/src/client.test.ts | 208 |
1 files changed, 208 insertions, 0 deletions
diff --git a/packages/tool-web-search/src/client.test.ts b/packages/tool-web-search/src/client.test.ts new file mode 100644 index 0000000..f020a83 --- /dev/null +++ b/packages/tool-web-search/src/client.test.ts @@ -0,0 +1,208 @@ +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<Response>((_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"); + }); +}); |
