diff --git a/extensions/firecrawl/src/firecrawl-client.test.ts b/extensions/firecrawl/src/firecrawl-client.test.ts new file mode 100644 index 00000000000..7e3e31968da --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-client.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { __testing } from "./firecrawl-client.js"; + +describe("firecrawl client helpers", () => { + it("normalizes mixed search payload shapes into search items", () => { + expect( + __testing.resolveSearchItems({ + data: { + results: [ + { + sourceURL: "https://www.example.com/post", + snippet: "Snippet text", + markdown: "# Title\nBody", + metadata: { + title: "Example title", + publishedDate: "2026-03-22", + }, + }, + { + url: "", + }, + ], + }, + }), + ).toEqual([ + { + title: "Example title", + url: "https://www.example.com/post", + description: "Snippet text", + content: "# Title\nBody", + published: "2026-03-22", + siteName: "example.com", + }, + ]); + }); + + it("parses scrape payloads, extracts text, and marks truncation", () => { + const result = __testing.parseFirecrawlScrapePayload({ + payload: { + data: { + markdown: "# Hello\n\nThis is a long body for scraping.", + metadata: { + title: "Example page", + sourceURL: "https://docs.example.com/page", + statusCode: 200, + }, + }, + warning: "cached result", + }, + url: "https://docs.example.com/page", + extractMode: "text", + maxChars: 12, + }); + + expect(result.finalUrl).toBe("https://docs.example.com/page"); + expect(result.status).toBe(200); + expect(result.extractMode).toBe("text"); + expect(result.truncated).toBe(true); + expect(result.rawLength).toBeGreaterThan(12); + expect(String(result.text)).toContain("Hello"); + expect(String(result.title)).toContain("Example page"); + expect(String(result.warning)).toContain("cached result"); + }); + + it("throws when scrape payload has no usable content", () => { + expect(() => + __testing.parseFirecrawlScrapePayload({ + payload: { + data: {}, + }, + url: "https://docs.example.com/page", + extractMode: "markdown", + maxChars: 100, + }), + ).toThrow("Firecrawl scrape returned no content."); + }); +}); diff --git a/extensions/firecrawl/src/firecrawl-scrape-tool.test.ts b/extensions/firecrawl/src/firecrawl-scrape-tool.test.ts new file mode 100644 index 00000000000..0ffa14c2da0 --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-scrape-tool.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; + +const runFirecrawlScrape = vi.fn(async (params: Record) => ({ + ok: true, + params, +})); + +vi.mock("./firecrawl-client.js", () => ({ + runFirecrawlScrape, +})); + +describe("firecrawl scrape tool", () => { + it("maps scrape params and defaults extract mode to markdown", async () => { + const { createFirecrawlScrapeTool } = await import("./firecrawl-scrape-tool.js"); + const tool = createFirecrawlScrapeTool({ + config: { env: "test" }, + } as never); + + const result = await tool.execute("call-1", { + url: "https://docs.openclaw.ai", + maxChars: 1500, + onlyMainContent: false, + maxAgeMs: 5000, + proxy: "stealth", + storeInCache: false, + timeoutSeconds: 22, + }); + + expect(runFirecrawlScrape).toHaveBeenCalledWith({ + cfg: { env: "test" }, + url: "https://docs.openclaw.ai", + extractMode: "markdown", + maxChars: 1500, + onlyMainContent: false, + maxAgeMs: 5000, + proxy: "stealth", + storeInCache: false, + timeoutSeconds: 22, + }); + expect(result).toMatchObject({ + details: { + ok: true, + params: { + cfg: { env: "test" }, + url: "https://docs.openclaw.ai", + extractMode: "markdown", + maxChars: 1500, + onlyMainContent: false, + maxAgeMs: 5000, + proxy: "stealth", + storeInCache: false, + timeoutSeconds: 22, + }, + }, + }); + }); + + it("passes text mode through and ignores invalid proxy values", async () => { + const { createFirecrawlScrapeTool } = await import("./firecrawl-scrape-tool.js"); + const tool = createFirecrawlScrapeTool({ + config: { env: "test" }, + } as never); + + await tool.execute("call-2", { + url: "https://docs.openclaw.ai", + extractMode: "text", + proxy: "invalid", + }); + + expect(runFirecrawlScrape).toHaveBeenCalledWith({ + cfg: { env: "test" }, + url: "https://docs.openclaw.ai", + extractMode: "text", + maxChars: undefined, + onlyMainContent: undefined, + maxAgeMs: undefined, + proxy: undefined, + storeInCache: undefined, + timeoutSeconds: undefined, + }); + }); +}); diff --git a/extensions/firecrawl/src/firecrawl-search-provider.test.ts b/extensions/firecrawl/src/firecrawl-search-provider.test.ts new file mode 100644 index 00000000000..20e70666f3d --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-search-provider.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; + +const runFirecrawlSearch = vi.fn(async (params: Record) => params); + +vi.mock("./firecrawl-client.js", () => ({ + runFirecrawlSearch, +})); + +describe("firecrawl web search provider", () => { + it("exposes selection metadata and enables the plugin in config", async () => { + const { createFirecrawlWebSearchProvider } = await import("./firecrawl-search-provider.js"); + + const provider = createFirecrawlWebSearchProvider(); + const applied = provider.applySelectionConfig({}); + + expect(provider.id).toBe("firecrawl"); + expect(provider.credentialPath).toBe("plugins.entries.firecrawl.config.webSearch.apiKey"); + expect(applied.plugins?.entries?.firecrawl?.enabled).toBe(true); + }); + + it("maps generic arguments into firecrawl search params", async () => { + const { createFirecrawlWebSearchProvider } = await import("./firecrawl-search-provider.js"); + const provider = createFirecrawlWebSearchProvider(); + const tool = provider.createTool({ + config: { test: true }, + } as never); + + const result = await tool.execute({ + query: "openclaw docs", + count: 4, + }); + + expect(runFirecrawlSearch).toHaveBeenCalledWith({ + cfg: { test: true }, + query: "openclaw docs", + count: 4, + }); + expect(result).toEqual({ + cfg: { test: true }, + query: "openclaw docs", + count: 4, + }); + }); +}); diff --git a/extensions/firecrawl/src/firecrawl-search-tool.test.ts b/extensions/firecrawl/src/firecrawl-search-tool.test.ts new file mode 100644 index 00000000000..d2cba2e9592 --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-search-tool.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +const runFirecrawlSearch = vi.fn(async (params: Record) => ({ + ok: true, + params, +})); + +vi.mock("./firecrawl-client.js", () => ({ + runFirecrawlSearch, +})); + +describe("firecrawl search tool", () => { + it("normalizes optional search parameters before invoking Firecrawl", async () => { + const { createFirecrawlSearchTool } = await import("./firecrawl-search-tool.js"); + const tool = createFirecrawlSearchTool({ + config: { env: "test" }, + } as never); + + const result = await tool.execute("call-1", { + query: "web search", + count: 6, + timeoutSeconds: 12, + sources: ["web", "", "news"], + categories: ["research", ""], + scrapeResults: true, + }); + + expect(runFirecrawlSearch).toHaveBeenCalledWith({ + cfg: { env: "test" }, + query: "web search", + count: 6, + timeoutSeconds: 12, + sources: ["web", "news"], + categories: ["research"], + scrapeResults: true, + }); + expect(result).toMatchObject({ + details: { + ok: true, + params: { + cfg: { env: "test" }, + query: "web search", + count: 6, + timeoutSeconds: 12, + sources: ["web", "news"], + categories: ["research"], + scrapeResults: true, + }, + }, + }); + }); +});