import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildProviderPluginMethodChoice, resolveProviderModelPickerEntries, resolveProviderPluginChoice, resolveProviderWizardOptions, runProviderModelSelectedHook, } from "./provider-wizard.js"; import type { ProviderPlugin } from "./types.js"; const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); vi.mock("./providers.runtime.js", () => ({ resolvePluginProviders, })); function makeProvider(overrides: Partial & Pick) { return { auth: [], ...overrides, } satisfies ProviderPlugin; } function createSglangSetupProvider() { return makeProvider({ id: "sglang", label: "SGLang", auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], wizard: { setup: { choiceLabel: "SGLang setup", groupId: "sglang", groupLabel: "SGLang", }, }, }); } function createSglangConfig() { return { plugins: { allow: ["sglang"], }, }; } function resolveWizardOptionsTwice(params: { config?: object; env: NodeJS.ProcessEnv; workspaceDir?: string; }) { resolveProviderWizardOptions({ config: params.config ?? createSglangConfig(), workspaceDir: params.workspaceDir ?? "/tmp/workspace", env: params.env, }); resolveProviderWizardOptions({ config: params.config ?? createSglangConfig(), workspaceDir: params.workspaceDir ?? "/tmp/workspace", env: params.env, }); } describe("provider wizard boundaries", () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); }); it("uses explicit setup choice ids and bound method ids", () => { const provider = makeProvider({ id: "vllm", label: "vLLM", auth: [ { id: "local", label: "Local", kind: "custom", run: vi.fn() }, { id: "cloud", label: "Cloud", kind: "custom", run: vi.fn() }, ], wizard: { setup: { choiceId: "self-hosted-vllm", methodId: "local", choiceLabel: "vLLM local", groupId: "local-runtimes", groupLabel: "Local runtimes", }, }, }); resolvePluginProviders.mockReturnValue([provider]); expect(resolveProviderWizardOptions({})).toEqual([ { value: "self-hosted-vllm", label: "vLLM local", groupId: "local-runtimes", groupLabel: "Local runtimes", }, ]); expect( resolveProviderPluginChoice({ providers: [provider], choice: "self-hosted-vllm", }), ).toEqual({ provider, method: provider.auth[0], wizard: provider.wizard?.setup, }); }); it("builds wizard options from method-level metadata", () => { const provider = makeProvider({ id: "openai", label: "OpenAI", auth: [ { id: "api-key", label: "OpenAI API key", kind: "api_key", wizard: { choiceId: "openai-api-key", choiceLabel: "OpenAI API key", groupId: "openai", groupLabel: "OpenAI", onboardingScopes: ["text-inference"], }, run: vi.fn(), }, ], }); resolvePluginProviders.mockReturnValue([provider]); expect(resolveProviderWizardOptions({})).toEqual([ { value: "openai-api-key", label: "OpenAI API key", groupId: "openai", groupLabel: "OpenAI", onboardingScopes: ["text-inference"], }, ]); expect( resolveProviderPluginChoice({ providers: [provider], choice: "openai-api-key", }), ).toEqual({ provider, method: provider.auth[0], wizard: provider.auth[0]?.wizard, }); }); it("preserves onboarding scopes on wizard options", () => { const provider = makeProvider({ id: "fal", label: "fal", auth: [ { id: "api-key", label: "fal API key", kind: "api_key", wizard: { choiceId: "fal-api-key", choiceLabel: "fal API key", groupId: "fal", groupLabel: "fal", onboardingScopes: ["image-generation"], }, run: vi.fn(), }, ], }); resolvePluginProviders.mockReturnValue([provider]); expect(resolveProviderWizardOptions({})).toEqual([ { value: "fal-api-key", label: "fal API key", groupId: "fal", groupLabel: "fal", onboardingScopes: ["image-generation"], }, ]); }); it("returns method wizard metadata for canonical choices", () => { const provider = makeProvider({ id: "anthropic", label: "Anthropic", auth: [ { id: "setup-token", label: "setup-token", kind: "token", wizard: { choiceId: "token", modelAllowlist: { allowedKeys: ["anthropic/claude-sonnet-4-6"], initialSelections: ["anthropic/claude-sonnet-4-6"], message: "Anthropic OAuth models", }, }, run: vi.fn(), }, ], }); expect( resolveProviderPluginChoice({ providers: [provider], choice: "token", }), ).toEqual({ provider, method: provider.auth[0], wizard: provider.auth[0]?.wizard, }); }); it("builds model-picker entries from plugin metadata and provider-method choices", () => { const provider = makeProvider({ id: "sglang", label: "SGLang", auth: [ { id: "server", label: "Server", kind: "custom", run: vi.fn() }, { id: "cloud", label: "Cloud", kind: "custom", run: vi.fn() }, ], wizard: { modelPicker: { label: "SGLang server", hint: "OpenAI-compatible local runtime", methodId: "server", }, }, }); resolvePluginProviders.mockReturnValue([provider]); expect(resolveProviderModelPickerEntries({})).toEqual([ { value: buildProviderPluginMethodChoice("sglang", "server"), label: "SGLang server", hint: "OpenAI-compatible local runtime", }, ]); }); it("reuses provider resolution across wizard consumers for the same config and env", () => { const provider = makeProvider({ id: "sglang", label: "SGLang", auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], wizard: { setup: { choiceLabel: "SGLang setup", groupId: "sglang", groupLabel: "SGLang", }, modelPicker: { label: "SGLang server", methodId: "server", }, }, }); const config = {}; const env = { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); expect( resolveProviderWizardOptions({ config, workspaceDir: "/tmp/workspace", env, }), ).toHaveLength(1); expect( resolveProviderModelPickerEntries({ config, workspaceDir: "/tmp/workspace", env, }), ).toHaveLength(1); expect(resolvePluginProviders).toHaveBeenCalledTimes(1); expect(resolvePluginProviders).toHaveBeenCalledWith({ config, workspaceDir: "/tmp/workspace", env, }); }); it("invalidates the wizard cache when config or env contents change in place", () => { const provider = createSglangSetupProvider(); const config = createSglangConfig(); const env = { OPENCLAW_HOME: "/tmp/openclaw-home-a" } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); expect( resolveProviderWizardOptions({ config, workspaceDir: "/tmp/workspace", env, }), ).toHaveLength(1); config.plugins.allow = ["vllm"]; env.OPENCLAW_HOME = "/tmp/openclaw-home-b"; expect( resolveProviderWizardOptions({ config, workspaceDir: "/tmp/workspace", env, }), ).toHaveLength(1); expect(resolvePluginProviders).toHaveBeenCalledTimes(2); }); it("skips provider-wizard memoization when plugin cache opt-outs are set", () => { const provider = createSglangSetupProvider(); const config = createSglangConfig(); const env = { OPENCLAW_HOME: "/tmp/openclaw-home", OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); resolveWizardOptionsTwice({ config, env }); expect(resolvePluginProviders).toHaveBeenCalledTimes(2); }); it("skips provider-wizard memoization when discovery cache ttl is zero", () => { const provider = createSglangSetupProvider(); const config = createSglangConfig(); const env = { OPENCLAW_HOME: "/tmp/openclaw-home", OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0", } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); resolveWizardOptionsTwice({ config, env }); expect(resolvePluginProviders).toHaveBeenCalledTimes(2); }); it("expires provider-wizard memoization after the shortest plugin cache ttl", () => { vi.useFakeTimers(); const provider = createSglangSetupProvider(); const config = {}; const env = { OPENCLAW_HOME: "/tmp/openclaw-home", OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5", OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "20", } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); resolveProviderWizardOptions({ config, workspaceDir: "/tmp/workspace", env, }); vi.advanceTimersByTime(4); resolveProviderWizardOptions({ config, workspaceDir: "/tmp/workspace", env, }); vi.advanceTimersByTime(2); resolveProviderWizardOptions({ config, workspaceDir: "/tmp/workspace", env, }); expect(resolvePluginProviders).toHaveBeenCalledTimes(2); }); it("invalidates provider-wizard snapshots when cache-control env values change in place", () => { const provider = createSglangSetupProvider(); const config = {}; const env = { OPENCLAW_HOME: "/tmp/openclaw-home", OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "1000", } as NodeJS.ProcessEnv; resolvePluginProviders.mockReturnValue([provider]); resolveProviderWizardOptions({ config, workspaceDir: "/tmp/workspace", env, }); env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS = "5"; resolveProviderWizardOptions({ config, workspaceDir: "/tmp/workspace", env, }); expect(resolvePluginProviders).toHaveBeenCalledTimes(2); }); it("routes model-selected hooks only to the matching provider", async () => { const matchingHook = vi.fn(async () => {}); const otherHook = vi.fn(async () => {}); resolvePluginProviders.mockReturnValue([ makeProvider({ id: "ollama", label: "Ollama", onModelSelected: otherHook, }), makeProvider({ id: "vllm", label: "vLLM", onModelSelected: matchingHook, }), ]); const env = { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv; await runProviderModelSelectedHook({ config: {}, model: "vllm/qwen3-coder", prompter: {} as never, agentDir: "/tmp/agent", workspaceDir: "/tmp/workspace", env, }); expect(resolvePluginProviders).toHaveBeenCalledWith({ config: {}, workspaceDir: "/tmp/workspace", env, }); expect(matchingHook).toHaveBeenCalledWith({ config: {}, model: "vllm/qwen3-coder", prompter: {}, agentDir: "/tmp/agent", workspaceDir: "/tmp/workspace", }); expect(otherHook).not.toHaveBeenCalled(); }); });