summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-web-search/src/client.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 13:11:29 +0900
committerAdam Malczewski <[email protected]>2026-06-21 13:11:29 +0900
commit8a4a624d16422467a8e85434c674bb591877e8ea (patch)
tree54052da00bbc580742913e5c031b7cc1b160db19 /packages/tool-web-search/src/client.test.ts
parentd23de3254374d4d63c8e15c6ab9311c3c6f4da5b (diff)
downloaddispatch-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.ts208
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");
+ });
+});