diff --git a/extensions/diffs/src/browser.test.ts b/extensions/diffs/src/browser.test.ts index 8c16530ec15..5374e10076a 100644 --- a/extensions/diffs/src/browser.test.ts +++ b/extensions/diffs/src/browser.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../api.js"; import { createTempDiffRoot } from "./test-helpers.js"; @@ -8,6 +8,9 @@ const { launchMock } = vi.hoisted(() => ({ launchMock: vi.fn(), })); +let PlaywrightDiffScreenshotter: typeof import("./browser.js").PlaywrightDiffScreenshotter; +let resetSharedBrowserStateForTests: typeof import("./browser.js").resetSharedBrowserStateForTests; + vi.mock("playwright-core", () => ({ chromium: { launch: launchMock, @@ -19,18 +22,22 @@ describe("PlaywrightDiffScreenshotter", () => { let outputPath: string; let cleanupRootDir: () => Promise; + beforeAll(async () => { + vi.resetModules(); + ({ PlaywrightDiffScreenshotter, resetSharedBrowserStateForTests } = + await import("./browser.js")); + }); + beforeEach(async () => { vi.useFakeTimers(); ({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-")); outputPath = path.join(rootDir, "preview.png"); launchMock.mockReset(); - const browserModule = await import("./browser.js"); - await browserModule.resetSharedBrowserStateForTests(); + await resetSharedBrowserStateForTests(); }); afterEach(async () => { - const browserModule = await import("./browser.js"); - await browserModule.resetSharedBrowserStateForTests(); + await resetSharedBrowserStateForTests(); vi.useRealTimers(); await cleanupRootDir(); }); @@ -131,8 +138,6 @@ describe("PlaywrightDiffScreenshotter", () => { boundingBox: { x: 40, y: 40, width: 960, height: 60_000 }, }); launchMock.mockResolvedValue(browser); - const { PlaywrightDiffScreenshotter } = await import("./browser.js"); - const screenshotter = new PlaywrightDiffScreenshotter({ config: createConfig(), browserIdleMs: 1_000, @@ -200,7 +205,6 @@ async function createScreenshotterHarness(options?: { }> = []; const browser = createMockBrowser(pages, options); launchMock.mockResolvedValue(browser); - const { PlaywrightDiffScreenshotter } = await import("./browser.js"); const screenshotter = new PlaywrightDiffScreenshotter({ config: createConfig(), browserIdleMs: 1_000, diff --git a/extensions/exa/index.test.ts b/extensions/exa/index.test.ts deleted file mode 100644 index 20d0e3854ea..00000000000 --- a/extensions/exa/index.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; -import plugin from "./index.js"; - -describe("exa plugin", () => { - it("registers the web search provider", () => { - const registrations: { webSearchProviders: unknown[] } = { webSearchProviders: [] }; - - const mockApi = { - registerWebSearchProvider(provider: unknown) { - registrations.webSearchProviders.push(provider); - }, - config: {}, - }; - - plugin.register(mockApi as never); - - expect(plugin.id).toBe("exa"); - expect(plugin.name).toBe("Exa Plugin"); - expect(registrations.webSearchProviders).toHaveLength(1); - - const provider = registrations.webSearchProviders[0] as Record; - expect(provider.id).toBe("exa"); - expect(provider.autoDetectOrder).toBe(65); - expect(provider.envVars).toEqual(["EXA_API_KEY"]); - }); -}); diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 562a8596939..7b5125b0dcd 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -1,7 +1,30 @@ import { describe, expect, it } from "vitest"; +import plugin from "../index.js"; import { __testing, createExaWebSearchProvider } from "./exa-web-search-provider.js"; describe("exa web search provider", () => { + it("registers the web search provider", () => { + const registrations: { webSearchProviders: unknown[] } = { webSearchProviders: [] }; + + const mockApi = { + registerWebSearchProvider(provider: unknown) { + registrations.webSearchProviders.push(provider); + }, + config: {}, + }; + + plugin.register(mockApi as never); + + expect(plugin.id).toBe("exa"); + expect(plugin.name).toBe("Exa Plugin"); + expect(registrations.webSearchProviders).toHaveLength(1); + + const provider = registrations.webSearchProviders[0] as Record; + expect(provider.id).toBe("exa"); + expect(provider.autoDetectOrder).toBe(65); + expect(provider.envVars).toEqual(["EXA_API_KEY"]); + }); + it("exposes the expected metadata and selection wiring", () => { const provider = createExaWebSearchProvider(); if (!provider.applySelectionConfig) { diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index fe4e04dc310..efaf8ce198a 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js"; type CreateFeishuClient = typeof import("./client.js").createFeishuClient; @@ -61,7 +61,7 @@ function firstWsClientOptions(): { agent?: unknown } { return calls[0]?.[0] ?? {}; } -beforeEach(async () => { +beforeAll(async () => { vi.resetModules(); vi.doMock("@larksuiteoapi/node-sdk", () => ({ AppType: { SelfBuild: "self" }, @@ -85,7 +85,9 @@ beforeEach(async () => { FEISHU_HTTP_TIMEOUT_MAX_MS, FEISHU_HTTP_TIMEOUT_ENV_VAR, } = await import("./client.js")); +}); +beforeEach(() => { priorProxyEnv = {}; priorFeishuTimeoutEnv = process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR]; delete process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR]; @@ -94,6 +96,7 @@ beforeEach(async () => { delete process.env[key]; } vi.clearAllMocks(); + clearClientCache(); setFeishuClientRuntimeForTest({ sdk: { AppType: { SelfBuild: "self" } as never, @@ -129,10 +132,6 @@ afterEach(() => { }); describe("createFeishuClient HTTP timeout", () => { - beforeEach(() => { - clearClientCache(); - }); - const getLastClientHttpInstance = () => { const calls = clientCtorMock.mock.calls as unknown as Array<[options: unknown]>; const lastCall = calls[calls.length - 1]?.[0] as diff --git a/extensions/firecrawl/src/config.test.ts b/extensions/firecrawl/src/config.test.ts deleted file mode 100644 index 1a7e33e6154..00000000000 --- a/extensions/firecrawl/src/config.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - DEFAULT_FIRECRAWL_BASE_URL, - DEFAULT_FIRECRAWL_MAX_AGE_MS, - DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS, - DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS, - resolveFirecrawlApiKey, - resolveFirecrawlBaseUrl, - resolveFirecrawlMaxAgeMs, - resolveFirecrawlOnlyMainContent, - resolveFirecrawlScrapeTimeoutSeconds, - resolveFirecrawlSearchConfig, - resolveFirecrawlSearchTimeoutSeconds, -} from "./config.js"; - -describe("firecrawl config helpers", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("prefers plugin webSearch config over legacy tool search config", () => { - const cfg = { - plugins: { - entries: { - firecrawl: { - config: { - webSearch: { - apiKey: "plugin-key", - baseUrl: "https://plugin.firecrawl.test", - }, - }, - }, - }, - }, - tools: { - web: { - search: { - firecrawl: { - apiKey: "legacy-key", - baseUrl: "https://legacy.firecrawl.test", - }, - }, - }, - }, - } as OpenClawConfig; - - expect(resolveFirecrawlSearchConfig(cfg)).toEqual({ - apiKey: "plugin-key", - baseUrl: "https://plugin.firecrawl.test", - }); - expect(resolveFirecrawlApiKey(cfg)).toBe("plugin-key"); - expect(resolveFirecrawlBaseUrl(cfg)).toBe("https://plugin.firecrawl.test"); - }); - - it("falls back to environment and defaults for fetch config values", () => { - vi.stubEnv("FIRECRAWL_API_KEY", "env-key"); - vi.stubEnv("FIRECRAWL_BASE_URL", "https://env.firecrawl.test"); - - expect(resolveFirecrawlApiKey()).toBe("env-key"); - expect(resolveFirecrawlBaseUrl()).toBe("https://env.firecrawl.test"); - expect(resolveFirecrawlOnlyMainContent()).toBe(true); - expect(resolveFirecrawlMaxAgeMs()).toBe(DEFAULT_FIRECRAWL_MAX_AGE_MS); - expect(resolveFirecrawlScrapeTimeoutSeconds()).toBe(DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS); - expect(resolveFirecrawlSearchTimeoutSeconds()).toBe(DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS); - expect(resolveFirecrawlBaseUrl({} as OpenClawConfig)).not.toBe(DEFAULT_FIRECRAWL_BASE_URL); - }); - - it("respects positive numeric overrides for scrape and cache behavior", () => { - const cfg = { - tools: { - web: { - fetch: { - firecrawl: { - onlyMainContent: false, - maxAgeMs: 1234, - timeoutSeconds: 42, - }, - }, - }, - }, - } as OpenClawConfig; - - expect(resolveFirecrawlOnlyMainContent(cfg)).toBe(false); - expect(resolveFirecrawlMaxAgeMs(cfg)).toBe(1234); - expect(resolveFirecrawlMaxAgeMs(cfg, 77.9)).toBe(77); - expect(resolveFirecrawlScrapeTimeoutSeconds(cfg)).toBe(42); - expect(resolveFirecrawlScrapeTimeoutSeconds(cfg, 19.8)).toBe(19); - expect(resolveFirecrawlSearchTimeoutSeconds(9.7)).toBe(9); - }); -}); diff --git a/extensions/firecrawl/src/firecrawl-client.test.ts b/extensions/firecrawl/src/firecrawl-client.test.ts deleted file mode 100644 index 7e3e31968da..00000000000 --- a/extensions/firecrawl/src/firecrawl-client.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index fd026126f3e..00000000000 --- a/extensions/firecrawl/src/firecrawl-scrape-tool.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { runFirecrawlScrape } = vi.hoisted(() => ({ - runFirecrawlScrape: vi.fn(async (params: Record) => ({ - ok: true, - params, - })), -})); - -vi.mock("./firecrawl-client.js", () => ({ - runFirecrawlScrape, -})); - -describe("firecrawl scrape tool", () => { - beforeEach(() => { - vi.resetModules(); - runFirecrawlScrape.mockReset(); - runFirecrawlScrape.mockImplementation(async (params: Record) => ({ - ok: true, - params, - })); - }); - - 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 deleted file mode 100644 index aafd400f725..00000000000 --- a/extensions/firecrawl/src/firecrawl-search-provider.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { runFirecrawlSearch } = vi.hoisted(() => ({ - runFirecrawlSearch: vi.fn(async (params: Record) => params), -})); - -vi.mock("./firecrawl-client.js", () => ({ - runFirecrawlSearch, -})); - -describe("firecrawl web search provider", () => { - beforeEach(() => { - vi.resetModules(); - runFirecrawlSearch.mockReset(); - runFirecrawlSearch.mockImplementation(async (params: Record) => params); - }); - - it("exposes selection metadata and enables the plugin in config", async () => { - const { createFirecrawlWebSearchProvider } = await import("./firecrawl-search-provider.js"); - - const provider = createFirecrawlWebSearchProvider(); - if (!provider.applySelectionConfig) { - throw new Error("Expected applySelectionConfig to be defined"); - } - 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); - if (!tool) { - throw new Error("Expected tool definition"); - } - - 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 deleted file mode 100644 index c1401e08e5f..00000000000 --- a/extensions/firecrawl/src/firecrawl-search-tool.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { runFirecrawlSearch } = vi.hoisted(() => ({ - runFirecrawlSearch: vi.fn(async (params: Record) => ({ - ok: true, - params, - })), -})); - -vi.mock("./firecrawl-client.js", () => ({ - runFirecrawlSearch, -})); - -describe("firecrawl search tool", () => { - beforeEach(() => { - vi.resetModules(); - runFirecrawlSearch.mockReset(); - runFirecrawlSearch.mockImplementation(async (params: Record) => ({ - ok: true, - params, - })); - }); - - 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, - }, - }, - }); - }); -}); diff --git a/extensions/firecrawl/src/firecrawl-tools.test.ts b/extensions/firecrawl/src/firecrawl-tools.test.ts new file mode 100644 index 00000000000..386ffad9774 --- /dev/null +++ b/extensions/firecrawl/src/firecrawl-tools.test.ts @@ -0,0 +1,347 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_FIRECRAWL_BASE_URL, + DEFAULT_FIRECRAWL_MAX_AGE_MS, + DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS, + DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS, + resolveFirecrawlApiKey, + resolveFirecrawlBaseUrl, + resolveFirecrawlMaxAgeMs, + resolveFirecrawlOnlyMainContent, + resolveFirecrawlScrapeTimeoutSeconds, + resolveFirecrawlSearchConfig, + resolveFirecrawlSearchTimeoutSeconds, +} from "./config.js"; + +const { runFirecrawlSearch, runFirecrawlScrape } = vi.hoisted(() => ({ + runFirecrawlSearch: vi.fn(async (params: Record) => params), + runFirecrawlScrape: vi.fn(async (params: Record) => ({ + ok: true, + params, + })), +})); + +vi.mock("./firecrawl-client.js", () => ({ + runFirecrawlSearch, + runFirecrawlScrape, +})); + +describe("firecrawl tools", () => { + let createFirecrawlWebSearchProvider: typeof import("./firecrawl-search-provider.js").createFirecrawlWebSearchProvider; + let createFirecrawlSearchTool: typeof import("./firecrawl-search-tool.js").createFirecrawlSearchTool; + let createFirecrawlScrapeTool: typeof import("./firecrawl-scrape-tool.js").createFirecrawlScrapeTool; + let firecrawlClientTesting: typeof import("./firecrawl-client.js").__testing; + + beforeAll(async () => { + vi.resetModules(); + ({ createFirecrawlWebSearchProvider } = await import("./firecrawl-search-provider.js")); + ({ createFirecrawlSearchTool } = await import("./firecrawl-search-tool.js")); + ({ createFirecrawlScrapeTool } = await import("./firecrawl-scrape-tool.js")); + ({ __testing: firecrawlClientTesting } = + await vi.importActual("./firecrawl-client.js")); + }); + + beforeEach(() => { + runFirecrawlSearch.mockReset(); + runFirecrawlSearch.mockImplementation(async (params: Record) => params); + runFirecrawlScrape.mockReset(); + runFirecrawlScrape.mockImplementation(async (params: Record) => ({ + ok: true, + params, + })); + vi.unstubAllEnvs(); + }); + + it("exposes selection metadata and enables the plugin in config", () => { + const provider = createFirecrawlWebSearchProvider(); + if (!provider.applySelectionConfig) { + throw new Error("Expected applySelectionConfig to be defined"); + } + 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 provider args into firecrawl search params", async () => { + const provider = createFirecrawlWebSearchProvider(); + const tool = provider.createTool({ + config: { test: true }, + } as never); + if (!tool) { + throw new Error("Expected tool definition"); + } + + 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, + }); + }); + + it("normalizes optional search parameters before invoking Firecrawl", async () => { + runFirecrawlSearch.mockImplementationOnce(async (params: Record) => ({ + ok: true, + params, + })); + 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, + }, + }, + }); + }); + + it("maps scrape params and defaults extract mode to markdown", async () => { + 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 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, + }); + }); + + it("prefers plugin webSearch config over legacy tool search config", () => { + const cfg = { + plugins: { + entries: { + firecrawl: { + config: { + webSearch: { + apiKey: "plugin-key", + baseUrl: "https://plugin.firecrawl.test", + }, + }, + }, + }, + }, + tools: { + web: { + search: { + firecrawl: { + apiKey: "legacy-key", + baseUrl: "https://legacy.firecrawl.test", + }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolveFirecrawlSearchConfig(cfg)).toEqual({ + apiKey: "plugin-key", + baseUrl: "https://plugin.firecrawl.test", + }); + expect(resolveFirecrawlApiKey(cfg)).toBe("plugin-key"); + expect(resolveFirecrawlBaseUrl(cfg)).toBe("https://plugin.firecrawl.test"); + }); + + it("falls back to environment and defaults for fetch config values", () => { + vi.stubEnv("FIRECRAWL_API_KEY", "env-key"); + vi.stubEnv("FIRECRAWL_BASE_URL", "https://env.firecrawl.test"); + + expect(resolveFirecrawlApiKey()).toBe("env-key"); + expect(resolveFirecrawlBaseUrl()).toBe("https://env.firecrawl.test"); + expect(resolveFirecrawlOnlyMainContent()).toBe(true); + expect(resolveFirecrawlMaxAgeMs()).toBe(DEFAULT_FIRECRAWL_MAX_AGE_MS); + expect(resolveFirecrawlScrapeTimeoutSeconds()).toBe(DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS); + expect(resolveFirecrawlSearchTimeoutSeconds()).toBe(DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS); + expect(resolveFirecrawlBaseUrl({} as OpenClawConfig)).not.toBe(DEFAULT_FIRECRAWL_BASE_URL); + }); + + it("respects positive numeric overrides for scrape and cache behavior", () => { + const cfg = { + tools: { + web: { + fetch: { + firecrawl: { + onlyMainContent: false, + maxAgeMs: 1234, + timeoutSeconds: 42, + }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolveFirecrawlOnlyMainContent(cfg)).toBe(false); + expect(resolveFirecrawlMaxAgeMs(cfg)).toBe(1234); + expect(resolveFirecrawlMaxAgeMs(cfg, 77.9)).toBe(77); + expect(resolveFirecrawlScrapeTimeoutSeconds(cfg)).toBe(42); + expect(resolveFirecrawlScrapeTimeoutSeconds(cfg, 19.8)).toBe(19); + expect(resolveFirecrawlSearchTimeoutSeconds(9.7)).toBe(9); + }); + + it("normalizes mixed search payload shapes into search items", () => { + expect( + firecrawlClientTesting.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 = firecrawlClientTesting.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(() => + firecrawlClientTesting.parseFirecrawlScrapePayload({ + payload: { + data: {}, + }, + url: "https://docs.example.com/page", + extractMode: "markdown", + maxChars: 100, + }), + ).toThrow("Firecrawl scrape returned no content."); + }); +}); diff --git a/extensions/tavily/src/config.test.ts b/extensions/tavily/src/config.test.ts deleted file mode 100644 index 87978b160a7..00000000000 --- a/extensions/tavily/src/config.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - DEFAULT_TAVILY_BASE_URL, - DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS, - DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS, - resolveTavilyApiKey, - resolveTavilyBaseUrl, - resolveTavilyExtractTimeoutSeconds, - resolveTavilySearchConfig, - resolveTavilySearchTimeoutSeconds, -} from "./config.js"; - -describe("tavily config helpers", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("reads plugin web search config and prefers it over env defaults", () => { - vi.stubEnv("TAVILY_API_KEY", "env-key"); - vi.stubEnv("TAVILY_BASE_URL", "https://env.tavily.test"); - - const cfg = { - plugins: { - entries: { - tavily: { - config: { - webSearch: { - apiKey: "plugin-key", - baseUrl: "https://plugin.tavily.test", - }, - }, - }, - }, - }, - } as OpenClawConfig; - - expect(resolveTavilySearchConfig(cfg)).toEqual({ - apiKey: "plugin-key", - baseUrl: "https://plugin.tavily.test", - }); - expect(resolveTavilyApiKey(cfg)).toBe("plugin-key"); - expect(resolveTavilyBaseUrl(cfg)).toBe("https://plugin.tavily.test"); - }); - - it("falls back to environment values and defaults", () => { - vi.stubEnv("TAVILY_API_KEY", "env-key"); - vi.stubEnv("TAVILY_BASE_URL", "https://env.tavily.test"); - - expect(resolveTavilyApiKey()).toBe("env-key"); - expect(resolveTavilyBaseUrl()).toBe("https://env.tavily.test"); - expect(resolveTavilyBaseUrl({} as OpenClawConfig)).not.toBe(DEFAULT_TAVILY_BASE_URL); - expect(resolveTavilySearchTimeoutSeconds()).toBe(DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS); - expect(resolveTavilyExtractTimeoutSeconds()).toBe(DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS); - }); - - it("accepts positive numeric timeout overrides and floors them", () => { - expect(resolveTavilySearchTimeoutSeconds(19.9)).toBe(19); - expect(resolveTavilyExtractTimeoutSeconds(42.7)).toBe(42); - expect(resolveTavilySearchTimeoutSeconds(0)).toBe(DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS); - expect(resolveTavilyExtractTimeoutSeconds(Number.NaN)).toBe( - DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS, - ); - }); -}); diff --git a/extensions/tavily/src/tavily-client.test.ts b/extensions/tavily/src/tavily-client.test.ts deleted file mode 100644 index f16f14fa1b3..00000000000 --- a/extensions/tavily/src/tavily-client.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { __testing } from "./tavily-client.js"; - -describe("tavily client helpers", () => { - it("appends endpoints to reverse-proxy base urls", () => { - expect(__testing.resolveEndpoint("https://proxy.example/api/tavily", "/search")).toBe( - "https://proxy.example/api/tavily/search", - ); - expect(__testing.resolveEndpoint("https://proxy.example/api/tavily/", "/extract")).toBe( - "https://proxy.example/api/tavily/extract", - ); - }); - - it("falls back to the default host for invalid base urls", () => { - expect(__testing.resolveEndpoint("not a url", "/search")).toBe("https://api.tavily.com/search"); - expect(__testing.resolveEndpoint("", "/extract")).toBe("https://api.tavily.com/extract"); - }); -}); diff --git a/extensions/tavily/src/tavily-extract-tool.test.ts b/extensions/tavily/src/tavily-extract-tool.test.ts deleted file mode 100644 index 1f1ddfb5963..00000000000 --- a/extensions/tavily/src/tavily-extract-tool.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { runTavilyExtract } = vi.hoisted(() => ({ - runTavilyExtract: vi.fn(async (params: unknown) => ({ ok: true, params })), -})); - -vi.mock("./tavily-client.js", () => ({ - runTavilyExtract, -})); - -let createTavilyExtractTool: typeof import("./tavily-extract-tool.js").createTavilyExtractTool; - -function fakeApi(): OpenClawPluginApi { - return { - config: {}, - } as OpenClawPluginApi; -} - -describe("tavily_extract", () => { - beforeEach(async () => { - vi.resetModules(); - runTavilyExtract.mockReset(); - runTavilyExtract.mockImplementation(async (params: unknown) => ({ ok: true, params })); - ({ createTavilyExtractTool } = await import("./tavily-extract-tool.js")); - }); - - it("rejects chunks_per_source without query", async () => { - const tool = createTavilyExtractTool(fakeApi()); - - await expect( - tool.execute("id", { - urls: ["https://example.com"], - chunks_per_source: 2, - }), - ).rejects.toThrow("tavily_extract requires query when chunks_per_source is set."); - - expect(runTavilyExtract).not.toHaveBeenCalled(); - }); - - it("forwards query-scoped chunking when query is provided", async () => { - const tool = createTavilyExtractTool(fakeApi()); - - await tool.execute("id", { - urls: ["https://example.com"], - query: "pricing", - chunks_per_source: 2, - }); - - expect(runTavilyExtract).toHaveBeenCalledWith( - expect.objectContaining({ - cfg: {}, - urls: ["https://example.com"], - query: "pricing", - chunksPerSource: 2, - }), - ); - }); -}); diff --git a/extensions/tavily/src/tavily-search-provider.test.ts b/extensions/tavily/src/tavily-search-provider.test.ts deleted file mode 100644 index e4ee543080e..00000000000 --- a/extensions/tavily/src/tavily-search-provider.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { runTavilySearch } = vi.hoisted(() => ({ - runTavilySearch: vi.fn(async (params: Record) => params), -})); - -vi.mock("./tavily-client.js", () => ({ - runTavilySearch, -})); - -describe("tavily web search provider", () => { - beforeEach(() => { - vi.resetModules(); - runTavilySearch.mockReset(); - runTavilySearch.mockImplementation(async (params: Record) => params); - }); - - it("exposes the expected metadata and selection wiring", async () => { - const { createTavilyWebSearchProvider } = await import("./tavily-search-provider.js"); - - const provider = createTavilyWebSearchProvider(); - if (!provider.applySelectionConfig) { - throw new Error("Expected applySelectionConfig to be defined"); - } - const applied = provider.applySelectionConfig({}); - - expect(provider.id).toBe("tavily"); - expect(provider.credentialPath).toBe("plugins.entries.tavily.config.webSearch.apiKey"); - expect(applied.plugins?.entries?.tavily?.enabled).toBe(true); - }); - - it("maps generic tool arguments into Tavily search params", async () => { - const { createTavilyWebSearchProvider } = await import("./tavily-search-provider.js"); - const provider = createTavilyWebSearchProvider(); - const tool = provider.createTool({ - config: { test: true }, - } as never); - if (!tool) { - throw new Error("Expected tool definition"); - } - - const result = await tool.execute({ - query: "weather sf", - count: 7, - }); - - expect(runTavilySearch).toHaveBeenCalledWith({ - cfg: { test: true }, - query: "weather sf", - maxResults: 7, - }); - expect(result).toEqual({ - cfg: { test: true }, - query: "weather sf", - maxResults: 7, - }); - }); -}); diff --git a/extensions/tavily/src/tavily-search-tool.test.ts b/extensions/tavily/src/tavily-search-tool.test.ts deleted file mode 100644 index f856829d007..00000000000 --- a/extensions/tavily/src/tavily-search-tool.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { runTavilySearch } = vi.hoisted(() => ({ - runTavilySearch: vi.fn(async (params: Record) => ({ - ok: true, - params, - })), -})); - -vi.mock("./tavily-client.js", () => ({ - runTavilySearch, -})); - -describe("tavily search tool", () => { - beforeEach(() => { - vi.resetModules(); - runTavilySearch.mockReset(); - runTavilySearch.mockImplementation(async (params: Record) => ({ - ok: true, - params, - })); - }); - - it("normalizes optional parameters before invoking Tavily", async () => { - const { createTavilySearchTool } = await import("./tavily-search-tool.js"); - const tool = createTavilySearchTool({ - config: { env: "test" }, - } as never); - - const result = await tool.execute("call-1", { - query: "best docs", - search_depth: "advanced", - topic: "news", - max_results: 5, - include_answer: true, - time_range: "week", - include_domains: ["docs.openclaw.ai", "", "openclaw.ai"], - exclude_domains: ["bad.example", ""], - }); - - expect(runTavilySearch).toHaveBeenCalledWith({ - cfg: { env: "test" }, - query: "best docs", - searchDepth: "advanced", - topic: "news", - maxResults: 5, - includeAnswer: true, - timeRange: "week", - includeDomains: ["docs.openclaw.ai", "openclaw.ai"], - excludeDomains: ["bad.example"], - }); - expect(result).toMatchObject({ - details: { - ok: true, - params: { - cfg: { env: "test" }, - query: "best docs", - searchDepth: "advanced", - topic: "news", - maxResults: 5, - includeAnswer: true, - timeRange: "week", - includeDomains: ["docs.openclaw.ai", "openclaw.ai"], - excludeDomains: ["bad.example"], - }, - }, - }); - expect(result.content[0]).toMatchObject({ - type: "text", - }); - }); - - it("requires a query and drops empty domain arrays", async () => { - const { createTavilySearchTool } = await import("./tavily-search-tool.js"); - const tool = createTavilySearchTool({ - config: { env: "test" }, - } as never); - - await expect( - tool.execute("call-2", { - query: "simple", - include_domains: [""], - exclude_domains: [], - }), - ).resolves.toMatchObject({ - details: { - ok: true, - params: { - cfg: { env: "test" }, - query: "simple", - includeAnswer: false, - }, - }, - }); - }); -}); diff --git a/extensions/tavily/src/tavily-tools.test.ts b/extensions/tavily/src/tavily-tools.test.ts new file mode 100644 index 00000000000..93b2f3308e4 --- /dev/null +++ b/extensions/tavily/src/tavily-tools.test.ts @@ -0,0 +1,264 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_TAVILY_BASE_URL, + DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS, + DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS, + resolveTavilyApiKey, + resolveTavilyBaseUrl, + resolveTavilyExtractTimeoutSeconds, + resolveTavilySearchConfig, + resolveTavilySearchTimeoutSeconds, +} from "./config.js"; + +const { runTavilySearch, runTavilyExtract } = vi.hoisted(() => ({ + runTavilySearch: vi.fn(async (params: Record) => params), + runTavilyExtract: vi.fn(async (params: unknown) => ({ ok: true, params })), +})); + +vi.mock("./tavily-client.js", () => ({ + runTavilySearch, + runTavilyExtract, +})); + +function fakeApi(): OpenClawPluginApi { + return { + config: {}, + } as OpenClawPluginApi; +} + +describe("tavily tools", () => { + let createTavilyWebSearchProvider: typeof import("./tavily-search-provider.js").createTavilyWebSearchProvider; + let createTavilySearchTool: typeof import("./tavily-search-tool.js").createTavilySearchTool; + let createTavilyExtractTool: typeof import("./tavily-extract-tool.js").createTavilyExtractTool; + let tavilyClientTesting: typeof import("./tavily-client.js").__testing; + + beforeAll(async () => { + vi.resetModules(); + ({ createTavilyWebSearchProvider } = await import("./tavily-search-provider.js")); + ({ createTavilySearchTool } = await import("./tavily-search-tool.js")); + ({ createTavilyExtractTool } = await import("./tavily-extract-tool.js")); + ({ __testing: tavilyClientTesting } = + await vi.importActual("./tavily-client.js")); + }); + + beforeEach(() => { + runTavilySearch.mockReset(); + runTavilySearch.mockImplementation(async (params: Record) => params); + runTavilyExtract.mockReset(); + runTavilyExtract.mockImplementation(async (params: unknown) => ({ ok: true, params })); + vi.unstubAllEnvs(); + }); + + it("exposes the expected metadata and selection wiring", () => { + const provider = createTavilyWebSearchProvider(); + if (!provider.applySelectionConfig) { + throw new Error("Expected applySelectionConfig to be defined"); + } + const applied = provider.applySelectionConfig({}); + + expect(provider.id).toBe("tavily"); + expect(provider.credentialPath).toBe("plugins.entries.tavily.config.webSearch.apiKey"); + expect(applied.plugins?.entries?.tavily?.enabled).toBe(true); + }); + + it("maps generic provider args into Tavily search params", async () => { + const provider = createTavilyWebSearchProvider(); + const tool = provider.createTool({ + config: { test: true }, + } as never); + if (!tool) { + throw new Error("Expected tool definition"); + } + + const result = await tool.execute({ + query: "weather sf", + count: 7, + }); + + expect(runTavilySearch).toHaveBeenCalledWith({ + cfg: { test: true }, + query: "weather sf", + maxResults: 7, + }); + expect(result).toEqual({ + cfg: { test: true }, + query: "weather sf", + maxResults: 7, + }); + }); + + it("normalizes optional parameters before invoking Tavily", async () => { + runTavilySearch.mockImplementationOnce(async (params: Record) => ({ + ok: true, + params, + })); + const tool = createTavilySearchTool({ + config: { env: "test" }, + } as never); + + const result = await tool.execute("call-1", { + query: "best docs", + search_depth: "advanced", + topic: "news", + max_results: 5, + include_answer: true, + time_range: "week", + include_domains: ["docs.openclaw.ai", "", "openclaw.ai"], + exclude_domains: ["bad.example", ""], + }); + + expect(runTavilySearch).toHaveBeenCalledWith({ + cfg: { env: "test" }, + query: "best docs", + searchDepth: "advanced", + topic: "news", + maxResults: 5, + includeAnswer: true, + timeRange: "week", + includeDomains: ["docs.openclaw.ai", "openclaw.ai"], + excludeDomains: ["bad.example"], + }); + expect(result).toMatchObject({ + details: { + ok: true, + params: { + cfg: { env: "test" }, + query: "best docs", + searchDepth: "advanced", + topic: "news", + maxResults: 5, + includeAnswer: true, + timeRange: "week", + includeDomains: ["docs.openclaw.ai", "openclaw.ai"], + excludeDomains: ["bad.example"], + }, + }, + }); + expect(result.content[0]).toMatchObject({ + type: "text", + }); + }); + + it("drops empty domain arrays and forwards query-scoped chunking", async () => { + runTavilySearch.mockImplementationOnce(async (params: Record) => ({ + ok: true, + params, + })); + const searchTool = createTavilySearchTool({ + config: { env: "test" }, + } as never); + + await expect( + searchTool.execute("call-2", { + query: "simple", + include_domains: [""], + exclude_domains: [], + }), + ).resolves.toMatchObject({ + details: { + ok: true, + params: { + cfg: { env: "test" }, + query: "simple", + includeAnswer: false, + }, + }, + }); + + const extractTool = createTavilyExtractTool(fakeApi()); + await extractTool.execute("id", { + urls: ["https://example.com"], + query: "pricing", + chunks_per_source: 2, + }); + + expect(runTavilyExtract).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: {}, + urls: ["https://example.com"], + query: "pricing", + chunksPerSource: 2, + }), + ); + }); + + it("rejects chunks_per_source without query", async () => { + const tool = createTavilyExtractTool(fakeApi()); + + await expect( + tool.execute("id", { + urls: ["https://example.com"], + chunks_per_source: 2, + }), + ).rejects.toThrow("tavily_extract requires query when chunks_per_source is set."); + + expect(runTavilyExtract).not.toHaveBeenCalled(); + }); + + it("reads plugin web search config and prefers it over env defaults", () => { + vi.stubEnv("TAVILY_API_KEY", "env-key"); + vi.stubEnv("TAVILY_BASE_URL", "https://env.tavily.test"); + + const cfg = { + plugins: { + entries: { + tavily: { + config: { + webSearch: { + apiKey: "plugin-key", + baseUrl: "https://plugin.tavily.test", + }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolveTavilySearchConfig(cfg)).toEqual({ + apiKey: "plugin-key", + baseUrl: "https://plugin.tavily.test", + }); + expect(resolveTavilyApiKey(cfg)).toBe("plugin-key"); + expect(resolveTavilyBaseUrl(cfg)).toBe("https://plugin.tavily.test"); + }); + + it("falls back to environment values and defaults", () => { + vi.stubEnv("TAVILY_API_KEY", "env-key"); + vi.stubEnv("TAVILY_BASE_URL", "https://env.tavily.test"); + + expect(resolveTavilyApiKey()).toBe("env-key"); + expect(resolveTavilyBaseUrl()).toBe("https://env.tavily.test"); + expect(resolveTavilyBaseUrl({} as OpenClawConfig)).not.toBe(DEFAULT_TAVILY_BASE_URL); + expect(resolveTavilySearchTimeoutSeconds()).toBe(DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS); + expect(resolveTavilyExtractTimeoutSeconds()).toBe(DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS); + }); + + it("accepts positive numeric timeout overrides and floors them", () => { + expect(resolveTavilySearchTimeoutSeconds(19.9)).toBe(19); + expect(resolveTavilyExtractTimeoutSeconds(42.7)).toBe(42); + expect(resolveTavilySearchTimeoutSeconds(0)).toBe(DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS); + expect(resolveTavilyExtractTimeoutSeconds(Number.NaN)).toBe( + DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS, + ); + }); + + it("appends endpoints to reverse-proxy base urls", () => { + expect(tavilyClientTesting.resolveEndpoint("https://proxy.example/api/tavily", "/search")).toBe( + "https://proxy.example/api/tavily/search", + ); + expect( + tavilyClientTesting.resolveEndpoint("https://proxy.example/api/tavily/", "/extract"), + ).toBe("https://proxy.example/api/tavily/extract"); + }); + + it("falls back to the default host for invalid base urls", () => { + expect(tavilyClientTesting.resolveEndpoint("not a url", "/search")).toBe( + "https://api.tavily.com/search", + ); + expect(tavilyClientTesting.resolveEndpoint("", "/extract")).toBe( + "https://api.tavily.com/extract", + ); + }); +});