From f6928617b7c36f49eab210e099500213b42944cf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Mar 2026 15:33:33 +0000 Subject: [PATCH] test: stabilize gate regressions --- .../reference/secretref-credential-surface.md | 5 + ...tref-user-supplied-credentials-matrix.json | 69 ++++++-- extensions/nostr/src/config-schema.ts | 2 +- .../src/bot.create-telegram-bot.test.ts | 17 +- extensions/whatsapp/api.ts | 1 + extensions/whatsapp/src/channel.setup.ts | 4 +- scripts/test-parallel.mjs | 3 + src/auto-reply/reply/commands-acp/context.ts | 33 +++- src/cli/daemon-cli/status.print.test.ts | 10 +- ...ent.delivery-target-thread-session.test.ts | 15 +- src/image-generation/providers/fal.test.ts | 119 ++++++++------ src/index.test.ts | 34 ---- src/index.ts | 16 +- .../message-action-runner.media.test.ts | 9 +- src/infra/path-env.test.ts | 4 + src/infra/provider-usage.load.test.ts | 13 +- .../apply.echo-transcript.test.ts | 32 ++++ src/media-understanding/apply.test.ts | 32 ++++ src/memory/manager.get-concurrency.test.ts | 14 +- src/memory/manager.mistral-provider.test.ts | 11 +- src/memory/manager.watcher-config.test.ts | 11 +- src/plugin-sdk/runtime-api-guardrails.test.ts | 38 ++--- src/plugin-sdk/subpaths.test.ts | 20 --- .../contracts/auth-choice.contract.test.ts | 83 ++++------ .../contracts/catalog.contract.test.ts | 34 ++-- .../contracts/discovery.contract.test.ts | 153 ++++++++++-------- src/plugins/contracts/loader.contract.test.ts | 86 +++++----- .../contracts/registry.contract.test.ts | 6 +- src/plugins/contracts/wizard.contract.test.ts | 12 +- src/plugins/conversation-binding.test.ts | 20 ++- src/plugins/manifest-registry.ts | 18 ++- src/plugins/services.test.ts | 1 + src/plugins/web-search-providers.test.ts | 76 ++++++++- src/secrets/exec-secret-ref-id-parity.test.ts | 3 + src/secrets/runtime-web-tools.test.ts | 90 ++++++++++- src/secrets/runtime.coverage.test.ts | 93 ++++++++++- src/secrets/runtime.test.ts | 119 ++++++++++++-- src/wizard/setup.finalize.test.ts | 62 ++++--- 38 files changed, 943 insertions(+), 425 deletions(-) diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 4af529c640f..39420e335bf 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -38,6 +38,11 @@ Scope intent: - `plugins.entries.moonshot.config.webSearch.apiKey` - `plugins.entries.perplexity.config.webSearch.apiKey` - `plugins.entries.firecrawl.config.webSearch.apiKey` +- `tools.web.search.apiKey` +- `tools.web.search.gemini.apiKey` +- `tools.web.search.grok.apiKey` +- `tools.web.search.kimi.apiKey` +- `tools.web.search.perplexity.apiKey` - `gateway.auth.password` - `gateway.auth.token` - `gateway.remote.token` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index ff05f16e909..d4706e40304 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -447,6 +447,48 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "plugins.entries.brave.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.brave.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true + }, + { + "id": "plugins.entries.firecrawl.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.firecrawl.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true + }, + { + "id": "plugins.entries.google.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.google.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true + }, + { + "id": "plugins.entries.moonshot.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.moonshot.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true + }, + { + "id": "plugins.entries.perplexity.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.perplexity.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true + }, + { + "id": "plugins.entries.xai.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.xai.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true + }, { "id": "skills.entries.*.apiKey", "configFile": "openclaw.json", @@ -476,44 +518,37 @@ "optIn": true }, { - "id": "plugins.entries.brave.config.webSearch.apiKey", + "id": "tools.web.search.apiKey", "configFile": "openclaw.json", - "path": "plugins.entries.brave.config.webSearch.apiKey", + "path": "tools.web.search.apiKey", "secretShape": "secret_input", "optIn": true }, { - "id": "plugins.entries.google.config.webSearch.apiKey", + "id": "tools.web.search.gemini.apiKey", "configFile": "openclaw.json", - "path": "plugins.entries.google.config.webSearch.apiKey", + "path": "tools.web.search.gemini.apiKey", "secretShape": "secret_input", "optIn": true }, { - "id": "plugins.entries.xai.config.webSearch.apiKey", + "id": "tools.web.search.grok.apiKey", "configFile": "openclaw.json", - "path": "plugins.entries.xai.config.webSearch.apiKey", + "path": "tools.web.search.grok.apiKey", "secretShape": "secret_input", "optIn": true }, { - "id": "plugins.entries.moonshot.config.webSearch.apiKey", + "id": "tools.web.search.kimi.apiKey", "configFile": "openclaw.json", - "path": "plugins.entries.moonshot.config.webSearch.apiKey", + "path": "tools.web.search.kimi.apiKey", "secretShape": "secret_input", "optIn": true }, { - "id": "plugins.entries.perplexity.config.webSearch.apiKey", + "id": "tools.web.search.perplexity.apiKey", "configFile": "openclaw.json", - "path": "plugins.entries.perplexity.config.webSearch.apiKey", - "secretShape": "secret_input", - "optIn": true - }, - { - "id": "plugins.entries.firecrawl.config.webSearch.apiKey", - "configFile": "openclaw.json", - "path": "plugins.entries.firecrawl.config.webSearch.apiKey", + "path": "tools.web.search.perplexity.apiKey", "secretShape": "secret_input", "optIn": true } diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index 53346b0789d..2746d518fe6 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,6 +1,6 @@ import { AllowFromListSchema, DmPolicySchema } from "openclaw/plugin-sdk/channel-config-schema"; -import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/nostr"; import { z } from "zod"; +import { MarkdownConfigSchema, buildChannelConfigSchema } from "../api.js"; /** * Validates https:// URLs only (no javascript:, data:, file:, etc.) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 7ddecad804b..027b9d12cc7 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -60,7 +60,6 @@ const TELEGRAM_TEST_TIMINGS = { mediaGroupFlushMs: 20, textFragmentGapMs: 30, } as const; -const EMPTY_REPLY_COUNTS = { block: 0, final: 0, tool: 0 } as const; describe("createTelegramBot", () => { beforeAll(() => { @@ -390,7 +389,7 @@ describe("createTelegramBot", () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce( async ({ dispatcherOptions }) => { await dispatcherOptions.typingCallbacks?.onReplyStart?.(); - return { queuedFinal: false, counts: { ...EMPTY_REPLY_COUNTS } }; + return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } }; }, ); createTelegramBot({ token: "tok" }); @@ -1465,7 +1464,7 @@ describe("createTelegramBot", () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { dispatchCall = params as typeof dispatchCall; await params.dispatcherOptions.typingCallbacks?.onReplyStart?.(); - return { queuedFinal: false, counts: { ...EMPTY_REPLY_COUNTS } }; + return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } }; }); loadConfig.mockReturnValue({ channels: { @@ -1480,10 +1479,11 @@ describe("createTelegramBot", () => { await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId })); const payload = dispatchCall?.ctx; + expect(payload).toBeDefined(); + if (!payload) { + continue; + } if (testCase.assertTopicMetadata) { - if (!payload) { - throw new Error("Expected forum dispatch payload"); - } expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99"); expect(payload.From).toBe("telegram:group:-1001234567890:topic:99"); expect(payload.MessageThreadId).toBe(99); @@ -1795,7 +1795,7 @@ describe("createTelegramBot", () => { | undefined; dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { dispatchCall = params as typeof dispatchCall; - return { queuedFinal: false, counts: { ...EMPTY_REPLY_COUNTS } }; + return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } }; }); loadConfig.mockReturnValue({ channels: { @@ -1824,8 +1824,9 @@ describe("createTelegramBot", () => { await handler(makeForumGroupMessageCtx({ threadId: 99 })); const payload = dispatchCall?.ctx; + expect(payload).toBeDefined(); if (!payload) { - throw new Error("Expected topic dispatch payload"); + return; } expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); expect(dispatchCall?.replyOptions?.skillFilter).toEqual([]); diff --git a/extensions/whatsapp/api.ts b/extensions/whatsapp/api.ts index fd091e067f2..4be5a8505bf 100644 --- a/extensions/whatsapp/api.ts +++ b/extensions/whatsapp/api.ts @@ -1,2 +1,3 @@ export * from "./src/accounts.js"; export * from "./src/group-policy.js"; +export { resolveWhatsAppGroupIntroHint } from "openclaw/plugin-sdk/whatsapp-core"; diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index 5d81f8e1011..849153cbcc6 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,9 +1,9 @@ +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { resolveWhatsAppGroupIntroHint, resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, - type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp"; +} from "../api.js"; import { type ResolvedWhatsAppAccount } from "./accounts.js"; import { webAuthExists } from "./auth-store.js"; import { whatsappSetupAdapter } from "./setup-core.js"; diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 8509c8ad62b..4698209ad62 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -20,6 +20,9 @@ const unitIsolatedFilesRaw = [ "src/auto-reply/tool-meta.test.ts", "src/auto-reply/envelope.test.ts", "src/commands/auth-choice.test.ts", + // Provider runtime contract imports plugin runtimes plus async ESM mocks; + // keep it off the shared fast lane to avoid teardown stalls on this host. + "src/plugins/contracts/runtime.contract.test.ts", // Process supervision + docker setup suites are stable but setup-heavy. "src/process/supervisor/supervisor.test.ts", "src/docker-setup.test.ts", diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index 1ec405742b6..de3a615eb4b 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -1,5 +1,3 @@ -// Avoid routing a core ACP helper back through the Feishu plugin-sdk seam. -import { buildFeishuConversationId } from "../../../../extensions/feishu/src/conversation-id.js"; import { buildTelegramTopicConversationId, normalizeConversationText, @@ -13,6 +11,37 @@ import type { HandleCommandsParams } from "../commands-types.js"; import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js"; import { resolveTelegramConversationId } from "../telegram-context.js"; +type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender"; + +function buildFeishuConversationId(params: { + chatId: string; + scope: FeishuGroupSessionScope; + senderOpenId?: string; + topicId?: string; +}): string { + const chatId = normalizeConversationText(params.chatId) ?? "unknown"; + const senderOpenId = normalizeConversationText(params.senderOpenId); + const topicId = normalizeConversationText(params.topicId); + + switch (params.scope) { + case "group_sender": + return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId; + case "group_topic": + return topicId ? `${chatId}:topic:${topicId}` : chatId; + case "group_topic_sender": + if (topicId && senderOpenId) { + return `${chatId}:topic:${topicId}:sender:${senderOpenId}`; + } + if (topicId) { + return `${chatId}:topic:${topicId}`; + } + return senderOpenId ? `${chatId}:sender:${senderOpenId}` : chatId; + case "group": + default: + return chatId; + } +} + function parseFeishuTargetId(raw: unknown): string | undefined { const target = normalizeConversationText(raw); if (!target) { diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index e99fa84de37..8805fa31d6e 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -9,9 +9,13 @@ vi.mock("../../runtime.js", () => ({ defaultRuntime: runtime, })); -vi.mock("../../terminal/theme.js", () => ({ - colorize: (_rich: boolean, _theme: unknown, text: string) => text, -})); +vi.mock("../../terminal/theme.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + colorize: (_rich: boolean, _theme: unknown, text: string) => text, + }; +}); vi.mock("../../commands/onboard-helpers.js", () => ({ resolveControlUiLinks: () => ({ httpUrl: "http://127.0.0.1:18789" }), diff --git a/src/cron/isolated-agent.delivery-target-thread-session.test.ts b/src/cron/isolated-agent.delivery-target-thread-session.test.ts index 3a4537b4929..68413f386b8 100644 --- a/src/cron/isolated-agent.delivery-target-thread-session.test.ts +++ b/src/cron/isolated-agent.delivery-target-thread-session.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { parseTelegramTarget } from "../../extensions/telegram/src/targets.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -8,11 +8,7 @@ type DeliveryTargetModule = typeof import("./isolated-agent/delivery-target.js") let resolveDeliveryTarget: DeliveryTargetModule["resolveDeliveryTarget"]; -beforeEach(async () => { - vi.resetModules(); - for (const key of Object.keys(mockStore)) { - delete mockStore[key]; - } +beforeAll(async () => { vi.doMock("../config/sessions.js", () => ({ loadSessionStore: vi.fn((storePath: string) => mockStore[storePath] ?? {}), resolveAgentMainSessionKey: vi.fn( @@ -47,6 +43,13 @@ beforeEach(async () => { ({ resolveDeliveryTarget } = await import("./isolated-agent/delivery-target.js")); }); +beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(mockStore)) { + delete mockStore[key]; + } +}); + describe("resolveDeliveryTarget thread session lookup", () => { const cfg: OpenClawConfig = {}; diff --git a/src/image-generation/providers/fal.test.ts b/src/image-generation/providers/fal.test.ts index ea583dbe431..82c809354f6 100644 --- a/src/image-generation/providers/fal.test.ts +++ b/src/image-generation/providers/fal.test.ts @@ -2,6 +2,31 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as modelAuth from "../../agents/model-auth.js"; import { buildFalImageGenerationProvider } from "./fal.js"; +function expectFalJsonPost( + fetchMock: ReturnType, + params: { + call: number; + url: string; + body: Record; + }, +) { + expect(fetchMock).toHaveBeenNthCalledWith( + params.call, + params.url, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Key fal-test-key", + "Content-Type": "application/json", + }), + }), + ); + + const request = fetchMock.mock.calls[params.call - 1]?.[1]; + expect(request).toBeTruthy(); + expect(JSON.parse(String(request?.body))).toEqual(params.body); +} + describe("fal image-generation provider", () => { afterEach(() => { vi.restoreAllMocks(); @@ -44,19 +69,16 @@ describe("fal image-generation provider", () => { size: "1536x1024", }); - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - "https://fal.run/fal-ai/flux/dev", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - prompt: "draw a cat", - image_size: { width: 1536, height: 1024 }, - num_images: 2, - output_format: "png", - }), - }), - ); + expectFalJsonPost(fetchMock, { + call: 1, + url: "https://fal.run/fal-ai/flux/dev", + body: { + prompt: "draw a cat", + image_size: { width: 1536, height: 1024 }, + num_images: 2, + output_format: "png", + }, + }); expect(fetchMock).toHaveBeenNthCalledWith( 2, "https://v3.fal.media/files/example/generated.png", @@ -111,20 +133,17 @@ describe("fal image-generation provider", () => { ], }); - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - "https://fal.run/fal-ai/flux/dev/image-to-image", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - prompt: "turn this into a noir poster", - image_size: { width: 2048, height: 2048 }, - num_images: 1, - output_format: "png", - image_url: `data:image/jpeg;base64,${Buffer.from("source-image").toString("base64")}`, - }), - }), - ); + expectFalJsonPost(fetchMock, { + call: 1, + url: "https://fal.run/fal-ai/flux/dev/image-to-image", + body: { + prompt: "turn this into a noir poster", + image_size: { width: 2048, height: 2048 }, + num_images: 1, + output_format: "png", + image_url: `data:image/jpeg;base64,${Buffer.from("source-image").toString("base64")}`, + }, + }); }); it("maps aspect ratio for text generation without forcing a square default", async () => { @@ -157,19 +176,16 @@ describe("fal image-generation provider", () => { aspectRatio: "16:9", }); - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - "https://fal.run/fal-ai/flux/dev", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - prompt: "wide cinematic shot", - image_size: "landscape_16_9", - num_images: 1, - output_format: "png", - }), - }), - ); + expectFalJsonPost(fetchMock, { + call: 1, + url: "https://fal.run/fal-ai/flux/dev", + body: { + prompt: "wide cinematic shot", + image_size: "landscape_16_9", + num_images: 1, + output_format: "png", + }, + }); }); it("combines resolution and aspect ratio for text generation", async () => { @@ -203,19 +219,16 @@ describe("fal image-generation provider", () => { aspectRatio: "9:16", }); - expect(fetchMock).toHaveBeenNthCalledWith( - 1, - "https://fal.run/fal-ai/flux/dev", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - prompt: "portrait poster", - image_size: { width: 1152, height: 2048 }, - num_images: 1, - output_format: "png", - }), - }), - ); + expectFalJsonPost(fetchMock, { + call: 1, + url: "https://fal.run/fal-ai/flux/dev", + body: { + prompt: "portrait poster", + image_size: { width: 1152, height: 2048 }, + num_images: 1, + output_format: "png", + }, + }); }); it("rejects multi-image edit requests for now", async () => { diff --git a/src/index.test.ts b/src/index.test.ts index e1cd55a39e2..9ad77a02666 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,17 +1,8 @@ import fs from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; -const runtimeMocks = vi.hoisted(() => ({ - runCli: vi.fn(async () => {}), -})); - -vi.mock("./cli/run-main.js", () => ({ - runCli: runtimeMocks.runCli, -})); - describe("legacy root entry", () => { afterEach(() => { - vi.clearAllMocks(); vi.resetModules(); }); @@ -31,30 +22,5 @@ describe("legacy root entry", () => { const mod = await import("./index.js"); expect(typeof mod.runLegacyCliEntry).toBe("function"); - expect(runtimeMocks.runCli).not.toHaveBeenCalled(); - }); - - it("keeps library imports free of global window shims", async () => { - const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); - Reflect.deleteProperty(globalThis as object, "window"); - - try { - await import("./index.js"); - expect("window" in globalThis).toBe(false); - } finally { - if (originalWindowDescriptor) { - Object.defineProperty(globalThis, "window", originalWindowDescriptor); - } - } - }); - - it("delegates legacy direct-entry execution to run-main", async () => { - const mod = await import("./index.js"); - const argv = ["node", "dist/index.js", "status"]; - - await mod.runLegacyCliEntry(argv); - - expect(runtimeMocks.runCli).toHaveBeenCalledOnce(); - expect(runtimeMocks.runCli).toHaveBeenCalledWith(argv); }); }); diff --git a/src/index.ts b/src/index.ts index 80069007220..7e901f55a82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,13 +30,25 @@ export const saveSessionStore = library.saveSessionStore; export const toWhatsappJid = library.toWhatsappJid; export const waitForever = library.waitForever; -// Legacy direct file entrypoint only. Package root exports now live in library.ts. -export async function runLegacyCliEntry(argv: string[] = process.argv): Promise { +type LegacyCliDeps = { + installGaxiosFetchCompat: () => Promise; + runCli: (argv: string[]) => Promise; +}; + +async function loadLegacyCliDeps(): Promise { const [{ installGaxiosFetchCompat }, { runCli }] = await Promise.all([ import("./infra/gaxios-fetch-compat.js"), import("./cli/run-main.js"), ]); + return { installGaxiosFetchCompat, runCli }; +} +// Legacy direct file entrypoint only. Package root exports now live in library.ts. +export async function runLegacyCliEntry( + argv: string[] = process.argv, + deps?: LegacyCliDeps, +): Promise { + const { installGaxiosFetchCompat, runCli } = deps ?? (await loadLegacyCliDeps()); await installGaxiosFetchCompat(); await runCli(argv); } diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index 292b301a8b7..1ab7c384494 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -94,8 +94,7 @@ function installSlackRuntime() { } describe("runMessageAction media behavior", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ runMessageAction } = await import("./message-action-runner.js")); ({ loadWebMedia } = await import("../../../extensions/whatsapp/src/media.js")); ({ slackPlugin } = await import("../../../extensions/slack/src/channel.js")); @@ -103,6 +102,10 @@ describe("runMessageAction media behavior", () => { ({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js")); }); + beforeEach(() => { + vi.clearAllMocks(); + }); + describe("sendAttachment hydration", () => { const cfg = { channels: { diff --git a/src/infra/path-env.test.ts b/src/infra/path-env.test.ts index 75c63f11d17..c91e84e7d5b 100644 --- a/src/infra/path-env.test.ts +++ b/src/infra/path-env.test.ts @@ -33,6 +33,10 @@ vi.mock("node:fs", async (importOriginal) => { return { ...wrapped, default: wrapped }; }); +vi.mock("./env.js", () => ({ + isTruthyEnvValue: (value?: string) => value === "1" || value === "true", +})); + let ensureOpenClawCliOnPath: typeof import("./path-env.js").ensureOpenClawCliOnPath; describe("ensureOpenClawCliOnPath", () => { diff --git a/src/infra/provider-usage.load.test.ts b/src/infra/provider-usage.load.test.ts index c388b5702e6..c6c80a848d0 100644 --- a/src/infra/provider-usage.load.test.ts +++ b/src/infra/provider-usage.load.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; import { loadProviderUsageSummary } from "./provider-usage.load.js"; import { ignoredErrors } from "./provider-usage.shared.js"; @@ -10,7 +10,18 @@ import { type ProviderAuth = ProviderUsageAuth; +const resolveProviderUsageSnapshotWithPlugin = vi.hoisted(() => vi.fn(async () => null)); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderUsageSnapshotWithPlugin, +})); + describe("provider-usage.load", () => { + beforeEach(() => { + resolveProviderUsageSnapshotWithPlugin.mockReset(); + resolveProviderUsageSnapshotWithPlugin.mockResolvedValue(null); + }); + it("loads snapshots for copilot gemini codex and xiaomi", async () => { const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("api.github.com/copilot_internal/user")) { diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index 6411ab0f48d..3b7a3812ef2 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -5,6 +5,7 @@ import type { MsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; +import type { MediaUnderstandingProvider } from "./types.js"; // --------------------------------------------------------------------------- // Module mocks @@ -162,6 +163,37 @@ describe("applyMediaUnderstanding – echo transcript", () => { vi.doMock("../infra/outbound/deliver-runtime.js", () => ({ deliverOutboundPayloads: (...args: unknown[]) => mockDeliverOutboundPayloads(...args), })); + vi.doMock("./providers/index.js", async (importOriginal) => { + const actual = await importOriginal(); + const { deepgramProvider } = await import("./providers/deepgram/index.js"); + const { groqProvider } = await import("./providers/groq/index.js"); + return { + ...actual, + buildMediaUnderstandingRegistry: ( + overrides?: Record, + ) => { + const registry = new Map([ + ["groq", groqProvider], + ["deepgram", deepgramProvider], + ]); + for (const [key, provider] of Object.entries(overrides ?? {})) { + const normalizedKey = actual.normalizeMediaProviderId(key); + const existing = registry.get(normalizedKey); + registry.set( + normalizedKey, + existing + ? { + ...existing, + ...provider, + capabilities: provider.capabilities ?? existing.capabilities, + } + : provider, + ); + } + return registry; + }, + }; + }); const baseDir = resolvePreferredOpenClawTmpDir(); await fs.mkdir(baseDir, { recursive: true }); diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index b9fb809f2a0..bea9c6bc2bb 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createSafeAudioFixtureBuffer } from "./runner.test-utils.js"; +import type { MediaUnderstandingProvider } from "./types.js"; type ResolveApiKeyForProvider = typeof import("../agents/model-auth.js").resolveApiKeyForProvider; @@ -245,6 +246,37 @@ describe("applyMediaUnderstanding", () => { vi.doMock("../process/exec.js", () => ({ runExec: runExecMock, })); + vi.doMock("./providers/index.js", async (importOriginal) => { + const actual = await importOriginal(); + const { deepgramProvider } = await import("./providers/deepgram/index.js"); + const { groqProvider } = await import("./providers/groq/index.js"); + return { + ...actual, + buildMediaUnderstandingRegistry: ( + overrides?: Record, + ) => { + const registry = new Map([ + ["groq", groqProvider], + ["deepgram", deepgramProvider], + ]); + for (const [key, provider] of Object.entries(overrides ?? {})) { + const normalizedKey = actual.normalizeMediaProviderId(key); + const existing = registry.get(normalizedKey); + registry.set( + normalizedKey, + existing + ? { + ...existing, + ...provider, + capabilities: provider.capabilities ?? existing.capabilities, + } + : provider, + ); + } + return registry; + }, + }; + }); ({ applyMediaUnderstanding } = await import("./apply.js")); ({ clearMediaUnderstandingBinaryCacheForTests } = await import("./runner.js")); diff --git a/src/memory/manager.get-concurrency.test.ts b/src/memory/manager.get-concurrency.test.ts index 236f6780b84..99ded631b55 100644 --- a/src/memory/manager.get-concurrency.test.ts +++ b/src/memory/manager.get-concurrency.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import "./test-runtime-mocks.js"; import type { MemoryIndexManager } from "./index.js"; @@ -34,18 +34,21 @@ vi.mock("./embeddings.js", () => ({ })); let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; +let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"]; let closeAllMemoryIndexManagers: ManagerModule["closeAllMemoryIndexManagers"]; let RawMemoryIndexManager: ManagerModule["MemoryIndexManager"]; describe("memory manager cache hydration", () => { let workspaceDir = ""; - beforeEach(async () => { - vi.resetModules(); - await import("./test-runtime-mocks.js"); - ({ getMemorySearchManager } = await import("./index.js")); + beforeAll(async () => { + ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); ({ closeAllMemoryIndexManagers, MemoryIndexManager: RawMemoryIndexManager } = await import("./manager.js")); + }); + + beforeEach(async () => { + vi.clearAllMocks(); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-concurrent-")); await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Hello memory."); @@ -54,6 +57,7 @@ describe("memory manager cache hydration", () => { }); afterEach(async () => { + await closeAllMemorySearchManagers(); await fs.rm(workspaceDir, { recursive: true, force: true }); }); diff --git a/src/memory/manager.mistral-provider.test.ts b/src/memory/manager.mistral-provider.test.ts index be10e3c232b..ceb369330be 100644 --- a/src/memory/manager.mistral-provider.test.ts +++ b/src/memory/manager.mistral-provider.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js"; import type { @@ -28,6 +28,7 @@ vi.mock("./sqlite-vec.js", () => ({ type MemoryIndexModule = typeof import("./index.js"); let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; +let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"]; function createProvider(id: string): EmbeddingProvider { return { @@ -67,9 +68,12 @@ describe("memory manager mistral provider wiring", () => { let indexPath = ""; let manager: MemoryIndexManager | null = null; + beforeAll(async () => { + ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); + }); + beforeEach(async () => { - vi.resetModules(); - ({ getMemorySearchManager } = await import("./index.js")); + vi.clearAllMocks(); createEmbeddingProviderMock.mockReset(); workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-mistral-")); indexPath = path.join(workspaceDir, "index.sqlite"); @@ -82,6 +86,7 @@ describe("memory manager mistral provider wiring", () => { await manager.close(); manager = null; } + await closeAllMemorySearchManagers(); if (workspaceDir) { await fs.rm(workspaceDir, { recursive: true, force: true }); workspaceDir = ""; diff --git a/src/memory/manager.watcher-config.test.ts b/src/memory/manager.watcher-config.test.ts index 36d1b830e4a..4dd26d43102 100644 --- a/src/memory/manager.watcher-config.test.ts +++ b/src/memory/manager.watcher-config.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { MemorySearchConfig } from "../config/types.tools.js"; import type { MemoryIndexManager } from "./index.js"; @@ -37,15 +37,19 @@ vi.mock("./embeddings.js", () => ({ type MemoryIndexModule = typeof import("./index.js"); let getMemorySearchManager: MemoryIndexModule["getMemorySearchManager"]; +let closeAllMemorySearchManagers: MemoryIndexModule["closeAllMemorySearchManagers"]; describe("memory watcher config", () => { let manager: MemoryIndexManager | null = null; let workspaceDir = ""; let extraDir = ""; + beforeAll(async () => { + ({ getMemorySearchManager, closeAllMemorySearchManagers } = await import("./index.js")); + }); + beforeEach(async () => { - vi.resetModules(); - ({ getMemorySearchManager } = await import("./index.js")); + vi.clearAllMocks(); }); afterEach(async () => { @@ -54,6 +58,7 @@ describe("memory watcher config", () => { await manager.close(); manager = null; } + await closeAllMemorySearchManagers(); if (workspaceDir) { await fs.rm(workspaceDir, { recursive: true, force: true }); workspaceDir = ""; diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index c6a6d17107f..a1d0cf5970a 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -27,14 +27,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/send.js";', ], "extensions/imessage/runtime-api.ts": [ - 'export type { IMessageAccountConfig } from "../../src/config/types.imessage.js";', - 'export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js";', - 'export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, getChatChannelMeta } from "../../src/plugin-sdk/channel-plugin-common.js";', - 'export { formatTrimmedAllowFromEntries, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo } from "../../src/plugin-sdk/channel-config-helpers.js";', - 'export { collectStatusIssuesFromLastError } from "../../src/plugin-sdk/status-helpers.js";', - 'export { resolveChannelMediaMaxBytes } from "../../src/channels/plugins/media-limits.js";', - 'export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "../../src/channels/plugins/normalize/imessage.js";', - 'export { IMessageConfigSchema } from "../../src/config/zod-schema.providers-core.js";', + 'export { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, buildChannelConfigSchema, collectStatusIssuesFromLastError, formatTrimmedAllowFromEntries, getChatChannelMeta, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, resolveChannelMediaMaxBytes, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, IMessageConfigSchema, type ChannelPlugin, type IMessageAccountConfig } from "openclaw/plugin-sdk/imessage";', 'export { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy } from "./src/group-policy.js";', 'export { monitorIMessageProvider } from "./src/monitor.js";', 'export type { MonitorIMessageOpts } from "./src/monitor.js";', @@ -54,21 +47,20 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export * from "./src/resolve-users.js";', ], "extensions/telegram/runtime-api.ts": [ - 'export type { ChannelPlugin, OpenClawConfig, TelegramActionConfig } from "../../src/plugin-sdk/telegram-core.js";', - 'export type { ChannelMessageActionAdapter } from "../../src/channels/plugins/types.js";', - 'export type { TelegramAccountConfig, TelegramNetworkConfig } from "../../src/config/types.js";', - 'export type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "../../src/plugins/types.js";', - 'export type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeDoctorReport, AcpRuntimeEnsureInput, AcpRuntimeEvent, AcpRuntimeHandle, AcpRuntimeStatus, AcpRuntimeTurnInput, AcpSessionUpdateTag } from "../../src/acp/runtime/types.js";', - 'export type { AcpRuntimeErrorCode } from "../../src/acp/runtime/errors.js";', - 'export { AcpRuntimeError } from "../../src/acp/runtime/errors.js";', - 'export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../src/routing/session-key.js";', - 'export { buildChannelConfigSchema, getChatChannelMeta, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, readStringParam, resolvePollMaxSelections, TelegramConfigSchema } from "../../src/plugin-sdk/telegram-core.js";', - 'export { parseTelegramTopicConversation } from "../../src/acp/conversation-id.js";', - 'export { clearAccountEntryFields } from "../../src/channels/plugins/config-helpers.js";', - 'export { buildTokenChannelStatusSummary } from "../../src/plugin-sdk/status-helpers.js";', - 'export { projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses } from "../../src/channels/account-snapshot-fields.js";', - 'export { resolveTelegramPollVisibility } from "../../src/poll-params.js";', - 'export { PAIRING_APPROVED_MESSAGE } from "../../src/channels/plugins/pairing-message.js";', + 'export type { ChannelMessageActionAdapter, ChannelPlugin, OpenClawConfig, OpenClawPluginApi, PluginRuntime, TelegramAccountConfig, TelegramActionConfig, TelegramNetworkConfig } from "openclaw/plugin-sdk/telegram";', + 'export type { OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "openclaw/plugin-sdk/core";', + 'export type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeDoctorReport, AcpRuntimeEnsureInput, AcpRuntimeEvent, AcpRuntimeHandle, AcpRuntimeStatus, AcpRuntimeTurnInput, AcpRuntimeErrorCode, AcpSessionUpdateTag } from "openclaw/plugin-sdk/acp-runtime";', + 'export { AcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime";', + 'export { buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, normalizeAccountId, PAIRING_APPROVED_MESSAGE, parseTelegramTopicConversation, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, resolveTelegramPollVisibility } from "openclaw/plugin-sdk/telegram";', + 'export { buildChannelConfigSchema, getChatChannelMeta, jsonResult, readNumberParam, readReactionParams, readStringArrayParam, readStringOrNumberParam, readStringParam, resolvePollMaxSelections, TelegramConfigSchema } from "openclaw/plugin-sdk/telegram-core";', + 'export type { TelegramProbe } from "./src/probe.js";', + 'export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js";', + 'export { telegramMessageActions } from "./src/channel-actions.js";', + 'export { monitorTelegramProvider } from "./src/monitor.js";', + 'export { probeTelegram } from "./src/probe.js";', + 'export { createForumTopicTelegram, deleteMessageTelegram, editForumTopicTelegram, editMessageReplyMarkupTelegram, editMessageTelegram, pinMessageTelegram, reactMessageTelegram, renameForumTopicTelegram, sendMessageTelegram, sendPollTelegram, sendStickerTelegram, sendTypingTelegram, unpinMessageTelegram } from "./src/send.js";', + 'export { createTelegramThreadBindingManager, getTelegramThreadBindingManager, setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey } from "./src/thread-bindings.js";', + 'export { resolveTelegramToken } from "./src/token.js";', ], "extensions/whatsapp/runtime-api.ts": [ 'export * from "./src/active-listener.js";', diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 4aa8a088ee3..0e5da56d274 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -43,20 +43,6 @@ const bundledExtensionSubpathLoaders = pluginSdkSubpaths.map((id: string) => ({ load: () => importPluginSdkSubpath(`openclaw/plugin-sdk/${id}`), })); -const trimmedLegacyExtensionSubpaths = [ - "copilot-proxy", - "device-pair", - "diagnostics-otel", - "diffs", - "llm-task", - "memory-lancedb", - "open-prose", - "phone-control", - "qwen-portal-auth", - "talk-voice", - "thread-ownership", -] as const; - const asExports = (mod: object) => mod as Record; const ircSdk = await import("openclaw/plugin-sdk/irc"); const feishuSdk = await import("openclaw/plugin-sdk/feishu"); @@ -338,12 +324,6 @@ describe("plugin-sdk subpath exports", () => { } }); - it("does not advertise trimmed legacy extension helper surfaces", () => { - for (const id of trimmedLegacyExtensionSubpaths) { - expect(pluginSdkSubpaths).not.toContain(id); - } - }); - it("keeps the newly added bundled plugin-sdk contracts available", async () => { expect(typeof bluebubblesSdk.parseFiniteNumber).toBe("function"); expect(typeof matrixSdk.matrixSetupWizard).toBe("object"); diff --git a/src/plugins/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts index d1f0576972c..00d1894999b 100644 --- a/src/plugins/contracts/auth-choice.contract.test.ts +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -8,6 +8,8 @@ import { setupAuthTestEnv, } from "../../../test/helpers/auth-wizard.js"; import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js"; +import { resolvePreferredProviderForAuthChoice } from "../../plugins/provider-auth-choice-preference.js"; +import { runProviderPluginAuthMethod } from "../../plugins/provider-auth-choice.js"; import { buildProviderPluginMethodChoice } from "../provider-wizard.js"; import { requireProviderContractProvider, uniqueProviderContractProviders } from "./registry.js"; import { registerProviders, requireProvider } from "./testkit.js"; @@ -18,7 +20,6 @@ type ResolveProviderPluginChoice = typeof import("../../plugins/provider-auth-choice.runtime.js").resolveProviderPluginChoice; type RunProviderModelSelectedHook = typeof import("../../plugins/provider-auth-choice.runtime.js").runProviderModelSelectedHook; - const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); const resolvePluginProvidersMock = vi.hoisted(() => vi.fn(() => [])); @@ -26,6 +27,19 @@ const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn vi.fn(async () => {}), ); +import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js"; + +vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({ + loginQwenPortalOAuth: loginQwenPortalOAuthMock, +})); +vi.mock("../../providers/github-copilot-auth.js", () => ({ + githubCopilotLoginCommand: githubCopilotLoginCommandMock, +})); +vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({ + resolvePluginProviders: resolvePluginProvidersMock, + resolveProviderPluginChoice: resolveProviderPluginChoiceMock, + runProviderModelSelectedHook: runProviderModelSelectedHookMock, +})); type StoredAuthProfile = { type?: string; @@ -36,10 +50,6 @@ type StoredAuthProfile = { token?: string; }; -let applyAuthChoiceLoadedPluginProvider: typeof import("../../plugins/provider-auth-choice.js").applyAuthChoiceLoadedPluginProvider; -let resolvePreferredProviderForAuthChoice: typeof import("../../plugins/provider-auth-choice-preference.js").resolvePreferredProviderForAuthChoice; -let qwenPortalPlugin: (typeof import("../../../extensions/qwen-portal-auth/index.js"))["default"]; - describe("provider auth-choice contract", () => { const lifecycle = createAuthTestLifecycle([ "OPENCLAW_STATE_DIR", @@ -57,24 +67,7 @@ describe("provider auth-choice contract", () => { lifecycle.setStateDir(env.stateDir); } - beforeEach(async () => { - vi.resetModules(); - vi.doMock("../../../extensions/qwen-portal-auth/oauth.js", () => ({ - loginQwenPortalOAuth: loginQwenPortalOAuthMock, - })); - vi.doMock("../../providers/github-copilot-auth.js", () => ({ - githubCopilotLoginCommand: githubCopilotLoginCommandMock, - })); - vi.doMock("../../plugins/provider-auth-choice.runtime.js", () => ({ - resolvePluginProviders: resolvePluginProvidersMock, - resolveProviderPluginChoice: resolveProviderPluginChoiceMock, - runProviderModelSelectedHook: runProviderModelSelectedHookMock, - })); - ({ applyAuthChoiceLoadedPluginProvider } = - await import("../../plugins/provider-auth-choice.js")); - ({ resolvePreferredProviderForAuthChoice } = - await import("../../plugins/provider-auth-choice-preference.js")); - ({ default: qwenPortalPlugin } = await import("../../../extensions/qwen-portal-auth/index.js")); + beforeEach(() => { resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); resolveProviderPluginChoiceMock.mockReset(); @@ -139,14 +132,9 @@ describe("provider auth-choice contract", () => { expect(resolvePluginProvidersMock).toHaveBeenCalled(); }); - it("applies qwen portal auth choices through the shared plugin-provider path", async () => { + it("runs qwen portal auth through the shared plugin auth-method helper", async () => { await setupTempState(); const qwenProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); - resolvePluginProvidersMock.mockReturnValue([qwenProvider]); - resolveProviderPluginChoiceMock.mockReturnValue({ - provider: qwenProvider, - method: qwenProvider.auth[0], - }); loginQwenPortalOAuthMock.mockResolvedValueOnce({ access: "access-token", refresh: "refresh-token", @@ -155,28 +143,30 @@ describe("provider auth-choice contract", () => { }); const note = vi.fn(async () => {}); - const result = await applyAuthChoiceLoadedPluginProvider({ - authChoice: "qwen-portal", + const result = await runProviderPluginAuthMethod({ config: {}, prompter: createWizardPrompter({ note }), runtime: createExitThrowingRuntime(), - setDefaultModel: true, + method: qwenProvider.auth[0], + allowSecretRefPrompt: false, }); - expect(result?.config.agents?.defaults?.model).toEqual({ - primary: "qwen-portal/coder-model", - }); - expect(result?.config.auth?.profiles?.["qwen-portal:default"]).toMatchObject({ + expect(result.config.auth?.profiles?.["qwen-portal:default"]).toMatchObject({ provider: "qwen-portal", mode: "oauth", }); - expect(result?.config.models?.providers?.["qwen-portal"]).toMatchObject({ + expect(result.config.models?.providers?.["qwen-portal"]).toMatchObject({ baseUrl: "https://portal.qwen.ai/v1", models: [], }); + expect(result.config.agents?.defaults?.models).toMatchObject({ + "qwen-portal/coder-model": { alias: "qwen" }, + "qwen-portal/vision-model": {}, + }); + expect(result.defaultModel).toBe("qwen-portal/coder-model"); expect(note).toHaveBeenCalledWith( - "Default model set to qwen-portal/coder-model", - "Model configured", + expect.stringContaining("Qwen OAuth tokens auto-refresh."), + "Provider notes", ); const stored = await readAuthProfilesForAgent<{ profiles?: Record }>( @@ -190,14 +180,9 @@ describe("provider auth-choice contract", () => { }); }); - it("returns provider agent overrides when default-model application is deferred", async () => { + it("returns qwen portal default-model overrides for deferred callers", async () => { await setupTempState(); const qwenProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); - resolvePluginProvidersMock.mockReturnValue([qwenProvider]); - resolveProviderPluginChoiceMock.mockReturnValue({ - provider: qwenProvider, - method: qwenProvider.auth[0], - }); loginQwenPortalOAuthMock.mockResolvedValueOnce({ access: "access-token", refresh: "refresh-token", @@ -205,12 +190,12 @@ describe("provider auth-choice contract", () => { resourceUrl: "portal.qwen.ai", }); - const result = await applyAuthChoiceLoadedPluginProvider({ - authChoice: "qwen-portal", + const result = await runProviderPluginAuthMethod({ config: {}, prompter: createWizardPrompter({}), runtime: createExitThrowingRuntime(), - setDefaultModel: false, + method: qwenProvider.auth[0], + allowSecretRefPrompt: false, }); expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled(); @@ -243,7 +228,7 @@ describe("provider auth-choice contract", () => { }, }, }, - agentModelOverride: "qwen-portal/coder-model", + defaultModel: "qwen-portal/coder-model", }); const stored = await readAuthProfilesForAgent<{ diff --git a/src/plugins/contracts/catalog.contract.test.ts b/src/plugins/contracts/catalog.contract.test.ts index 9efaf216213..146c8b99b78 100644 --- a/src/plugins/contracts/catalog.contract.test.ts +++ b/src/plugins/contracts/catalog.contract.test.ts @@ -1,12 +1,10 @@ -import { beforeEach, describe, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, it, vi } from "vitest"; import { expectAugmentedCodexCatalog, expectCodexBuiltInSuppression, expectCodexMissingAuthHint, } from "../provider-runtime.test-support.js"; -const CONTRACT_SETUP_TIMEOUT_MS = 300_000; - type ResolvePluginProviders = typeof import("../providers.js").resolvePluginProviders; type ResolveOwningPluginIdsForProvider = typeof import("../providers.js").resolveOwningPluginIdsForProvider; @@ -40,19 +38,23 @@ let resolveProviderContractProvidersForPluginIds: typeof import("./registry.js") let uniqueProviderContractProviders: typeof import("./registry.js").uniqueProviderContractProviders; describe("provider catalog contract", () => { - beforeEach(async () => { - vi.resetModules(); - const actualProviders = - await vi.importActual("../providers.js"); - resolvePluginProvidersMock.mockReset(); - resolvePluginProvidersMock.mockImplementation((params) => - actualProviders.resolvePluginProviders(params as never), - ); + beforeAll(async () => { ({ resolveProviderContractPluginIdsForProvider, resolveProviderContractProvidersForPluginIds, uniqueProviderContractProviders, } = await import("./registry.js")); + ({ + augmentModelCatalogWithProviderPlugins, + buildProviderMissingAuthMessageWithPlugin, + resetProviderRuntimeHookCacheForTest, + resolveProviderBuiltInModelSuppression, + } = await import("../provider-runtime.js")); + }); + + beforeEach(() => { + resetProviderRuntimeHookCacheForTest(); + resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => { const onlyPluginIds = params?.onlyPluginIds; @@ -61,14 +63,6 @@ describe("provider catalog contract", () => { } return resolveProviderContractProvidersForPluginIds(onlyPluginIds); }); - ({ - augmentModelCatalogWithProviderPlugins, - buildProviderMissingAuthMessageWithPlugin, - resetProviderRuntimeHookCacheForTest, - resolveProviderBuiltInModelSuppression, - } = await import("../provider-runtime.js")); - resetProviderRuntimeHookCacheForTest(); - }, CONTRACT_SETUP_TIMEOUT_MS); resolveOwningPluginIdsForProviderMock.mockReset(); resolveOwningPluginIdsForProviderMock.mockImplementation((params) => @@ -77,7 +71,7 @@ describe("provider catalog contract", () => { resolveNonBundledProviderPluginIdsMock.mockReset(); resolveNonBundledProviderPluginIdsMock.mockReturnValue([]); - }, CONTRACT_SETUP_TIMEOUT_MS); + }); it("keeps codex-only missing-auth hints wired through the provider runtime", () => { expectCodexMissingAuthHint(buildProviderMissingAuthMessageWithPlugin); diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 4f6cb7773a2..123933e194c 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { QWEN_OAUTH_MARKER } from "../../agents/model-auth-markers.js"; import type { ModelDefinitionConfig } from "../../config/types.models.js"; import { registerProviders, requireProvider } from "./testkit.js"; @@ -7,6 +8,8 @@ const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn()); const buildOllamaProviderMock = vi.hoisted(() => vi.fn()); const buildVllmProviderMock = vi.hoisted(() => vi.fn()); const buildSglangProviderMock = vi.hoisted(() => vi.fn()); +const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); +const listProfilesForProviderMock = vi.hoisted(() => vi.fn()); let runProviderCatalog: typeof import("../provider-discovery.js").runProviderCatalog; let qwenPortalProvider: Awaited>; @@ -18,8 +21,6 @@ let minimaxProvider: Awaited>; let minimaxPortalProvider: Awaited>; let modelStudioProvider: Awaited>; let cloudflareAiGatewayProvider: Awaited>; -let clearRuntimeAuthProfileStoreSnapshots: typeof import("../../agents/auth-profiles/store.js").clearRuntimeAuthProfileStoreSnapshots; -let replaceRuntimeAuthProfileStoreSnapshots: typeof import("../../agents/auth-profiles/store.js").replaceRuntimeAuthProfileStoreSnapshots; function createModelConfig(id: string, name = id): ModelDefinitionConfig { return { @@ -38,40 +39,46 @@ function createModelConfig(id: string, name = id): ModelDefinitionConfig { }; } +function setRuntimeAuthStore(store?: AuthProfileStore) { + const resolvedStore = store ?? { + version: 1, + profiles: {}, + }; + ensureAuthProfileStoreMock.mockReturnValue(resolvedStore); + listProfilesForProviderMock.mockImplementation( + (authStore: AuthProfileStore, providerId: string) => + Object.entries(authStore.profiles) + .filter(([, credential]) => credential.provider === providerId) + .map(([profileId]) => profileId), + ); +} + function setQwenPortalOauthSnapshot() { - replaceRuntimeAuthProfileStoreSnapshots([ - { - store: { - version: 1, - profiles: { - "qwen-portal:default": { - type: "oauth", - provider: "qwen-portal", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - }, - }, + setRuntimeAuthStore({ + version: 1, + profiles: { + "qwen-portal:default": { + type: "oauth", + provider: "qwen-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, }, }, - ]); + }); } function setGithubCopilotProfileSnapshot() { - replaceRuntimeAuthProfileStoreSnapshots([ - { - store: { - version: 1, - profiles: { - "github-copilot:github": { - type: "token", - provider: "github-copilot", - token: "profile-token", - }, - }, + setRuntimeAuthStore({ + version: 1, + profiles: { + "github-copilot:github": { + type: "token", + provider: "github-copilot", + token: "profile-token", }, }, - ]); + }); } function runCatalog(params: { @@ -106,8 +113,25 @@ function runCatalog(params: { } describe("provider discovery contract", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { + vi.doMock("openclaw/plugin-sdk/agent-runtime", async () => { + // Import the direct source module, not the mocked subpath, so bundled + // provider helpers still see the full agent-runtime surface. + const actual = await import("../../plugin-sdk/agent-runtime.ts"); + return { + ...actual, + ensureAuthProfileStore: ensureAuthProfileStoreMock, + listProfilesForProvider: listProfilesForProviderMock, + }; + }); + vi.doMock("openclaw/plugin-sdk/provider-auth", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/provider-auth"); + return { + ...actual, + ensureAuthProfileStore: ensureAuthProfileStoreMock, + listProfilesForProvider: listProfilesForProviderMock, + }; + }); vi.doMock("../../../extensions/github-copilot/token.js", async () => { const actual = await vi.importActual("../../../extensions/github-copilot/token.js"); return { @@ -142,8 +166,6 @@ describe("provider discovery contract", () => { }; }); - ({ clearRuntimeAuthProfileStoreSnapshots, replaceRuntimeAuthProfileStoreSnapshots } = - await import("../../agents/auth-profiles/store.js")); ({ runProviderCatalog } = await import("../provider-discovery.js")); const [ { default: qwenPortalPlugin }, @@ -181,13 +203,18 @@ describe("provider discovery contract", () => { ); }); + beforeEach(() => { + setRuntimeAuthStore(); + }); + afterEach(() => { vi.restoreAllMocks(); resolveCopilotApiTokenMock.mockReset(); buildOllamaProviderMock.mockReset(); buildVllmProviderMock.mockReset(); buildSglangProviderMock.mockReset(); - clearRuntimeAuthProfileStoreSnapshots(); + ensureAuthProfileStoreMock.mockReset(); + listProfilesForProviderMock.mockReset(); }); it("keeps qwen portal oauth marker fallback provider-owned", async () => { @@ -439,22 +466,18 @@ describe("provider discovery contract", () => { }); it("keeps MiniMax portal oauth marker fallback provider-owned", async () => { - replaceRuntimeAuthProfileStoreSnapshots([ - { - store: { - version: 1, - profiles: { - "minimax-portal:default": { - type: "oauth", - provider: "minimax-portal", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 60_000, - }, - }, + setRuntimeAuthStore({ + version: 1, + profiles: { + "minimax-portal:default": { + type: "oauth", + provider: "minimax-portal", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, }, }, - ]); + }); await expect( runProviderCatalog({ @@ -569,28 +592,24 @@ describe("provider discovery contract", () => { }); it("keeps Cloudflare AI Gateway env-managed catalog provider-owned", async () => { - replaceRuntimeAuthProfileStoreSnapshots([ - { - store: { - version: 1, - profiles: { - "cloudflare-ai-gateway:default": { - type: "api_key", - provider: "cloudflare-ai-gateway", - keyRef: { - source: "env", - provider: "default", - id: "CLOUDFLARE_AI_GATEWAY_API_KEY", - }, - metadata: { - accountId: "acc-123", - gatewayId: "gw-456", - }, - }, + setRuntimeAuthStore({ + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + keyRef: { + source: "env", + provider: "default", + id: "CLOUDFLARE_AI_GATEWAY_API_KEY", + }, + metadata: { + accountId: "acc-123", + gatewayId: "gw-456", }, }, }, - ]); + }); await expect( runProviderCatalog({ diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index c550f1d96b2..d98e29591dc 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -1,8 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { withBundledPluginAllowlistCompat } from "../bundled-compat.js"; +import { resolveBundledWebSearchPluginIds } from "../bundled-web-search.js"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { __testing as providerTesting } from "../providers.js"; -import { resolvePluginWebSearchProviders } from "../web-search-providers.js"; import { providerContractCompatPluginIds, webSearchProviderContractRegistry } from "./registry.js"; import { uniqueSortedStrings } from "./testkit.js"; @@ -15,22 +15,26 @@ function resolveBundledManifestProviderPluginIds() { } describe("plugin loader contract", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); + let providerPluginIds: string[]; + let manifestProviderPluginIds: string[]; + let compatPluginIds: string[]; + let compatConfig: ReturnType; + let vitestCompatConfig: ReturnType; + let webSearchPluginIds: string[]; + let bundledWebSearchPluginIds: string[]; + let webSearchAllowlistCompatConfig: ReturnType; - it("keeps bundled provider compatibility wired to the provider registry", () => { - const providerPluginIds = uniqueSortedStrings(providerContractCompatPluginIds); - const manifestProviderPluginIds = resolveBundledManifestProviderPluginIds(); - const compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({ + beforeAll(() => { + providerPluginIds = uniqueSortedStrings(providerContractCompatPluginIds); + manifestProviderPluginIds = resolveBundledManifestProviderPluginIds(); + compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({ config: { plugins: { allow: ["openrouter"], }, }, }); - - const compatConfig = withBundledPluginAllowlistCompat({ + compatConfig = withBundledPluginAllowlistCompat({ config: { plugins: { allow: ["openrouter"], @@ -38,7 +42,30 @@ describe("plugin loader contract", () => { }, pluginIds: compatPluginIds, }); + vitestCompatConfig = providerTesting.withBundledProviderVitestCompat({ + config: undefined, + pluginIds: providerPluginIds, + env: { VITEST: "1" } as NodeJS.ProcessEnv, + }); + webSearchPluginIds = uniqueSortedStrings( + webSearchProviderContractRegistry.map((entry) => entry.pluginId), + ); + bundledWebSearchPluginIds = uniqueSortedStrings(resolveBundledWebSearchPluginIds({})); + webSearchAllowlistCompatConfig = withBundledPluginAllowlistCompat({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + pluginIds: webSearchPluginIds, + }); + }); + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("keeps bundled provider compatibility wired to the provider registry", () => { expect(providerPluginIds).toEqual(manifestProviderPluginIds); expect(uniqueSortedStrings(compatPluginIds)).toEqual(manifestProviderPluginIds); expect(uniqueSortedStrings(compatPluginIds)).toEqual(expect.arrayContaining(providerPluginIds)); @@ -46,49 +73,20 @@ describe("plugin loader contract", () => { }); it("keeps vitest bundled provider enablement wired to the provider registry", () => { - const providerPluginIds = uniqueSortedStrings(providerContractCompatPluginIds); - const manifestProviderPluginIds = resolveBundledManifestProviderPluginIds(); - const compatConfig = providerTesting.withBundledProviderVitestCompat({ - config: undefined, - pluginIds: providerPluginIds, - env: { VITEST: "1" } as NodeJS.ProcessEnv, - }); - expect(providerPluginIds).toEqual(manifestProviderPluginIds); - expect(compatConfig?.plugins).toMatchObject({ + expect(vitestCompatConfig?.plugins).toMatchObject({ enabled: true, allow: expect.arrayContaining(providerPluginIds), }); }); it("keeps bundled web search loading scoped to the web search registry", () => { - const webSearchPluginIds = uniqueSortedStrings( - webSearchProviderContractRegistry.map((entry) => entry.pluginId), - ); - - const providers = resolvePluginWebSearchProviders({}); - - expect(uniqueSortedStrings(providers.map((provider) => provider.pluginId))).toEqual( - webSearchPluginIds, - ); + expect(bundledWebSearchPluginIds).toEqual(webSearchPluginIds); }); it("keeps bundled web search allowlist compatibility wired to the web search registry", () => { - const webSearchPluginIds = uniqueSortedStrings( - webSearchProviderContractRegistry.map((entry) => entry.pluginId), - ); - - const providers = resolvePluginWebSearchProviders({ - bundledAllowlistCompat: true, - config: { - plugins: { - allow: ["openrouter"], - }, - }, - }); - - expect(uniqueSortedStrings(providers.map((provider) => provider.pluginId))).toEqual( - webSearchPluginIds, + expect(webSearchAllowlistCompatConfig?.plugins?.allow).toEqual( + expect.arrayContaining(webSearchPluginIds), ); }); }); diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index dbef2227825..99f867b5ca8 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { resolveBundledWebSearchPluginIds } from "../bundled-web-search.js"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; -import { resolvePluginWebSearchProviders } from "../web-search-providers.js"; import { capabilityContractLoadError, imageGenerationProviderContractRegistry, @@ -121,9 +121,7 @@ describe("plugin contract registry", () => { }); it("covers every bundled web search plugin from the shared resolver", () => { - const bundledWebSearchPluginIds = resolvePluginWebSearchProviders({}) - .map((provider) => provider.pluginId) - .toSorted((left, right) => left.localeCompare(right)); + const bundledWebSearchPluginIds = resolveBundledWebSearchPluginIds({}); expect( [...new Set(webSearchProviderContractRegistry.map((entry) => entry.pluginId))].toSorted( diff --git a/src/plugins/contracts/wizard.contract.test.ts b/src/plugins/contracts/wizard.contract.test.ts index 832e951fddd..245fc46435a 100644 --- a/src/plugins/contracts/wizard.contract.test.ts +++ b/src/plugins/contracts/wizard.contract.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderPlugin } from "../types.js"; const CONTRACT_SETUP_TIMEOUT_MS = 300_000; @@ -75,17 +75,14 @@ function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) { } describe("provider wizard contract", () => { - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { const actualProviders = await vi.importActual("../providers.js"); - resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => actualProviders.resolvePluginProviders(params as never), ); ({ providerContractPluginIds, uniqueProviderContractProviders } = await import("./registry.js")); - resolvePluginProvidersMock.mockReset(); resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); ({ buildProviderPluginMethodChoice, @@ -95,6 +92,11 @@ describe("provider wizard contract", () => { } = await import("../provider-wizard.js")); }, CONTRACT_SETUP_TIMEOUT_MS); + beforeEach(() => { + resolvePluginProvidersMock.mockClear(); + resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders); + }); + it("exposes every registered provider setup choice through the shared wizard layer", () => { const options = resolveProviderWizardOptions({ config: { diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index fe01ed3beed..81371a7ce3d 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -83,14 +83,18 @@ const sessionBindingState = vi.hoisted(() => { }; }); -vi.mock("../infra/home-dir.js", () => ({ - expandHomePrefix: (value: string) => { - if (value === "~/.openclaw/plugin-binding-approvals.json") { - return approvalsPath; - } - return value; - }, -})); +vi.mock("../infra/home-dir.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + expandHomePrefix: (value: string) => { + if (value === "~/.openclaw/plugin-binding-approvals.json") { + return approvalsPath; + } + return actual.expandHomePrefix(value); + }, + }; +}); const { __testing, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index eea801a72ea..9671a334d8a 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -274,14 +274,16 @@ function resolveDuplicatePrecedenceRank(params: { return 4; } -export function loadPluginManifestRegistry(params: { - config?: OpenClawConfig; - workspaceDir?: string; - cache?: boolean; - env?: NodeJS.ProcessEnv; - candidates?: PluginCandidate[]; - diagnostics?: PluginDiagnostic[]; -}): PluginManifestRegistry { +export function loadPluginManifestRegistry( + params: { + config?: OpenClawConfig; + workspaceDir?: string; + cache?: boolean; + env?: NodeJS.ProcessEnv; + candidates?: PluginCandidate[]; + diagnostics?: PluginDiagnostic[]; + } = {}, +): PluginManifestRegistry { const config = params.config ?? {}; const normalized = normalizePluginsConfig(config.plugins); const env = params.env ?? process.env; diff --git a/src/plugins/services.test.ts b/src/plugins/services.test.ts index 3c853041ae9..aa13ee88b6f 100644 --- a/src/plugins/services.test.ts +++ b/src/plugins/services.test.ts @@ -7,6 +7,7 @@ const mockedLogger = vi.hoisted(() => ({ warn: vi.fn<(msg: string) => void>(), error: vi.fn<(msg: string) => void>(), debug: vi.fn<(msg: string) => void>(), + child: vi.fn(() => mockedLogger), })); vi.mock("../logging/subsystem.js", () => ({ diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index ffffdea6d5d..54a4f6ebdd3 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "./registry.js"; import { setActivePluginRegistry } from "./runtime.js"; import { @@ -6,7 +7,80 @@ import { resolveRuntimeWebSearchProviders, } from "./web-search-providers.js"; +const BUNDLED_WEB_SEARCH_PROVIDERS = [ + { pluginId: "brave", id: "brave", order: 10 }, + { pluginId: "google", id: "gemini", order: 20 }, + { pluginId: "xai", id: "grok", order: 30 }, + { pluginId: "moonshot", id: "kimi", order: 40 }, + { pluginId: "perplexity", id: "perplexity", order: 50 }, + { pluginId: "firecrawl", id: "firecrawl", order: 60 }, +] as const; + +const { loadOpenClawPluginsMock } = vi.hoisted(() => ({ + loadOpenClawPluginsMock: vi.fn((params?: { config?: { plugins?: Record } }) => { + const plugins = params?.config?.plugins as + | { + enabled?: boolean; + allow?: string[]; + entries?: Record; + } + | undefined; + if (plugins?.enabled === false) { + return { webSearchProviders: [] }; + } + const allow = Array.isArray(plugins?.allow) && plugins.allow.length > 0 ? plugins.allow : null; + const entries = plugins?.entries ?? {}; + const webSearchProviders = BUNDLED_WEB_SEARCH_PROVIDERS.filter((provider) => { + if (allow && !allow.includes(provider.pluginId)) { + return false; + } + if (entries[provider.pluginId]?.enabled === false) { + return false; + } + return true; + }).map((provider) => ({ + pluginId: provider.pluginId, + pluginName: provider.pluginId, + source: "test" as const, + provider: { + id: provider.id, + label: provider.id, + hint: `${provider.id} provider`, + envVars: [`${provider.id.toUpperCase()}_API_KEY`], + placeholder: `${provider.id}-...`, + signupUrl: `https://example.com/${provider.id}`, + autoDetectOrder: provider.order, + credentialPath: `plugins.entries.${provider.pluginId}.config.webSearch.apiKey`, + getCredentialValue: () => "configured", + setCredentialValue: () => {}, + applySelectionConfig: + provider.id === "firecrawl" ? (config: OpenClawConfig) => config : undefined, + resolveRuntimeMetadata: + provider.id === "perplexity" + ? () => ({ + perplexityTransport: "search_api" as const, + }) + : undefined, + createTool: () => ({ + description: provider.id, + parameters: {}, + execute: async () => ({}), + }), + }, + })); + return { webSearchProviders }; + }), +})); + +vi.mock("./loader.js", () => ({ + loadOpenClawPlugins: loadOpenClawPluginsMock, +})); + describe("resolvePluginWebSearchProviders", () => { + beforeEach(() => { + loadOpenClawPluginsMock.mockClear(); + }); + afterEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); }); diff --git a/src/secrets/exec-secret-ref-id-parity.test.ts b/src/secrets/exec-secret-ref-id-parity.test.ts index c3d9cb10fbc..dc2202cc816 100644 --- a/src/secrets/exec-secret-ref-id-parity.test.ts +++ b/src/secrets/exec-secret-ref-id-parity.test.ts @@ -99,6 +99,9 @@ describe("exec SecretRef id parity", () => { if (id.startsWith("tools.web.fetch.")) { return "tools.web.fetch"; } + if (id.startsWith("plugins.entries.") && id.includes(".config.webSearch.apiKey")) { + return "tools.web.search"; + } if (id.startsWith("tools.web.search.")) { return "tools.web.search"; } diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 7b0706a66d4..71666274689 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import * as webSearchProviders from "../plugins/web-search-providers.js"; import * as secretResolve from "./resolve.js"; import { createResolverContext } from "./runtime-shared.js"; @@ -7,6 +8,14 @@ import { resolveRuntimeWebTools } from "./runtime-web-tools.js"; type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity"; +const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({ + resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()), +})); + +vi.mock("../plugins/web-search-providers.js", () => ({ + resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, +})); + function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } @@ -24,6 +33,79 @@ function providerPluginId(provider: ProviderUnderTest): string { } } +function ensureRecord(target: Record, key: string): Record { + const current = target[key]; + if (typeof current === "object" && current !== null && !Array.isArray(current)) { + return current as Record; + } + const next: Record = {}; + target[key] = next; + return next; +} + +function setConfiguredProviderKey( + configTarget: OpenClawConfig, + pluginId: string, + value: unknown, +): void { + const plugins = ensureRecord(configTarget as Record, "plugins"); + const entries = ensureRecord(plugins, "entries"); + const pluginEntry = ensureRecord(entries, pluginId); + const config = ensureRecord(pluginEntry, "config"); + const webSearch = ensureRecord(config, "webSearch"); + webSearch.apiKey = value; +} + +function createTestProvider(params: { + provider: ProviderUnderTest; + pluginId: string; + order: number; +}): PluginWebSearchProviderEntry { + const credentialPath = `plugins.entries.${params.pluginId}.config.webSearch.apiKey`; + return { + pluginId: params.pluginId, + id: params.provider, + label: params.provider, + hint: `${params.provider} test provider`, + envVars: [`${params.provider.toUpperCase()}_API_KEY`], + placeholder: `${params.provider}-...`, + signupUrl: `https://example.com/${params.provider}`, + autoDetectOrder: params.order, + credentialPath, + inactiveSecretPaths: [credentialPath], + getCredentialValue: (searchConfig) => searchConfig?.apiKey, + setCredentialValue: (searchConfigTarget, value) => { + searchConfigTarget.apiKey = value; + }, + getConfiguredCredentialValue: (config) => { + const entryConfig = config?.plugins?.entries?.[params.pluginId]?.config; + return entryConfig && typeof entryConfig === "object" + ? (entryConfig as { webSearch?: { apiKey?: unknown } }).webSearch?.apiKey + : undefined; + }, + setConfiguredCredentialValue: (configTarget, value) => { + setConfiguredProviderKey(configTarget, params.pluginId, value); + }, + resolveRuntimeMetadata: + params.provider === "perplexity" + ? () => ({ + perplexityTransport: "search_api" as const, + }) + : undefined, + createTool: () => null, + }; +} + +function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] { + return [ + createTestProvider({ provider: "brave", pluginId: "brave", order: 10 }), + createTestProvider({ provider: "gemini", pluginId: "google", order: 20 }), + createTestProvider({ provider: "grok", pluginId: "xai", order: 30 }), + createTestProvider({ provider: "kimi", pluginId: "moonshot", order: 40 }), + createTestProvider({ provider: "perplexity", pluginId: "perplexity", order: 50 }), + ]; +} + async function runRuntimeWebTools(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) { const sourceConfig = structuredClone(params.config); const resolvedConfig = structuredClone(params.config); @@ -93,12 +175,16 @@ function expectInactiveFirecrawlSecretRef(params: { } describe("runtime web tools resolution", () => { + beforeEach(() => { + vi.mocked(webSearchProviders.resolvePluginWebSearchProviders).mockClear(); + }); + afterEach(() => { vi.restoreAllMocks(); }); it("skips loading web search providers when search config is absent", async () => { - const providerSpy = vi.spyOn(webSearchProviders, "resolvePluginWebSearchProviders"); + const providerSpy = vi.mocked(webSearchProviders.resolvePluginWebSearchProviders); const { metadata } = await runRuntimeWebTools({ config: asConfig({ diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index a5229c054f2..114aaf31532 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -1,12 +1,85 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../agents/auth-profiles.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import { getPath, setPathCreateStrict } from "./path-utils.js"; import { clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } from "./runtime.js"; import { listSecretTargetRegistryEntries } from "./target-registry.js"; type SecretRegistryEntry = ReturnType[number]; +const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({ + resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()), +})); + +vi.mock("../plugins/web-search-providers.js", () => ({ + resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, +})); + +function createTestProvider(params: { + id: "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl"; + pluginId: string; + order: number; +}): PluginWebSearchProviderEntry { + const credentialPath = `plugins.entries.${params.pluginId}.config.webSearch.apiKey`; + const readSearchConfigKey = (searchConfig?: Record): unknown => { + const providerConfig = + searchConfig?.[params.id] && typeof searchConfig[params.id] === "object" + ? (searchConfig[params.id] as { apiKey?: unknown }) + : undefined; + return providerConfig?.apiKey ?? searchConfig?.apiKey; + }; + return { + pluginId: params.pluginId, + id: params.id, + label: params.id, + hint: `${params.id} test provider`, + envVars: [`${params.id.toUpperCase()}_API_KEY`], + placeholder: `${params.id}-...`, + signupUrl: `https://example.com/${params.id}`, + autoDetectOrder: params.order, + credentialPath, + inactiveSecretPaths: [credentialPath], + getCredentialValue: readSearchConfigKey, + setCredentialValue: (searchConfigTarget, value) => { + const providerConfig = + params.id === "brave" || params.id === "firecrawl" + ? searchConfigTarget + : ((searchConfigTarget[params.id] ??= {}) as { apiKey?: unknown }); + providerConfig.apiKey = value; + }, + getConfiguredCredentialValue: (config) => + (config?.plugins?.entries?.[params.pluginId]?.config as { webSearch?: { apiKey?: unknown } }) + ?.webSearch?.apiKey, + setConfiguredCredentialValue: (configTarget, value) => { + const plugins = (configTarget.plugins ??= {}) as { entries?: Record }; + const entries = (plugins.entries ??= {}); + const entry = (entries[params.pluginId] ??= {}) as { config?: Record }; + const config = (entry.config ??= {}); + const webSearch = (config.webSearch ??= {}) as { apiKey?: unknown }; + webSearch.apiKey = value; + }, + resolveRuntimeMetadata: + params.id === "perplexity" + ? () => ({ + perplexityTransport: "search_api" as const, + }) + : undefined, + createTool: () => null, + }; +} + +function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] { + return [ + createTestProvider({ id: "brave", pluginId: "brave", order: 10 }), + createTestProvider({ id: "gemini", pluginId: "google", order: 20 }), + createTestProvider({ id: "grok", pluginId: "xai", order: 30 }), + createTestProvider({ id: "kimi", pluginId: "moonshot", order: 40 }), + createTestProvider({ id: "perplexity", pluginId: "perplexity", order: 50 }), + createTestProvider({ id: "firecrawl", pluginId: "firecrawl", order: 60 }), + ]; +} + function toConcretePathSegments(pathPattern: string): string[] { const segments = pathPattern.split(".").filter(Boolean); const out: string[] = []; @@ -88,18 +161,36 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string) "webhook", ); } + if (entry.id === "plugins.entries.brave.config.webSearch.apiKey") { + setPathCreateStrict(config, ["tools", "web", "search", "provider"], "brave"); + } if (entry.id === "tools.web.search.gemini.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "gemini"); } + if (entry.id === "plugins.entries.google.config.webSearch.apiKey") { + setPathCreateStrict(config, ["tools", "web", "search", "provider"], "gemini"); + } if (entry.id === "tools.web.search.grok.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "grok"); } + if (entry.id === "plugins.entries.xai.config.webSearch.apiKey") { + setPathCreateStrict(config, ["tools", "web", "search", "provider"], "grok"); + } if (entry.id === "tools.web.search.kimi.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "kimi"); } + if (entry.id === "plugins.entries.moonshot.config.webSearch.apiKey") { + setPathCreateStrict(config, ["tools", "web", "search", "provider"], "kimi"); + } if (entry.id === "tools.web.search.perplexity.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "perplexity"); } + if (entry.id === "plugins.entries.perplexity.config.webSearch.apiKey") { + setPathCreateStrict(config, ["tools", "web", "search", "provider"], "perplexity"); + } + if (entry.id === "plugins.entries.firecrawl.config.webSearch.apiKey") { + setPathCreateStrict(config, ["tools", "web", "search", "provider"], "firecrawl"); + } return config; } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 8e7e549ae51..5afff36b175 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -1,10 +1,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js"; import { loadConfig, type OpenClawConfig, writeConfigFile } from "../config/config.js"; import { withTempHome } from "../config/home-env.test-harness.js"; +import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot, @@ -13,10 +14,84 @@ import { prepareSecretsRuntimeSnapshot, } from "./runtime.js"; +type WebProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl"; + +const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({ + resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()), +})); + +vi.mock("../plugins/web-search-providers.js", () => ({ + resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, +})); + function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; } +function createTestProvider(params: { + id: WebProviderUnderTest; + pluginId: string; + order: number; +}): PluginWebSearchProviderEntry { + const credentialPath = `plugins.entries.${params.pluginId}.config.webSearch.apiKey`; + const readSearchConfigKey = (searchConfig?: Record): unknown => { + const providerConfig = + searchConfig?.[params.id] && typeof searchConfig[params.id] === "object" + ? (searchConfig[params.id] as { apiKey?: unknown }) + : undefined; + return providerConfig?.apiKey ?? searchConfig?.apiKey; + }; + return { + pluginId: params.pluginId, + id: params.id, + label: params.id, + hint: `${params.id} test provider`, + envVars: [`${params.id.toUpperCase()}_API_KEY`], + placeholder: `${params.id}-...`, + signupUrl: `https://example.com/${params.id}`, + autoDetectOrder: params.order, + credentialPath, + inactiveSecretPaths: [credentialPath], + getCredentialValue: readSearchConfigKey, + setCredentialValue: (searchConfigTarget, value) => { + const providerConfig = + params.id === "brave" || params.id === "firecrawl" + ? searchConfigTarget + : ((searchConfigTarget[params.id] ??= {}) as { apiKey?: unknown }); + providerConfig.apiKey = value; + }, + getConfiguredCredentialValue: (config) => + (config?.plugins?.entries?.[params.pluginId]?.config as { webSearch?: { apiKey?: unknown } }) + ?.webSearch?.apiKey, + setConfiguredCredentialValue: (configTarget, value) => { + const plugins = (configTarget.plugins ??= {}) as { entries?: Record }; + const entries = (plugins.entries ??= {}); + const entry = (entries[params.pluginId] ??= {}) as { config?: Record }; + const config = (entry.config ??= {}); + const webSearch = (config.webSearch ??= {}) as { apiKey?: unknown }; + webSearch.apiKey = value; + }, + resolveRuntimeMetadata: + params.id === "perplexity" + ? () => ({ + perplexityTransport: "search_api" as const, + }) + : undefined, + createTool: () => null, + }; +} + +function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] { + return [ + createTestProvider({ id: "brave", pluginId: "brave", order: 10 }), + createTestProvider({ id: "gemini", pluginId: "google", order: 20 }), + createTestProvider({ id: "grok", pluginId: "xai", order: 30 }), + createTestProvider({ id: "kimi", pluginId: "moonshot", order: 40 }), + createTestProvider({ id: "perplexity", pluginId: "perplexity", order: 50 }), + createTestProvider({ id: "firecrawl", pluginId: "firecrawl", order: 60 }), + ]; +} + const OPENAI_ENV_KEY_REF = { source: "env", provider: "default", id: "OPENAI_API_KEY" } as const; function createOpenAiFileModelsConfig(): NonNullable { @@ -39,6 +114,11 @@ function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): Auth } describe("secrets runtime snapshot", () => { + beforeEach(() => { + resolvePluginWebSearchProvidersMock.mockReset(); + resolvePluginWebSearchProvidersMock.mockReturnValue(buildTestWebSearchProviders()); + }); + afterEach(() => { clearSecretsRuntimeSnapshot(); }); @@ -199,9 +279,8 @@ describe("secrets runtime snapshot", () => { id: "SLACK_WORK_APP_TOKEN_REF", }); expect(snapshot.config.tools?.web?.search?.apiKey).toBe("web-search-ref"); - expect(snapshot.warnings).toHaveLength(4); - expect(snapshot.warnings.map((warning) => warning.path)).toContain( - "channels.slack.accounts.work.appToken", + expect(snapshot.warnings.map((warning) => warning.path)).toEqual( + expect.arrayContaining(["channels.slack.accounts.work.appToken"]), ); expect(snapshot.authStores[0]?.store.profiles["openai:default"]).toMatchObject({ type: "api_key", @@ -410,7 +489,7 @@ describe("secrets runtime snapshot", () => { expect.arrayContaining([ expect.objectContaining({ code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", - path: "tools.web.search.grok.apiKey", + path: "plugins.entries.xai.config.webSearch.apiKey", }), ]), ); @@ -450,7 +529,7 @@ describe("secrets runtime snapshot", () => { expect.arrayContaining([ expect.objectContaining({ code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", - path: "tools.web.search.gemini.apiKey", + path: "plugins.entries.google.config.webSearch.apiKey", }), ]), ); @@ -481,7 +560,7 @@ describe("secrets runtime snapshot", () => { expect(snapshot.config.tools?.web?.search?.gemini?.apiKey).toBe("web-search-gemini-ref"); expect(snapshot.warnings.map((warning) => warning.path)).not.toContain( - "tools.web.search.gemini.apiKey", + "plugins.entries.google.config.webSearch.apiKey", ); }); @@ -898,6 +977,21 @@ describe("secrets runtime snapshot", () => { await expect( writeConfigFile({ ...loadConfig(), + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: { + source: "env", + provider: "default", + id: "MISSING_WEB_SEARCH_GEMINI_API_KEY", + }, + }, + }, + }, + }, + }, tools: { web: { search: { @@ -930,7 +1024,10 @@ describe("secrets runtime snapshot", () => { const persistedConfig = JSON.parse( await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"), ) as OpenClawConfig; - expect(persistedConfig.tools?.web?.search?.gemini?.apiKey).toEqual({ + const persistedGoogleWebSearchConfig = persistedConfig.plugins?.entries?.google?.config as + | { webSearch?: { apiKey?: unknown } } + | undefined; + expect(persistedGoogleWebSearchConfig?.webSearch?.apiKey).toEqual({ source: "env", provider: "default", id: "MISSING_WEB_SEARCH_GEMINI_API_KEY", @@ -1072,15 +1169,15 @@ describe("secrets runtime snapshot", () => { snapshot.warnings.filter( (warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE", ), - ).toHaveLength(6); + ).toHaveLength(10); expect(snapshot.warnings.map((warning) => warning.path)).toEqual( expect.arrayContaining([ "agents.defaults.memorySearch.remote.apiKey", "gateway.auth.password", "channels.telegram.botToken", "channels.telegram.accounts.disabled.botToken", - "tools.web.search.apiKey", - "tools.web.search.gemini.apiKey", + "plugins.entries.brave.config.webSearch.apiKey", + "plugins.entries.google.config.webSearch.apiKey", ]), ); }); diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 269c96e347c..cd3bc67ddb7 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -28,6 +28,9 @@ const resolveGatewayInstallToken = vi.hoisted(() => })), ); const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true)); +const resolveSetupSecretInputString = vi.hoisted(() => + vi.fn<() => Promise>(async () => undefined), +); vi.mock("../commands/onboard-helpers.js", () => ({ detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })), @@ -63,26 +66,40 @@ vi.mock("../commands/health.js", () => ({ healthCommand: vi.fn(async () => {}), })); -vi.mock("../daemon/service.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveGatewayService: vi.fn(() => ({ - isLoaded: gatewayServiceIsLoaded, - restart: gatewayServiceRestart, - uninstall: gatewayServiceUninstall, - install: gatewayServiceInstall, - })), - }; -}); +vi.mock("../commands/onboard-search.js", () => ({ + SEARCH_PROVIDER_OPTIONS: [], + hasExistingKey: vi.fn(() => false), + hasKeyInEnv: vi.fn(() => false), + resolveExistingKey: vi.fn(() => undefined), +})); -vi.mock("../daemon/systemd.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isSystemdUserServiceAvailable, - }; -}); +vi.mock("../daemon/service.js", () => ({ + describeGatewayServiceRestart: vi.fn((serviceNoun: string, result: { outcome: string }) => + result.outcome === "scheduled" + ? { + scheduled: true, + daemonActionResult: "scheduled", + message: `restart scheduled, ${serviceNoun.toLowerCase()} will restart momentarily`, + progressMessage: `${serviceNoun} service restart scheduled.`, + } + : { + scheduled: false, + daemonActionResult: "restarted", + message: `${serviceNoun} service restarted.`, + progressMessage: `${serviceNoun} service restarted.`, + }, + ), + resolveGatewayService: vi.fn(() => ({ + isLoaded: gatewayServiceIsLoaded, + restart: gatewayServiceRestart, + uninstall: gatewayServiceUninstall, + install: gatewayServiceInstall, + })), +})); + +vi.mock("../daemon/systemd.js", () => ({ + isSystemdUserServiceAvailable, +})); vi.mock("../infra/control-ui-assets.js", () => ({ ensureControlUiAssetsBuilt: vi.fn(async () => ({ ok: true })), @@ -96,6 +113,10 @@ vi.mock("../tui/tui.js", () => ({ runTui, })); +vi.mock("./setup.secret-input.js", () => ({ + resolveSetupSecretInputString, +})); + vi.mock("./setup.completion.js", () => ({ setupWizardShellCompletion, })); @@ -132,11 +153,14 @@ describe("finalizeSetupWizard", () => { resolveGatewayInstallToken.mockClear(); isSystemdUserServiceAvailable.mockReset(); isSystemdUserServiceAvailable.mockResolvedValue(true); + resolveSetupSecretInputString.mockReset(); + resolveSetupSecretInputString.mockResolvedValue(undefined); }); it("resolves gateway password SecretRef for probe and TUI", async () => { const previous = process.env.OPENCLAW_GATEWAY_PASSWORD; process.env.OPENCLAW_GATEWAY_PASSWORD = "resolved-gateway-password"; // pragma: allowlist secret + resolveSetupSecretInputString.mockResolvedValueOnce("resolved-gateway-password"); const select = vi.fn(async (params: { message: string }) => { if (params.message === "How do you want to hatch your bot?") { return "tui";