Files
openclaw/src/commands/configure.wizard.test.ts
T
Peter Steinberger c6b2ec28c8 refactor: mechanical dedup batch (protocol types, update-cli bridge, talk fallback) (#114432)
* refactor(onboarding): remove search setup barrel

* refactor(plugins): reuse detected package manifest

* test(update): replace global helper bridges

* refactor(talk): remove dead legacy response fallback

* refactor(protocol): derive root types from schema

The maintainer approved broadening the additive schema-backed type surface without a protocol version bump.

* fix(plugins): drop stale package path import

* test(protocol): type dynamic registry lookups

* docs(talk): explain canonical response boundary

* refactor(talk): enforce canonical response input

* fix(protocol): keep root type exports registry-free

* refactor(update): expose helpers through test facades

* fix(protocol): keep result types on leaf schema modules
2026-07-27 06:55:07 -04:00

1058 lines
35 KiB
TypeScript

// Configure wizard tests cover guided setup routing across gateway, auth, channels, skills, and search.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.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(),
ensureControlUiAssetsBuilt: vi.fn(),
createClackPrompter: vi.fn(),
note: vi.fn(),
printWizardHeader: vi.fn(),
probeGatewayReachable: vi.fn(),
waitForGatewayReachable: vi.fn(),
resolveAdvertisedControlUiLinks: vi.fn(),
resolveControlUiLinks: vi.fn(),
resolveLocalControlUiProbeLinks: vi.fn(),
inspectWindowsGatewayFirewall: vi.fn(),
summarizeExistingConfig: vi.fn(),
promptAuthConfig: vi.fn(),
promptGatewayConfig: vi.fn(),
promptRemoteGatewayConfig: vi.fn(async (cfg: 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",
},
}),
writeConfigFile: mocks.writeConfigFile,
replaceConfigFile: mocks.replaceConfigFile,
resolveGatewayPort: mocks.resolveGatewayPort,
}));
vi.mock("../infra/control-ui-assets.js", () => ({
ensureControlUiAssetsBuilt: mocks.ensureControlUiAssetsBuilt,
}));
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", () => ({
healthCommand: vi.fn(),
}));
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 { WizardCancelledError } from "../wizard/prompts.js";
import { maybeInstallDaemon } from "./configure.daemon.js";
import { runConfigureWizard } from "./configure.wizard.js";
const EMPTY_CONFIG_SNAPSHOT = {
exists: false,
valid: true,
config: {},
issues: [],
};
function createRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
function createSearchProviderOption(overrides: Record<string, unknown>) {
return overrides;
}
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,
},
},
});
}
function setupBaseWizardState(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() })),
});
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`expected ${label}`);
}
return value as Record<string, unknown>;
}
function mockCallArg(
mock: { mock: { calls: ReadonlyArray<ReadonlyArray<unknown>> } },
label: string,
callIndex = 0,
): unknown {
const call = mock.mock.calls[callIndex];
if (!call) {
throw new Error(`Expected ${label} call ${callIndex}`);
}
return call[0];
}
function requireWriteConfig(callIndex = 0) {
return requireRecord(
mockCallArg(mocks.writeConfigFile, "writeConfigFile", callIndex),
"written config",
);
}
function getGateway(config: Record<string, unknown>) {
return requireRecord(config.gateway, "gateway config");
}
function getWebSearch(config: Record<string, unknown>) {
const tools = requireRecord(config.tools, "tools config");
const web = requireRecord(tools.web, "web config");
return requireRecord(web.search, "web search config");
}
function getPluginEntry(config: Record<string, unknown>, pluginId: string) {
const plugins = requireRecord(config.plugins, "plugins config");
const entries = requireRecord(plugins.entries, "plugin entries");
return requireRecord(entries[pluginId], `${pluginId} entry`);
}
function queueWizardPrompts(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);
}
async function runWebConfigureWizard() {
await runConfigureWizard({ command: "configure", sections: ["web"] }, createRuntime());
}
describe("runConfigureWizard", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.assertConfigPathForWrite.mockImplementation(() => {});
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
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) => 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);
});
it("runs selected sections in canonical order and commits their combined config once", async () => {
setupBaseWizardState();
queueWizardPrompts({ select: ["local", "configure"], confirm: [] });
const events: string[] = [];
mocks.promptAuthConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => {
events.push("model");
return cfg;
});
mocks.promptGatewayConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => {
events.push("gateway");
return { config: cfg, port: 18789 };
});
mocks.setupChannels.mockImplementationOnce(async (cfg: OpenClawConfig) => {
events.push("channels");
return cfg;
});
mocks.writeConfigFile.mockImplementationOnce(async () => {
events.push("commit");
});
await runConfigureWizard(
{ command: "configure", sections: ["channels", "gateway", "model"] },
createRuntime(),
);
expect(events).toEqual(["model", "gateway", "channels", "commit"]);
expect(mocks.writeConfigFile).toHaveBeenCalledOnce();
});
it("commits every interactive section before running the next section", async () => {
setupBaseWizardState();
queueWizardPrompts({
select: ["local", "model", "gateway", "channels", "configure", "__continue"],
confirm: [],
});
const events: string[] = [];
mocks.promptAuthConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => {
events.push("model");
return cfg;
});
mocks.promptGatewayConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => {
events.push("gateway");
return { config: cfg, port: 18789 };
});
mocks.setupChannels.mockImplementationOnce(async (cfg: OpenClawConfig) => {
events.push("channels");
return cfg;
});
for (let index = 0; index < 3; index += 1) {
mocks.writeConfigFile.mockImplementationOnce(async () => {
events.push("commit");
});
}
await runConfigureWizard({ command: "configure" }, createRuntime());
expect(events).toEqual(["model", "commit", "gateway", "commit", "channels", "commit"]);
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(3);
});
it("commits selected gateway config before installing its configured daemon port", async () => {
setupBaseWizardState();
queueWizardPrompts({ select: ["local"], confirm: [] });
const events: string[] = [];
mocks.promptGatewayConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => {
events.push("gateway");
return { config: cfg, port: 18991 };
});
mocks.writeConfigFile.mockImplementationOnce(async () => {
events.push("commit");
});
vi.mocked(maybeInstallDaemon).mockImplementationOnce(async () => {
events.push("daemon");
});
await runConfigureWizard(
{ command: "configure", sections: ["daemon", "gateway"] },
createRuntime(),
);
expect(events).toEqual(["gateway", "commit", "daemon"]);
expect(maybeInstallDaemon).toHaveBeenCalledWith(expect.objectContaining({ port: 18991 }));
expect(mocks.clackText).not.toHaveBeenCalled();
});
it("persists gateway.mode=local when only the run mode is selected", async () => {
setupBaseWizardState();
queueWizardPrompts({
select: ["local", "__continue"],
confirm: [false],
});
await runConfigureWizard({ command: "configure" }, createRuntime());
expect(getGateway(requireWriteConfig()).mode).toBe("local");
const replaceParams = requireRecord(
mockCallArg(mocks.replaceConfigFile, "replaceConfigFile"),
"replace config params",
);
const writeOptions = requireRecord(replaceParams.writeOptions, "write options");
expect(Object.keys(writeOptions).toSorted()).toEqual([
"assertConfigPathForWrite",
"expectedConfigPath",
"ownedConfigPathForWrite",
]);
});
it("keeps startup gateway hint probes bounded", async () => {
setupBaseWizardState({
gateway: {
mode: "local",
remote: {
url: "wss://gateway.example.test",
token: "token",
},
},
});
await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime());
const probeRequests = mocks.probeGatewayReachable.mock.calls.map(([request]) =>
requireRecord(request, "probe request"),
);
const localProbe = probeRequests.find((request) => request.url === "ws://127.0.0.1:18789");
const remoteProbe = probeRequests.find(
(request) => request.url === "wss://gateway.example.test",
);
expect(localProbe?.timeoutMs).toBe(300);
expect(remoteProbe?.token).toBe("token");
expect(remoteProbe?.timeoutMs).toBe(300);
});
it("uses the resolved configured port for the local gateway startup hint", async () => {
setupBaseWizardState({
gateway: {
mode: "local",
port: 18991,
},
});
mocks.resolveGatewayPort.mockReturnValue(18991);
mocks.probeGatewayReachable
.mockResolvedValueOnce({ ok: true })
.mockResolvedValue({ ok: false });
mocks.clackSelect.mockResolvedValue("local");
await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime());
expect(mocks.probeGatewayReachable).toHaveBeenCalledWith(
expect.objectContaining({ url: "ws://127.0.0.1:18991", timeoutMs: 300 }),
);
expect(mocks.clackSelect).toHaveBeenCalledWith(
expect.objectContaining({
message: "Where will the Gateway run?",
options: expect.arrayContaining([
expect.objectContaining({
value: "local",
hint: "Gateway reachable (ws://127.0.0.1:18991)",
}),
]),
}),
);
});
it("advertises LAN Control UI links while probing the local gateway", async () => {
setupBaseWizardState({
gateway: {
mode: "local",
bind: "lan",
auth: { token: "token" },
},
});
mocks.resolveAdvertisedControlUiLinks.mockResolvedValueOnce({
httpUrl: "http://10.211.55.3:18789/",
wsUrl: "ws://10.211.55.3:18789",
});
mocks.resolveLocalControlUiProbeLinks.mockReturnValueOnce({
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
});
await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime());
expect(mocks.resolveAdvertisedControlUiLinks).toHaveBeenCalledWith(
expect.objectContaining({ bind: "lan", port: 18789 }),
);
expect(mocks.probeGatewayReachable).toHaveBeenCalledWith(
expect.objectContaining({ url: "ws://127.0.0.1:18789" }),
);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Web UI: http://10.211.55.3:18789/"),
"Control UI",
);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Gateway WS: ws://10.211.55.3:18789"),
"Control UI",
);
});
it("shows static Windows Firewall guidance for LAN Gateway links without inspection", async () => {
setupBaseWizardState({
gateway: {
mode: "local",
bind: "lan",
auth: { token: "token" },
},
});
await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime());
expect(mocks.inspectWindowsGatewayFirewall).not.toHaveBeenCalled();
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Windows firewall: if another device cannot connect to the LAN URL"),
"Control UI",
);
});
it("exits with code 1 when configure wizard is cancelled", async () => {
const runtime = createRuntime();
setupBaseWizardState();
mocks.clackSelect.mockRejectedValueOnce(new WizardCancelledError());
await runConfigureWizard({ command: "configure" }, runtime);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("uses nonzero exit semantics for cancellation at the first direct Clack prompt", async () => {
const runtime = createRuntime();
setupBaseWizardState();
mocks.guardCancel.mockImplementationOnce(
(_value: unknown, promptRuntime: RuntimeEnv, exitCode?: number) => {
promptRuntime.exit(exitCode ?? 0);
throw new Error("direct prompt cancelled");
},
);
await expect(runConfigureWizard({ command: "configure" }, runtime)).rejects.toThrow(
"direct prompt cancelled",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("does not gate model-only configure behind Gateway run-mode selection", async () => {
setupBaseWizardState();
await runConfigureWizard({ command: "configure", sections: ["model"] }, createRuntime());
expect(mocks.promptAuthConfig).toHaveBeenCalledOnce();
expect(mocks.clackSelect).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Where will the Gateway run?" }),
);
expect(mocks.probeGatewayReachable).not.toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: 300 }),
);
expect(mocks.ensureControlUiAssetsBuilt).not.toHaveBeenCalled();
expect(mocks.resolveControlUiLinks).not.toHaveBeenCalled();
expect(requireWriteConfig().gateway).toBeUndefined();
});
it("runs model-only configure for existing remote Gateway configs", async () => {
setupBaseWizardState({
gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } },
});
await runConfigureWizard({ command: "configure", sections: ["model"] }, createRuntime());
expect(mocks.promptAuthConfig).toHaveBeenCalledOnce();
expect(mocks.promptRemoteGatewayConfig).not.toHaveBeenCalled();
expect(getGateway(requireWriteConfig()).mode).toBe("remote");
expect(mocks.ensureControlUiAssetsBuilt).not.toHaveBeenCalled();
expect(mocks.resolveControlUiLinks).not.toHaveBeenCalled();
expect(mocks.probeGatewayReachable).not.toHaveBeenCalled();
expect(mocks.note).toHaveBeenCalledWith(
[
"Remote Gateway:",
"wss://gateway.example.test",
"Docs: https://docs.openclaw.ai/gateway/remote",
].join("\n"),
"Gateway",
);
});
it("persists provider-owned web search config changes returned by setupSearch", async () => {
setupBaseWizardState();
mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => {
const configured = createEnabledWebSearchConfig("firecrawl", {
enabled: true,
config: { webSearch: { apiKey: "fc-entered-key" } },
})(cfg);
return {
...configured,
tools: {
...configured.tools,
web: {
...configured.tools?.web,
fetch: { provider: "firecrawl" },
},
},
};
});
queueWizardPrompts({
select: [],
confirm: [true, true],
});
await runWebConfigureWizard();
const setupConfig = requireRecord(
mockCallArg(mocks.setupSearch, "setupSearch"),
"setupSearch config",
);
expect(setupConfig.gateway).toBeUndefined();
const written = requireWriteConfig();
const search = getWebSearch(written);
expect(search.provider).toBe("firecrawl");
expect(search.enabled).toBe(true);
const tools = requireRecord(written.tools, "tools config");
const web = requireRecord(tools.web, "web config");
expect(requireRecord(web.fetch, "web fetch config")).toEqual({
enabled: true,
provider: "firecrawl",
});
const firecrawl = getPluginEntry(written, "firecrawl");
expect(firecrawl.enabled).toBe(true);
const firecrawlConfig = requireRecord(firecrawl.config, "firecrawl config");
expect(requireRecord(firecrawlConfig.webSearch, "firecrawl web search").apiKey).toBe(
"fc-entered-key",
);
expect(mocks.setupSearch).toHaveBeenCalledOnce();
expect(mocks.setupSearch).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
{ preserveDisabledSearchState: false },
);
});
it("keeps web_search disabled when provider setup has no credential", async () => {
setupBaseWizardState();
mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => ({
...cfg,
tools: {
...cfg.tools,
web: {
...cfg.tools?.web,
fetch: { provider: "firecrawl" },
search: { enabled: false, provider: "firecrawl" },
},
},
}));
queueWizardPrompts({
select: [],
confirm: [true, true],
});
await runWebConfigureWizard();
const written = requireWriteConfig();
expect(getWebSearch(written)).toMatchObject({
enabled: false,
provider: "firecrawl",
});
const tools = requireRecord(written.tools, "tools config");
const web = requireRecord(tools.web, "web config");
expect(requireRecord(web.fetch, "web fetch config")).toEqual({
enabled: true,
provider: "firecrawl",
});
});
it("notes unavailable web search providers under plugin policy", async () => {
setupBaseWizardState();
mocks.resolveSearchProviderOptions.mockReturnValue([]);
queueWizardPrompts({
select: [],
confirm: [true, false],
});
await expect(runWebConfigureWizard()).resolves.toBeUndefined();
expect(mocks.note).toHaveBeenCalledWith(
[
"No web search providers are currently available under this plugin policy.",
"Enable plugins or remove deny rules, then rerun configure.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
expect(getWebSearch(requireWriteConfig()).enabled).toBe(false);
});
it("does not load managed search provider options when web search is disabled", async () => {
setupBaseWizardState();
queueWizardPrompts({
select: [],
confirm: [false, true],
});
await runWebConfigureWizard();
const ownersRequest = requireRecord(
mockCallArg(mocks.resolvePluginContributionOwners, "plugin owner request"),
"plugin owner request",
);
expect(ownersRequest.contribution).toBe("contracts");
expect(ownersRequest.matches).toBe("webSearchProviders");
expect(mocks.resolveSearchProviderOptions).not.toHaveBeenCalled();
expect(mocks.setupSearch).not.toHaveBeenCalled();
});
it("defers channel status checks until a channel is selected", async () => {
setupBaseWizardState();
queueWizardPrompts({
select: ["configure"],
confirm: [],
});
await runConfigureWizard({ command: "configure", sections: ["channels"] }, createRuntime());
const setupChannelsCall = mocks.setupChannels.mock.calls[0] as Array<unknown> | undefined;
const setupChannelsConfig = requireRecord(setupChannelsCall?.[0], "setupChannels config");
expect(setupChannelsConfig.gateway).toBeUndefined();
const setupChannelsOptions = requireRecord(setupChannelsCall?.[3], "setupChannels options");
expect(setupChannelsOptions.deferStatusUntilSelection).toBe(true);
expect(setupChannelsOptions.skipStatusNote).toBe(true);
});
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",
requiresCredential: false,
envVars: [],
placeholder: "(no key needed)",
signupUrl: "https://duckduckgo.com/",
docsUrl: "https://docs.openclaw.ai/tools/web",
credentialPath: "",
}),
]);
mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) =>
createEnabledWebSearchConfig("duckduckgo", {
enabled: true,
})(cfg),
);
queueWizardPrompts({
select: [],
confirm: [true, false],
});
await runWebConfigureWizard();
expect(mocks.clackText).not.toHaveBeenCalled();
expect(mocks.setupSearch).toHaveBeenCalledOnce();
});
it("can enable native Codex search without configuring a managed provider", async () => {
setupBaseWizardState({
auth: {
profiles: {
"openai:default": {
provider: "openai",
mode: "oauth",
},
},
},
});
queueWizardPrompts({
select: ["cached"],
confirm: [true, true, false, true],
});
await runWebConfigureWizard();
const search = getWebSearch(requireWriteConfig());
expect(search.enabled).toBe(true);
const codexSearch = requireRecord(search.openaiCodex, "Codex native search");
expect(codexSearch.enabled).toBe(true);
expect(codexSearch.mode).toBe("cached");
expect(mocks.setupSearch).not.toHaveBeenCalled();
expect(mocks.note).toHaveBeenCalledWith(
[
"Web search lets your agent look things up online using the `web_search` tool.",
"Codex-capable models can use native Codex web search.",
"Other models use a separate web search provider, which you can configure here.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
expect(mocks.note).toHaveBeenCalledWith(
[
"Codex-capable models can use native Codex web search instead of a separate provider.",
"Other models need a separate web search provider.",
"If you do not choose one, OpenClaw can select a provider from available credentials; otherwise other models may not have web search.",
].join("\n"),
"Codex native search",
);
expect(mocks.note).toHaveBeenCalledWith(
[
"`web_fetch` is a separate tool for reading a specific URL.",
"It does not require an API key and works independently of web search providers, including Codex.",
].join("\n"),
"Web fetch",
);
expect(mocks.clackConfirm).toHaveBeenCalledWith(
expect.objectContaining({ message: "Enable the web_search tool?" }),
);
expect(mocks.clackConfirm).toHaveBeenCalledWith(
expect.objectContaining({
message: "Enable native Codex web search for Codex-capable models?",
}),
);
expect(mocks.clackSelect).toHaveBeenCalledWith(
expect.objectContaining({ message: "Native Codex web search mode" }),
);
expect(mocks.clackConfirm).toHaveBeenCalledWith(
expect.objectContaining({
message: "Also configure a separate web search provider for other models?",
}),
);
expect(mocks.clackConfirm).toHaveBeenCalledWith(
expect.objectContaining({ message: "Enable the web_fetch tool?" }),
);
});
it("preserves disabled native Codex search when toggled off", async () => {
setupBaseWizardState({
auth: {
profiles: {
"openai:default": {
provider: "openai",
mode: "oauth",
},
},
},
tools: {
web: {
search: {
enabled: true,
openaiCodex: {
enabled: true,
mode: "live",
},
},
},
},
});
queueWizardPrompts({
select: ["firecrawl"],
confirm: [true, false, true, false],
});
await runWebConfigureWizard();
const search = getWebSearch(requireWriteConfig());
expect(search.enabled).toBe(true);
const codexSearch = requireRecord(search.openaiCodex, "Codex native search");
expect(codexSearch.enabled).toBe(false);
expect(codexSearch.mode).toBe("live");
expect(mocks.setupSearch).toHaveBeenCalledOnce();
});
it("retries without dropping nested plugin config written during wizard flow (issue #64188)", async () => {
const baseConfig: OpenClawConfig = {
plugins: {
entries: {
"github-copilot": {
enabled: false,
config: {
region: "us-east-1",
},
},
},
},
};
setupBaseWizardState(baseConfig);
queueWizardPrompts({
select: [],
confirm: [],
});
// Simulate plugin mutation: first replaceConfigFile call throws conflict,
// second call after hash refresh succeeds
let callCount = 0;
const originalHash = "hash-before-plugin-mutation";
const newHashAfterMutation = "hash-after-plugin-mutation";
const finalHashAfterWrite = "hash-after-wizard-write";
mocks.replaceConfigFile.mockImplementation(
async (params: { nextConfig: unknown; baseHash?: string }) => {
callCount++;
if (callCount === 1) {
// First call: simulate plugin mutating config during promptAuthConfig
expect(params.baseHash).toBe(originalHash);
throw new ConfigMutationConflictError("config changed since last load", {
currentHash: newHashAfterMutation,
});
}
// Second call: succeeds with refreshed hash
expect(params.baseHash).toBe(newHashAfterMutation);
await mocks.writeConfigFile(params.nextConfig);
},
);
// Mock readConfigFileSnapshot to return different hashes/configs on each call
mocks.readConfigFileSnapshot
.mockResolvedValueOnce({
...EMPTY_CONFIG_SNAPSHOT,
hash: originalHash,
config: baseConfig,
sourceConfig: baseConfig,
})
.mockResolvedValueOnce({
...EMPTY_CONFIG_SNAPSHOT,
hash: newHashAfterMutation,
config: {
plugins: {
entries: {
"github-copilot": {
enabled: false,
config: {
region: "us-east-1",
accessToken: "plugin-wrote-this",
},
},
},
},
},
sourceConfig: {
plugins: {
entries: {
"github-copilot": {
enabled: false,
config: {
region: "us-east-1",
accessToken: "plugin-wrote-this",
},
},
},
},
},
valid: true,
})
.mockResolvedValueOnce({
...EMPTY_CONFIG_SNAPSHOT,
hash: finalHashAfterWrite,
config: {},
});
await runConfigureWizard({ command: "configure", sections: ["workspace"] }, createRuntime());
// Verify retry happened: first call threw, second call succeeded
expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(2);
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
// Verify readConfigFileSnapshot was called: initial read, after conflict, after successful write
expect(mocks.readConfigFileSnapshot).toHaveBeenCalledTimes(3);
// Verify plugin-written nested config survived the retry merge.
const retryCall = mockCallArg(mocks.replaceConfigFile, "replaceConfigFile", 1) as {
nextConfig: Record<string, unknown>;
};
const agents = requireRecord(retryCall.nextConfig.agents, "agents config");
const defaults = requireRecord(agents.defaults, "agent defaults");
expect(String(defaults.workspace)).toContain("/.openclaw/workspace");
const githubCopilot = getPluginEntry(retryCall.nextConfig, "github-copilot");
expect(githubCopilot.enabled).toBe(false);
const pluginConfig = requireRecord(githubCopilot.config, "github-copilot config");
expect(pluginConfig.region).toBe("us-east-1");
expect(pluginConfig.accessToken).toBe("plugin-wrote-this");
});
it("does not retry after config path ownership changes", async () => {
setupBaseWizardState();
queueWizardPrompts({
select: [],
confirm: [],
});
mocks.assertConfigPathForWrite.mockImplementation(() => {
throw new ConfigMutationConflictError("config path changed since last load", {
currentHash: null,
retryable: false,
});
});
mocks.replaceConfigFile.mockImplementation(
async (params: {
nextConfig: unknown;
writeOptions?: { assertConfigPathForWrite?: () => void };
}) => {
params.writeOptions?.assertConfigPathForWrite?.();
await mocks.writeConfigFile(params.nextConfig);
},
);
await expect(
runConfigureWizard({ command: "configure", sections: ["workspace"] }, createRuntime()),
).rejects.toThrow("config path changed since last load");
expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1);
expect(mocks.readConfigFileSnapshot).toHaveBeenCalledTimes(1);
});
});