From 5c5af2b14e45aa7a8970f4508ab0f76da5cdf3a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 19:18:07 +0000 Subject: [PATCH] perf(wizard): lazy-load onboarding deps --- src/wizard/onboarding.test.ts | 248 +++++++++++++++++++++++++--------- src/wizard/onboarding.ts | 75 +++++----- 2 files changed, 219 insertions(+), 104 deletions(-) diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index a6911ac48ae..4aed0cd4776 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -1,12 +1,67 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "./prompts.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js"; import { runOnboardingWizard } from "./onboarding.js"; +const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); +const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn(async () => "skip")); +const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config }))); +const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(() => "openai")); +const warnIfModelConfigLooksOff = vi.hoisted(() => vi.fn(async () => {})); +const applyPrimaryModel = vi.hoisted(() => vi.fn((cfg) => cfg)); +const promptDefaultModel = vi.hoisted(() => vi.fn(async () => ({ config: null, model: null }))); +const promptCustomApiConfig = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config }))); +const configureGatewayForOnboarding = 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 finalizeOnboardingWizard = vi.hoisted(() => + vi.fn(async (options) => { + if (!process.env.BRAVE_API_KEY) { + await options.prompter.note("hint", "Web search (optional)"); + } + + if (options.opts.skipUi) { + return { launchedTui: false }; + } + + const hatch = await options.prompter.select({ + message: "How do you want to hatch your bot?", + 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({ 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 setupChannels = vi.hoisted(() => vi.fn(async (cfg) => cfg)); const setupSkills = vi.hoisted(() => vi.fn(async (cfg) => cfg)); const healthCommand = vi.hoisted(() => vi.fn(async () => {})); @@ -29,34 +84,68 @@ vi.mock("../commands/onboard-skills.js", () => ({ setupSkills, })); +vi.mock("../agents/auth-profiles.js", () => ({ + ensureAuthProfileStore, +})); + +vi.mock("../commands/auth-choice-prompt.js", () => ({ + promptAuthChoiceGrouped, +})); + +vi.mock("../commands/auth-choice.js", () => ({ + applyAuthChoice, + resolvePreferredProviderForAuthChoice, + warnIfModelConfigLooksOff, +})); + +vi.mock("../commands/model-picker.js", () => ({ + applyPrimaryModel, + promptDefaultModel, +})); + +vi.mock("../commands/onboard-custom.js", () => ({ + promptCustomApiConfig, +})); + vi.mock("../commands/health.js", () => ({ healthCommand, })); -vi.mock("../config/config.js", async (importActual) => { - const actual = await importActual(); - return { - ...actual, - readConfigFileSnapshot, - writeConfigFile, - }; -}); +vi.mock("../commands/onboard-hooks.js", () => ({ + setupInternalHooks, +})); -vi.mock("../commands/onboard-helpers.js", async (importActual) => { - const actual = await importActual(); - return { - ...actual, - ensureWorkspaceAndSessions, - detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })), - openUrl: vi.fn(async () => true), - printWizardHeader: vi.fn(), - probeGatewayReachable: vi.fn(async () => ({ ok: true })), - resolveControlUiLinks: vi.fn(() => ({ - httpUrl: "http://127.0.0.1:18789", - wsUrl: "ws://127.0.0.1:18789", - })), - }; -}); +vi.mock("../config/config.js", () => ({ + DEFAULT_GATEWAY_PORT: 18789, + resolveGatewayPort: () => 18789, + readConfigFileSnapshot, + writeConfigFile, +})); + +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: vi.fn(async () => ({ ok: true })), + 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, @@ -70,10 +159,26 @@ vi.mock("../infra/control-ui-assets.js", () => ({ ensureControlUiAssetsBuilt, })); +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins, +})); + +vi.mock("../config/logging.js", () => ({ + logConfigUpdated, +})); + vi.mock("../tui/tui.js", () => ({ runTui, })); +vi.mock("./onboarding.gateway-config.js", () => ({ + configureGatewayForOnboarding, +})); + +vi.mock("./onboarding.finalize.js", () => ({ + finalizeOnboardingWizard, +})); + vi.mock("./onboarding.completion.js", () => ({ setupOnboardingShellCompletion, })); @@ -111,6 +216,25 @@ function createRuntime(opts?: { throwsOnExit?: boolean }): RuntimeEnv { } describe("runOnboardingWizard", () => { + 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; + } + it("exits when config is invalid", async () => { readConfigFileSnapshot.mockResolvedValueOnce({ path: "/tmp/.openclaw/openclaw.json", @@ -182,47 +306,43 @@ describe("runOnboardingWizard", () => { }) { runTui.mockClear(); - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-")); - try { - if (params.writeBootstrapFile) { - await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}"); - } - - const select: WizardPrompter["select"] = vi.fn(async (opts) => { - if (opts.message === "How do you want to hatch your bot?") { - return "tui"; - } - return "quickstart"; - }); - - const prompter = createWizardPrompter({ select }); - const runtime = createRuntime({ throwsOnExit: true }); - - await runOnboardingWizard( - { - acceptRisk: true, - flow: "quickstart", - mode: "local", - workspace: workspaceDir, - authChoice: "skip", - skipProviders: true, - skipSkills: true, - skipHealth: true, - installDaemon: false, - }, - runtime, - prompter, - ); - - expect(runTui).toHaveBeenCalledWith( - expect.objectContaining({ - deliver: false, - message: params.expectedMessage, - }), - ); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }); + const workspaceDir = await makeCaseDir("workspace-"); + if (params.writeBootstrapFile) { + await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}"); } + + const select: WizardPrompter["select"] = vi.fn(async (opts) => { + if (opts.message === "How do you want to hatch your bot?") { + return "tui"; + } + return "quickstart"; + }); + + const prompter = createWizardPrompter({ select }); + const runtime = createRuntime({ throwsOnExit: true }); + + await runOnboardingWizard( + { + acceptRisk: true, + flow: "quickstart", + mode: "local", + workspace: workspaceDir, + authChoice: "skip", + skipProviders: true, + skipSkills: true, + skipHealth: true, + installDaemon: false, + }, + runtime, + prompter, + ); + + expect(runTui).toHaveBeenCalledWith( + expect.objectContaining({ + deliver: false, + message: params.expectedMessage, + }), + ); } it("launches TUI without auto-delivery when hatching", async () => { diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index ae15e406e72..a5bff274ce8 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -7,42 +7,15 @@ import type { import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js"; -import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; -import { listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { promptAuthChoiceGrouped } from "../commands/auth-choice-prompt.js"; -import { - applyAuthChoice, - resolvePreferredProviderForAuthChoice, - warnIfModelConfigLooksOff, -} from "../commands/auth-choice.js"; -import { applyPrimaryModel, promptDefaultModel } from "../commands/model-picker.js"; -import { setupChannels } from "../commands/onboard-channels.js"; -import { applyOnboardingLocalWorkspaceConfig } from "../commands/onboard-config.js"; -import { promptCustomApiConfig } from "../commands/onboard-custom.js"; -import { - applyWizardMetadata, - DEFAULT_WORKSPACE, - ensureWorkspaceAndSessions, - handleReset, - printWizardHeader, - probeGatewayReachable, - summarizeExistingConfig, -} from "../commands/onboard-helpers.js"; -import { setupInternalHooks } from "../commands/onboard-hooks.js"; -import { promptRemoteGatewayConfig } from "../commands/onboard-remote.js"; -import { setupSkills } from "../commands/onboard-skills.js"; import { DEFAULT_GATEWAY_PORT, readConfigFileSnapshot, resolveGatewayPort, writeConfigFile, } from "../config/config.js"; -import { logConfigUpdated } from "../config/logging.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; -import { finalizeOnboardingWizard } from "./onboarding.finalize.js"; -import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js"; import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; async function requireRiskAcknowledgement(params: { @@ -93,7 +66,8 @@ export async function runOnboardingWizard( runtime: RuntimeEnv = defaultRuntime, prompter: WizardPrompter, ) { - printWizardHeader(runtime); + const onboardHelpers = await import("../commands/onboard-helpers.js"); + onboardHelpers.printWizardHeader(runtime); await prompter.intro("OpenClaw onboarding"); await requireRiskAcknowledgement({ opts, prompter }); @@ -101,7 +75,7 @@ export async function runOnboardingWizard( let baseConfig: OpenClawConfig = snapshot.valid ? snapshot.config : {}; if (snapshot.exists && !snapshot.valid) { - await prompter.note(summarizeExistingConfig(baseConfig), "Invalid config"); + await prompter.note(onboardHelpers.summarizeExistingConfig(baseConfig), "Invalid config"); if (snapshot.issues.length > 0) { await prompter.note( [ @@ -156,7 +130,10 @@ export async function runOnboardingWizard( } if (snapshot.exists) { - await prompter.note(summarizeExistingConfig(baseConfig), "Existing config detected"); + await prompter.note( + onboardHelpers.summarizeExistingConfig(baseConfig), + "Existing config detected", + ); const action = await prompter.select({ message: "Config handling", @@ -168,7 +145,8 @@ export async function runOnboardingWizard( }); if (action === "reset") { - const workspaceDefault = baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE; + const workspaceDefault = + baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE; const resetScope = (await prompter.select({ message: "Reset scope", options: [ @@ -183,7 +161,7 @@ export async function runOnboardingWizard( }, ], })) as ResetScope; - await handleReset(resetScope, resolveUserPath(workspaceDefault), runtime); + await onboardHelpers.handleReset(resetScope, resolveUserPath(workspaceDefault), runtime); baseConfig = {}; } } @@ -294,14 +272,14 @@ export async function runOnboardingWizard( const localPort = resolveGatewayPort(baseConfig); const localUrl = `ws://127.0.0.1:${localPort}`; - const localProbe = await probeGatewayReachable({ + const localProbe = await onboardHelpers.probeGatewayReachable({ url: localUrl, token: baseConfig.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN, password: baseConfig.gateway?.auth?.password ?? process.env.OPENCLAW_GATEWAY_PASSWORD, }); const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? ""; const remoteProbe = remoteUrl - ? await probeGatewayReachable({ + ? await onboardHelpers.probeGatewayReachable({ url: remoteUrl, token: baseConfig.gateway?.remote?.token, }) @@ -334,8 +312,10 @@ export async function runOnboardingWizard( })) as OnboardMode)); if (mode === "remote") { + const { promptRemoteGatewayConfig } = await import("../commands/onboard-remote.js"); + const { logConfigUpdated } = await import("../config/logging.js"); let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter); - nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); + nextConfig = onboardHelpers.applyWizardMetadata(nextConfig, { command: "onboard", mode }); await writeConfigFile(nextConfig); logConfigUpdated(runtime); await prompter.outro("Remote gateway configured."); @@ -345,16 +325,24 @@ export async function runOnboardingWizard( const workspaceInput = opts.workspace ?? (flow === "quickstart" - ? (baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE) + ? (baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE) : await prompter.text({ message: "Workspace directory", - initialValue: baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE, + initialValue: baseConfig.agents?.defaults?.workspace ?? onboardHelpers.DEFAULT_WORKSPACE, })); - const workspaceDir = resolveUserPath(workspaceInput.trim() || DEFAULT_WORKSPACE); + const workspaceDir = resolveUserPath(workspaceInput.trim() || onboardHelpers.DEFAULT_WORKSPACE); + const { applyOnboardingLocalWorkspaceConfig } = await import("../commands/onboard-config.js"); let nextConfig: OpenClawConfig = applyOnboardingLocalWorkspaceConfig(baseConfig, workspaceDir); + const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); + const { promptAuthChoiceGrouped } = await import("../commands/auth-choice-prompt.js"); + const { promptCustomApiConfig } = await import("../commands/onboard-custom.js"); + const { applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff } = + await import("../commands/auth-choice.js"); + const { applyPrimaryModel, promptDefaultModel } = await import("../commands/model-picker.js"); + const authStore = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false, }); @@ -408,6 +396,7 @@ export async function runOnboardingWizard( await warnIfModelConfigLooksOff(nextConfig, prompter); + const { configureGatewayForOnboarding } = await import("./onboarding.gateway-config.js"); const gateway = await configureGatewayForOnboarding({ flow, baseConfig, @@ -423,6 +412,8 @@ export async function runOnboardingWizard( if (opts.skipChannels ?? opts.skipProviders) { await prompter.note("Skipping channel setup.", "Channels"); } else { + const { listChannelPlugins } = await import("../channels/plugins/index.js"); + const { setupChannels } = await import("../commands/onboard-channels.js"); const quickstartAllowFromChannels = flow === "quickstart" ? listChannelPlugins() @@ -439,23 +430,27 @@ export async function runOnboardingWizard( } await writeConfigFile(nextConfig); + const { logConfigUpdated } = await import("../config/logging.js"); logConfigUpdated(runtime); - await ensureWorkspaceAndSessions(workspaceDir, runtime, { + await onboardHelpers.ensureWorkspaceAndSessions(workspaceDir, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), }); if (opts.skipSkills) { await prompter.note("Skipping skills setup.", "Skills"); } else { + const { setupSkills } = await import("../commands/onboard-skills.js"); nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter); } // Setup hooks (session memory on /new) + const { setupInternalHooks } = await import("../commands/onboard-hooks.js"); nextConfig = await setupInternalHooks(nextConfig, runtime, prompter); - nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); + nextConfig = onboardHelpers.applyWizardMetadata(nextConfig, { command: "onboard", mode }); await writeConfigFile(nextConfig); + const { finalizeOnboardingWizard } = await import("./onboarding.finalize.js"); const { launchedTui } = await finalizeOnboardingWizard({ flow, opts,