From 410c2dba65bd99a4f7f7fd8880a1cd8d27bb3a84 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Mar 2026 04:22:00 +0000 Subject: [PATCH] test: collapse provider plugin suites --- extensions/device-pair/index.test.ts | 44 ++++ extensions/device-pair/notify.test.ts | 41 ---- extensions/feishu/index.test.ts | 75 ------- extensions/feishu/src/client.test.ts | 73 +++++++ extensions/firecrawl/index.test.ts | 84 -------- .../firecrawl/src/firecrawl-tools.test.ts | 79 +++++++ .../github-copilot/models-defaults.test.ts | 39 ---- extensions/github-copilot/models.test.ts | 198 +++++++++++++++++- extensions/github-copilot/token.test.ts | 84 -------- extensions/github-copilot/usage.test.ts | 76 ------- .../google/image-generation-provider.test.ts | 17 ++ .../src/gemini-web-search-provider.test.ts | 18 -- extensions/lobster/src/lobster-tool.test.ts | 108 ++++++++++ extensions/lobster/src/windows-spawn.test.ts | 118 ----------- extensions/memory-lancedb/index.test.ts | 175 ++++++++++++++++ .../memory-lancedb/lancedb-runtime.test.ts | 176 ---------------- .../openai/image-generation-provider.test.ts | 83 -------- extensions/openai/index.test.ts | 130 +++++++++++- .../openai-codex-provider.runtime.test.ts | 51 ----- extensions/tavily/index.test.ts | 37 ---- extensions/tavily/src/tavily-tools.test.ts | 34 +++ extensions/xai/provider-models.test.ts | 157 -------------- extensions/xai/web-search.test.ts | 156 ++++++++++++++ 23 files changed, 1012 insertions(+), 1041 deletions(-) delete mode 100644 extensions/device-pair/notify.test.ts delete mode 100644 extensions/feishu/index.test.ts delete mode 100644 extensions/firecrawl/index.test.ts delete mode 100644 extensions/github-copilot/models-defaults.test.ts delete mode 100644 extensions/github-copilot/token.test.ts delete mode 100644 extensions/github-copilot/usage.test.ts delete mode 100644 extensions/google/src/gemini-web-search-provider.test.ts delete mode 100644 extensions/lobster/src/windows-spawn.test.ts delete mode 100644 extensions/memory-lancedb/lancedb-runtime.test.ts delete mode 100644 extensions/openai/image-generation-provider.test.ts delete mode 100644 extensions/openai/openai-codex-provider.runtime.test.ts delete mode 100644 extensions/tavily/index.test.ts delete mode 100644 extensions/xai/provider-models.test.ts diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index a23b86fb7bc..616f1bbb634 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -8,6 +8,7 @@ import type { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; import type { OpenClawPluginApi } from "./api.js"; +import type { PendingPairingRequest } from "./notify.ts"; const pluginApiMocks = vi.hoisted(() => ({ clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })), @@ -385,6 +386,49 @@ describe("device-pair /pair qr", () => { }); }); +describe("device-pair notify pending formatting", () => { + it("includes role and scopes for pending requests", async () => { + const { formatPendingRequests } = + await vi.importActual("./notify.ts"); + const pending: PendingPairingRequest[] = [ + { + requestId: "req-1", + deviceId: "device-1", + displayName: "dev one", + platform: "ios", + role: "operator", + scopes: ["operator.admin", "operator.read"], + remoteIp: "198.51.100.2", + }, + ]; + + const text = formatPendingRequests(pending); + expect(text).toContain("Pending device pairing requests:"); + expect(text).toContain("name=dev one"); + expect(text).toContain("platform=ios"); + expect(text).toContain("role=operator"); + expect(text).toContain("scopes=operator.admin, operator.read"); + expect(text).toContain("ip=198.51.100.2"); + }); + + it("falls back to roles list and no scopes when role/scopes are absent", async () => { + const { formatPendingRequests } = + await vi.importActual("./notify.ts"); + const pending: PendingPairingRequest[] = [ + { + requestId: "req-2", + deviceId: "device-2", + roles: ["node", "operator"], + scopes: [], + }, + ]; + + const text = formatPendingRequests(pending); + expect(text).toContain("role=node, operator"); + expect(text).toContain("scopes=none"); + }); +}); + describe("device-pair /pair approve", () => { it("rejects internal gateway callers without operator.pairing", async () => { vi.mocked(listDevicePairing).mockResolvedValueOnce({ diff --git a/extensions/device-pair/notify.test.ts b/extensions/device-pair/notify.test.ts deleted file mode 100644 index f3e226f49f7..00000000000 --- a/extensions/device-pair/notify.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { formatPendingRequests, type PendingPairingRequest } from "./notify.ts"; - -describe("device-pair notify pending formatting", () => { - it("includes role and scopes for pending requests", () => { - const pending: PendingPairingRequest[] = [ - { - requestId: "req-1", - deviceId: "device-1", - displayName: "dev one", - platform: "ios", - role: "operator", - scopes: ["operator.admin", "operator.read"], - remoteIp: "198.51.100.2", - }, - ]; - - const text = formatPendingRequests(pending); - expect(text).toContain("Pending device pairing requests:"); - expect(text).toContain("name=dev one"); - expect(text).toContain("platform=ios"); - expect(text).toContain("role=operator"); - expect(text).toContain("scopes=operator.admin, operator.read"); - expect(text).toContain("ip=198.51.100.2"); - }); - - it("falls back to roles list and no scopes when role/scopes are absent", () => { - const pending: PendingPairingRequest[] = [ - { - requestId: "req-2", - deviceId: "device-2", - roles: ["node", "operator"], - scopes: [], - }, - ]; - - const text = formatPendingRequests(pending); - expect(text).toContain("role=node, operator"); - expect(text).toContain("scopes=none"); - }); -}); diff --git a/extensions/feishu/index.test.ts b/extensions/feishu/index.test.ts deleted file mode 100644 index f916498c5a2..00000000000 --- a/extensions/feishu/index.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawPluginApi } from "./runtime-api.js"; - -const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn()); -const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn()); -const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn()); -const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn()); -const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn()); -const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn()); -const feishuPluginMock = vi.hoisted(() => ({ id: "feishu-test-plugin" })); -const setFeishuRuntimeMock = vi.hoisted(() => vi.fn()); -const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn()); - -vi.mock("./src/channel.js", () => ({ - feishuPlugin: feishuPluginMock, -})); - -vi.mock("./src/docx.js", () => ({ - registerFeishuDocTools: registerFeishuDocToolsMock, -})); - -vi.mock("./src/chat.js", () => ({ - registerFeishuChatTools: registerFeishuChatToolsMock, -})); - -vi.mock("./src/wiki.js", () => ({ - registerFeishuWikiTools: registerFeishuWikiToolsMock, -})); - -vi.mock("./src/drive.js", () => ({ - registerFeishuDriveTools: registerFeishuDriveToolsMock, -})); - -vi.mock("./src/perm.js", () => ({ - registerFeishuPermTools: registerFeishuPermToolsMock, -})); - -vi.mock("./src/bitable.js", () => ({ - registerFeishuBitableTools: registerFeishuBitableToolsMock, -})); - -vi.mock("./src/runtime.js", () => ({ - setFeishuRuntime: setFeishuRuntimeMock, -})); - -vi.mock("./src/subagent-hooks.js", () => ({ - registerFeishuSubagentHooks: registerFeishuSubagentHooksMock, -})); - -describe("feishu plugin register", () => { - it("registers the Feishu channel, tools, and subagent hooks", async () => { - const { default: plugin } = await import("./index.js"); - const registerChannel = vi.fn(); - const api = { - runtime: { log: vi.fn() }, - registerChannel, - on: vi.fn(), - config: {}, - registrationMode: "full", - } as unknown as OpenClawPluginApi; - - plugin.register(api); - - expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime); - expect(registerChannel).toHaveBeenCalledTimes(1); - expect(registerChannel).toHaveBeenCalledWith({ plugin: feishuPluginMock }); - expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api); - expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api); - expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api); - expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api); - expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api); - expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api); - expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api); - }); -}); diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index efaf8ce198a..218893ffca8 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawPluginApi } from "../runtime-api.js"; import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js"; type CreateFeishuClient = typeof import("./client.js").createFeishuClient; @@ -33,6 +34,15 @@ const mockBaseHttpInstance = vi.hoisted(() => ({ })); const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const; type ProxyEnvKey = (typeof proxyEnvKeys)[number]; +const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn()); +const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn()); +const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn()); +const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn()); +const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn()); +const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn()); +const feishuPluginMock = vi.hoisted(() => ({ id: "feishu-test-plugin" })); +const setFeishuRuntimeMock = vi.hoisted(() => vi.fn()); +const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn()); let createFeishuClient: CreateFeishuClient; let createFeishuWSClient: CreateFeishuWSClient; @@ -45,6 +55,42 @@ let FEISHU_HTTP_TIMEOUT_ENV_VAR: string; let priorProxyEnv: Partial> = {}; let priorFeishuTimeoutEnv: string | undefined; +vi.mock("./channel.js", () => ({ + feishuPlugin: feishuPluginMock, +})); + +vi.mock("./docx.js", () => ({ + registerFeishuDocTools: registerFeishuDocToolsMock, +})); + +vi.mock("./chat.js", () => ({ + registerFeishuChatTools: registerFeishuChatToolsMock, +})); + +vi.mock("./wiki.js", () => ({ + registerFeishuWikiTools: registerFeishuWikiToolsMock, +})); + +vi.mock("./drive.js", () => ({ + registerFeishuDriveTools: registerFeishuDriveToolsMock, +})); + +vi.mock("./perm.js", () => ({ + registerFeishuPermTools: registerFeishuPermToolsMock, +})); + +vi.mock("./bitable.js", () => ({ + registerFeishuBitableTools: registerFeishuBitableToolsMock, +})); + +vi.mock("./runtime.js", () => ({ + setFeishuRuntime: setFeishuRuntimeMock, +})); + +vi.mock("./subagent-hooks.js", () => ({ + registerFeishuSubagentHooks: registerFeishuSubagentHooksMock, +})); + const baseAccount: ResolvedFeishuAccount = { accountId: "main", selectionSource: "explicit", @@ -290,6 +336,33 @@ describe("createFeishuClient HTTP timeout", () => { }); }); +describe("feishu plugin register", () => { + it("registers the Feishu channel, tools, and subagent hooks", async () => { + const { default: plugin } = await import("../index.js"); + const registerChannel = vi.fn(); + const api = { + runtime: { log: vi.fn() }, + registerChannel, + on: vi.fn(), + config: {}, + registrationMode: "full", + } as unknown as OpenClawPluginApi; + + plugin.register(api); + + expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime); + expect(registerChannel).toHaveBeenCalledTimes(1); + expect(registerChannel).toHaveBeenCalledWith({ plugin: feishuPluginMock }); + expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api); + expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api); + expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api); + expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api); + expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api); + expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api); + expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api); + }); +}); + describe("createFeishuWSClient proxy handling", () => { it("does not set a ws proxy agent when proxy env is absent", () => { createFeishuWSClient(baseAccount); diff --git a/extensions/firecrawl/index.test.ts b/extensions/firecrawl/index.test.ts deleted file mode 100644 index dbc276e265f..00000000000 --- a/extensions/firecrawl/index.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from "vitest"; -import plugin from "./index.js"; -import { __testing as firecrawlClientTesting } from "./src/firecrawl-client.js"; - -describe("firecrawl plugin", () => { - it("parses scrape payloads into wrapped external-content results", () => { - const result = firecrawlClientTesting.parseFirecrawlScrapePayload({ - payload: { - success: true, - data: { - markdown: "# Hello\n\nWorld", - metadata: { - title: "Example page", - sourceURL: "https://example.com/final", - statusCode: 200, - }, - }, - }, - url: "https://example.com/start", - extractMode: "text", - maxChars: 1000, - }); - - expect(result.finalUrl).toBe("https://example.com/final"); - expect(result.status).toBe(200); - expect(result.extractor).toBe("firecrawl"); - expect(String(result.text)).toContain("Hello"); - expect(String(result.text)).toContain("World"); - expect(result.truncated).toBe(false); - }); - - it("extracts search items from flexible Firecrawl payload shapes", () => { - const items = firecrawlClientTesting.resolveSearchItems({ - success: true, - data: [ - { - title: "Docs", - url: "https://docs.example.com/path", - description: "Reference docs", - markdown: "Body", - }, - ], - }); - - expect(items).toEqual([ - { - title: "Docs", - url: "https://docs.example.com/path", - description: "Reference docs", - content: "Body", - published: undefined, - siteName: "docs.example.com", - }, - ]); - }); - - it("extracts search items from Firecrawl v2 data.web payloads", () => { - const items = firecrawlClientTesting.resolveSearchItems({ - success: true, - data: { - web: [ - { - title: "API Platform - OpenAI", - url: "https://openai.com/api/", - description: "Build on the OpenAI API platform.", - markdown: "# API Platform", - position: 1, - }, - ], - }, - }); - - expect(items).toEqual([ - { - title: "API Platform - OpenAI", - url: "https://openai.com/api/", - description: "Build on the OpenAI API platform.", - content: "# API Platform", - published: undefined, - siteName: "openai.com", - }, - ]); - }); -}); diff --git a/extensions/firecrawl/src/firecrawl-tools.test.ts b/extensions/firecrawl/src/firecrawl-tools.test.ts index 386ffad9774..7fd6558e55d 100644 --- a/extensions/firecrawl/src/firecrawl-tools.test.ts +++ b/extensions/firecrawl/src/firecrawl-tools.test.ts @@ -65,6 +65,85 @@ describe("firecrawl tools", () => { expect(applied.plugins?.entries?.firecrawl?.enabled).toBe(true); }); + it("parses scrape payloads into wrapped external-content results", () => { + const result = firecrawlClientTesting.parseFirecrawlScrapePayload({ + payload: { + success: true, + data: { + markdown: "# Hello\n\nWorld", + metadata: { + title: "Example page", + sourceURL: "https://example.com/final", + statusCode: 200, + }, + }, + }, + url: "https://example.com/start", + extractMode: "text", + maxChars: 1000, + }); + + expect(result.finalUrl).toBe("https://example.com/final"); + expect(result.status).toBe(200); + expect(result.extractor).toBe("firecrawl"); + expect(String(result.text)).toContain("Hello"); + expect(String(result.text)).toContain("World"); + expect(result.truncated).toBe(false); + }); + + it("extracts search items from flexible Firecrawl payload shapes", () => { + const items = firecrawlClientTesting.resolveSearchItems({ + success: true, + data: [ + { + title: "Docs", + url: "https://docs.example.com/path", + description: "Reference docs", + markdown: "Body", + }, + ], + }); + + expect(items).toEqual([ + { + title: "Docs", + url: "https://docs.example.com/path", + description: "Reference docs", + content: "Body", + published: undefined, + siteName: "docs.example.com", + }, + ]); + }); + + it("extracts search items from Firecrawl v2 data.web payloads", () => { + const items = firecrawlClientTesting.resolveSearchItems({ + success: true, + data: { + web: [ + { + title: "API Platform - OpenAI", + url: "https://openai.com/api/", + description: "Build on the OpenAI API platform.", + markdown: "# API Platform", + position: 1, + }, + ], + }, + }); + + expect(items).toEqual([ + { + title: "API Platform - OpenAI", + url: "https://openai.com/api/", + description: "Build on the OpenAI API platform.", + content: "# API Platform", + published: undefined, + siteName: "openai.com", + }, + ]); + }); + it("maps generic provider args into firecrawl search params", async () => { const provider = createFirecrawlWebSearchProvider(); const tool = provider.createTool({ diff --git a/extensions/github-copilot/models-defaults.test.ts b/extensions/github-copilot/models-defaults.test.ts deleted file mode 100644 index c029961b5fd..00000000000 --- a/extensions/github-copilot/models-defaults.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildCopilotModelDefinition, getDefaultCopilotModelIds } from "./models-defaults.js"; - -describe("github-copilot model defaults", () => { - describe("getDefaultCopilotModelIds", () => { - it("includes claude-sonnet-4.6", () => { - expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.6"); - }); - - it("includes claude-sonnet-4.5", () => { - expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.5"); - }); - - it("returns a mutable copy", () => { - const a = getDefaultCopilotModelIds(); - const b = getDefaultCopilotModelIds(); - expect(a).not.toBe(b); - expect(a).toEqual(b); - }); - }); - - describe("buildCopilotModelDefinition", () => { - it("builds a valid definition for claude-sonnet-4.6", () => { - const def = buildCopilotModelDefinition("claude-sonnet-4.6"); - expect(def.id).toBe("claude-sonnet-4.6"); - expect(def.api).toBe("openai-responses"); - }); - - it("trims whitespace from model id", () => { - const def = buildCopilotModelDefinition(" gpt-4o "); - expect(def.id).toBe("gpt-4o"); - }); - - it("throws on empty model id", () => { - expect(() => buildCopilotModelDefinition("")).toThrow("Model id required"); - expect(() => buildCopilotModelDefinition(" ")).toThrow("Model id required"); - }); - }); -}); diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 4bea7d88565..e53b360697f 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -1,4 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createProviderUsageFetch, + makeResponse, +} from "../../test/helpers/extensions/provider-usage-fetch.js"; +import { buildCopilotModelDefinition, getDefaultCopilotModelIds } from "./models-defaults.js"; +import { fetchCopilotUsage } from "./usage.js"; vi.mock("@mariozechner/pi-ai/oauth", async () => { const actual = await vi.importActual( @@ -15,9 +21,24 @@ vi.mock("openclaw/plugin-sdk/provider-models", () => ({ normalizeModelCompat: (model: Record) => model, })); +const loadJsonFile = vi.fn(); +const saveJsonFile = vi.fn(); + +vi.mock("openclaw/plugin-sdk/json-store", () => ({ + loadJsonFile, + saveJsonFile, +})); + +vi.mock("openclaw/plugin-sdk/state-paths", () => ({ + resolveStateDir: () => "/tmp/openclaw-state", +})); + import type { ProviderResolveDynamicModelContext } from "openclaw/plugin-sdk/core"; import { resolveCopilotForwardCompatModel } from "./models.js"; +let deriveCopilotApiBaseUrlFromToken: typeof import("./token.js").deriveCopilotApiBaseUrlFromToken; +let resolveCopilotApiToken: typeof import("./token.js").resolveCopilotApiToken; + function createMockCtx( modelId: string, registryModels: Record> = {}, @@ -40,6 +61,43 @@ function requireResolvedModel(ctx: ProviderResolveDynamicModelContext) { return result; } +describe("github-copilot model defaults", () => { + describe("getDefaultCopilotModelIds", () => { + it("includes claude-sonnet-4.6", () => { + expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.6"); + }); + + it("includes claude-sonnet-4.5", () => { + expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.5"); + }); + + it("returns a mutable copy", () => { + const a = getDefaultCopilotModelIds(); + const b = getDefaultCopilotModelIds(); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); + }); + + describe("buildCopilotModelDefinition", () => { + it("builds a valid definition for claude-sonnet-4.6", () => { + const def = buildCopilotModelDefinition("claude-sonnet-4.6"); + expect(def.id).toBe("claude-sonnet-4.6"); + expect(def.api).toBe("openai-responses"); + }); + + it("trims whitespace from model id", () => { + const def = buildCopilotModelDefinition(" gpt-4o "); + expect(def.id).toBe("gpt-4o"); + }); + + it("throws on empty model id", () => { + expect(() => buildCopilotModelDefinition("")).toThrow("Model id required"); + expect(() => buildCopilotModelDefinition(" ")).toThrow("Model id required"); + }); + }); +}); + describe("resolveCopilotForwardCompatModel", () => { it("returns undefined for empty modelId", () => { expect(resolveCopilotForwardCompatModel(createMockCtx(""))).toBeUndefined(); @@ -108,3 +166,141 @@ describe("resolveCopilotForwardCompatModel", () => { } }); }); + +describe("fetchCopilotUsage", () => { + it("returns HTTP errors for failed requests", async () => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom")); + const result = await fetchCopilotUsage("token", 5000, mockFetch); + + expect(result.error).toBe("HTTP 500"); + expect(result.windows).toHaveLength(0); + }); + + it("parses premium/chat usage from remaining percentages", async () => { + const mockFetch = createProviderUsageFetch(async (_url, init) => { + const headers = (init?.headers as Record | undefined) ?? {}; + expect(headers.Authorization).toBe("token token"); + expect(headers["X-Github-Api-Version"]).toBe("2025-04-01"); + + return makeResponse(200, { + quota_snapshots: { + premium_interactions: { percent_remaining: 20 }, + chat: { percent_remaining: 75 }, + }, + copilot_plan: "pro", + }); + }); + + const result = await fetchCopilotUsage("token", 5000, mockFetch); + + expect(result.plan).toBe("pro"); + expect(result.windows).toEqual([ + { label: "Premium", usedPercent: 80 }, + { label: "Chat", usedPercent: 25 }, + ]); + }); + + it("defaults missing snapshot values and clamps invalid remaining percentages", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + quota_snapshots: { + premium_interactions: { percent_remaining: null }, + chat: { percent_remaining: 140 }, + }, + }), + ); + + const result = await fetchCopilotUsage("token", 5000, mockFetch); + + expect(result.windows).toEqual([ + { label: "Premium", usedPercent: 100 }, + { label: "Chat", usedPercent: 0 }, + ]); + expect(result.plan).toBeUndefined(); + }); + + it("returns an empty window list when quota snapshots are missing", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + copilot_plan: "free", + }), + ); + + const result = await fetchCopilotUsage("token", 5000, mockFetch); + + expect(result).toEqual({ + provider: "github-copilot", + displayName: "Copilot", + windows: [], + plan: "free", + }); + }); +}); + +describe("github-copilot token", () => { + const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json"; + + beforeEach(async () => { + vi.resetModules(); + loadJsonFile.mockClear(); + saveJsonFile.mockClear(); + ({ deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } = await import("./token.js")); + }); + + it("derives baseUrl from token", async () => { + expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe( + "https://api.example.com", + ); + expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBe( + "https://api.foo.bar", + ); + }); + + it("uses cache when token is still valid", async () => { + const now = Date.now(); + loadJsonFile.mockReturnValue({ + token: "cached;proxy-ep=proxy.example.com;", + expiresAt: now + 60 * 60 * 1000, + updatedAt: now, + }); + + const fetchImpl = vi.fn(); + const res = await resolveCopilotApiToken({ + githubToken: "gh", + cachePath, + loadJsonFileImpl: loadJsonFile, + saveJsonFileImpl: saveJsonFile, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(res.token).toBe("cached;proxy-ep=proxy.example.com;"); + expect(res.baseUrl).toBe("https://api.example.com"); + expect(String(res.source)).toContain("cache:"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("fetches and stores token when cache is missing", async () => { + loadJsonFile.mockReturnValue(undefined); + + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + token: "fresh;proxy-ep=https://proxy.contoso.test;", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + }); + + const res = await resolveCopilotApiToken({ + githubToken: "gh", + cachePath, + loadJsonFileImpl: loadJsonFile, + saveJsonFileImpl: saveJsonFile, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;"); + expect(res.baseUrl).toBe("https://api.contoso.test"); + expect(saveJsonFile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/github-copilot/token.test.ts b/extensions/github-copilot/token.test.ts deleted file mode 100644 index 4f15e43b6dc..00000000000 --- a/extensions/github-copilot/token.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const loadJsonFile = vi.fn(); -const saveJsonFile = vi.fn(); - -vi.mock("openclaw/plugin-sdk/json-store", () => ({ - loadJsonFile, - saveJsonFile, -})); - -vi.mock("openclaw/plugin-sdk/state-paths", () => ({ - resolveStateDir: () => "/tmp/openclaw-state", -})); - -let deriveCopilotApiBaseUrlFromToken: typeof import("./token.js").deriveCopilotApiBaseUrlFromToken; -let resolveCopilotApiToken: typeof import("./token.js").resolveCopilotApiToken; - -describe("github-copilot token", () => { - const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json"; - - beforeEach(async () => { - vi.resetModules(); - loadJsonFile.mockClear(); - saveJsonFile.mockClear(); - ({ deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } = await import("./token.js")); - }); - - it("derives baseUrl from token", async () => { - expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe( - "https://api.example.com", - ); - expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBe( - "https://api.foo.bar", - ); - }); - - it("uses cache when token is still valid", async () => { - const now = Date.now(); - loadJsonFile.mockReturnValue({ - token: "cached;proxy-ep=proxy.example.com;", - expiresAt: now + 60 * 60 * 1000, - updatedAt: now, - }); - - const fetchImpl = vi.fn(); - const res = await resolveCopilotApiToken({ - githubToken: "gh", - cachePath, - loadJsonFileImpl: loadJsonFile, - saveJsonFileImpl: saveJsonFile, - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - - expect(res.token).toBe("cached;proxy-ep=proxy.example.com;"); - expect(res.baseUrl).toBe("https://api.example.com"); - expect(String(res.source)).toContain("cache:"); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("fetches and stores token when cache is missing", async () => { - loadJsonFile.mockReturnValue(undefined); - - const fetchImpl = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ - token: "fresh;proxy-ep=https://proxy.contoso.test;", - expires_at: Math.floor(Date.now() / 1000) + 3600, - }), - }); - - const res = await resolveCopilotApiToken({ - githubToken: "gh", - cachePath, - loadJsonFileImpl: loadJsonFile, - saveJsonFileImpl: saveJsonFile, - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - - expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;"); - expect(res.baseUrl).toBe("https://api.contoso.test"); - expect(saveJsonFile).toHaveBeenCalledTimes(1); - }); -}); diff --git a/extensions/github-copilot/usage.test.ts b/extensions/github-copilot/usage.test.ts deleted file mode 100644 index f0687c33b0a..00000000000 --- a/extensions/github-copilot/usage.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - createProviderUsageFetch, - makeResponse, -} from "../../test/helpers/extensions/provider-usage-fetch.js"; -import { fetchCopilotUsage } from "./usage.js"; - -describe("fetchCopilotUsage", () => { - it("returns HTTP errors for failed requests", async () => { - const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom")); - const result = await fetchCopilotUsage("token", 5000, mockFetch); - - expect(result.error).toBe("HTTP 500"); - expect(result.windows).toHaveLength(0); - }); - - it("parses premium/chat usage from remaining percentages", async () => { - const mockFetch = createProviderUsageFetch(async (_url, init) => { - const headers = (init?.headers as Record | undefined) ?? {}; - expect(headers.Authorization).toBe("token token"); - expect(headers["X-Github-Api-Version"]).toBe("2025-04-01"); - - return makeResponse(200, { - quota_snapshots: { - premium_interactions: { percent_remaining: 20 }, - chat: { percent_remaining: 75 }, - }, - copilot_plan: "pro", - }); - }); - - const result = await fetchCopilotUsage("token", 5000, mockFetch); - - expect(result.plan).toBe("pro"); - expect(result.windows).toEqual([ - { label: "Premium", usedPercent: 80 }, - { label: "Chat", usedPercent: 25 }, - ]); - }); - - it("defaults missing snapshot values and clamps invalid remaining percentages", async () => { - const mockFetch = createProviderUsageFetch(async () => - makeResponse(200, { - quota_snapshots: { - premium_interactions: { percent_remaining: null }, - chat: { percent_remaining: 140 }, - }, - }), - ); - - const result = await fetchCopilotUsage("token", 5000, mockFetch); - - expect(result.windows).toEqual([ - { label: "Premium", usedPercent: 100 }, - { label: "Chat", usedPercent: 0 }, - ]); - expect(result.plan).toBeUndefined(); - }); - - it("returns an empty window list when quota snapshots are missing", async () => { - const mockFetch = createProviderUsageFetch(async () => - makeResponse(200, { - copilot_plan: "free", - }), - ); - - const result = await fetchCopilotUsage("token", 5000, mockFetch); - - expect(result).toEqual({ - provider: "github-copilot", - displayName: "Copilot", - windows: [], - plan: "free", - }); - }); -}); diff --git a/extensions/google/image-generation-provider.test.ts b/extensions/google/image-generation-provider.test.ts index 4db871fa77e..b10c41a3950 100644 --- a/extensions/google/image-generation-provider.test.ts +++ b/extensions/google/image-generation-provider.test.ts @@ -1,6 +1,7 @@ import * as providerAuth from "openclaw/plugin-sdk/provider-auth"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildGoogleImageGenerationProvider } from "./image-generation-provider.js"; +import { __testing as geminiWebSearchTesting } from "./src/gemini-web-search-provider.js"; function mockGoogleApiKeyAuth() { vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ @@ -282,4 +283,20 @@ describe("Google image-generation provider", () => { expect.any(Object), ); }); + + it("prefers scoped configured Gemini API keys over environment fallbacks", () => { + expect( + geminiWebSearchTesting.resolveGeminiApiKey({ + apiKey: "gemini-secret", + }), + ).toBe("gemini-secret"); + }); + + it("falls back to the default Gemini model when unset or blank", () => { + expect(geminiWebSearchTesting.resolveGeminiModel()).toBe("gemini-2.5-flash"); + expect(geminiWebSearchTesting.resolveGeminiModel({ model: " " })).toBe("gemini-2.5-flash"); + expect(geminiWebSearchTesting.resolveGeminiModel({ model: "gemini-2.5-pro" })).toBe( + "gemini-2.5-pro", + ); + }); }); diff --git a/extensions/google/src/gemini-web-search-provider.test.ts b/extensions/google/src/gemini-web-search-provider.test.ts deleted file mode 100644 index 42ba5676510..00000000000 --- a/extensions/google/src/gemini-web-search-provider.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { __testing } from "./gemini-web-search-provider.js"; - -describe("gemini web search provider", () => { - it("prefers scoped configured api keys over environment fallbacks", () => { - expect( - __testing.resolveGeminiApiKey({ - apiKey: "gemini-secret", - }), - ).toBe("gemini-secret"); - }); - - it("falls back to the default Gemini model when unset or blank", () => { - expect(__testing.resolveGeminiModel()).toBe("gemini-2.5-flash"); - expect(__testing.resolveGeminiModel({ model: " " })).toBe("gemini-2.5-flash"); - expect(__testing.resolveGeminiModel({ model: "gemini-2.5-pro" })).toBe("gemini-2.5-pro"); - }); -}); diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 5ca3305953e..ff78b3728d3 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -11,6 +11,7 @@ import { setProcessPlatform, snapshotPlatformPathEnv, } from "./test-helpers.js"; +import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; const spawnState = vi.hoisted(() => ({ queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>, @@ -75,6 +76,19 @@ function fakeCtx(overrides: Partial = {}): OpenClawPl }; } +async function expectUnwrappedShim(params: { + scriptPath: string; + shimPath: string; + shimLine: string; +}) { + await createWindowsCmdShimFixture(params); + + const target = resolveWindowsLobsterSpawn(params.shimPath, ["run", "noop"], process.env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([params.scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); +} + describe("lobster plugin tool", () => { let tempDir = ""; const originalProcessState = snapshotPlatformPathEnv(); @@ -317,3 +331,97 @@ describe("lobster plugin tool", () => { expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster"); }); }); + +describe("resolveWindowsLobsterSpawn", () => { + let tempDir = ""; + const originalProcessState = snapshotPlatformPathEnv(); + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-")); + setProcessPlatform("win32"); + }); + + afterEach(async () => { + restorePlatformPathEnv(originalProcessState); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = ""; + } + }); + + it("unwraps cmd shim with %dp0% token", async () => { + const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); + const shimPath = path.join(tempDir, "shim", "lobster.cmd"); + await expectUnwrappedShim({ + shimPath, + scriptPath, + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); + }); + + it("unwraps cmd shim with %~dp0% token", async () => { + const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); + const shimPath = path.join(tempDir, "shim", "lobster.cmd"); + await expectUnwrappedShim({ + shimPath, + scriptPath, + shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); + }); + + it("ignores node.exe shim entries and picks lobster script", async () => { + const shimDir = path.join(tempDir, "shim-with-node"); + const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs"); + const shimPath = path.join(shimDir, "lobster.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(shimDir, { recursive: true }); + await fs.writeFile(path.join(shimDir, "node.exe"), "", "utf8"); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile( + shimPath, + `@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`, + "utf8", + ); + + const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + }); + + it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => { + const binDir = path.join(tempDir, "node_modules", ".bin"); + const packageDir = path.join(tempDir, "node_modules", "lobster"); + const scriptPath = path.join(packageDir, "dist", "cli.js"); + const shimPath = path.join(binDir, "lobster.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.writeFile(shimPath, "@echo off\r\n", "utf8"); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }), + "utf8", + ); + await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); + + const env = { + ...process.env, + PATH: `${binDir};${process.env.PATH ?? ""}`, + PATHEXT: ".CMD;.EXE", + }; + const target = resolveWindowsLobsterSpawn("lobster", ["run", "noop"], env); + expect(target.command).toBe(process.execPath); + expect(target.argv).toEqual([scriptPath, "run", "noop"]); + expect(target.windowsHide).toBe(true); + }); + + it("fails fast when wrapper cannot be resolved without shell execution", async () => { + const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd"); + await fs.mkdir(path.dirname(badShimPath), { recursive: true }); + await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8"); + + expect(() => resolveWindowsLobsterSpawn(badShimPath, ["run", "noop"], process.env)).toThrow( + /without shell execution/, + ); + }); +}); diff --git a/extensions/lobster/src/windows-spawn.test.ts b/extensions/lobster/src/windows-spawn.test.ts deleted file mode 100644 index 48e6ddc9a54..00000000000 --- a/extensions/lobster/src/windows-spawn.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - createWindowsCmdShimFixture, - restorePlatformPathEnv, - setProcessPlatform, - snapshotPlatformPathEnv, -} from "./test-helpers.js"; -import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; - -describe("resolveWindowsLobsterSpawn", () => { - let tempDir = ""; - const originalProcessState = snapshotPlatformPathEnv(); - - async function expectUnwrappedShim(params: { - scriptPath: string; - shimPath: string; - shimLine: string; - }) { - await createWindowsCmdShimFixture(params); - - const target = resolveWindowsLobsterSpawn(params.shimPath, ["run", "noop"], process.env); - expect(target.command).toBe(process.execPath); - expect(target.argv).toEqual([params.scriptPath, "run", "noop"]); - expect(target.windowsHide).toBe(true); - } - - beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-")); - setProcessPlatform("win32"); - }); - - afterEach(async () => { - restorePlatformPathEnv(originalProcessState); - if (tempDir) { - await fs.rm(tempDir, { recursive: true, force: true }); - tempDir = ""; - } - }); - - it("unwraps cmd shim with %dp0% token", async () => { - const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); - const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await expectUnwrappedShim({ - shimPath, - scriptPath, - shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, - }); - }); - - it("unwraps cmd shim with %~dp0% token", async () => { - const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); - const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await expectUnwrappedShim({ - shimPath, - scriptPath, - shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, - }); - }); - - it("ignores node.exe shim entries and picks lobster script", async () => { - const shimDir = path.join(tempDir, "shim-with-node"); - const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs"); - const shimPath = path.join(shimDir, "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(shimDir, { recursive: true }); - await fs.writeFile(path.join(shimDir, "node.exe"), "", "utf8"); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( - shimPath, - `@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); - - const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); - expect(target.command).toBe(process.execPath); - expect(target.argv).toEqual([scriptPath, "run", "noop"]); - expect(target.windowsHide).toBe(true); - }); - - it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => { - const binDir = path.join(tempDir, "node_modules", ".bin"); - const packageDir = path.join(tempDir, "node_modules", "lobster"); - const scriptPath = path.join(packageDir, "dist", "cli.js"); - const shimPath = path.join(binDir, "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(binDir, { recursive: true }); - await fs.writeFile(shimPath, "@echo off\r\n", "utf8"); - await fs.writeFile( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }), - "utf8", - ); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - - const env = { - ...process.env, - PATH: `${binDir};${process.env.PATH ?? ""}`, - PATHEXT: ".CMD;.EXE", - }; - const target = resolveWindowsLobsterSpawn("lobster", ["run", "noop"], env); - expect(target.command).toBe(process.execPath); - expect(target.argv).toEqual([scriptPath, "run", "noop"]); - expect(target.windowsHide).toBe(true); - }); - - it("fails fast when wrapper cannot be resolved without shell execution", async () => { - const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd"); - await fs.mkdir(path.dirname(badShimPath), { recursive: true }); - await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8"); - - expect(() => resolveWindowsLobsterSpawn(badShimPath, ["run", "noop"], process.env)).toThrow( - /without shell execution/, - ); - }); -}); diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index 72b1ab8076a..762d6990f63 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -12,6 +12,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { createLanceDbRuntimeLoader, type LanceDbRuntimeLogger } from "./lancedb-runtime.js"; const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key"; const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY); @@ -30,6 +31,23 @@ type MemoryPluginTestConfig = { autoRecall?: boolean; }; +const TEST_RUNTIME_MANIFEST = { + name: "openclaw-memory-lancedb-runtime", + private: true as const, + type: "module" as const, + dependencies: { + "@lancedb/lancedb": "^0.27.1", + }, +}; + +type LanceDbModule = typeof import("@lancedb/lancedb"); +type RuntimeManifest = { + name: string; + private: true; + type: "module"; + dependencies: Record; +}; + function installTmpDirHarness(params: { prefix: string }) { let tmpDir = ""; let dbPath = ""; @@ -51,6 +69,47 @@ function installTmpDirHarness(params: { prefix: string }) { }; } +function createMockModule(): LanceDbModule { + return { + connect: vi.fn(), + } as unknown as LanceDbModule; +} + +function createRuntimeLoader( + overrides: { + env?: NodeJS.ProcessEnv; + importBundled?: () => Promise; + importResolved?: (resolvedPath: string) => Promise; + resolveRuntimeEntry?: (params: { + runtimeDir: string; + manifest: RuntimeManifest; + }) => string | null; + installRuntime?: (params: { + runtimeDir: string; + manifest: RuntimeManifest; + env: NodeJS.ProcessEnv; + logger?: LanceDbRuntimeLogger; + }) => Promise; + } = {}, +) { + return createLanceDbRuntimeLoader({ + env: overrides.env ?? ({} as NodeJS.ProcessEnv), + resolveStateDir: () => "/tmp/openclaw-state", + runtimeManifest: TEST_RUNTIME_MANIFEST, + importBundled: + overrides.importBundled ?? + (async () => { + throw new Error("Cannot find package '@lancedb/lancedb'"); + }), + importResolved: overrides.importResolved ?? (async () => createMockModule()), + resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? (() => null), + installRuntime: + overrides.installRuntime ?? + (async ({ runtimeDir }: { runtimeDir: string }) => + `${runtimeDir}/node_modules/@lancedb/lancedb/index.js`), + }); +} + describe("memory plugin e2e", () => { const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-test-" }); @@ -289,6 +348,122 @@ describe("memory plugin e2e", () => { }); }); +describe("lancedb runtime loader", () => { + test("uses the bundled module when it is already available", async () => { + const bundledModule = createMockModule(); + const importBundled = vi.fn(async () => bundledModule); + const importResolved = vi.fn(async () => createMockModule()); + const resolveRuntimeEntry = vi.fn(() => null); + const installRuntime = vi.fn(async () => "/tmp/openclaw-state/plugin-runtimes/lancedb.js"); + const loader = createRuntimeLoader({ + importBundled, + importResolved, + resolveRuntimeEntry, + installRuntime, + }); + + await expect(loader.load()).resolves.toBe(bundledModule); + + expect(resolveRuntimeEntry).not.toHaveBeenCalled(); + expect(installRuntime).not.toHaveBeenCalled(); + expect(importResolved).not.toHaveBeenCalled(); + }); + + test("reuses an existing user runtime install before attempting a reinstall", async () => { + const runtimeModule = createMockModule(); + const importResolved = vi.fn(async () => runtimeModule); + const resolveRuntimeEntry = vi.fn( + () => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js", + ); + const installRuntime = vi.fn( + async () => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js", + ); + const loader = createRuntimeLoader({ + importResolved, + resolveRuntimeEntry, + installRuntime, + }); + + await expect(loader.load()).resolves.toBe(runtimeModule); + + expect(resolveRuntimeEntry).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb", + }), + ); + expect(installRuntime).not.toHaveBeenCalled(); + }); + + test("installs LanceDB into user state when the bundled runtime is unavailable", async () => { + const runtimeModule = createMockModule(); + const logger: LanceDbRuntimeLogger = { + warn: vi.fn(), + info: vi.fn(), + }; + const importResolved = vi.fn(async () => runtimeModule); + const resolveRuntimeEntry = vi.fn(() => null); + const installRuntime = vi.fn( + async ({ runtimeDir }: { runtimeDir: string }) => + `${runtimeDir}/node_modules/@lancedb/lancedb/index.js`, + ); + const loader = createRuntimeLoader({ + importResolved, + resolveRuntimeEntry, + installRuntime, + }); + + await expect(loader.load(logger)).resolves.toBe(runtimeModule); + + expect(installRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb", + manifest: TEST_RUNTIME_MANIFEST, + }), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + "installing runtime deps under /tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb", + ), + ); + }); + + test("fails fast in nix mode instead of attempting auto-install", async () => { + const installRuntime = vi.fn( + async ({ runtimeDir }: { runtimeDir: string }) => + `${runtimeDir}/node_modules/@lancedb/lancedb/index.js`, + ); + const loader = createRuntimeLoader({ + env: { OPENCLAW_NIX_MODE: "1" } as NodeJS.ProcessEnv, + installRuntime, + }); + + await expect(loader.load()).rejects.toThrow( + "memory-lancedb: failed to load LanceDB and Nix mode disables auto-install.", + ); + expect(installRuntime).not.toHaveBeenCalled(); + }); + + test("clears the cached failure so later calls can retry the install", async () => { + const runtimeModule = createMockModule(); + const installRuntime = vi + .fn() + .mockRejectedValueOnce(new Error("network down")) + .mockResolvedValueOnce( + "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb/node_modules/@lancedb/lancedb/index.js", + ); + const importResolved = vi.fn(async () => runtimeModule); + const loader = createRuntimeLoader({ + installRuntime, + importResolved, + }); + + await expect(loader.load()).rejects.toThrow("network down"); + await expect(loader.load()).resolves.toBe(runtimeModule); + + expect(installRuntime).toHaveBeenCalledTimes(2); + }); +}); + // Live tests that require OpenAI API key and actually use LanceDB describeLive("memory plugin live tests", () => { const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-live-" }); diff --git a/extensions/memory-lancedb/lancedb-runtime.test.ts b/extensions/memory-lancedb/lancedb-runtime.test.ts deleted file mode 100644 index 0e12ed2b8b6..00000000000 --- a/extensions/memory-lancedb/lancedb-runtime.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createLanceDbRuntimeLoader, type LanceDbRuntimeLogger } from "./lancedb-runtime.js"; - -const TEST_RUNTIME_MANIFEST = { - name: "openclaw-memory-lancedb-runtime", - private: true as const, - type: "module" as const, - dependencies: { - "@lancedb/lancedb": "^0.27.1", - }, -}; - -type LanceDbModule = typeof import("@lancedb/lancedb"); -type RuntimeManifest = { - name: string; - private: true; - type: "module"; - dependencies: Record; -}; - -function createMockModule(): LanceDbModule { - return { - connect: vi.fn(), - } as unknown as LanceDbModule; -} - -function createLoader( - overrides: { - env?: NodeJS.ProcessEnv; - importBundled?: () => Promise; - importResolved?: (resolvedPath: string) => Promise; - resolveRuntimeEntry?: (params: { - runtimeDir: string; - manifest: RuntimeManifest; - }) => string | null; - installRuntime?: (params: { - runtimeDir: string; - manifest: RuntimeManifest; - env: NodeJS.ProcessEnv; - logger?: LanceDbRuntimeLogger; - }) => Promise; - } = {}, -) { - return createLanceDbRuntimeLoader({ - env: overrides.env ?? ({} as NodeJS.ProcessEnv), - resolveStateDir: () => "/tmp/openclaw-state", - runtimeManifest: TEST_RUNTIME_MANIFEST, - importBundled: - overrides.importBundled ?? - (async () => { - throw new Error("Cannot find package '@lancedb/lancedb'"); - }), - importResolved: overrides.importResolved ?? (async () => createMockModule()), - resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? (() => null), - installRuntime: - overrides.installRuntime ?? - (async ({ runtimeDir }: { runtimeDir: string }) => - `${runtimeDir}/node_modules/@lancedb/lancedb/index.js`), - }); -} - -describe("lancedb runtime loader", () => { - it("uses the bundled module when it is already available", async () => { - const bundledModule = createMockModule(); - const importBundled = vi.fn(async () => bundledModule); - const importResolved = vi.fn(async () => createMockModule()); - const resolveRuntimeEntry = vi.fn(() => null); - const installRuntime = vi.fn(async () => "/tmp/openclaw-state/plugin-runtimes/lancedb.js"); - const loader = createLoader({ - importBundled, - importResolved, - resolveRuntimeEntry, - installRuntime, - }); - - await expect(loader.load()).resolves.toBe(bundledModule); - - expect(resolveRuntimeEntry).not.toHaveBeenCalled(); - expect(installRuntime).not.toHaveBeenCalled(); - expect(importResolved).not.toHaveBeenCalled(); - }); - - it("reuses an existing user runtime install before attempting a reinstall", async () => { - const runtimeModule = createMockModule(); - const importResolved = vi.fn(async () => runtimeModule); - const resolveRuntimeEntry = vi.fn( - () => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js", - ); - const installRuntime = vi.fn( - async () => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js", - ); - const loader = createLoader({ - importResolved, - resolveRuntimeEntry, - installRuntime, - }); - - await expect(loader.load()).resolves.toBe(runtimeModule); - - expect(resolveRuntimeEntry).toHaveBeenCalledWith( - expect.objectContaining({ - runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb", - }), - ); - expect(installRuntime).not.toHaveBeenCalled(); - }); - - it("installs LanceDB into user state when the bundled runtime is unavailable", async () => { - const runtimeModule = createMockModule(); - const logger: LanceDbRuntimeLogger = { - warn: vi.fn(), - info: vi.fn(), - }; - const importResolved = vi.fn(async () => runtimeModule); - const resolveRuntimeEntry = vi.fn(() => null); - const installRuntime = vi.fn( - async ({ runtimeDir }: { runtimeDir: string }) => - `${runtimeDir}/node_modules/@lancedb/lancedb/index.js`, - ); - const loader = createLoader({ - importResolved, - resolveRuntimeEntry, - installRuntime, - }); - - await expect(loader.load(logger)).resolves.toBe(runtimeModule); - - expect(installRuntime).toHaveBeenCalledWith( - expect.objectContaining({ - runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb", - manifest: TEST_RUNTIME_MANIFEST, - }), - ); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining( - "installing runtime deps under /tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb", - ), - ); - }); - - it("fails fast in nix mode instead of attempting auto-install", async () => { - const installRuntime = vi.fn( - async ({ runtimeDir }: { runtimeDir: string }) => - `${runtimeDir}/node_modules/@lancedb/lancedb/index.js`, - ); - const loader = createLoader({ - env: { OPENCLAW_NIX_MODE: "1" } as NodeJS.ProcessEnv, - installRuntime, - }); - - await expect(loader.load()).rejects.toThrow( - "memory-lancedb: failed to load LanceDB and Nix mode disables auto-install.", - ); - expect(installRuntime).not.toHaveBeenCalled(); - }); - - it("clears the cached failure so later calls can retry the install", async () => { - const runtimeModule = createMockModule(); - const installRuntime = vi - .fn() - .mockRejectedValueOnce(new Error("network down")) - .mockResolvedValueOnce( - "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb/node_modules/@lancedb/lancedb/index.js", - ); - const importResolved = vi.fn(async () => runtimeModule); - const loader = createLoader({ - installRuntime, - importResolved, - }); - - await expect(loader.load()).rejects.toThrow("network down"); - await expect(loader.load()).resolves.toBe(runtimeModule); - - expect(installRuntime).toHaveBeenCalledTimes(2); - }); -}); diff --git a/extensions/openai/image-generation-provider.test.ts b/extensions/openai/image-generation-provider.test.ts deleted file mode 100644 index 397b30335f3..00000000000 --- a/extensions/openai/image-generation-provider.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import * as providerAuth from "openclaw/plugin-sdk/provider-auth"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js"; - -describe("OpenAI image-generation provider", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("generates PNG buffers from the OpenAI Images API", async () => { - const resolveApiKeySpy = vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ - apiKey: "sk-test", - source: "env", - mode: "api-key", - }); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - data: [ - { - b64_json: Buffer.from("png-data").toString("base64"), - revised_prompt: "revised", - }, - ], - }), - }); - vi.stubGlobal("fetch", fetchMock); - - const provider = buildOpenAIImageGenerationProvider(); - const authStore = { version: 1, profiles: {} }; - const result = await provider.generateImage({ - provider: "openai", - model: "gpt-image-1", - prompt: "draw a cat", - cfg: {}, - authStore, - }); - - expect(resolveApiKeySpy).toHaveBeenCalledWith( - expect.objectContaining({ - provider: "openai", - store: authStore, - }), - ); - expect(fetchMock).toHaveBeenCalledWith( - "https://api.openai.com/v1/images/generations", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - model: "gpt-image-1", - prompt: "draw a cat", - n: 1, - size: "1024x1024", - }), - }), - ); - expect(result).toEqual({ - images: [ - { - buffer: Buffer.from("png-data"), - mimeType: "image/png", - fileName: "image-1.png", - revisedPrompt: "revised", - }, - ], - model: "gpt-image-1", - }); - }); - - it("rejects reference-image edits for now", async () => { - const provider = buildOpenAIImageGenerationProvider(); - - await expect( - provider.generateImage({ - provider: "openai", - model: "gpt-image-1", - prompt: "Edit this image", - cfg: {}, - inputImages: [{ buffer: Buffer.from("x"), mimeType: "image/png" }], - }), - ).rejects.toThrow("does not support reference-image edits"); - }); -}); diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts index 92b137e3024..0b1d6f4bfb2 100644 --- a/extensions/openai/index.test.ts +++ b/extensions/openai/index.test.ts @@ -2,7 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import OpenAI from "openai"; -import { describe, expect, it } from "vitest"; +import * as providerAuth from "openclaw/plugin-sdk/provider-auth"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../src/config/config.js"; import { loadConfig } from "../../src/config/config.js"; import { encodePngRgba, fillPixel } from "../../src/media/png-encode.js"; @@ -11,8 +12,24 @@ import { registerProviderPlugin, requireRegisteredProvider, } from "../../test/helpers/extensions/provider-registration.js"; +import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js"; import plugin from "./index.js"; +const runtimeMocks = vi.hoisted(() => ({ + ensureGlobalUndiciEnvProxyDispatcher: vi.fn(), + getOAuthApiKey: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({ + ensureGlobalUndiciEnvProxyDispatcher: runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher, +})); + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: runtimeMocks.getOAuthApiKey, +})); + +import { getOAuthApiKey as getCodexOAuthApiKey } from "./openai-codex-provider.runtime.js"; + const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENAI_PLUGIN_MODEL?.trim() || "gpt-5.4-nano"; const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_OPENAI_IMAGE_MODEL?.trim() || "gpt-image-1"; @@ -164,6 +181,14 @@ async function createTempAgentDir(): Promise { } describe("openai plugin", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it("registers the expected provider surfaces", () => { const { providers, speechProviders, mediaProviders, imageProviders } = registerOpenAIPlugin(); @@ -179,6 +204,109 @@ describe("openai plugin", () => { expect(mediaProviders).toHaveLength(1); expect(imageProviders).toHaveLength(1); }); + + it("generates PNG buffers from the OpenAI Images API", async () => { + const resolveApiKeySpy = vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "sk-test", + source: "env", + mode: "api-key", + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { + b64_json: Buffer.from("png-data").toString("base64"), + revised_prompt: "revised", + }, + ], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildOpenAIImageGenerationProvider(); + const authStore = { version: 1, profiles: {} }; + const result = await provider.generateImage({ + provider: "openai", + model: "gpt-image-1", + prompt: "draw a cat", + cfg: {}, + authStore, + }); + + expect(resolveApiKeySpy).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + store: authStore, + }), + ); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.openai.com/v1/images/generations", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + model: "gpt-image-1", + prompt: "draw a cat", + n: 1, + size: "1024x1024", + }), + }), + ); + expect(result).toEqual({ + images: [ + { + buffer: Buffer.from("png-data"), + mimeType: "image/png", + fileName: "image-1.png", + revisedPrompt: "revised", + }, + ], + model: "gpt-image-1", + }); + }); + + it("rejects reference-image edits for now", async () => { + const provider = buildOpenAIImageGenerationProvider(); + + await expect( + provider.generateImage({ + provider: "openai", + model: "gpt-image-1", + prompt: "Edit this image", + cfg: {}, + inputImages: [{ buffer: Buffer.from("x"), mimeType: "image/png" }], + }), + ).rejects.toThrow("does not support reference-image edits"); + }); + + it("bootstraps the env proxy dispatcher before refreshing oauth credentials", async () => { + const refreshed = { + newCredentials: { + access: "next-access", + refresh: "next-refresh", + expires: Date.now() + 60_000, + }, + }; + runtimeMocks.getOAuthApiKey.mockResolvedValue(refreshed); + + await expect( + getCodexOAuthApiKey("openai-codex", { + "openai-codex": { + provider: "openai-codex", + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: Date.now(), + }, + }), + ).resolves.toBe(refreshed); + + expect(runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher).toHaveBeenCalledOnce(); + expect(runtimeMocks.getOAuthApiKey).toHaveBeenCalledOnce(); + expect( + runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher.mock.invocationCallOrder[0], + ).toBeLessThan(runtimeMocks.getOAuthApiKey.mock.invocationCallOrder[0]); + }); }); describeLive("openai plugin live", () => { diff --git a/extensions/openai/openai-codex-provider.runtime.test.ts b/extensions/openai/openai-codex-provider.runtime.test.ts deleted file mode 100644 index 8b26fbc767d..00000000000 --- a/extensions/openai/openai-codex-provider.runtime.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - ensureGlobalUndiciEnvProxyDispatcher: vi.fn(), - getOAuthApiKey: vi.fn(), -})); - -vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({ - ensureGlobalUndiciEnvProxyDispatcher: mocks.ensureGlobalUndiciEnvProxyDispatcher, -})); - -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: mocks.getOAuthApiKey, -})); - -import { getOAuthApiKey } from "./openai-codex-provider.runtime.js"; - -describe("openai-codex-provider.runtime", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("bootstraps the env proxy dispatcher before refreshing oauth credentials", async () => { - const refreshed = { - newCredentials: { - access: "next-access", - refresh: "next-refresh", - expires: Date.now() + 60_000, - }, - }; - mocks.getOAuthApiKey.mockResolvedValue(refreshed); - - await expect( - getOAuthApiKey("openai-codex", { - "openai-codex": { - provider: "openai-codex", - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: Date.now(), - }, - }), - ).resolves.toBe(refreshed); - - expect(mocks.ensureGlobalUndiciEnvProxyDispatcher).toHaveBeenCalledOnce(); - expect(mocks.getOAuthApiKey).toHaveBeenCalledOnce(); - expect(mocks.ensureGlobalUndiciEnvProxyDispatcher.mock.invocationCallOrder[0]).toBeLessThan( - mocks.getOAuthApiKey.mock.invocationCallOrder[0], - ); - }); -}); diff --git a/extensions/tavily/index.test.ts b/extensions/tavily/index.test.ts deleted file mode 100644 index 0d7fd39564b..00000000000 --- a/extensions/tavily/index.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import plugin from "./index.js"; - -describe("tavily plugin", () => { - it("registers web search provider and two tools", () => { - const registrations: { - webSearchProviders: unknown[]; - tools: unknown[]; - } = { webSearchProviders: [], tools: [] }; - - const mockApi = { - registerWebSearchProvider(provider: unknown) { - registrations.webSearchProviders.push(provider); - }, - registerTool(tool: unknown) { - registrations.tools.push(tool); - }, - config: {}, - }; - - plugin.register(mockApi as never); - - expect(plugin.id).toBe("tavily"); - expect(plugin.name).toBe("Tavily Plugin"); - expect(registrations.webSearchProviders).toHaveLength(1); - expect(registrations.tools).toHaveLength(2); - - const provider = registrations.webSearchProviders[0] as Record; - expect(provider.id).toBe("tavily"); - expect(provider.autoDetectOrder).toBe(70); - expect(provider.envVars).toEqual(["TAVILY_API_KEY"]); - - const toolNames = registrations.tools.map((t) => (t as Record).name); - expect(toolNames).toContain("tavily_search"); - expect(toolNames).toContain("tavily_extract"); - }); -}); diff --git a/extensions/tavily/src/tavily-tools.test.ts b/extensions/tavily/src/tavily-tools.test.ts index 93b2f3308e4..3a6c49e6083 100644 --- a/extensions/tavily/src/tavily-tools.test.ts +++ b/extensions/tavily/src/tavily-tools.test.ts @@ -1,6 +1,7 @@ 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 plugin from "../index.js"; import { DEFAULT_TAVILY_BASE_URL, DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS, @@ -63,6 +64,39 @@ describe("tavily tools", () => { expect(applied.plugins?.entries?.tavily?.enabled).toBe(true); }); + it("registers web search provider and two tools", () => { + const registrations: { + webSearchProviders: unknown[]; + tools: unknown[]; + } = { webSearchProviders: [], tools: [] }; + + const mockApi = { + registerWebSearchProvider(provider: unknown) { + registrations.webSearchProviders.push(provider); + }, + registerTool(tool: unknown) { + registrations.tools.push(tool); + }, + config: {}, + }; + + plugin.register(mockApi as never); + + expect(plugin.id).toBe("tavily"); + expect(plugin.name).toBe("Tavily Plugin"); + expect(registrations.webSearchProviders).toHaveLength(1); + expect(registrations.tools).toHaveLength(2); + + const provider = registrations.webSearchProviders[0] as Record; + expect(provider.id).toBe("tavily"); + expect(provider.autoDetectOrder).toBe(70); + expect(provider.envVars).toEqual(["TAVILY_API_KEY"]); + + const toolNames = registrations.tools.map((t) => (t as Record).name); + expect(toolNames).toContain("tavily_search"); + expect(toolNames).toContain("tavily_extract"); + }); + it("maps generic provider args into Tavily search params", async () => { const provider = createTavilyWebSearchProvider(); const tool = provider.createTool({ diff --git a/extensions/xai/provider-models.test.ts b/extensions/xai/provider-models.test.ts deleted file mode 100644 index 8dacad8cf1d..00000000000 --- a/extensions/xai/provider-models.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveXaiCatalogEntry } from "./model-definitions.js"; -import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; - -describe("xai provider models", () => { - it("publishes the newer Grok fast and code models in the bundled catalog", () => { - expect(resolveXaiCatalogEntry("grok-4-1-fast")).toMatchObject({ - id: "grok-4-1-fast", - reasoning: true, - input: ["text", "image"], - contextWindow: 2_000_000, - maxTokens: 30_000, - }); - expect(resolveXaiCatalogEntry("grok-code-fast-1")).toMatchObject({ - id: "grok-code-fast-1", - reasoning: true, - contextWindow: 256_000, - maxTokens: 10_000, - }); - }); - - it("publishes Grok 4.20 reasoning and non-reasoning models", () => { - expect(resolveXaiCatalogEntry("grok-4.20-beta-latest-reasoning")).toMatchObject({ - id: "grok-4.20-beta-latest-reasoning", - reasoning: true, - input: ["text", "image"], - contextWindow: 2_000_000, - }); - expect(resolveXaiCatalogEntry("grok-4.20-beta-latest-non-reasoning")).toMatchObject({ - id: "grok-4.20-beta-latest-non-reasoning", - reasoning: false, - contextWindow: 2_000_000, - }); - }); - - it("keeps older Grok aliases resolving with current limits", () => { - expect(resolveXaiCatalogEntry("grok-4-1-fast-reasoning")).toMatchObject({ - id: "grok-4-1-fast-reasoning", - reasoning: true, - contextWindow: 2_000_000, - maxTokens: 30_000, - }); - expect(resolveXaiCatalogEntry("grok-4.20-reasoning")).toMatchObject({ - id: "grok-4.20-reasoning", - reasoning: true, - contextWindow: 2_000_000, - maxTokens: 30_000, - }); - }); - - it("publishes the remaining Grok 3 family that Pi still carries", () => { - expect(resolveXaiCatalogEntry("grok-3-mini-fast")).toMatchObject({ - id: "grok-3-mini-fast", - reasoning: true, - contextWindow: 131_072, - maxTokens: 8_192, - }); - expect(resolveXaiCatalogEntry("grok-3-fast")).toMatchObject({ - id: "grok-3-fast", - reasoning: false, - contextWindow: 131_072, - maxTokens: 8_192, - }); - }); - - it("marks current Grok families as modern while excluding multi-agent ids", () => { - expect(isModernXaiModel("grok-4.20-beta-latest-reasoning")).toBe(true); - expect(isModernXaiModel("grok-code-fast-1")).toBe(true); - expect(isModernXaiModel("grok-3-mini-fast")).toBe(true); - expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false); - }); - - it("builds forward-compatible runtime models for newer Grok ids", () => { - const grok41 = resolveXaiForwardCompatModel({ - providerId: "xai", - ctx: { - provider: "xai", - modelId: "grok-4-1-fast", - modelRegistry: { find: () => null } as never, - providerConfig: { - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - }, - }, - }); - const grok420 = resolveXaiForwardCompatModel({ - providerId: "xai", - ctx: { - provider: "xai", - modelId: "grok-4.20-beta-latest-reasoning", - modelRegistry: { find: () => null } as never, - providerConfig: { - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - }, - }, - }); - const grok3Mini = resolveXaiForwardCompatModel({ - providerId: "xai", - ctx: { - provider: "xai", - modelId: "grok-3-mini-fast", - modelRegistry: { find: () => null } as never, - providerConfig: { - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - }, - }, - }); - - expect(grok41).toMatchObject({ - provider: "xai", - id: "grok-4-1-fast", - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - contextWindow: 2_000_000, - maxTokens: 30_000, - }); - expect(grok420).toMatchObject({ - provider: "xai", - id: "grok-4.20-beta-latest-reasoning", - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - input: ["text", "image"], - contextWindow: 2_000_000, - maxTokens: 30_000, - }); - expect(grok3Mini).toMatchObject({ - provider: "xai", - id: "grok-3-mini-fast", - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - reasoning: true, - contextWindow: 131_072, - maxTokens: 8_192, - }); - }); - - it("refuses the unsupported multi-agent endpoint ids", () => { - const model = resolveXaiForwardCompatModel({ - providerId: "xai", - ctx: { - provider: "xai", - modelId: "grok-4.20-multi-agent-experimental-beta-0304", - modelRegistry: { find: () => null } as never, - providerConfig: { - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - }, - }, - }); - - expect(model).toBeUndefined(); - }); -}); diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 26bd7521ab2..6b015ebd118 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -4,6 +4,8 @@ import { } from "openclaw/plugin-sdk/provider-web-search"; import { describe, expect, it } from "vitest"; import { withEnv } from "../../test/helpers/extensions/env.js"; +import { resolveXaiCatalogEntry } from "./model-definitions.js"; +import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; import { __testing as grokProviderTesting } from "./src/grok-web-search-provider.js"; import { __testing } from "./web-search.js"; @@ -159,3 +161,157 @@ describe("xai web search response parsing", () => { expect(result.annotationCitations).toEqual(["https://example.com/direct"]); }); }); + +describe("xai provider models", () => { + it("publishes the newer Grok fast and code models in the bundled catalog", () => { + expect(resolveXaiCatalogEntry("grok-4-1-fast")).toMatchObject({ + id: "grok-4-1-fast", + reasoning: true, + input: ["text", "image"], + contextWindow: 2_000_000, + maxTokens: 30_000, + }); + expect(resolveXaiCatalogEntry("grok-code-fast-1")).toMatchObject({ + id: "grok-code-fast-1", + reasoning: true, + contextWindow: 256_000, + maxTokens: 10_000, + }); + }); + + it("publishes Grok 4.20 reasoning and non-reasoning models", () => { + expect(resolveXaiCatalogEntry("grok-4.20-beta-latest-reasoning")).toMatchObject({ + id: "grok-4.20-beta-latest-reasoning", + reasoning: true, + input: ["text", "image"], + contextWindow: 2_000_000, + }); + expect(resolveXaiCatalogEntry("grok-4.20-beta-latest-non-reasoning")).toMatchObject({ + id: "grok-4.20-beta-latest-non-reasoning", + reasoning: false, + contextWindow: 2_000_000, + }); + }); + + it("keeps older Grok aliases resolving with current limits", () => { + expect(resolveXaiCatalogEntry("grok-4-1-fast-reasoning")).toMatchObject({ + id: "grok-4-1-fast-reasoning", + reasoning: true, + contextWindow: 2_000_000, + maxTokens: 30_000, + }); + expect(resolveXaiCatalogEntry("grok-4.20-reasoning")).toMatchObject({ + id: "grok-4.20-reasoning", + reasoning: true, + contextWindow: 2_000_000, + maxTokens: 30_000, + }); + }); + + it("publishes the remaining Grok 3 family that Pi still carries", () => { + expect(resolveXaiCatalogEntry("grok-3-mini-fast")).toMatchObject({ + id: "grok-3-mini-fast", + reasoning: true, + contextWindow: 131_072, + maxTokens: 8_192, + }); + expect(resolveXaiCatalogEntry("grok-3-fast")).toMatchObject({ + id: "grok-3-fast", + reasoning: false, + contextWindow: 131_072, + maxTokens: 8_192, + }); + }); + + it("marks current Grok families as modern while excluding multi-agent ids", () => { + expect(isModernXaiModel("grok-4.20-beta-latest-reasoning")).toBe(true); + expect(isModernXaiModel("grok-code-fast-1")).toBe(true); + expect(isModernXaiModel("grok-3-mini-fast")).toBe(true); + expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false); + }); + + it("builds forward-compatible runtime models for newer Grok ids", () => { + const grok41 = resolveXaiForwardCompatModel({ + providerId: "xai", + ctx: { + provider: "xai", + modelId: "grok-4-1-fast", + modelRegistry: { find: () => null } as never, + providerConfig: { + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + }, + }, + }); + const grok420 = resolveXaiForwardCompatModel({ + providerId: "xai", + ctx: { + provider: "xai", + modelId: "grok-4.20-beta-latest-reasoning", + modelRegistry: { find: () => null } as never, + providerConfig: { + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + }, + }, + }); + const grok3Mini = resolveXaiForwardCompatModel({ + providerId: "xai", + ctx: { + provider: "xai", + modelId: "grok-3-mini-fast", + modelRegistry: { find: () => null } as never, + providerConfig: { + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + }, + }, + }); + + expect(grok41).toMatchObject({ + provider: "xai", + id: "grok-4-1-fast", + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + contextWindow: 2_000_000, + maxTokens: 30_000, + }); + expect(grok420).toMatchObject({ + provider: "xai", + id: "grok-4.20-beta-latest-reasoning", + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + contextWindow: 2_000_000, + maxTokens: 30_000, + }); + expect(grok3Mini).toMatchObject({ + provider: "xai", + id: "grok-3-mini-fast", + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + contextWindow: 131_072, + maxTokens: 8_192, + }); + }); + + it("refuses the unsupported multi-agent endpoint ids", () => { + const model = resolveXaiForwardCompatModel({ + providerId: "xai", + ctx: { + provider: "xai", + modelId: "grok-4.20-multi-agent-experimental-beta-0304", + modelRegistry: { find: () => null } as never, + providerConfig: { + api: "openai-completions", + baseUrl: "https://api.x.ai/v1", + }, + }, + }); + + expect(model).toBeUndefined(); + }); +});