diff --git a/src/commands/configure.wizard-test-helpers.ts b/src/commands/configure.wizard-test-helpers.ts deleted file mode 100644 index c63ee6497d5b..000000000000 --- a/src/commands/configure.wizard-test-helpers.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { vi, type Mock } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; - -export const EMPTY_CONFIG_SNAPSHOT = { - exists: false, - valid: true, - config: {}, - issues: [], -}; - -export function createWizardTestRuntime() { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; -} - -type WizardStateMocks = { - readConfigFileSnapshot: Mock; - resolveGatewayPort: Mock; - probeGatewayReachable: Mock; - resolveControlUiLinks: Mock; - resolveLocalControlUiProbeLinks: Mock; - resolveAdvertisedControlUiLinks: Mock; - inspectWindowsGatewayFirewall: Mock; - summarizeExistingConfig: Mock; - createClackPrompter: Mock; -}; - -export function setupBaseWizardTestState(mocks: WizardStateMocks, config: OpenClawConfig = {}) { - mocks.readConfigFileSnapshot.mockResolvedValue({ ...EMPTY_CONFIG_SNAPSHOT, config }); - mocks.resolveGatewayPort.mockReturnValue(18789); - mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); - mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); - mocks.resolveLocalControlUiProbeLinks.mockReturnValue({ - httpUrl: "http://127.0.0.1:18789/", - wsUrl: "ws://127.0.0.1:18789", - }); - mocks.resolveAdvertisedControlUiLinks.mockResolvedValue({ - httpUrl: "http://127.0.0.1:18789/", - wsUrl: "ws://127.0.0.1:18789", - }); - mocks.inspectWindowsGatewayFirewall.mockResolvedValue({ - applies: false, - severity: "info", - code: "windows_firewall_not_applicable", - message: "Windows LAN firewall diagnostics do not apply.", - details: [], - }); - mocks.summarizeExistingConfig.mockReturnValue(""); - mocks.createClackPrompter.mockReturnValue({ - intro: vi.fn(async () => {}), - outro: vi.fn(async () => {}), - note: vi.fn(async () => {}), - select: vi.fn(async () => "firecrawl"), - multiselect: vi.fn(async () => []), - text: vi.fn(async () => ""), - confirm: vi.fn(async () => true), - progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), - }); -} - -export function queueWizardTestPrompts( - mocks: { - clackSelect: Mock; - clackConfirm: Mock; - clackText: Mock; - clackIntro: Mock; - clackOutro: Mock; - }, - params: { select: string[]; confirm: boolean[]; text?: string }, -) { - const selectQueue = [...params.select]; - const confirmQueue = [...params.confirm]; - mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); - mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); - mocks.clackText.mockResolvedValue(params.text ?? ""); - mocks.clackIntro.mockResolvedValue(undefined); - mocks.clackOutro.mockResolvedValue(undefined); -} - -export function createSearchProviderOption(overrides: Record) { - return overrides; -} - -export function createEnabledWebSearchConfig( - provider: string, - pluginEntry: Record, -) { - return (cfg: OpenClawConfig) => ({ - ...cfg, - tools: { - ...cfg.tools, - web: { - ...cfg.tools?.web, - search: { - provider, - enabled: true, - }, - }, - }, - plugins: { - ...cfg.plugins, - entries: { - ...cfg.plugins?.entries, - [provider]: pluginEntry, - }, - }, - }); -} diff --git a/src/commands/configure.wizard.gateway.test.ts b/src/commands/configure.wizard.gateway.test.ts index 217074c92b87..e12fdae6a1ae 100644 --- a/src/commands/configure.wizard.gateway.test.ts +++ b/src/commands/configure.wizard.gateway.test.ts @@ -4,234 +4,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { ExitError, type RuntimeEnv } from "../runtime.js"; import { withEnvAsync } from "../test-utils/env.js"; -import { - createWizardTestRuntime, - queueWizardTestPrompts, - setupBaseWizardTestState, -} from "./configure.wizard-test-helpers.js"; - -const mocks = vi.hoisted(() => { - const writeConfigFile = vi.fn(); - return { - clackIntro: vi.fn(), - clackOutro: vi.fn(), - clackSelect: vi.fn(), - clackText: vi.fn(), - clackConfirm: vi.fn(), - clackPassword: vi.fn(), - resolveSearchProviderOptions: vi.fn(), - resolvePluginContributionOwners: vi.fn(), - setupSearch: vi.fn(), - assertConfigPathForWrite: vi.fn(), - readConfigFileSnapshot: vi.fn(), - writeConfigFile, - replaceConfigFile: vi.fn( - async (params: { - nextConfig: unknown; - writeOptions?: { assertConfigPathForWrite?: () => void }; - }) => { - params.writeOptions?.assertConfigPathForWrite?.(); - await writeConfigFile(params.nextConfig); - }, - ), - resolveGatewayPort: vi.fn(), - createClackPrompter: vi.fn(), - note: vi.fn(), - printWizardHeader: vi.fn(), - probeGatewayReachable: vi.fn(), - waitForGatewayReachable: vi.fn(async () => ({ ok: true })), - resolveAdvertisedControlUiLinks: vi.fn(), - resolveControlUiLinks: vi.fn(), - resolveLocalControlUiProbeLinks: vi.fn(), - inspectWindowsGatewayFirewall: vi.fn(), - summarizeExistingConfig: vi.fn(), - healthCommand: vi.fn(), - promptAuthConfig: vi.fn(), - promptGatewayConfig: vi.fn(), - promptRemoteGatewayConfig: vi.fn( - async (cfg: OpenClawConfig): Promise => ({ - ...cfg, - gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } }, - }), - ), - isCodexNativeWebSearchRelevant: vi.fn(({ config }: { config: OpenClawConfig }) => - Boolean(config.auth?.profiles?.["openai:default"]), - ), - setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg), - guardCancel: vi.fn((value: unknown, _runtime: RuntimeEnv, _exitCode?: number) => value), - }; -}); - -vi.mock("@clack/prompts", () => ({ - intro: mocks.clackIntro, - outro: mocks.clackOutro, - select: mocks.clackSelect, - text: mocks.clackText, - confirm: mocks.clackConfirm, - password: mocks.clackPassword, -})); - -vi.mock("../config/config.js", () => ({ - CONFIG_PATH: "~/.openclaw/openclaw.json", - createConfigIO: () => ({ - readConfigFileSnapshotForWrite: async () => ({ - snapshot: await mocks.readConfigFileSnapshot(), - writeOptions: { - assertConfigPathForWrite: mocks.assertConfigPathForWrite, - expectedConfigPath: "/tmp/openclaw.json", - ownedConfigPathForWrite: "/tmp/openclaw.json", - }, - }), - }), - readConfigFileSnapshot: mocks.readConfigFileSnapshot, - readConfigFileSnapshotForWrite: async () => ({ - snapshot: await mocks.readConfigFileSnapshot(), - writeOptions: { - assertConfigPathForWrite: mocks.assertConfigPathForWrite, - envSnapshotForRestore: { SECRET: "resolved-secret" }, - expectedConfigPath: "/tmp/openclaw.json", - includeFileHashesForWrite: { "/tmp/plugins.json5": "stale-hash" }, - ownedConfigPathForWrite: "/tmp/openclaw.json", - }, - }), - resolveConfigWriteAfterWrite: (afterWrite?: { mode: string }) => afterWrite ?? { mode: "auto" }, - transformConfigFileWithRetry: async ( - params: Parameters[0], - ) => { - const maxAttempts = params.maxAttempts ?? 5; - for (let attempt = 0; ; attempt += 1) { - const snapshot = await mocks.readConfigFileSnapshot(); - const previousHash = snapshot.hash ?? null; - const config = - params.base === "runtime" - ? (snapshot.runtimeConfig ?? snapshot.config) - : (snapshot.sourceConfig ?? snapshot.config); - try { - const transformed = await params.transform(config, { snapshot, previousHash, attempt }); - const committed = await params.commit!({ - nextConfig: transformed.nextConfig, - snapshot, - ...(previousHash ? { baseHash: previousHash } : {}), - writeOptions: params.writeOptions, - afterWrite: { mode: "auto" }, - }); - return { nextConfig: committed.config }; - } catch (error) { - if ( - !(error instanceof Error) || - error.name !== "ConfigMutationConflictError" || - (error as { retryable?: boolean }).retryable === false || - attempt === maxAttempts - 1 - ) { - throw error; - } - } - } - }, - writeConfigFile: mocks.writeConfigFile, - replaceConfigFile: mocks.replaceConfigFile, - resolveGatewayPort: mocks.resolveGatewayPort, -})); - -vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({ - inspectWindowsGatewayFirewall: mocks.inspectWindowsGatewayFirewall, - formatWindowsGatewayFirewallGuidance: (params: { bind?: string }) => - params.bind === "lan" - ? [ - "Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.", - ] - : [], -})); - -vi.mock("../wizard/clack-prompter.js", () => ({ - createClackPrompter: mocks.createClackPrompter, -})); - -vi.mock("../../packages/terminal-core/src/note.js", () => ({ - note: mocks.note, -})); - -vi.mock("./onboard-helpers.js", () => ({ - DEFAULT_WORKSPACE: "~/.openclaw/workspace", - applyWizardMetadata: (cfg: OpenClawConfig) => cfg, - ensureWorkspaceAndSessions: vi.fn(), - guardCancel: mocks.guardCancel, - printWizardHeader: mocks.printWizardHeader, - probeGatewayReachable: mocks.probeGatewayReachable, - resolveAdvertisedControlUiLinks: mocks.resolveAdvertisedControlUiLinks, - resolveControlUiLinks: mocks.resolveControlUiLinks, - resolveLocalControlUiProbeLinks: mocks.resolveLocalControlUiProbeLinks, - summarizeExistingConfig: mocks.summarizeExistingConfig, - waitForGatewayReachable: mocks.waitForGatewayReachable, -})); - -vi.mock("./health.js", () => ({ - healthCommandNonExiting: mocks.healthCommand, -})); - -vi.mock("./health-format.js", () => ({ - formatHealthCheckFailure: vi.fn(), -})); - -vi.mock("./configure.gateway.js", () => ({ - promptGatewayConfig: mocks.promptGatewayConfig, -})); - -vi.mock("./configure.gateway-auth.js", () => ({ - promptAuthConfig: mocks.promptAuthConfig, -})); - -vi.mock("./configure.channels.js", () => ({ - removeChannelConfigWizard: vi.fn(), -})); - -vi.mock("./configure.daemon.js", () => ({ - maybeInstallDaemon: vi.fn(), -})); - -vi.mock("./onboard-remote.js", () => ({ - promptRemoteGatewayConfig: mocks.promptRemoteGatewayConfig, -})); - -vi.mock("./onboard-skills.js", () => ({ - setupSkills: vi.fn(), -})); - -vi.mock("./onboard-channels.js", () => ({ - setupChannels: mocks.setupChannels, -})); - -vi.mock("../flows/search-setup.js", () => ({ - resolveSearchProviderOptions: mocks.resolveSearchProviderOptions, - runSearchSetupFlow: mocks.setupSearch, -})); - -vi.mock("../plugins/plugin-registry.js", () => ({ - resolvePluginContributionOwners: mocks.resolvePluginContributionOwners, -})); - -vi.mock("../agents/codex-native-web-search.js", () => ({ - isCodexNativeWebSearchRelevant: mocks.isCodexNativeWebSearchRelevant, -})); - -vi.mock("../config/mutate.js", async () => { - const actual = await vi.importActual("../config/mutate.js"); - return { - ...actual, - ConfigMutationConflictError: actual.ConfigMutationConflictError, - }; -}); - import { WizardCancelledError } from "../wizard/prompts.js"; -import { maybeInstallDaemon } from "./configure.daemon.js"; -import { runConfigureWizard } from "./configure.wizard.js"; -import { formatHealthCheckFailure } from "./health-format.js"; +import { + createWizardTestRuntime as createRuntime, + queueWizardTestPrompts as queueWizardPrompts, + runConfigureWizard, + setupWizardTestDefaults, + setupBaseWizardTestState as setupBaseWizardState, + wizardTestMocks as mocks, +} from "./configure.wizard.test-support.js"; -const createRuntime = createWizardTestRuntime; - -function setupBaseWizardState(config: OpenClawConfig = {}) { - setupBaseWizardTestState(mocks, config); -} +const { maybeInstallDaemon, formatHealthCheckFailure } = mocks; const requireRecord = createRequireRecord("object", "expected-label"); @@ -258,42 +41,10 @@ function getGateway(config: Record) { return requireRecord(config.gateway, "gateway config"); } -function queueWizardPrompts(params: { select: string[]; confirm: boolean[]; text?: string }) { - queueWizardTestPrompts(mocks, params); -} - describe("runConfigureWizard", () => { beforeEach(() => { - vi.clearAllMocks(); - mocks.healthCommand.mockReset(); - mocks.assertConfigPathForWrite.mockImplementation(() => {}); - mocks.resolvePluginContributionOwners.mockReturnValue(["firecrawl"]); - mocks.resolveSearchProviderOptions.mockReturnValue([ - { - id: "firecrawl", - label: "Firecrawl Search", - hint: "Structured results with optional result scraping", - credentialLabel: "Firecrawl API key", - envVars: ["FIRECRAWL_API_KEY"], - placeholder: "fc-...", - signupUrl: "https://www.firecrawl.dev/", - credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", - }, - ]); - mocks.setupSearch.mockReset(); - mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => ({ - outcome: "completed", - config: cfg, - })); - mocks.promptAuthConfig.mockReset(); - mocks.promptAuthConfig.mockImplementation(async (cfg: OpenClawConfig) => cfg); - mocks.promptGatewayConfig.mockReset(); - mocks.promptGatewayConfig.mockImplementation(async (cfg: OpenClawConfig) => ({ - config: cfg, - port: 18789, - })); - mocks.guardCancel.mockReset(); - mocks.guardCancel.mockImplementation((value: unknown) => value); + vi.resetAllMocks(); + setupWizardTestDefaults(); }); it("runs selected sections in canonical order and commits their combined config once", async () => { diff --git a/src/commands/configure.wizard.test-support.ts b/src/commands/configure.wizard.test-support.ts new file mode 100644 index 000000000000..13fb7e2dcdab --- /dev/null +++ b/src/commands/configure.wizard.test-support.ts @@ -0,0 +1,328 @@ +import { vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; + +const wizardTestMocks = vi.hoisted(() => { + const writeConfigFile = vi.fn(); + return { + clackIntro: vi.fn(), + clackOutro: vi.fn(), + clackSelect: vi.fn(), + clackText: vi.fn(), + clackConfirm: vi.fn(), + clackPassword: vi.fn(), + resolveSearchProviderOptions: vi.fn(), + resolvePluginContributionOwners: vi.fn(), + setupSearch: vi.fn(), + assertConfigPathForWrite: vi.fn(), + readConfigFileSnapshot: vi.fn(), + writeConfigFile, + replaceConfigFile: vi.fn( + async (params: { + nextConfig: unknown; + writeOptions?: { assertConfigPathForWrite?: () => void }; + }) => { + params.writeOptions?.assertConfigPathForWrite?.(); + await writeConfigFile(params.nextConfig); + }, + ), + resolveGatewayPort: vi.fn(), + createClackPrompter: vi.fn(), + note: vi.fn(), + printWizardHeader: vi.fn(), + probeGatewayReachable: vi.fn(), + waitForGatewayReachable: vi.fn(async () => ({ ok: true })), + resolveAdvertisedControlUiLinks: vi.fn(), + resolveControlUiLinks: vi.fn(), + resolveLocalControlUiProbeLinks: vi.fn(), + inspectWindowsGatewayFirewall: vi.fn(), + summarizeExistingConfig: vi.fn(), + healthCommand: vi.fn(), + formatHealthCheckFailure: vi.fn(), + maybeInstallDaemon: vi.fn(), + promptAuthConfig: vi.fn(), + promptGatewayConfig: vi.fn(), + promptRemoteGatewayConfig: vi.fn( + async (cfg: OpenClawConfig): Promise => ({ + ...cfg, + gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } }, + }), + ), + isCodexNativeWebSearchRelevant: vi.fn(({ config }: { config: OpenClawConfig }) => + Boolean(config.auth?.profiles?.["openai:default"]), + ), + setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg), + guardCancel: vi.fn((value: unknown, _runtime: RuntimeEnv, _exitCode?: number) => value), + }; +}); + +vi.mock("@clack/prompts", () => ({ + intro: wizardTestMocks.clackIntro, + outro: wizardTestMocks.clackOutro, + select: wizardTestMocks.clackSelect, + text: wizardTestMocks.clackText, + confirm: wizardTestMocks.clackConfirm, + password: wizardTestMocks.clackPassword, +})); + +vi.mock("../config/config.js", () => ({ + CONFIG_PATH: "~/.openclaw/openclaw.json", + createConfigIO: () => ({ + readConfigFileSnapshotForWrite: async () => ({ + snapshot: await wizardTestMocks.readConfigFileSnapshot(), + writeOptions: { + assertConfigPathForWrite: wizardTestMocks.assertConfigPathForWrite, + expectedConfigPath: "/tmp/openclaw.json", + ownedConfigPathForWrite: "/tmp/openclaw.json", + }, + }), + }), + readConfigFileSnapshot: wizardTestMocks.readConfigFileSnapshot, + readConfigFileSnapshotForWrite: async () => ({ + snapshot: await wizardTestMocks.readConfigFileSnapshot(), + writeOptions: { + assertConfigPathForWrite: wizardTestMocks.assertConfigPathForWrite, + envSnapshotForRestore: { SECRET: "resolved-secret" }, + expectedConfigPath: "/tmp/openclaw.json", + includeFileHashesForWrite: { "/tmp/plugins.json5": "stale-hash" }, + ownedConfigPathForWrite: "/tmp/openclaw.json", + }, + }), + resolveConfigWriteAfterWrite: (afterWrite?: { mode: string }) => afterWrite ?? { mode: "auto" }, + transformConfigFileWithRetry: async ( + params: Parameters[0], + ) => { + const maxAttempts = params.maxAttempts ?? 5; + for (let attempt = 0; ; attempt += 1) { + const snapshot = await wizardTestMocks.readConfigFileSnapshot(); + const previousHash = snapshot.hash ?? null; + const config = + params.base === "runtime" + ? (snapshot.runtimeConfig ?? snapshot.config) + : (snapshot.sourceConfig ?? snapshot.config); + try { + const transformed = await params.transform(config, { snapshot, previousHash, attempt }); + const committed = await params.commit!({ + nextConfig: transformed.nextConfig, + snapshot, + ...(previousHash ? { baseHash: previousHash } : {}), + writeOptions: params.writeOptions, + afterWrite: { mode: "auto" }, + }); + return { nextConfig: committed.config }; + } catch (error) { + if ( + !(error instanceof Error) || + error.name !== "ConfigMutationConflictError" || + (error as { retryable?: boolean }).retryable === false || + attempt === maxAttempts - 1 + ) { + throw error; + } + } + } + }, + writeConfigFile: wizardTestMocks.writeConfigFile, + replaceConfigFile: wizardTestMocks.replaceConfigFile, + resolveGatewayPort: wizardTestMocks.resolveGatewayPort, +})); + +vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({ + inspectWindowsGatewayFirewall: wizardTestMocks.inspectWindowsGatewayFirewall, + formatWindowsGatewayFirewallGuidance: (params: { bind?: string }) => + params.bind === "lan" + ? [ + "Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.", + ] + : [], +})); + +vi.mock("../wizard/clack-prompter.js", () => ({ + createClackPrompter: wizardTestMocks.createClackPrompter, +})); + +vi.mock("../../packages/terminal-core/src/note.js", () => ({ + note: wizardTestMocks.note, +})); + +vi.mock("./onboard-helpers.js", () => ({ + DEFAULT_WORKSPACE: "~/.openclaw/workspace", + applyWizardMetadata: (cfg: OpenClawConfig) => cfg, + ensureWorkspaceAndSessions: vi.fn(), + guardCancel: wizardTestMocks.guardCancel, + printWizardHeader: wizardTestMocks.printWizardHeader, + probeGatewayReachable: wizardTestMocks.probeGatewayReachable, + resolveAdvertisedControlUiLinks: wizardTestMocks.resolveAdvertisedControlUiLinks, + resolveControlUiLinks: wizardTestMocks.resolveControlUiLinks, + resolveLocalControlUiProbeLinks: wizardTestMocks.resolveLocalControlUiProbeLinks, + summarizeExistingConfig: wizardTestMocks.summarizeExistingConfig, + waitForGatewayReachable: wizardTestMocks.waitForGatewayReachable, +})); + +vi.mock("./health.js", () => ({ + healthCommandNonExiting: wizardTestMocks.healthCommand, +})); + +vi.mock("./health-format.js", () => ({ + formatHealthCheckFailure: wizardTestMocks.formatHealthCheckFailure, +})); + +vi.mock("./configure.gateway.js", () => ({ + promptGatewayConfig: wizardTestMocks.promptGatewayConfig, +})); + +vi.mock("./configure.gateway-auth.js", () => ({ + promptAuthConfig: wizardTestMocks.promptAuthConfig, +})); + +vi.mock("./configure.channels.js", () => ({ + removeChannelConfigWizard: vi.fn(), +})); + +vi.mock("./configure.daemon.js", () => ({ + maybeInstallDaemon: wizardTestMocks.maybeInstallDaemon, +})); + +vi.mock("./onboard-remote.js", () => ({ + promptRemoteGatewayConfig: wizardTestMocks.promptRemoteGatewayConfig, +})); + +vi.mock("./onboard-skills.js", () => ({ + setupSkills: vi.fn(), +})); + +vi.mock("./onboard-channels.js", () => ({ + setupChannels: wizardTestMocks.setupChannels, +})); + +vi.mock("../flows/search-setup.js", () => ({ + resolveSearchProviderOptions: wizardTestMocks.resolveSearchProviderOptions, + runSearchSetupFlow: wizardTestMocks.setupSearch, +})); + +vi.mock("../plugins/plugin-registry.js", () => ({ + resolvePluginContributionOwners: wizardTestMocks.resolvePluginContributionOwners, +})); + +vi.mock("../agents/codex-native-web-search.js", () => ({ + isCodexNativeWebSearchRelevant: wizardTestMocks.isCodexNativeWebSearchRelevant, +})); + +// Load the wizard through this fixture so mocks register before its dependencies. +const { runConfigureWizard } = await import("./configure.wizard.js"); +export { runConfigureWizard, wizardTestMocks }; + +export function setupWizardTestDefaults() { + wizardTestMocks.assertConfigPathForWrite.mockImplementation(() => {}); + wizardTestMocks.resolvePluginContributionOwners.mockReturnValue(["firecrawl"]); + wizardTestMocks.resolveSearchProviderOptions.mockReturnValue([ + { + id: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results with optional result scraping", + credentialLabel: "Firecrawl API key", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://www.firecrawl.dev/", + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + }, + ]); + wizardTestMocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => ({ + outcome: "completed", + config: cfg, + })); + wizardTestMocks.promptAuthConfig.mockImplementation(async (cfg: OpenClawConfig) => cfg); + wizardTestMocks.promptGatewayConfig.mockImplementation(async (cfg: OpenClawConfig) => ({ + config: cfg, + port: 18789, + })); + wizardTestMocks.guardCancel.mockImplementation((value: unknown) => value); +} + +export const EMPTY_CONFIG_SNAPSHOT = { + exists: false, + valid: true, + config: {}, + issues: [], +}; + +export function createWizardTestRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; +} + +export function setupBaseWizardTestState(config: OpenClawConfig = {}) { + wizardTestMocks.readConfigFileSnapshot.mockResolvedValue({ ...EMPTY_CONFIG_SNAPSHOT, config }); + wizardTestMocks.resolveGatewayPort.mockReturnValue(18789); + wizardTestMocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + wizardTestMocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + wizardTestMocks.resolveLocalControlUiProbeLinks.mockReturnValue({ + httpUrl: "http://127.0.0.1:18789/", + wsUrl: "ws://127.0.0.1:18789", + }); + wizardTestMocks.resolveAdvertisedControlUiLinks.mockResolvedValue({ + httpUrl: "http://127.0.0.1:18789/", + wsUrl: "ws://127.0.0.1:18789", + }); + wizardTestMocks.inspectWindowsGatewayFirewall.mockResolvedValue({ + applies: false, + severity: "info", + code: "windows_firewall_not_applicable", + message: "Windows LAN firewall diagnostics do not apply.", + details: [], + }); + wizardTestMocks.summarizeExistingConfig.mockReturnValue(""); + wizardTestMocks.createClackPrompter.mockReturnValue({ + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async () => "firecrawl"), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => ""), + confirm: vi.fn(async () => true), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }); +} + +export function queueWizardTestPrompts(params: { + select: string[]; + confirm: boolean[]; + text?: string; +}) { + const selectQueue = [...params.select]; + const confirmQueue = [...params.confirm]; + wizardTestMocks.clackSelect.mockImplementation(async () => selectQueue.shift()); + wizardTestMocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); + wizardTestMocks.clackText.mockResolvedValue(params.text ?? ""); + wizardTestMocks.clackIntro.mockResolvedValue(undefined); + wizardTestMocks.clackOutro.mockResolvedValue(undefined); +} + +export function createEnabledWebSearchConfig( + provider: string, + pluginEntry: Record, +) { + return (cfg: OpenClawConfig) => ({ + ...cfg, + tools: { + ...cfg.tools, + web: { + ...cfg.tools?.web, + search: { + provider, + enabled: true, + }, + }, + }, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + [provider]: pluginEntry, + }, + }, + }); +} diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 4f3e53ad8fbe..8a2067a18ebb 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -2,236 +2,17 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { ConfigMutationConflictError } from "../config/mutate.js"; import { createEnabledWebSearchConfig, - createSearchProviderOption, - createWizardTestRuntime, + createWizardTestRuntime as createRuntime, EMPTY_CONFIG_SNAPSHOT, - queueWizardTestPrompts, - setupBaseWizardTestState, -} from "./configure.wizard-test-helpers.js"; - -const mocks = vi.hoisted(() => { - const writeConfigFile = vi.fn(); - return { - clackIntro: vi.fn(), - clackOutro: vi.fn(), - clackSelect: vi.fn(), - clackText: vi.fn(), - clackConfirm: vi.fn(), - clackPassword: vi.fn(), - resolveSearchProviderOptions: vi.fn(), - resolvePluginContributionOwners: vi.fn(), - setupSearch: vi.fn(), - assertConfigPathForWrite: vi.fn(), - readConfigFileSnapshot: vi.fn(), - writeConfigFile, - replaceConfigFile: vi.fn( - async (params: { - nextConfig: unknown; - writeOptions?: { assertConfigPathForWrite?: () => void }; - }) => { - params.writeOptions?.assertConfigPathForWrite?.(); - await writeConfigFile(params.nextConfig); - }, - ), - resolveGatewayPort: vi.fn(), - createClackPrompter: vi.fn(), - note: vi.fn(), - printWizardHeader: vi.fn(), - probeGatewayReachable: vi.fn(), - waitForGatewayReachable: vi.fn(async () => ({ ok: true })), - resolveAdvertisedControlUiLinks: vi.fn(), - resolveControlUiLinks: vi.fn(), - resolveLocalControlUiProbeLinks: vi.fn(), - inspectWindowsGatewayFirewall: vi.fn(), - summarizeExistingConfig: vi.fn(), - healthCommand: vi.fn(), - promptAuthConfig: vi.fn(), - promptGatewayConfig: vi.fn(), - promptRemoteGatewayConfig: vi.fn( - async (cfg: OpenClawConfig): Promise => ({ - ...cfg, - gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } }, - }), - ), - isCodexNativeWebSearchRelevant: vi.fn(({ config }: { config: OpenClawConfig }) => - Boolean(config.auth?.profiles?.["openai:default"]), - ), - setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg), - guardCancel: vi.fn((value: unknown, _runtime: RuntimeEnv, _exitCode?: number) => value), - }; -}); - -vi.mock("@clack/prompts", () => ({ - intro: mocks.clackIntro, - outro: mocks.clackOutro, - select: mocks.clackSelect, - text: mocks.clackText, - confirm: mocks.clackConfirm, - password: mocks.clackPassword, -})); - -vi.mock("../config/config.js", () => ({ - CONFIG_PATH: "~/.openclaw/openclaw.json", - createConfigIO: () => ({ - readConfigFileSnapshotForWrite: async () => ({ - snapshot: await mocks.readConfigFileSnapshot(), - writeOptions: { - assertConfigPathForWrite: mocks.assertConfigPathForWrite, - expectedConfigPath: "/tmp/openclaw.json", - ownedConfigPathForWrite: "/tmp/openclaw.json", - }, - }), - }), - readConfigFileSnapshot: mocks.readConfigFileSnapshot, - readConfigFileSnapshotForWrite: async () => ({ - snapshot: await mocks.readConfigFileSnapshot(), - writeOptions: { - assertConfigPathForWrite: mocks.assertConfigPathForWrite, - envSnapshotForRestore: { SECRET: "resolved-secret" }, - expectedConfigPath: "/tmp/openclaw.json", - includeFileHashesForWrite: { "/tmp/plugins.json5": "stale-hash" }, - ownedConfigPathForWrite: "/tmp/openclaw.json", - }, - }), - resolveConfigWriteAfterWrite: (afterWrite?: { mode: string }) => afterWrite ?? { mode: "auto" }, - transformConfigFileWithRetry: async ( - params: Parameters[0], - ) => { - const maxAttempts = params.maxAttempts ?? 5; - for (let attempt = 0; ; attempt += 1) { - const snapshot = await mocks.readConfigFileSnapshot(); - const previousHash = snapshot.hash ?? null; - const config = - params.base === "runtime" - ? (snapshot.runtimeConfig ?? snapshot.config) - : (snapshot.sourceConfig ?? snapshot.config); - try { - const transformed = await params.transform(config, { snapshot, previousHash, attempt }); - const committed = await params.commit!({ - nextConfig: transformed.nextConfig, - snapshot, - ...(previousHash ? { baseHash: previousHash } : {}), - writeOptions: params.writeOptions, - afterWrite: { mode: "auto" }, - }); - return { nextConfig: committed.config }; - } catch (error) { - if ( - !(error instanceof Error) || - error.name !== "ConfigMutationConflictError" || - (error as { retryable?: boolean }).retryable === false || - attempt === maxAttempts - 1 - ) { - throw error; - } - } - } - }, - writeConfigFile: mocks.writeConfigFile, - replaceConfigFile: mocks.replaceConfigFile, - resolveGatewayPort: mocks.resolveGatewayPort, -})); - -vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({ - inspectWindowsGatewayFirewall: mocks.inspectWindowsGatewayFirewall, - formatWindowsGatewayFirewallGuidance: (params: { bind?: string }) => - params.bind === "lan" - ? [ - "Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.", - ] - : [], -})); - -vi.mock("../wizard/clack-prompter.js", () => ({ - createClackPrompter: mocks.createClackPrompter, -})); - -vi.mock("../../packages/terminal-core/src/note.js", () => ({ - note: mocks.note, -})); - -vi.mock("./onboard-helpers.js", () => ({ - DEFAULT_WORKSPACE: "~/.openclaw/workspace", - applyWizardMetadata: (cfg: OpenClawConfig) => cfg, - ensureWorkspaceAndSessions: vi.fn(), - guardCancel: mocks.guardCancel, - printWizardHeader: mocks.printWizardHeader, - probeGatewayReachable: mocks.probeGatewayReachable, - resolveAdvertisedControlUiLinks: mocks.resolveAdvertisedControlUiLinks, - resolveControlUiLinks: mocks.resolveControlUiLinks, - resolveLocalControlUiProbeLinks: mocks.resolveLocalControlUiProbeLinks, - summarizeExistingConfig: mocks.summarizeExistingConfig, - waitForGatewayReachable: mocks.waitForGatewayReachable, -})); - -vi.mock("./health.js", () => ({ - healthCommandNonExiting: mocks.healthCommand, -})); - -vi.mock("./health-format.js", () => ({ - formatHealthCheckFailure: vi.fn(), -})); - -vi.mock("./configure.gateway.js", () => ({ - promptGatewayConfig: mocks.promptGatewayConfig, -})); - -vi.mock("./configure.gateway-auth.js", () => ({ - promptAuthConfig: mocks.promptAuthConfig, -})); - -vi.mock("./configure.channels.js", () => ({ - removeChannelConfigWizard: vi.fn(), -})); - -vi.mock("./configure.daemon.js", () => ({ - maybeInstallDaemon: vi.fn(), -})); - -vi.mock("./onboard-remote.js", () => ({ - promptRemoteGatewayConfig: mocks.promptRemoteGatewayConfig, -})); - -vi.mock("./onboard-skills.js", () => ({ - setupSkills: vi.fn(), -})); - -vi.mock("./onboard-channels.js", () => ({ - setupChannels: mocks.setupChannels, -})); - -vi.mock("../flows/search-setup.js", () => ({ - resolveSearchProviderOptions: mocks.resolveSearchProviderOptions, - runSearchSetupFlow: mocks.setupSearch, -})); - -vi.mock("../plugins/plugin-registry.js", () => ({ - resolvePluginContributionOwners: mocks.resolvePluginContributionOwners, -})); - -vi.mock("../agents/codex-native-web-search.js", () => ({ - isCodexNativeWebSearchRelevant: mocks.isCodexNativeWebSearchRelevant, -})); - -vi.mock("../config/mutate.js", async () => { - const actual = await vi.importActual("../config/mutate.js"); - return { - ...actual, - ConfigMutationConflictError: actual.ConfigMutationConflictError, - }; -}); - -import { ConfigMutationConflictError } from "../config/mutate.js"; -import { runConfigureWizard } from "./configure.wizard.js"; - -const createRuntime = createWizardTestRuntime; - -function setupBaseWizardState(config: OpenClawConfig = {}) { - setupBaseWizardTestState(mocks, config); -} + queueWizardTestPrompts as queueWizardPrompts, + runConfigureWizard, + setupWizardTestDefaults, + setupBaseWizardTestState as setupBaseWizardState, + wizardTestMocks as mocks, +} from "./configure.wizard.test-support.js"; const requireRecord = createRequireRecord("object", "expected-label"); @@ -266,46 +47,14 @@ function getPluginEntry(config: Record, pluginId: string) { return requireRecord(entries[pluginId], `${pluginId} entry`); } -function queueWizardPrompts(params: { select: string[]; confirm: boolean[]; text?: string }) { - queueWizardTestPrompts(mocks, params); -} - async function runWebConfigureWizard() { await runConfigureWizard({ command: "configure", sections: ["web"] }, createRuntime()); } describe("runConfigureWizard", () => { beforeEach(() => { - vi.clearAllMocks(); - mocks.healthCommand.mockReset(); - mocks.assertConfigPathForWrite.mockImplementation(() => {}); - mocks.resolvePluginContributionOwners.mockReturnValue(["firecrawl"]); - mocks.resolveSearchProviderOptions.mockReturnValue([ - { - id: "firecrawl", - label: "Firecrawl Search", - hint: "Structured results with optional result scraping", - credentialLabel: "Firecrawl API key", - envVars: ["FIRECRAWL_API_KEY"], - placeholder: "fc-...", - signupUrl: "https://www.firecrawl.dev/", - credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", - }, - ]); - mocks.setupSearch.mockReset(); - mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => ({ - outcome: "completed", - config: cfg, - })); - mocks.promptAuthConfig.mockReset(); - mocks.promptAuthConfig.mockImplementation(async (cfg: OpenClawConfig) => cfg); - mocks.promptGatewayConfig.mockReset(); - mocks.promptGatewayConfig.mockImplementation(async (cfg: OpenClawConfig) => ({ - config: cfg, - port: 18789, - })); - mocks.guardCancel.mockReset(); - mocks.guardCancel.mockImplementation((value: unknown) => value); + vi.resetAllMocks(); + setupWizardTestDefaults(); }); it("persists provider-owned web search config changes returned by setupSearch", async () => { @@ -462,7 +211,7 @@ describe("runConfigureWizard", () => { it("still supports keyless web search providers through the shared setup flow", async () => { setupBaseWizardState(); mocks.resolveSearchProviderOptions.mockReturnValue([ - createSearchProviderOption({ + { id: "duckduckgo", label: "DuckDuckGo Search (experimental)", hint: "Free fallback", @@ -472,7 +221,7 @@ describe("runConfigureWizard", () => { signupUrl: "https://duckduckgo.com/", docsUrl: "https://docs.openclaw.ai/tools/web", credentialPath: "", - }), + }, ]); mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => ({ outcome: "completed",