refactor(test): consolidate configure wizard fixtures (#130539)

* refactor(test): consolidate configure wizard fixtures

* refactor(test): own wizard fixture import ordering
This commit is contained in:
Peter Steinberger
2026-08-26 18:13:59 -07:00
committed by GitHub
parent 6f4a39c80e
commit d89de5c391
4 changed files with 351 additions and 634 deletions
@@ -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<string, unknown>) {
return overrides;
}
export function createEnabledWebSearchConfig(
provider: string,
pluginEntry: Record<string, unknown>,
) {
return (cfg: OpenClawConfig) => ({
...cfg,
tools: {
...cfg.tools,
web: {
...cfg.tools?.web,
search: {
provider,
enabled: true,
},
},
},
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
[provider]: pluginEntry,
},
},
});
}
+11 -260
View File
@@ -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<OpenClawConfig> => ({
...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<typeof import("../config/config.js").transformConfigFileWithRetry>[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<typeof import("../config/mutate.js")>("../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<string, unknown>) {
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 () => {
@@ -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<typeof import("./health-format.js").formatHealthCheckFailure>(),
maybeInstallDaemon: vi.fn<typeof import("./configure.daemon.js").maybeInstallDaemon>(),
promptAuthConfig: vi.fn(),
promptGatewayConfig: vi.fn(),
promptRemoteGatewayConfig: vi.fn(
async (cfg: OpenClawConfig): Promise<OpenClawConfig> => ({
...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<typeof import("../config/config.js").transformConfigFileWithRetry>[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<string, unknown>,
) {
return (cfg: OpenClawConfig) => ({
...cfg,
tools: {
...cfg.tools,
web: {
...cfg.tools?.web,
search: {
provider,
enabled: true,
},
},
},
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
[provider]: pluginEntry,
},
},
});
}
+12 -263
View File
@@ -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<OpenClawConfig> => ({
...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<typeof import("../config/config.js").transformConfigFileWithRetry>[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<typeof import("../config/mutate.js")>("../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<string, unknown>, 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",