// 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(async (args) => ({ config: args.config })), ); const prepareAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ ...(await applyAuthChoice(args)), authProfiles: [], persistAuthProfiles: async () => {}, })), ); const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider")); const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined), ); const resolvePluginSetupProvider = vi.hoisted(() => vi.fn(() => undefined), ); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn(() => null), ); const resolvePluginProvidersRuntime = vi.hoisted(() => vi.fn(() => []), ); const warnIfModelConfigLooksOff = vi.hoisted(() => vi.fn(async () => {})); const applyPrimaryModel = vi.hoisted(() => vi.fn((cfg) => cfg)); const promptDefaultModel = vi.hoisted(() => vi.fn(async () => ({}))); const promptCustomApiConfig = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config }))); const configureGatewayForSetup = vi.hoisted(() => vi.fn(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 }).hooks, internal: { ...(cfg as { hooks?: { internal?: Record } }).hooks?.internal, entries: { ...(cfg as { hooks?: { internal?: { entries?: Record } } }).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(async () => ({ kind: "no-imported-inference" })), ); const runSetupMemoryImportStep = vi.hoisted(() => vi.fn(async () => {})); const verifySetupInferenceConfig = vi.hoisted(() => vi.fn(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(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 & Pick, ): 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 { const persistCalls: Array = []; 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 { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`expected ${label} to be an object`); } return value as Record; } function expectRecordFields( value: unknown, expected: Record, label: string, ): Record { 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 { 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) => { 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) => { 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) => "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) => "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) => { 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) => { 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 }).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).mock.calls.flat() as unknown[]), ...((runtime.error as unknown as ReturnType).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[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[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[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[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. */