diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index d24890d45fb..5cee897c568 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -4,9 +4,8 @@ const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn((_cfg?: unknown, _agentId?: unknown) => "/tmp/openclaw-workspace"), ); const resolveDefaultAgentId = vi.hoisted(() => vi.fn((_cfg?: unknown) => "default")); -const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((_opts?: unknown): unknown[] => [])); -const getChannelPluginCatalogEntry = vi.hoisted(() => - vi.fn((_id?: unknown, _opts?: unknown) => undefined), +const listTrustedChannelPluginCatalogEntries = vi.hoisted(() => + vi.fn((_params?: unknown): unknown[] => []), ); const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => undefined)); const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); @@ -30,12 +29,6 @@ vi.mock("../agents/agent-scope.js", () => ({ resolveDefaultAgentId: (cfg?: unknown) => resolveDefaultAgentId(cfg), })); -vi.mock("../channels/plugins/catalog.js", () => ({ - listChannelPluginCatalogEntries: (opts?: unknown) => listChannelPluginCatalogEntries(opts), - getChannelPluginCatalogEntry: (id?: unknown, opts?: unknown) => - getChannelPluginCatalogEntry(id, opts), -})); - vi.mock("../channels/plugins/setup-registry.js", () => ({ getChannelSetupPlugin: (channel?: unknown) => getChannelSetupPlugin(channel), listChannelSetupPlugins: () => listChannelSetupPlugins(), @@ -63,6 +56,11 @@ vi.mock("../commands/channel-setup/registry.js", () => ({ resolveChannelSetupWizardAdapterForPlugin: () => undefined, })); +vi.mock("../commands/channel-setup/trusted-catalog.js", () => ({ + listTrustedChannelPluginCatalogEntries: (params?: unknown) => + listTrustedChannelPluginCatalogEntries(params), +})); + vi.mock("../config/channel-configured.js", () => ({ isChannelConfigured: (cfg?: unknown, channel?: unknown) => isChannelConfigured(cfg, channel), })); @@ -90,10 +88,11 @@ describe("setupChannels workspace shadow exclusion", () => { vi.clearAllMocks(); resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace"); resolveDefaultAgentId.mockReturnValue("default"); - listChannelPluginCatalogEntries.mockReturnValue([ + listTrustedChannelPluginCatalogEntries.mockReturnValue([ { id: "telegram", pluginId: "@openclaw/telegram-plugin", + origin: "bundled", }, ]); getChannelSetupPlugin.mockReturnValue(undefined); @@ -112,13 +111,7 @@ describe("setupChannels workspace shadow exclusion", () => { isChannelConfigured.mockReturnValue(true); }); - it("preloads configured external plugins from the bundled fallback for untrusted shadows", async () => { - listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) => - (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace - ? [{ id: "telegram", pluginId: "@openclaw/telegram-plugin", origin: "bundled" }] - : [{ id: "telegram", pluginId: "evil-telegram-shadow", origin: "workspace" }], - ); - + it("preloads configured external plugins from the trusted catalog boundary", async () => { await setupChannels( {} as never, {} as never, @@ -128,10 +121,12 @@ describe("setupChannels workspace shadow exclusion", () => { } as never, ); - const fallbackCall = listChannelPluginCatalogEntries.mock.calls.find( - ([opts]) => (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace === true, + expect(listTrustedChannelPluginCatalogEntries).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: {}, + workspaceDir: "/tmp/openclaw-workspace", + }), ); - expect(fallbackCall).toBeTruthy(); expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( expect.objectContaining({ channel: "telegram", @@ -142,7 +137,7 @@ describe("setupChannels workspace shadow exclusion", () => { }); it("keeps trusted workspace overrides eligible during preload", async () => { - listChannelPluginCatalogEntries.mockReturnValue([ + listTrustedChannelPluginCatalogEntries.mockReturnValue([ { id: "telegram", pluginId: "trusted-telegram-shadow", origin: "workspace" }, ]); diff --git a/src/memory-host-sdk/host/embeddings-gemini.test.ts b/src/memory-host-sdk/host/embeddings-gemini.test.ts index e01b982db3f..c0474ac01fe 100644 --- a/src/memory-host-sdk/host/embeddings-gemini.test.ts +++ b/src/memory-host-sdk/host/embeddings-gemini.test.ts @@ -12,14 +12,13 @@ import { } from "./embeddings-gemini.js"; import { createGeminiBatchFetchMock, - createGeminiFetchMock, + createJsonResponseFetchMock, installFetchMock, mockResolvedProviderKey, parseFetchBody, readFirstFetchRequest, type JsonFetchMock, } from "./embeddings-provider.test-support.js"; -import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; vi.mock("../../agents/model-auth.js", async () => { const { createModelAuthMockModule } = await import("../../test-utils/model-auth-mock.js"); @@ -42,7 +41,6 @@ async function createProviderWithFetch( options: Partial[0]> & { model: string }, ) { installFetchMock(fetchMock as unknown as typeof globalThis.fetch); - mockPublicPinnedHostname(); mockResolvedProviderKey(authModule.resolveApiKeyForProvider); const { provider } = await createGeminiEmbeddingProvider({ config: {} as never, @@ -169,55 +167,26 @@ describe("gemini embedding provider", () => { expect(parseFetchBody(legacyFetch, 0)).not.toHaveProperty("outputDimensionality"); expect(parseFetchBody(legacyFetch, 1)).not.toHaveProperty("outputDimensionality"); - const v2QueryFetch = createGeminiFetchMock([3, 4, Number.NaN]); - const v2QueryProvider = await createProviderWithFetch(v2QueryFetch, { - model: "gemini-embedding-2-preview", + const v2Fetch = createJsonResponseFetchMock((input) => { + const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url; + return url.endsWith(":batchEmbedContents") + ? { + embeddings: Array.from({ length: 2 }, () => ({ + values: [0, Number.POSITIVE_INFINITY, 5], + })), + } + : { embedding: { values: [3, 4, Number.NaN] } }; }); - await expect(v2QueryProvider.embedQuery(" ")).resolves.toEqual([]); - await expect(v2QueryProvider.embedBatch([])).resolves.toEqual([]); - await expect(v2QueryProvider.embedQuery("test query")).resolves.toEqual([0.6, 0.8, 0]); - - const v2BatchFetch = createGeminiBatchFetchMock(2, [0, Number.POSITIVE_INFINITY, 5]); - const v2BatchProvider = await createProviderWithFetch(v2BatchFetch, { - model: "gemini-embedding-2-preview", - }); - const batch = await v2BatchProvider.embedBatch(["text1", "text2"]); - expect(batch).toEqual([ - [0, 0, 1], - [0, 0, 1], - ]); - - expect(parseFetchBody(v2QueryFetch)).toMatchObject({ - outputDimensionality: 3072, - taskType: "RETRIEVAL_QUERY", - content: { parts: [{ text: "test query" }] }, - }); - expect(parseFetchBody(v2BatchFetch).requests).toEqual([ - { - model: "models/gemini-embedding-2-preview", - content: { parts: [{ text: "text1" }] }, - taskType: "RETRIEVAL_DOCUMENT", - outputDimensionality: 3072, - }, - { - model: "models/gemini-embedding-2-preview", - content: { parts: [{ text: "text2" }] }, - taskType: "RETRIEVAL_DOCUMENT", - outputDimensionality: 3072, - }, - ]); - }); - - it("supports custom dimensions, task type, multimodal inputs, and endpoint URL", async () => { - const fetchMock = createGeminiBatchFetchMock(2); - const provider = await createProviderWithFetch(fetchMock, { + const v2Provider = await createProviderWithFetch(v2Fetch, { model: "gemini-embedding-2-preview", outputDimensionality: 768, taskType: "SEMANTIC_SIMILARITY", }); + await expect(v2Provider.embedQuery(" ")).resolves.toEqual([]); + await expect(v2Provider.embedBatch([])).resolves.toEqual([]); + await expect(v2Provider.embedQuery("test query")).resolves.toEqual([0.6, 0.8, 0]); - await provider.embedQuery("test"); - const structuredBatch = await provider.embedBatchInputs?.([ + const structuredBatch = await v2Provider.embedBatchInputs?.([ { text: "Image file: diagram.png", parts: [ @@ -234,19 +203,20 @@ describe("gemini embedding provider", () => { }, ]); expect(structuredBatch).toEqual([ - [0.2672612419124244, 0.5345224838248488, 0.8017837257372732], - [0.2672612419124244, 0.5345224838248488, 0.8017837257372732], + [0, 0, 1], + [0, 0, 1], ]); - const { url } = readFirstFetchRequest(fetchMock); + const { url } = readFirstFetchRequest(v2Fetch); expect(url).toBe( "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2-preview:embedContent", ); - expect(parseFetchBody(fetchMock, 0)).toMatchObject({ + expect(parseFetchBody(v2Fetch, 0)).toMatchObject({ outputDimensionality: 768, taskType: "SEMANTIC_SIMILARITY", + content: { parts: [{ text: "test query" }] }, }); - expect(parseFetchBody(fetchMock, 1).requests).toEqual([ + expect(parseFetchBody(v2Fetch, 1).requests).toEqual([ { model: "models/gemini-embedding-2-preview", content: { diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index 94ca03f9479..bd3ab378c97 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -7,6 +7,48 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import { resolveOAuthDir } from "../config/paths.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { withEnvAsync } from "../test-utils/env.js"; + +vi.mock("../channels/plugins/pairing.js", () => ({ + getPairingAdapter: () => null, +})); + +vi.mock("../infra/file-lock.js", () => ({ + withFileLock: async (_path: string, _options: unknown, fn: () => unknown) => await fn(), +})); + +vi.mock("../plugin-sdk/json-store.js", async () => { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + + return { + readJsonFileWithFallback: async (filePath: string, fallback: T) => { + let raw: string; + try { + raw = await fs.readFile(filePath, "utf8"); + } catch (err) { + if ((err as { code?: string }).code === "ENOENT") { + return { value: fallback, exists: false }; + } + return { value: fallback, exists: false }; + } + try { + const parsed = JSON.parse(raw) as T; + return { + value: parsed ?? fallback, + exists: true, + }; + } catch { + return { value: fallback, exists: true }; + } + }, + writeJsonFileAtomically: async (filePath: string, value: unknown) => { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + }, + }; +}); + +import * as jsonStore from "../plugin-sdk/json-store.js"; import { addChannelAllowFromStoreEntry, clearPairingAllowFromReadCacheForTest, @@ -552,7 +594,7 @@ describe("pairing store", () => { await withTempStateDir(async (stateDir) => { for (const variant of [ { - createReadSpy: () => vi.spyOn(fs, "readFile"), + createReadSpy: () => vi.spyOn(jsonStore, "readJsonFileWithFallback"), readAllowFrom: () => readChannelAllowFromStore("telegram", process.env, "yy"), }, { diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index f3e3bab9bbc..59f6b11d488 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -2,7 +2,6 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { i18n } from "../../i18n/index.ts"; import { getSafeLocalStorage } from "../../local-storage.ts"; import { renderChatSessionSelect } from "../app-render.helpers.ts"; import type { AppViewState } from "../app-view-state.ts"; @@ -18,7 +17,6 @@ import type { GatewayBrowserClient } from "../gateway.ts"; import type { ModelCatalogEntry } from "../types.ts"; import type { SessionsListResult } from "../types.ts"; import { renderChat, type ChatProps } from "./chat.ts"; -import { renderOverview, type OverviewProps } from "./overview.ts"; function createSessions(): SessionsListResult { return { @@ -197,60 +195,6 @@ function createProps(overrides: Partial = {}): ChatProps { }; } -function createOverviewProps(overrides: Partial = {}): OverviewProps { - return { - warnQueryToken: false, - connected: false, - hello: null, - settings: { - gatewayUrl: "", - token: "", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "claw", - themeMode: "system", - chatFocusMode: false, - chatShowThinking: true, - chatShowToolCalls: true, - splitRatio: 0.6, - navCollapsed: false, - navWidth: 220, - navGroupsCollapsed: {}, - borderRadius: 50, - locale: "en", - }, - password: "", - lastError: null, - lastErrorCode: null, - presenceCount: 0, - sessionsCount: null, - cronEnabled: null, - cronNext: null, - lastChannelsRefresh: null, - modelAuthStatus: null, - usageResult: null, - sessionsResult: null, - skillsReport: null, - cronJobs: [], - cronStatus: null, - attentionItems: [], - eventLog: [], - overviewLogLines: [], - showGatewayToken: false, - showGatewayPassword: false, - onSettingsChange: () => undefined, - onPasswordChange: () => undefined, - onSessionKeyChange: () => undefined, - onToggleGatewayTokenVisibility: () => undefined, - onToggleGatewayPasswordVisibility: () => undefined, - onConnect: () => undefined, - onRefresh: () => undefined, - onNavigate: () => undefined, - onRefreshLogs: () => undefined, - ...overrides, - }; -} - describe("chat view", () => { it("renders BTW side results outside transcript history", () => { const container = document.createElement("div"); @@ -543,37 +487,6 @@ describe("chat view", () => { expect(groupedLogo?.getAttribute("src")).toBe("/openclaw/favicon.svg"); }); - it("keeps the persisted overview locale selected before i18n hydration finishes", async () => { - const container = document.createElement("div"); - const props = createOverviewProps({ - settings: { - ...createOverviewProps().settings, - locale: "zh-CN", - }, - }); - - getSafeLocalStorage()?.clear(); - await i18n.setLocale("en"); - - render(renderOverview(props), container); - await Promise.resolve(); - - let select = container.querySelector("select"); - expect(i18n.getLocale()).toBe("en"); - expect(select?.value).toBe("zh-CN"); - expect(select?.selectedOptions[0]?.textContent?.trim()).toBe("简体中文 (Simplified Chinese)"); - - await i18n.setLocale("zh-CN"); - render(renderOverview(props), container); - await Promise.resolve(); - - select = container.querySelector("select"); - expect(select?.value).toBe("zh-CN"); - expect(select?.selectedOptions[0]?.textContent?.trim()).toBe("简体中文 (简体中文)"); - - await i18n.setLocale("en"); - }); - it("renders compacting indicator as a badge", () => { const container = document.createElement("div"); render( diff --git a/ui/src/ui/views/overview.render.test.ts b/ui/src/ui/views/overview.render.test.ts new file mode 100644 index 00000000000..d10e0960fda --- /dev/null +++ b/ui/src/ui/views/overview.render.test.ts @@ -0,0 +1,94 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { i18n } from "../../i18n/index.ts"; +import { getSafeLocalStorage } from "../../local-storage.ts"; +import { renderOverview, type OverviewProps } from "./overview.ts"; + +function createOverviewProps(overrides: Partial = {}): OverviewProps { + return { + warnQueryToken: false, + connected: false, + hello: null, + settings: { + gatewayUrl: "", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + borderRadius: 50, + locale: "en", + }, + password: "", + lastError: null, + lastErrorCode: null, + presenceCount: 0, + sessionsCount: null, + cronEnabled: null, + cronNext: null, + lastChannelsRefresh: null, + modelAuthStatus: null, + usageResult: null, + sessionsResult: null, + skillsReport: null, + cronJobs: [], + cronStatus: null, + attentionItems: [], + eventLog: [], + overviewLogLines: [], + showGatewayToken: false, + showGatewayPassword: false, + onSettingsChange: () => undefined, + onPasswordChange: () => undefined, + onSessionKeyChange: () => undefined, + onToggleGatewayTokenVisibility: () => undefined, + onToggleGatewayPasswordVisibility: () => undefined, + onConnect: () => undefined, + onRefresh: () => undefined, + onNavigate: () => undefined, + onRefreshLogs: () => undefined, + ...overrides, + }; +} + +describe("overview view rendering", () => { + it("keeps the persisted overview locale selected before i18n hydration finishes", async () => { + const container = document.createElement("div"); + const props = createOverviewProps({ + settings: { + ...createOverviewProps().settings, + locale: "zh-CN", + }, + }); + + getSafeLocalStorage()?.clear(); + await i18n.setLocale("en"); + + render(renderOverview(props), container); + await Promise.resolve(); + + let select = container.querySelector("select"); + expect(i18n.getLocale()).toBe("en"); + expect(select?.value).toBe("zh-CN"); + expect(select?.selectedOptions[0]?.textContent?.trim()).toBe("简体中文 (Simplified Chinese)"); + + await i18n.setLocale("zh-CN"); + render(renderOverview(props), container); + await Promise.resolve(); + + select = container.querySelector("select"); + expect(select?.value).toBe("zh-CN"); + expect(select?.selectedOptions[0]?.textContent?.trim()).toBe("简体中文 (简体中文)"); + + await i18n.setLocale("en"); + }); +});