Files
openclaw/src/wizard/setup.test.ts
Peter Steinberger 29b6f56254 feat(system-agent): host skills and web-search setup in Ask OpenClaw (#115130)
* feat(system-agent): host skills and search setup

* test(system-agent): align stop-message assertions with channel-specific labels

* fix(system-agent): report failed search-provider install honestly

* fix(system-agent): preserve nonfatal search setup skips

* test(flows): satisfy plugin-install mock return type

* test(system-agent): split setup-flow operation tests
2026-07-28 06:59:28 -04:00

2869 lines
88 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Setup wizard tests cover end-to-end onboarding prompt flows.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import {
readAuthProfileStoreForTest,
removeOAuthTestTempRoot,
} from "../agents/auth-profiles/oauth-test-utils.js";
import { upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginCompatibilityNotice } from "../plugins/status.js";
import type { ProviderAuthResult } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { WizardCancelledError, type WizardPrompter, type WizardSelectParams } from "./prompts.js";
import { runSetupWizard } from "./setup.js";
type ResolveProviderPluginChoice =
typeof import("../plugins/provider-auth-choice.runtime.js").resolveProviderPluginChoice;
type ResolvePluginProvidersRuntime =
typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginProviders;
type ResolvePluginSetupProvider =
typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginSetupProvider;
type ResolveManifestProviderAuthChoice =
typeof import("../plugins/provider-auth-choices.js").resolveManifestProviderAuthChoice;
type PromptDefaultModel = typeof import("../commands/model-picker.js").promptDefaultModel;
type ApplyAuthChoice = typeof import("../commands/auth-choice.js").applyAuthChoice;
type PrepareAuthChoice = typeof import("../commands/auth-choice.js").prepareAuthChoice;
type VerifySetupInferenceConfig =
typeof import("../system-agent/setup-inference.js").verifySetupInferenceConfig;
type ConfigureGatewayForSetup = typeof import("./setup.gateway-config.js").configureGatewayForSetup;
type RunSetupMigrationImport = typeof import("./setup.migration-import.js").runSetupMigrationImport;
type RunSearchSetupFlow = typeof import("../flows/search-setup.js").runSearchSetupFlow;
const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} })));
const keepCurrentAuthChoice = vi.hoisted(() => "__keep-current" as const);
const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn(async () => "skip"));
const applyAuthChoice = vi.hoisted(() =>
vi.fn<ApplyAuthChoice>(async (args) => ({ config: args.config })),
);
const prepareAuthChoice = vi.hoisted(() =>
vi.fn<PrepareAuthChoice>(async (args) => ({
...(await applyAuthChoice(args)),
authProfiles: [],
persistAuthProfiles: async () => {},
})),
);
const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider"));
const resolveManifestProviderAuthChoice = vi.hoisted(() =>
vi.fn<ResolveManifestProviderAuthChoice>(() => undefined),
);
const resolvePluginSetupProvider = vi.hoisted(() =>
vi.fn<ResolvePluginSetupProvider>(() => undefined),
);
const resolveProviderPluginChoice = vi.hoisted(() =>
vi.fn<ResolveProviderPluginChoice>(() => null),
);
const resolvePluginProvidersRuntime = vi.hoisted(() =>
vi.fn<ResolvePluginProvidersRuntime>(() => []),
);
const warnIfModelConfigLooksOff = vi.hoisted(() => vi.fn(async () => {}));
const applyPrimaryModel = vi.hoisted(() => vi.fn((cfg) => cfg));
const promptDefaultModel = vi.hoisted(() => vi.fn<PromptDefaultModel>(async () => ({})));
const promptCustomApiConfig = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config })));
const configureGatewayForSetup = vi.hoisted(() =>
vi.fn<ConfigureGatewayForSetup>(async (args) => ({
nextConfig: args.nextConfig,
settings: {
port: args.localPort ?? 18789,
bind: "loopback",
authMode: "token",
gatewayToken: "test-token",
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
})),
);
const finalizeSetupWizard = vi.hoisted(() =>
vi.fn(async (options) => {
if (!options.nextConfig?.tools?.web?.search?.provider) {
await options.prompter.note("Web search was skipped.", "Web search");
}
if (options.opts.skipUi) {
return { launchedTui: false };
}
const hatch = await options.prompter.select({
message: "How do you want to hatch your agent?",
options: [],
});
if (hatch !== "tui") {
return { launchedTui: false };
}
let message: string | undefined;
try {
await fs.stat(path.join(options.workspaceDir, DEFAULT_BOOTSTRAP_FILENAME));
message = "Wake up, my friend!";
} catch {
message = undefined;
}
await runTui({ local: true, deliver: false, message });
return { launchedTui: true };
}),
);
const listChannelPlugins = vi.hoisted(() => vi.fn(() => []));
const logConfigUpdated = vi.hoisted(() => vi.fn(() => {}));
const setupInternalHooks = vi.hoisted(() => vi.fn(async (cfg) => cfg));
const enableDefaultOnboardingInternalHooks = vi.hoisted(() =>
vi.fn((cfg) => ({
...cfg,
hooks: {
...(cfg as { hooks?: Record<string, unknown> }).hooks,
internal: {
...(cfg as { hooks?: { internal?: Record<string, unknown> } }).hooks?.internal,
entries: {
...(cfg as { hooks?: { internal?: { entries?: Record<string, unknown> } } }).hooks
?.internal?.entries,
"session-memory": { enabled: true },
},
},
},
})),
);
const detectSetupMigrationSources = vi.hoisted(() => vi.fn(async () => []));
const listSetupMigrationOptions = vi.hoisted(() => vi.fn(async () => []));
const runSetupMigrationImport = vi.hoisted(() =>
vi.fn<RunSetupMigrationImport>(async () => ({ kind: "no-imported-inference" })),
);
const runSetupMemoryImportStep = vi.hoisted(() => vi.fn(async () => {}));
const verifySetupInferenceConfig = vi.hoisted(() =>
vi.fn<VerifySetupInferenceConfig>(async () => ({
ok: true,
modelRef: "openai/gpt-5.5",
latencyMs: 250,
})),
);
const setupChannels = vi.hoisted(() =>
vi.fn(
async (cfg: unknown, _runtime?: unknown, _prompter?: WizardPrompter, _options?: unknown) => cfg,
),
);
const setupSkills = vi.hoisted(() => vi.fn(async (cfg) => cfg));
const runSearchSetupFlow = vi.hoisted(() =>
vi.fn<RunSearchSetupFlow>(async (config) => ({ outcome: "completed", config })),
);
const promptRemoteGatewayConfig = vi.hoisted(() => vi.fn(async (cfg) => cfg));
const validateGatewayWebSocketUrl = vi.hoisted(() =>
vi.fn<(value: string) => string | undefined>(() => undefined),
);
function providerPluginStub(
overrides: Partial<ProviderPlugin> & Pick<ProviderPlugin, "id">,
): ProviderPlugin {
const { id, ...rest } = overrides;
return {
id,
label: id || "provider",
auth: [],
...rest,
};
}
const healthCommand = vi.hoisted(() => vi.fn(async () => {}));
const ensureWorkspaceAndSessions = vi.hoisted(() => vi.fn(async () => {}));
const replaceConfigFile = vi.hoisted(() =>
vi.fn(async (params: { nextConfig: OpenClawConfig }) => ({ config: params.nextConfig })),
);
const resolveGatewayPort = vi.hoisted(() =>
vi.fn((_cfg?: unknown, env?: NodeJS.ProcessEnv) => {
const raw = env?.OPENCLAW_GATEWAY_PORT ?? process.env.OPENCLAW_GATEWAY_PORT;
const port = raw ? Number.parseInt(raw, 10) : Number.NaN;
return Number.isFinite(port) && port > 0 ? port : 18789;
}),
);
const readConfigFileSnapshot = vi.hoisted(() =>
vi.fn(async () => ({
path: "/tmp/.openclaw/openclaw.json",
exists: false,
raw: null as string | null,
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: undefined as OpenClawConfig | undefined,
valid: true,
config: {},
issues: [] as Array<{ path: string; message: string }>,
warnings: [] as Array<{ path: string; message: string }>,
legacyIssues: [] as Array<{ path: string; message: string }>,
})),
);
const createConfigIO = vi.hoisted(() =>
vi.fn(() => ({
readConfigFileSnapshot,
})),
);
const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {}));
const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true));
const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {}));
const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {}));
const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const buildPluginCompatibilitySnapshotNotices = vi.hoisted(() =>
vi.fn((): PluginCompatibilityNotice[] => []),
);
const formatPluginCompatibilityNotice = vi.hoisted(() =>
vi.fn((notice: PluginCompatibilityNotice) => `${notice.pluginId} ${notice.message}`),
);
function getWizardNoteCalls(note: WizardPrompter["note"]) {
return (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
}
function modelConfigWithApiKey(apiKey: string): OpenClawConfig {
return {
agents: {
defaults: { model: { primary: "openai/gpt-5.5" } },
entries: { main: { default: true } },
},
auth: {
profiles: { "openai:default": { provider: "openai", mode: "api_key" } },
order: { openai: ["openai:default"] },
},
models: {
providers: {
openai: {
apiKey,
baseUrl: "https://api.openai.com/v1",
models: [],
},
},
},
};
}
function stagedOpenAiProfile(apiKey: string) {
return {
profileId: "openai:default",
credential: { type: "api_key" as const, provider: "openai", key: apiKey },
};
}
function prepareMockAuthProfilesIn(
agentDir: string,
): Array<ProviderAuthResult["profiles"] | undefined> {
const persistCalls: Array<ProviderAuthResult["profiles"] | undefined> = [];
prepareAuthChoice.mockImplementation(async (args) => {
const result = await applyAuthChoice(args);
const apiKey = result.config.models?.providers?.openai?.apiKey;
if (typeof apiKey !== "string") {
return {
...result,
authProfiles: [],
persistAuthProfiles: async () => {},
};
}
const profile = stagedOpenAiProfile(apiKey);
return {
...result,
authProfiles: [profile],
persistAuthProfiles: async (profiles) => {
persistCalls.push(profiles);
for (const candidate of profiles ?? [profile]) {
const updated = await upsertAuthProfileWithLock({ ...candidate, agentDir });
if (!updated) {
throw new Error("test auth profile write failed");
}
}
},
};
});
return persistCalls;
}
function persistedWizardConfigs(): OpenClawConfig[] {
return (replaceConfigFile.mock.calls as unknown[][]).map(
([params]) => (params as { nextConfig: OpenClawConfig }).nextConfig,
);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`expected ${label} to be an object`);
}
return value as Record<string, unknown>;
}
function expectRecordFields(
value: unknown,
expected: Record<string, unknown>,
label: string,
): Record<string, unknown> {
const record = requireRecord(value, label);
for (const [key, expectedValue] of Object.entries(expected)) {
expect(record[key], `${label}.${key}`).toEqual(expectedValue);
}
return record;
}
function getMockCallArg(
mock: { mock: { calls: readonly unknown[][] } },
callIndex: number,
argIndex: number,
label: string,
): unknown {
const call = (mock.mock.calls as unknown[][])[callIndex];
if (!call) {
throw new Error(`expected ${label} call ${callIndex}`);
}
return call[argIndex];
}
function expectMockCallArgNotNull(
mock: { mock: { calls: readonly unknown[][] } },
callIndex: number,
argIndex: number,
label: string,
): void {
const value = getMockCallArg(mock, callIndex, argIndex, label);
if (value === null) {
throw new Error(`expected ${label} arg ${argIndex} to be non-null`);
}
}
vi.mock("../commands/onboard-channels.js", () => ({
setupChannels,
}));
vi.mock("../commands/onboard-skills.js", () => ({
setupSkills,
}));
vi.mock("../flows/search-setup.js", () => ({
runSearchSetupFlow,
}));
vi.mock("../commands/onboard-remote.js", () => ({
promptRemoteGatewayConfig,
validateGatewayWebSocketUrl,
}));
vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore,
}));
vi.mock("../agents/auth-profiles.runtime.js", () => ({
ensureAuthProfileStore,
}));
vi.mock("../commands/auth-choice-prompt.js", () => ({
KEEP_CURRENT_AUTH_CHOICE: keepCurrentAuthChoice,
promptAuthChoiceGrouped,
}));
vi.mock("../commands/auth-choice.js", () => ({
applyAuthChoice,
prepareAuthChoice,
resolvePreferredProviderForAuthChoice,
warnIfModelConfigLooksOff,
}));
vi.mock("../plugins/provider-auth-choices.js", () => ({
resolveManifestProviderAuthChoice,
}));
vi.mock("../plugins/setup-registry.js", () => ({
resolvePluginSetupProvider,
}));
vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({
resolveProviderPluginChoice,
resolvePluginProviders: resolvePluginProvidersRuntime,
}));
vi.mock("../commands/model-picker.js", () => ({
applyPrimaryModel,
promptDefaultModel,
}));
vi.mock("../commands/onboard-custom.js", () => ({
promptCustomApiConfig,
}));
vi.mock("../commands/health.js", () => ({
healthCommand,
}));
vi.mock("../commands/onboard-hooks.js", () => ({
enableDefaultOnboardingInternalHooks,
setupInternalHooks,
}));
vi.mock("./setup.migration-import.js", () => ({
detectSetupMigrationSources,
listSetupMigrationOptions,
runSetupMigrationImport,
}));
vi.mock("./setup.memory-import.js", () => ({
runSetupMemoryImportStep,
}));
vi.mock("../system-agent/setup-inference.js", () => ({
verifySetupInferenceConfig,
}));
vi.mock("../config/config.js", () => ({
DEFAULT_GATEWAY_PORT: 18789,
createConfigIO,
readConfigFileSnapshot,
resolveGatewayPort,
replaceConfigFile,
}));
vi.mock("../commands/onboard-agent.js", async () => {
const { resolveDefaultAgentId } = await import("../agents/agent-scope-config.js");
return {
ensureOnboardingConfig: async (config: OpenClawConfig) => ({
config,
agentId: resolveDefaultAgentId(config),
bootstrapPending: true,
}),
};
});
vi.mock("../commands/onboard-helpers.js", () => ({
DEFAULT_WORKSPACE: "/tmp/openclaw-workspace",
applyWizardMetadata: (cfg: unknown) => cfg,
summarizeExistingConfig: () => "summary",
handleReset: async () => {},
randomToken: () => "test-token",
normalizeGatewayTokenInput: (value: unknown) => ({
ok: true,
token: typeof value === "string" ? value.trim() : "",
error: null,
}),
validateGatewayPasswordInput: () => ({ ok: true, error: null }),
ensureWorkspaceAndSessions,
detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })),
openUrl: vi.fn(async () => true),
printWizardHeader: vi.fn(),
probeGatewayReachable,
waitForGatewayReachable: vi.fn(async () => {}),
formatControlUiSshHint: vi.fn(() => "ssh hint"),
resolveControlUiLinks: vi.fn(() => ({
httpUrl: "http://127.0.0.1:18789",
wsUrl: "ws://127.0.0.1:18789",
})),
}));
vi.mock("../commands/systemd-linger.js", () => ({
ensureSystemdUserLingerInteractive,
}));
vi.mock("../daemon/systemd.js", () => ({
isSystemdUserServiceAvailable,
}));
vi.mock("../infra/control-ui-assets.js", () => ({
ensureControlUiAssetsBuilt,
}));
vi.mock("../plugins/status.js", () => ({
buildPluginCompatibilitySnapshotNotices,
formatPluginCompatibilityNotice,
}));
vi.mock("../channels/plugins/index.js", () => ({
listChannelPlugins,
}));
vi.mock("../config/logging.js", () => ({
logConfigUpdated,
}));
vi.mock("../tui/tui.js", () => ({
runTui,
}));
vi.mock("./setup.gateway-config.js", () => ({
configureGatewayForSetup,
}));
vi.mock("./setup.finalize.js", () => ({
finalizeSetupWizard,
}));
vi.mock("./setup.completion.js", () => ({
setupWizardShellCompletion,
}));
function createRuntime(opts?: { throwsOnExit?: boolean }): RuntimeEnv {
if (opts?.throwsOnExit) {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
}
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
describe("runSetupWizard", () => {
let suiteRoot = "";
let suiteCase = 0;
beforeAll(async () => {
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-suite-"));
});
afterAll(async () => {
await fs.rm(suiteRoot, { recursive: true, force: true });
suiteRoot = "";
suiteCase = 0;
});
async function makeCaseDir(prefix: string): Promise<string> {
const dir = path.join(suiteRoot, `${prefix}${++suiteCase}`);
await fs.mkdir(dir, { recursive: true });
return dir;
}
function configSnapshot(
config: OpenClawConfig,
exists = true,
runtimeConfig: OpenClawConfig = config,
) {
return {
path: "/tmp/.openclaw/openclaw.json",
exists,
raw: exists ? "{}" : null,
parsed: config,
resolved: config,
sourceConfigBeforeMigrations: config,
valid: true as const,
runtimeConfig,
config: runtimeConfig,
issues: [],
warnings: [],
legacyIssues: [],
};
}
beforeEach(() => {
vi.clearAllMocks();
promptAuthChoiceGrouped.mockReset();
promptAuthChoiceGrouped.mockResolvedValue("skip");
applyAuthChoice.mockReset();
applyAuthChoice.mockImplementation(async (args) => ({ config: args.config }));
prepareAuthChoice.mockReset();
prepareAuthChoice.mockImplementation(async (args) => ({
...(await applyAuthChoice(args)),
authProfiles: [],
persistAuthProfiles: async () => {},
}));
setupChannels.mockReset();
setupChannels.mockImplementation(async (cfg) => cfg);
setupSkills.mockReset();
setupSkills.mockImplementation(async (cfg) => cfg);
runSearchSetupFlow.mockReset();
runSearchSetupFlow.mockImplementation(async (config: OpenClawConfig) => ({
outcome: "completed",
config,
}));
promptRemoteGatewayConfig.mockReset();
promptRemoteGatewayConfig.mockImplementation(async (cfg) => cfg);
validateGatewayWebSocketUrl.mockReset();
validateGatewayWebSocketUrl.mockReturnValue(undefined);
configureGatewayForSetup.mockReset();
configureGatewayForSetup.mockImplementation(async (args) => ({
nextConfig: args.nextConfig,
settings: {
port: args.localPort ?? 18789,
bind: "loopback",
authMode: "token",
gatewayToken: "test-token",
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
}));
readConfigFileSnapshot.mockReset();
readConfigFileSnapshot.mockResolvedValue(
configSnapshot({}, false, { agents: { entries: { main: { default: true } } } }),
);
probeGatewayReachable.mockReset();
probeGatewayReachable.mockResolvedValue({ ok: false });
resolvePreferredProviderForAuthChoice.mockReset();
resolvePreferredProviderForAuthChoice.mockResolvedValue("demo-provider");
resolvePluginProvidersRuntime.mockReset();
resolvePluginProvidersRuntime.mockReturnValue([]);
resolveManifestProviderAuthChoice.mockReset();
resolveManifestProviderAuthChoice.mockReturnValue(undefined);
resolvePluginSetupProvider.mockReset();
resolvePluginSetupProvider.mockReturnValue(undefined);
resolveProviderPluginChoice.mockReset();
resolveProviderPluginChoice.mockReturnValue(null);
promptDefaultModel.mockReset();
promptDefaultModel.mockResolvedValue({});
warnIfModelConfigLooksOff.mockReset();
warnIfModelConfigLooksOff.mockResolvedValue(undefined);
buildPluginCompatibilitySnapshotNotices.mockReset();
buildPluginCompatibilitySnapshotNotices.mockReturnValue([]);
runSetupMigrationImport.mockReset();
runSetupMigrationImport.mockResolvedValue({ kind: "no-imported-inference" });
verifySetupInferenceConfig.mockReset();
verifySetupInferenceConfig.mockResolvedValue({
ok: true,
modelRef: "openai/gpt-5.5",
latencyMs: 250,
});
runSetupMemoryImportStep.mockReset();
runSetupMemoryImportStep.mockResolvedValue(undefined);
});
it("exits successfully after the auto-launched TUI returns", async () => {
const caseDir = await makeCaseDir("tui-success-exit-");
await fs.writeFile(path.join(caseDir, DEFAULT_BOOTSTRAP_FILENAME), "");
const select = vi.fn(async ({ message }: WizardSelectParams<unknown>) => {
if (message === "How do you want to hatch your agent?") {
return "tui";
}
return "__skip__";
}) as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ select });
const runtime = createRuntime({ throwsOnExit: true });
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: false,
workspace: caseDir,
},
runtime,
prompter,
),
).rejects.toThrow("exit:0");
expect(runTui).toHaveBeenCalledWith({
local: true,
deliver: false,
message: "Wake up, my friend!",
});
});
it("skips provider entries without an id during preferred-provider lookup", async () => {
setupChannels.mockClear();
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: {},
valid: true,
config: { agents: { entries: { main: { default: true } } } },
issues: [],
warnings: [],
legacyIssues: [],
});
resolvePreferredProviderForAuthChoice.mockResolvedValueOnce("demo-provider");
resolvePluginProvidersRuntime.mockReturnValueOnce([
providerPluginStub({ id: "" }),
providerPluginStub({ id: "demo-provider", wizard: { setup: {} } }),
]);
const caseDir = await makeCaseDir("provider-missing-id-");
const select = vi.fn(async ({ message }: WizardSelectParams<unknown>) => {
if (message === "Setup mode") {
return "quickstart";
}
if (message === "Select channel (QuickStart)") {
return "__skip__";
}
if (message === "How do you want to hatch your agent?") {
return "skip";
}
return "skip";
}) as unknown as WizardPrompter["select"];
const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"];
const prompter = buildWizardPrompter({ select, confirm });
const runtime = createRuntime({ throwsOnExit: true });
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "ollama",
installDaemon: false,
skipSkills: true,
skipSearch: true,
skipChannels: false,
skipUi: true,
workspace: caseDir,
},
runtime,
prompter,
),
).resolves.toBeUndefined();
expectRecordFields(
getMockCallArg(resolvePreferredProviderForAuthChoice, 0, 0, "preferred provider lookup"),
{ choice: "ollama" },
"preferred provider lookup params",
);
expect(resolvePluginProvidersRuntime).toHaveBeenCalled();
setupChannels.mockClear();
});
it("exits when config is invalid", async () => {
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: {},
valid: false,
config: {},
issues: [{ path: "routing.allowFrom", message: "Legacy key" }],
warnings: [],
legacyIssues: [{ path: "routing.allowFrom", message: "Legacy key" }],
});
const select = vi.fn(
async (_params: WizardSelectParams<unknown>) => "quickstart",
) as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ select });
const runtime = createRuntime({ throwsOnExit: true });
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
),
).rejects.toThrow("exit:1");
expect(select).not.toHaveBeenCalled();
expect(prompter.outro).toHaveBeenCalled();
});
it("skips prompts and setup steps when flags are set", async () => {
const select = vi.fn(
async (_params: WizardSelectParams<unknown>) => "quickstart",
) as unknown as WizardPrompter["select"];
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
const plain: WizardPrompter["plain"] = vi.fn(async () => {});
const prompter = buildWizardPrompter({ select, multiselect, plain });
const runtime = createRuntime({ throwsOnExit: true });
createConfigIO.mockClear();
ensureAuthProfileStore.mockClear();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
expect(createConfigIO).toHaveBeenCalledWith({ pluginValidation: "skip" });
expect(plain).not.toHaveBeenCalled();
expect(select).not.toHaveBeenCalled();
expect(ensureAuthProfileStore).not.toHaveBeenCalled();
expect(setupChannels).not.toHaveBeenCalled();
expect(setupSkills).not.toHaveBeenCalled();
expect(healthCommand).not.toHaveBeenCalled();
expect(runTui).not.toHaveBeenCalled();
});
it("seeds interactive remote setup from command flags", async () => {
const remoteToken = "REDACTED";
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: {},
valid: true,
config: {
gateway: {
remote: {
url: "wss://stored.example.com:18789",
token: { source: "env", provider: "default", id: "STORED_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "STORED_GATEWAY_PASSWORD" },
},
},
},
issues: [],
warnings: [],
legacyIssues: [],
});
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "advanced",
mode: "remote",
remoteUrl: " wss://flag.example.com:18789 ",
remoteToken: ` ${remoteToken} `,
},
runtime,
prompter,
);
expect(probeGatewayReachable).toHaveBeenCalledWith({
url: "wss://flag.example.com:18789",
token: remoteToken,
});
expect(promptRemoteGatewayConfig).toHaveBeenCalledWith(
expect.objectContaining({
gateway: expect.objectContaining({
remote: {
url: "wss://flag.example.com:18789",
token: remoteToken,
password: undefined,
},
}),
}),
expect.any(Object),
{ secretInputMode: undefined },
);
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining(remoteToken));
});
it("does not reuse stored remote credentials for an overridden URL", async () => {
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: {},
valid: true,
config: {
gateway: {
remote: {
url: "wss://stored.example.com:18789",
token: { source: "env", provider: "default", id: "STORED_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "STORED_GATEWAY_PASSWORD" },
},
},
},
issues: [],
warnings: [],
legacyIssues: [],
});
await runSetupWizard(
{
acceptRisk: true,
flow: "advanced",
mode: "remote",
remoteUrl: "wss://flag.example.com:18789",
},
createRuntime(),
buildWizardPrompter({}),
);
expect(probeGatewayReachable).toHaveBeenCalledWith({
url: "wss://flag.example.com:18789",
token: undefined,
});
expect(promptRemoteGatewayConfig).toHaveBeenCalledWith(
expect.objectContaining({
gateway: expect.objectContaining({
remote: {
url: "wss://flag.example.com:18789",
token: undefined,
password: undefined,
},
}),
}),
expect.any(Object),
{ secretInputMode: undefined },
);
});
it("does not probe an invalid CLI remote URL with its token", async () => {
const remoteToken = "REDACTED";
validateGatewayWebSocketUrl.mockReturnValueOnce("Use wss:// for public gateways");
await runSetupWizard(
{
acceptRisk: true,
flow: "advanced",
mode: "remote",
remoteUrl: "ws://public.example",
remoteToken,
},
createRuntime(),
buildWizardPrompter({}),
);
expect(validateGatewayWebSocketUrl).toHaveBeenCalledWith("ws://public.example");
expect(probeGatewayReachable).not.toHaveBeenCalledWith({
url: "ws://public.example",
token: remoteToken,
});
});
it("auto-enables the bundled session-memory hook without showing the hooks screen", async () => {
replaceConfigFile.mockClear();
setupInternalHooks.mockClear();
enableDefaultOnboardingInternalHooks.mockClear();
const prompter = buildWizardPrompter({});
const runtime = createRuntime({ throwsOnExit: true });
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
expect(setupInternalHooks).not.toHaveBeenCalled();
expect(enableDefaultOnboardingInternalHooks).toHaveBeenCalledOnce();
const finalCallIndex = replaceConfigFile.mock.calls.length - 1;
const replaceParams = requireRecord(
getMockCallArg(replaceConfigFile, finalCallIndex, 0, "final config replacement"),
"final config replacement params",
);
const nextConfig = requireRecord(replaceParams.nextConfig, "next config");
const hooks = requireRecord(nextConfig.hooks, "next config hooks");
const internal = requireRecord(hooks.internal, "next config internal hooks");
const entries = requireRecord(internal.entries, "next config hook entries");
expect(entries["session-memory"]).toEqual({ enabled: true });
});
it("does not auto-enable default hooks when skipHooks is set", async () => {
replaceConfigFile.mockClear();
enableDefaultOnboardingInternalHooks.mockClear();
const prompter = buildWizardPrompter({});
const runtime = createRuntime({ throwsOnExit: true });
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
skipHooks: true,
},
runtime,
prompter,
);
expect(enableDefaultOnboardingInternalHooks).not.toHaveBeenCalled();
const finalCallIndex = replaceConfigFile.mock.calls.length - 1;
const replaceParams = requireRecord(
getMockCallArg(replaceConfigFile, finalCallIndex, 0, "final config replacement"),
"final config replacement params",
);
expect(requireRecord(replaceParams.nextConfig, "next config").hooks).toBeUndefined();
});
it("persists the first security acknowledgement", async () => {
replaceConfigFile.mockClear();
const note: WizardPrompter["note"] = vi.fn(async () => {});
const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"];
const prompter = buildWizardPrompter({ note, confirm });
const runtime = createRuntime({ throwsOnExit: true });
await runSetupWizard(
{
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
const calls = getWizardNoteCalls(note);
expect(calls[0]?.[1]).toBe("Security disclaimer");
expect(confirm).toHaveBeenCalledOnce();
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
initialValue: true,
layout: "vertical",
}),
);
const replaceParams = requireRecord(
getMockCallArg(replaceConfigFile, 0, 0, "config replacement"),
"config replacement params",
);
expect(
requireRecord(requireRecord(replaceParams.nextConfig, "next config").wizard, "wizard")
.securityAcknowledgedAt,
).toEqual(expect.any(String));
});
it("skips the security acknowledgement after it was accepted once", async () => {
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: {},
valid: true,
config: {
wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" },
agents: { entries: { main: { default: true } } },
},
issues: [],
warnings: [],
legacyIssues: [],
});
const note: WizardPrompter["note"] = vi.fn(async () => {});
const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"];
const prompter = buildWizardPrompter({ note, confirm });
const runtime = createRuntime({ throwsOnExit: true });
await runSetupWizard(
{
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
const titles = getWizardNoteCalls(note).map((call) => call?.[1]);
expect(titles).not.toContain("Security disclaimer");
expect(confirm).not.toHaveBeenCalled();
});
it("persists skipBootstrap and skips workspace bootstrap creation when requested", async () => {
ensureWorkspaceAndSessions.mockClear();
replaceConfigFile.mockClear();
const workspaceDir = await makeCaseDir("skip-bootstrap-");
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipBootstrap: true,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
runtime,
prompter,
);
const replaceParams = requireRecord(
getMockCallArg(replaceConfigFile, 0, 0, "config replacement"),
"config replacement params",
);
const nextConfig = requireRecord(replaceParams.nextConfig, "next config");
const agents = requireRecord(nextConfig.agents, "next config agents");
expectRecordFields(
requireRecord(agents.defaults, "next config agent defaults"),
{
skipBootstrap: true,
workspace: workspaceDir,
},
"next config agent defaults",
);
expectRecordFields(
replaceParams.writeOptions,
{ allowConfigSizeDrop: false },
"config replacement write options",
);
expect(getMockCallArg(ensureWorkspaceAndSessions, 0, 0, "workspace setup")).toBe(workspaceDir);
expect(getMockCallArg(ensureWorkspaceAndSessions, 0, 1, "workspace setup")).toBe(runtime);
expectRecordFields(
getMockCallArg(ensureWorkspaceAndSessions, 0, 2, "workspace setup"),
{ skipBootstrap: true },
"workspace setup options",
);
});
it("runs memory import after workspace bootstrap in QuickStart", async () => {
const workspaceDir = await makeCaseDir("memory-import-step-");
const prompter = buildWizardPrompter();
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
runtime,
prompter,
);
expect(runSetupMemoryImportStep).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({ workspace: workspaceDir }),
}),
}),
runtime,
}),
);
expect(ensureWorkspaceAndSessions.mock.invocationCallOrder[0]).toBeLessThan(
runSetupMemoryImportStep.mock.invocationCallOrder[0]!,
);
});
it("does not run the memory page after the full import flow", async () => {
const workspaceDir = await makeCaseDir("full-import-flow-");
const prompter = buildWizardPrompter();
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
importFrom: "hermes",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
runtime,
prompter,
);
expect(runSetupMigrationImport).toHaveBeenCalledOnce();
expect(runSetupMemoryImportStep).not.toHaveBeenCalled();
});
it("continues onboarding after a recovered promotion", async () => {
const workspaceDir = await makeCaseDir("resumed-import-flow-");
const acknowledgePromotion = vi.fn(async () => {});
runSetupMigrationImport.mockResolvedValueOnce({
kind: "no-imported-inference",
acknowledgePromotion,
});
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
importFrom: "hermes",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
createRuntime(),
buildWizardPrompter(),
);
expect(finalizeSetupWizard).toHaveBeenCalledOnce();
expect(acknowledgePromotion).toHaveBeenCalledOnce();
});
it("consumes a verified imported model without testing it twice", async () => {
const workspaceDir = await makeCaseDir("verified-import-flow-");
runSetupMigrationImport.mockResolvedValueOnce({
kind: "verified-inference",
modelRef: "openai/gpt-5.6-sol",
});
const importedConfig = {
agents: {
defaults: { model: { primary: "openai/gpt-5.6-sol" } },
entries: { main: { default: true } },
},
};
readConfigFileSnapshot
.mockResolvedValueOnce(
configSnapshot({}, false, { agents: { entries: { main: { default: true } } } }),
)
.mockResolvedValue(configSnapshot(importedConfig));
const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"];
const prompter = buildWizardPrompter({ confirm });
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
importFrom: "hermes",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
createRuntime(),
prompter,
);
expect(verifySetupInferenceConfig).not.toHaveBeenCalled();
expect(applyAuthChoice).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Test AI access now with a live completion?" }),
);
});
it("does not reuse verification when the recovered model changed", async () => {
const workspaceDir = await makeCaseDir("changed-verified-import-flow-");
runSetupMigrationImport.mockResolvedValueOnce({
kind: "verified-inference",
modelRef: "openai/gpt-5.6-sol",
});
const importedConfig = {
agents: {
defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } },
entries: { main: { default: true } },
},
};
readConfigFileSnapshot
.mockResolvedValueOnce(
configSnapshot({}, false, { agents: { entries: { main: { default: true } } } }),
)
.mockResolvedValue(configSnapshot(importedConfig));
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
importFrom: "hermes",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
createRuntime(),
buildWizardPrompter(),
);
expect(applyAuthChoice).toHaveBeenCalledOnce();
});
it("keeps verification optional when provider setup supplies the post-import model", async () => {
const workspaceDir = await makeCaseDir("provider-after-import-");
readConfigFileSnapshot
.mockResolvedValueOnce(
configSnapshot({}, false, { agents: { entries: { main: { default: true } } } }),
)
.mockResolvedValue(
configSnapshot({}, true, { agents: { entries: { main: { default: true } } } }),
);
applyAuthChoice.mockImplementation(async (args) => ({
config: {
...args.config,
agents: {
...args.config.agents,
defaults: {
...args.config.agents?.defaults,
model: { primary: "openai/gpt-5.6" },
},
},
},
}));
const confirm = vi.fn(async () => false) as unknown as WizardPrompter["confirm"];
const prompter = buildWizardPrompter({ confirm });
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
importFrom: "claude",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
createRuntime(),
prompter,
);
expect(applyAuthChoice).toHaveBeenCalledOnce();
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({ message: "Test AI access now with a live completion?" }),
);
expect(verifySetupInferenceConfig).not.toHaveBeenCalled();
expect(persistedWizardConfigs().at(-1)?.agents?.defaults?.model).toEqual({
primary: "openai/gpt-5.6",
});
});
it("preserves imported fleet workspace ownership until the user confirms a move", async () => {
const currentWorkspace = await makeCaseDir("imported-fleet-current-");
const requestedWorkspace = await makeCaseDir("imported-fleet-requested-");
const importedConfig: OpenClawConfig = {
agents: {
defaults: { workspace: currentWorkspace },
entries: { main: { default: true }, ops: {} },
},
};
readConfigFileSnapshot
.mockResolvedValueOnce(
configSnapshot({}, false, { agents: { entries: { main: { default: true } } } }),
)
.mockResolvedValue(configSnapshot(importedConfig));
const confirm = vi.fn(async () => false) as unknown as WizardPrompter["confirm"];
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
importFrom: "hermes",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: requestedWorkspace,
},
createRuntime(),
buildWizardPrompter({ confirm }),
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Move the existing agent fleet"),
initialValue: false,
}),
);
expect(persistedWizardConfigs().at(-1)?.agents?.defaults?.workspace).toBe(currentWorkspace);
});
it("treats --import-source alone as import intent instead of prompting for a setup mode", async () => {
const workspaceDir = await makeCaseDir("import-source-intent-");
const prompter = buildWizardPrompter();
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
importSource: "~/.hermes",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
runtime,
prompter,
);
expect(runSetupMigrationImport).toHaveBeenCalledOnce();
expect(runSetupMigrationImport).toHaveBeenCalledWith(
expect.objectContaining({
opts: expect.objectContaining({ importSource: "~/.hermes" }),
}),
);
expect(prompter.select).not.toHaveBeenCalled();
});
it("allows size-drop writes for pending plugin install record migration", async () => {
replaceConfigFile.mockClear();
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: {},
valid: true,
config: {
agents: { entries: { main: { default: true } } },
plugins: {
installs: {
demo: { source: "npm", spec: "@openclaw/demo-plugin" },
},
},
},
issues: [],
warnings: [],
legacyIssues: [],
});
const workspaceDir = await makeCaseDir("plugin-install-migration-");
const select = vi.fn(async ({ options }: WizardSelectParams<unknown>) => {
const values = options.map((option) => option.value);
if (values.includes("keep")) {
return "keep";
}
if (values.includes("quickstart")) {
return "quickstart";
}
if (values.includes("__skip__")) {
return "__skip__";
}
return values[0];
}) as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ select });
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipBootstrap: true,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
runtime,
prompter,
);
// Migration write + pre-channels persist + post-channels write + final write.
expect(replaceConfigFile).toHaveBeenCalledTimes(4);
const migrationParams = requireRecord(
getMockCallArg(replaceConfigFile, 0, 0, "migration config replacement"),
"migration config replacement params",
);
expect(
requireRecord(migrationParams.nextConfig, "migration next config").plugins,
).toBeUndefined();
const migrationWriteOptions = expectRecordFields(
migrationParams.writeOptions,
{ allowConfigSizeDrop: true },
"migration config replacement write options",
);
expect(migrationWriteOptions.unsetPaths).toContainEqual(["plugins", "installs"]);
const replaceParams = requireRecord(
getMockCallArg(replaceConfigFile, 3, 0, "config replacement"),
"config replacement params",
);
expect(requireRecord(replaceParams.nextConfig, "next config").plugins).toBeUndefined();
expectRecordFields(
replaceParams.writeOptions,
{ allowConfigSizeDrop: false },
"config replacement write options",
);
});
it("fails fast if the auth choice prompt returns nothing", async () => {
promptAuthChoiceGrouped.mockImplementationOnce(async () => undefined as never);
const prompter = buildWizardPrompter();
const runtime = createRuntime();
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
),
).rejects.toThrow("auth choice is required");
});
it("keeps current model auth config when the matching provider keep option is selected", async () => {
promptAuthChoiceGrouped.mockClear();
applyAuthChoice.mockClear();
promptDefaultModel.mockClear();
replaceConfigFile.mockClear();
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: {},
valid: true,
config: {
wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" },
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5",
},
},
entries: { main: { default: true } },
},
},
issues: [],
warnings: [],
legacyIssues: [],
});
promptAuthChoiceGrouped.mockResolvedValueOnce(keepCurrentAuthChoice);
const workspaceDir = await makeCaseDir("keep-provider-config-");
const prompter = buildWizardPrompter();
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
runtime,
prompter,
);
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
expectRecordFields(
getMockCallArg(promptAuthChoiceGrouped, 0, 0, "auth choice prompt"),
{
includeSkip: true,
allowKeepCurrentProvider: true,
},
"auth choice prompt params",
);
expect(applyAuthChoice).not.toHaveBeenCalled();
expect(promptDefaultModel).not.toHaveBeenCalled();
const finalCallIndex = replaceConfigFile.mock.calls.length - 1;
const replaceParams = requireRecord(
getMockCallArg(replaceConfigFile, finalCallIndex, 0, "final config replacement"),
"final config replacement params",
);
const nextConfig = requireRecord(replaceParams.nextConfig, "next config");
const agents = requireRecord(nextConfig.agents, "next config agents");
const defaults = requireRecord(agents.defaults, "next config agent defaults");
const model = requireRecord(defaults.model, "next config default model");
expect(model.primary).toBe("openai/gpt-5.5");
});
it("moves an existing fleet workspace only after explicit confirmation", async () => {
const currentWorkspace = await makeCaseDir("current-fleet-workspace-");
const requestedWorkspace = await makeCaseDir("requested-fleet-workspace-");
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: {
agents: {
defaults: { workspace: currentWorkspace },
list: [{ id: "main", default: true }, { id: "ops" }],
},
},
valid: true,
config: {
wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" },
agents: {
defaults: { workspace: currentWorkspace },
list: [{ id: "main", default: true }, { id: "ops" }],
},
},
issues: [],
warnings: [],
legacyIssues: [],
});
const confirm = vi.fn(async () => true);
const prompter = buildWizardPrompter({ confirm });
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: requestedWorkspace,
},
createRuntime(),
prompter,
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Move the existing agent fleet"),
initialValue: false,
}),
);
expect(getWizardNoteCalls(prompter.note).flat().join("\n")).toContain(currentWorkspace);
const finalConfig = persistedWizardConfigs().at(-1);
expect(finalConfig?.agents?.defaults?.workspace).toBe(requestedWorkspace);
expect(ensureWorkspaceAndSessions).toHaveBeenCalledWith(
requestedWorkspace,
expect.anything(),
expect.any(Object),
);
});
async function runTuiHatchTestAndExpectLaunch(params: {
writeBootstrapFile: boolean;
expectedMessage: string | undefined;
}) {
runTui.mockClear();
const workspaceDir = await makeCaseDir("workspace-");
if (params.writeBootstrapFile) {
await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}");
}
const select = vi.fn(async (opts: WizardSelectParams<unknown>) => {
if (opts.message === "How do you want to hatch your agent?") {
return "tui";
}
return "quickstart";
}) as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ select });
const runtime = createRuntime({ throwsOnExit: true });
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
mode: "local",
workspace: workspaceDir,
authChoice: "skip",
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
installDaemon: false,
},
runtime,
prompter,
),
).rejects.toThrow("exit:0");
expectRecordFields(
getMockCallArg(runTui, 0, 0, "tui launch"),
{
local: true,
deliver: false,
message: params.expectedMessage,
},
"tui launch options",
);
}
it("launches TUI without auto-delivery when hatching", async () => {
await runTuiHatchTestAndExpectLaunch({
writeBootstrapFile: true,
expectedMessage: "Wake up, my friend!",
});
});
it("offers TUI hatch even without BOOTSTRAP.md", async () => {
await runTuiHatchTestAndExpectLaunch({
writeBootstrapFile: false,
expectedMessage: undefined,
});
});
it("shows the web search hint at the end of setup", async () => {
const prevBraveKey = process.env.BRAVE_API_KEY;
delete process.env.BRAVE_API_KEY;
try {
const note: WizardPrompter["note"] = vi.fn(async () => {});
const prompter = buildWizardPrompter({ note });
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
const calls = getWizardNoteCalls(note);
expect(calls.length).toBeGreaterThan(0);
const noteTitles = calls.map((call) => call?.[1]);
expect(noteTitles).toContain("Web search");
} finally {
if (prevBraveKey === undefined) {
delete process.env.BRAVE_API_KEY;
} else {
process.env.BRAVE_API_KEY = prevBraveKey;
}
}
});
it("continues onboarding when search-provider installation fails", async () => {
const config: OpenClawConfig = { agents: { defaults: { workspace: "/tmp/workspace" } } };
runSearchSetupFlow.mockResolvedValueOnce({
outcome: "install-failed",
config,
providerId: "brave",
reason: "failed",
});
readConfigFileSnapshot.mockResolvedValueOnce(configSnapshot(config));
const prompter = buildWizardPrompter({});
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipHealth: true,
skipUi: true,
},
createRuntime(),
prompter,
),
).resolves.toBeUndefined();
expect(runSearchSetupFlow).toHaveBeenCalledOnce();
expect(finalizeSetupWizard).toHaveBeenCalledOnce();
});
it("defers channel setup plugin loads during QuickStart until a channel is selected", async () => {
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: false,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
expectMockCallArgNotNull(setupChannels, 0, 0, "channel setup");
expectMockCallArgNotNull(setupChannels, 0, 1, "channel setup");
expectMockCallArgNotNull(setupChannels, 0, 2, "channel setup");
expectRecordFields(
getMockCallArg(setupChannels, 0, 3, "channel setup"),
{
deferStatusUntilSelection: true,
quickstartDefaults: true,
},
"channel setup options",
);
});
it("disables back navigation before side-effecting channel setup", async () => {
setupChannels.mockImplementationOnce(async (cfg, _runtime, channelPrompter) => {
if (!channelPrompter) {
throw new Error("expected channel setup prompter");
}
await channelPrompter.select({
message: "Channel side effect",
options: [{ value: "continue", label: "Continue" }],
});
return cfg;
});
const select = vi.fn(async () => "continue") as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ select });
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: false,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
expect(setupChannels).toHaveBeenCalledOnce();
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
message: "Channel side effect",
navigation: { canGoBack: false, canGoForward: false },
}),
);
});
it("prompts for a model during explicit interactive Ollama setup", async () => {
promptDefaultModel.mockClear();
warnIfModelConfigLooksOff.mockClear();
resolveProviderPluginChoice.mockReturnValue({
provider: {
id: "ollama",
label: "Ollama",
auth: [],
wizard: {
setup: {
modelSelection: {
promptWhenAuthChoiceProvided: true,
allowKeepCurrent: false,
},
},
},
},
method: {
id: "local",
label: "Ollama",
kind: "custom",
run: vi.fn(async () => ({ profiles: [] })),
},
wizard: {
modelSelection: {
promptWhenAuthChoiceProvided: true,
allowKeepCurrent: false,
},
},
});
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "ollama",
installDaemon: false,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
expectRecordFields(
getMockCallArg(promptDefaultModel, 0, 0, "default model prompt"),
{
allowKeep: false,
browseCatalogOnDemand: true,
},
"default model prompt params",
);
expectMockCallArgNotNull(warnIfModelConfigLooksOff, 0, 0, "model warning");
expectMockCallArgNotNull(warnIfModelConfigLooksOff, 0, 1, "model warning");
expectRecordFields(
getMockCallArg(warnIfModelConfigLooksOff, 0, 2, "model warning"),
{ validateCatalog: false },
"model warning options",
);
});
it("re-prompts for auth when applyAuthChoice requests retry selection", async () => {
promptAuthChoiceGrouped.mockReset();
promptAuthChoiceGrouped
.mockResolvedValueOnce("demo-provider-one")
.mockResolvedValueOnce("demo-provider-two");
applyAuthChoice.mockReset();
applyAuthChoice
.mockImplementationOnce(async (args) => ({
config: {
...args.config,
plugins: {
...args.config.plugins,
entries: {
...args.config.plugins?.entries,
"demo-provider-plugin": {
enabled: true,
},
},
},
},
retrySelection: true,
}))
.mockImplementationOnce(async (args) => ({
config: {
...args.config,
agents: {
...args.config.agents,
defaults: {
...args.config.agents?.defaults,
model: {
primary: "demo-provider-two/model",
},
},
},
},
}));
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
expect(promptAuthChoiceGrouped).toHaveBeenCalledTimes(2);
expect(applyAuthChoice).toHaveBeenCalledTimes(2);
const retryParams = requireRecord(
getMockCallArg(applyAuthChoice, 1, 0, "retry auth choice"),
"retry auth choice params",
);
expect(retryParams.authChoice).toBe("demo-provider-two");
const retryConfig = requireRecord(retryParams.config, "retry auth choice config");
expect(requireRecord(retryConfig.plugins, "retry plugins").entries).toEqual({
"demo-provider-plugin": { enabled: true },
});
const retryAgents = requireRecord(retryConfig.agents, "retry agents");
expect(retryAgents.entries).toEqual({ main: { default: true } });
expect(requireRecord(retryAgents.defaults, "retry defaults").workspace).toBe(
"/tmp/openclaw-workspace",
);
});
it("forwards provider-specific auth flags to applyAuthChoice opts", async () => {
applyAuthChoice.mockReset();
applyAuthChoice.mockImplementationOnce(async (args) => ({
config: {
...args.config,
agents: {
...args.config.agents,
defaults: {
...args.config.agents?.defaults,
model: { primary: "openai/gpt-5.5" },
},
},
},
}));
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "openai-chatgpt-api-key",
openaiApiKey: "sk-flag-value",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
skipHooks: true,
},
runtime,
prompter,
);
expect(applyAuthChoice).toHaveBeenCalledTimes(1);
const call = getMockCallArg(applyAuthChoice, 0, 0, "openai auth choice");
const opts = (call as { opts?: Record<string, unknown> }).opts ?? {};
expect(opts.openaiApiKey).toBe("sk-flag-value");
});
it("passes preserveExistingDefaultModel to applyAuthChoice to protect existing default model", async () => {
applyAuthChoice.mockReset();
applyAuthChoice.mockImplementationOnce(async (args) => ({
config: {
...args.config,
agents: {
...args.config.agents,
defaults: {
...args.config.agents?.defaults,
model: { primary: "google/gemini-3.1-pro-preview" },
},
},
},
}));
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "google-api-key",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
expect(applyAuthChoice).toHaveBeenCalledTimes(1);
const call = getMockCallArg(applyAuthChoice, 0, 0, "google auth choice");
// Preserve the user's existing default model when a new provider is
// configured through the setup wizard, matching the contract already
// used in configure.gateway-auth.ts. Without this flag, configuring a
// paid Google Gemini key would silently overwrite the user's default
// model, causing existing heartbeat turns to consume paid API quota.
expect((call as { preserveExistingDefaultModel?: boolean }).preserveExistingDefaultModel).toBe(
true,
);
});
it("shows plugin compatibility notices for an existing valid config", async () => {
buildPluginCompatibilitySnapshotNotices.mockReturnValue([
{
pluginId: "legacy-plugin",
code: "hook-only",
compatCode: "hook-only-plugin-shape",
severity: "info",
message:
"is hook-only. This remains a supported compatibility path, but it has not migrated to explicit capability registration yet.",
},
]);
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: {},
valid: true,
config: {
agents: { entries: { main: { default: true } } },
gateway: {},
},
issues: [],
warnings: [],
legacyIssues: [],
});
const note: WizardPrompter["note"] = vi.fn(async () => {});
const select = vi.fn(async () => "quickstart") as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ note, select });
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
const calls = getWizardNoteCalls(note);
const noteTitles = calls.map((call) => call?.[1]);
expect(noteTitles).toContain("Plugin compatibility");
expect(noteTitles).toContain("Existing config detected");
expect(select).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Config handling" }),
);
const noteBodies = calls
.map((call) => call?.[0])
.filter((body): body is string => typeof body === "string");
const legacyPluginNotes = noteBodies.filter((body) => body.includes("legacy-plugin"));
expect(legacyPluginNotes.length).toBeGreaterThan(0);
});
it("resolves gateway.auth.password SecretRef for local setup probe", async () => {
const previous = process.env.OPENCLAW_GATEWAY_PASSWORD;
process.env.OPENCLAW_GATEWAY_PASSWORD = "gateway-ref-password"; // pragma: allowlist secret
probeGatewayReachable.mockClear();
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
sourceConfigBeforeMigrations: {},
valid: true,
config: {
agents: { entries: { main: { default: true } } },
gateway: {
auth: {
mode: "password",
password: {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_PASSWORD",
},
},
},
},
issues: [],
warnings: [],
legacyIssues: [],
});
const select = vi.fn(async () => "quickstart") as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ select });
const runtime = createRuntime();
try {
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
mode: "local",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
} else {
process.env.OPENCLAW_GATEWAY_PASSWORD = previous;
}
}
expectRecordFields(
getMockCallArg(probeGatewayReachable, 0, 0, "gateway probe"),
{
url: "ws://127.0.0.1:18789",
password: "gateway-ref-password", // pragma: allowlist secret
},
"gateway probe params",
);
});
it("passes secretInputMode through to local gateway config step", async () => {
configureGatewayForSetup.mockClear();
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
mode: "local",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
secretInputMode: "ref", // pragma: allowlist secret
},
runtime,
prompter,
);
expectRecordFields(
getMockCallArg(configureGatewayForSetup, 0, 0, "gateway setup"),
{
secretInputMode: "ref", // pragma: allowlist secret
},
"gateway setup params",
);
});
it("persists explicit classic quickstart gateway options without printing the password", async () => {
const password = ["classic", "password", "placeholder"].join("-");
const note: WizardPrompter["note"] = vi.fn(async () => {});
const prompter = buildWizardPrompter({ note });
const runtime = createRuntime();
readConfigFileSnapshot.mockResolvedValueOnce(
configSnapshot({
agents: { entries: { main: { default: true } } },
gateway: {
port: 19111,
bind: "loopback",
auth: { mode: "token", token: "stored-token" },
tailscale: { mode: "off", resetOnExit: false },
},
}),
);
replaceConfigFile.mockClear();
configureGatewayForSetup.mockImplementationOnce(async (args) => ({
nextConfig: {
...args.nextConfig,
gateway: {
...args.nextConfig.gateway,
port: args.quickstartGateway.port,
bind: args.quickstartGateway.bind,
auth: {
...args.nextConfig.gateway?.auth,
mode: args.quickstartGateway.authMode,
password: args.quickstartGateway.password,
},
tailscale: {
...args.nextConfig.gateway?.tailscale,
mode: args.quickstartGateway.tailscaleMode,
resetOnExit: args.quickstartGateway.tailscaleResetOnExit,
},
},
},
settings: {
port: args.quickstartGateway.port,
bind: args.quickstartGateway.bind,
authMode: args.quickstartGateway.authMode,
gatewayToken: undefined,
tailscaleMode: args.quickstartGateway.tailscaleMode,
tailscaleResetOnExit: args.quickstartGateway.tailscaleResetOnExit,
},
}));
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
mode: "local",
authChoice: "skip",
gatewayPort: 19001,
gatewayBind: "lan",
gatewayAuth: "password",
gatewayPassword: password,
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
const gatewaySetup = getMockCallArg(configureGatewayForSetup, 0, 0, "gateway setup");
expect(requireRecord(gatewaySetup, "gateway setup").quickstartGateway).toMatchObject({
port: 19001,
bind: "lan",
authMode: "password",
password,
});
expect(
persistedWizardConfigs().some(
(config) =>
config.gateway?.port === 19001 &&
config.gateway.bind === "lan" &&
config.gateway.auth?.mode === "password" &&
config.gateway.auth.password === password,
),
).toBe(true);
const visibleOutput = [
...getWizardNoteCalls(note).flat(),
...((runtime.log as unknown as ReturnType<typeof vi.fn>).mock.calls.flat() as unknown[]),
...((runtime.error as unknown as ReturnType<typeof vi.fn>).mock.calls.flat() as unknown[]),
].join("\n");
expect(visibleOutput).toContain("19001");
expect(visibleOutput).not.toContain("Keeping your current gateway settings:");
expect(visibleOutput).not.toContain(password);
});
it("shows the resolved gateway port in quickstart for fresh envs", async () => {
const previousPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = "18791";
const note: WizardPrompter["note"] = vi.fn(async () => {});
const prompter = buildWizardPrompter({ note });
const runtime = createRuntime();
try {
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
} finally {
if (previousPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = previousPort;
}
}
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
const matchingQuickStartNotes = calls.filter(
(call) =>
call?.[1] === "QuickStart" &&
typeof call?.[0] === "string" &&
call[0].includes("Gateway port: 18791"),
);
expect(matchingQuickStartNotes.length).toBeGreaterThan(0);
});
it("localizes the quickstart summary", async () => {
const previousPort = process.env.OPENCLAW_GATEWAY_PORT;
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_GATEWAY_PORT = "18791";
process.env.OPENCLAW_LOCALE = "zh-CN";
const note: WizardPrompter["note"] = vi.fn(async () => {});
const prompter = buildWizardPrompter({ note });
const runtime = createRuntime();
try {
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
} finally {
if (previousPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = previousPort;
}
if (previousLocale === undefined) {
delete process.env.OPENCLAW_LOCALE;
} else {
process.env.OPENCLAW_LOCALE = previousLocale;
}
}
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
const matchingQuickStartNotes = calls.filter(
(call) =>
call?.[1] === "QuickStart" &&
typeof call?.[0] === "string" &&
call[0].includes("Gateway 端口18791") &&
call[0].includes("Tailscale 暴露方式:关闭"),
);
expect(matchingQuickStartNotes.length).toBeGreaterThan(0);
});
it("uses manifest setup metadata for post-auth model policy without loading provider runtime", async () => {
promptDefaultModel.mockClear();
resolvePluginProvidersRuntime.mockClear();
resolveManifestProviderAuthChoice.mockReturnValue({
pluginId: "openai",
providerId: "openai",
methodId: "oauth",
choiceId: "openai",
choiceLabel: "ChatGPT/Codex Browser Login",
});
resolvePluginSetupProvider.mockReturnValue({
id: "openai",
label: "OpenAI Codex",
auth: [
{
id: "oauth",
label: "ChatGPT/Codex Browser Login",
kind: "oauth",
wizard: {
modelSelection: {
allowKeepCurrent: false,
},
},
run: vi.fn(async () => ({ profiles: [] })),
},
],
});
promptAuthChoiceGrouped.mockResolvedValueOnce("openai");
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
installDaemon: false,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
);
expectRecordFields(
getMockCallArg(resolvePluginSetupProvider, 0, 0, "plugin setup provider"),
{
provider: "openai",
pluginIds: ["openai"],
},
"plugin setup provider params",
);
expect(resolvePluginProvidersRuntime).not.toHaveBeenCalled();
expectRecordFields(
getMockCallArg(promptDefaultModel, 0, 0, "default model prompt"),
{ allowKeep: false },
"default model prompt params",
);
});
it("offers a live AI check after classic model setup", async () => {
applyAuthChoice.mockImplementationOnce(async (args) => ({
config: {
...args.config,
agents: {
...args.config.agents,
defaults: {
...args.config.agents?.defaults,
model: { primary: "openai/gpt-5.5" },
},
},
},
}));
const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"];
const prompter = buildWizardPrompter({ confirm });
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
createRuntime(),
prompter,
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({ message: "Test AI access now with a live completion?" }),
);
expect(verifySetupInferenceConfig).toHaveBeenCalledOnce();
});
it("continues classic setup when live AI verification fails", async () => {
applyAuthChoice.mockImplementationOnce(async (args) => ({
config: {
...args.config,
agents: {
...args.config.agents,
defaults: {
...args.config.agents?.defaults,
model: { primary: "openai/gpt-5.5" },
},
},
},
}));
verifySetupInferenceConfig.mockResolvedValueOnce({
ok: false,
status: "auth",
error: "login expired",
});
const select = vi.fn(async () => "continue") as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select });
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
createRuntime(),
prompter,
),
).resolves.toBeUndefined();
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: "How would you like to continue?" }),
);
expect(verifySetupInferenceConfig).toHaveBeenCalledOnce();
expect(persistedWizardConfigs().at(-1)?.agents?.defaults?.model).toBeUndefined();
});
it("does not persist staged model or auth choices when live verification is cancelled", async () => {
const stateDir = await makeCaseDir("cancelled-auth-verification-");
const agentDir = path.join(stateDir, "agent");
const persistCalls = prepareMockAuthProfilesIn(agentDir);
applyAuthChoice.mockResolvedValueOnce({
config: modelConfigWithApiKey("test-cancelled-key"),
});
verifySetupInferenceConfig.mockRejectedValueOnce(new WizardCancelledError("cancelled"));
replaceConfigFile.mockClear();
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
createRuntime(),
buildWizardPrompter({ confirm: vi.fn(async () => true) }),
),
).rejects.toThrow("cancelled");
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(persistCalls).toEqual([]);
await expect(fs.access(agentDir)).rejects.toThrow();
});
it("keeps failed model/auth fixes in the verification loop without persisting them", async () => {
const stateDir = await makeCaseDir("failed-auth-profile-retry-");
const agentDir = path.join(stateDir, "agent");
await upsertAuthProfileWithLock({ ...stagedOpenAiProfile("test-original-key"), agentDir });
prepareMockAuthProfilesIn(agentDir);
applyAuthChoice
.mockResolvedValueOnce({
config: modelConfigWithApiKey("test-original-key"),
})
.mockResolvedValueOnce({
config: modelConfigWithApiKey("test-retry-invalid-key"),
})
.mockResolvedValueOnce({
config: modelConfigWithApiKey("test-retry-still-invalid-key"),
});
promptAuthChoiceGrouped.mockResolvedValue("demo-provider");
verifySetupInferenceConfig
.mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" })
.mockResolvedValueOnce({ ok: false, status: "auth", error: "key rejected" })
.mockResolvedValueOnce({ ok: false, status: "auth", error: "key still rejected" });
const select = vi
.fn()
.mockResolvedValueOnce("fix")
.mockResolvedValueOnce("fix")
.mockResolvedValueOnce("continue") as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select });
try {
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
createRuntime(),
prompter,
);
expect(applyAuthChoice).toHaveBeenCalledTimes(3);
expect(promptAuthChoiceGrouped).toHaveBeenCalledTimes(2);
expect(verifySetupInferenceConfig).toHaveBeenCalledTimes(3);
const thirdVerification = getMockCallArg(
verifySetupInferenceConfig,
2,
0,
"third verification",
) as Parameters<VerifySetupInferenceConfig>[0];
expect(thirdVerification.config.models?.providers?.openai?.apiKey).toBe(
"test-retry-still-invalid-key",
);
const secondRetry = getMockCallArg(
applyAuthChoice,
2,
0,
"second retry auth choice",
) as Parameters<ApplyAuthChoice>[0];
expect(secondRetry.config.models?.providers?.openai?.apiKey).toBe("test-original-key");
expect(select).toHaveBeenCalledTimes(3);
expect(thirdVerification.authProfiles).toEqual([
stagedOpenAiProfile("test-retry-still-invalid-key"),
]);
expect(
persistedWizardConfigs().some(
(config) =>
config.models?.providers?.openai?.apiKey === "test-original-key" ||
config.models?.providers?.openai?.apiKey === "test-retry-invalid-key" ||
config.models?.providers?.openai?.apiKey === "test-retry-still-invalid-key",
),
).toBe(false);
expect(readAuthProfileStoreForTest(agentDir).profiles["openai:default"]).toEqual(
stagedOpenAiProfile("test-original-key").credential,
);
} finally {
await removeOAuthTestTempRoot(stateDir);
}
});
it("persists a model/auth fix after its live verification succeeds", async () => {
const stateDir = await makeCaseDir("successful-auth-profile-retry-");
const agentDir = path.join(stateDir, "agent");
const persistCalls = prepareMockAuthProfilesIn(agentDir);
applyAuthChoice
.mockResolvedValueOnce({
config: modelConfigWithApiKey("test-original-key"),
})
.mockResolvedValueOnce({
config: modelConfigWithApiKey("test-retry-valid-key"),
});
promptAuthChoiceGrouped.mockResolvedValue("demo-provider");
verifySetupInferenceConfig
.mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" })
.mockResolvedValueOnce({
ok: true,
modelRef: "openai/gpt-5.5",
latencyMs: 300,
authProfiles: [stagedOpenAiProfile("test-retry-valid-key")],
});
const select = vi.fn(async () => "fix") as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select });
try {
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
createRuntime(),
prompter,
);
expect(applyAuthChoice).toHaveBeenCalledTimes(2);
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
expect(verifySetupInferenceConfig).toHaveBeenCalledTimes(2);
const retryVerification = getMockCallArg(
verifySetupInferenceConfig,
1,
0,
"retry verification",
) as Parameters<VerifySetupInferenceConfig>[0];
expect(retryVerification.config.models?.providers?.openai?.apiKey).toBe(
"test-retry-valid-key",
);
expect(retryVerification.authProfiles).toEqual([stagedOpenAiProfile("test-retry-valid-key")]);
expect(
persistedWizardConfigs().some(
(config) => config.models?.providers?.openai?.apiKey === "test-retry-valid-key",
),
).toBe(true);
expect(readAuthProfileStoreForTest(agentDir).profiles["openai:default"]).toEqual(
stagedOpenAiProfile("test-retry-valid-key").credential,
);
expect(persistCalls).toEqual([[stagedOpenAiProfile("test-retry-valid-key")]]);
} finally {
await removeOAuthTestTempRoot(stateDir);
}
});
it("retains a staged retry credential when a later Fix keeps the current auth", async () => {
const stateDir = await makeCaseDir("kept-auth-profile-retry-");
const agentDir = path.join(stateDir, "agent");
prepareMockAuthProfilesIn(agentDir);
applyAuthChoice
.mockResolvedValueOnce({
config: modelConfigWithApiKey("test-original-key"),
})
.mockResolvedValueOnce({
config: modelConfigWithApiKey("test-staged-key"),
});
promptAuthChoiceGrouped
.mockResolvedValueOnce("demo-provider")
.mockResolvedValueOnce(keepCurrentAuthChoice);
verifySetupInferenceConfig
.mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" })
.mockResolvedValueOnce({
ok: false,
status: "timeout",
error: "request timed out",
authProfiles: [stagedOpenAiProfile("test-refreshed-key")],
})
.mockResolvedValueOnce({
ok: true,
modelRef: "openai/gpt-5.5",
latencyMs: 300,
});
const select = vi.fn(async () => "fix") as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select });
try {
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
createRuntime(),
prompter,
);
expect(applyAuthChoice).toHaveBeenCalledTimes(2);
expect(promptAuthChoiceGrouped).toHaveBeenCalledTimes(2);
expect(verifySetupInferenceConfig).toHaveBeenCalledTimes(3);
const finalVerification = getMockCallArg(
verifySetupInferenceConfig,
2,
0,
"final verification",
) as Parameters<VerifySetupInferenceConfig>[0];
expect(finalVerification.authProfiles).toEqual([stagedOpenAiProfile("test-refreshed-key")]);
expect(readAuthProfileStoreForTest(agentDir).profiles["openai:default"]).toEqual(
stagedOpenAiProfile("test-staged-key").credential,
);
} finally {
await removeOAuthTestTempRoot(stateDir);
}
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */