import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { 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 setupChannels = vi.hoisted(() => vi.fn(async (cfg) => cfg)); const setupSkills = vi.hoisted(() => vi.fn(async (cfg) => cfg)); const healthCommand = vi.hoisted(() => vi.fn(async () => {})); const ensureWorkspaceAndSessions = vi.hoisted(() => vi.fn(async () => {})); const writeConfigFile = vi.hoisted(() => vi.fn(async () => {})); const readConfigFileSnapshot = vi.hoisted(() => vi.fn(async () => ({ exists: false, valid: true, config: {} })), ); 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 () => {})); const setupOnboardingShellCompletion = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../commands/onboard-channels.js", () => ({ setupChannels, })); vi.mock("../commands/onboard-skills.js", () => ({ setupSkills, })); 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-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("../commands/systemd-linger.js", () => ({ ensureSystemdUserLingerInteractive, })); vi.mock("../daemon/systemd.js", () => ({ isSystemdUserServiceAvailable, })); vi.mock("../infra/control-ui-assets.js", () => ({ ensureControlUiAssetsBuilt, })); vi.mock("../tui/tui.js", () => ({ runTui, })); vi.mock("./onboarding.completion.js", () => ({ setupOnboardingShellCompletion, })); function createWizardPrompter(overrides?: Partial): WizardPrompter { return { intro: vi.fn(async () => {}), outro: vi.fn(async () => {}), note: vi.fn(async () => {}), select: vi.fn(async () => "quickstart"), multiselect: vi.fn(async () => []), text: vi.fn(async () => ""), confirm: vi.fn(async () => false), progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), ...overrides, }; } 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("runOnboardingWizard", () => { it("exits when config is invalid", async () => { readConfigFileSnapshot.mockResolvedValueOnce({ path: "/tmp/.openclaw/openclaw.json", exists: true, raw: "{}", parsed: {}, valid: false, config: {}, issues: [{ path: "routing.allowFrom", message: "Legacy key" }], legacyIssues: [{ path: "routing.allowFrom", message: "Legacy key" }], }); const select: WizardPrompter["select"] = vi.fn(async () => "quickstart"); const prompter = createWizardPrompter({ select }); const runtime = createRuntime({ throwsOnExit: true }); await expect( runOnboardingWizard( { acceptRisk: true, flow: "quickstart", authChoice: "skip", installDaemon: false, skipProviders: true, skipSkills: 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: WizardPrompter["select"] = vi.fn(async () => "quickstart"); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter = createWizardPrompter({ select, multiselect }); const runtime = createRuntime({ throwsOnExit: true }); await runOnboardingWizard( { acceptRisk: true, flow: "quickstart", authChoice: "skip", installDaemon: false, skipProviders: true, skipSkills: true, skipHealth: true, skipUi: true, }, runtime, prompter, ); expect(select).not.toHaveBeenCalled(); expect(setupChannels).not.toHaveBeenCalled(); expect(setupSkills).not.toHaveBeenCalled(); expect(healthCommand).not.toHaveBeenCalled(); expect(runTui).not.toHaveBeenCalled(); }); async function runTuiHatchTest(params: { writeBootstrapFile: boolean; expectedMessage: string | undefined; }) { 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 }); } } it("launches TUI without auto-delivery when hatching", async () => { await runTuiHatchTest({ writeBootstrapFile: true, expectedMessage: "Wake up, my friend!" }); }); it("offers TUI hatch even without BOOTSTRAP.md", async () => { await runTuiHatchTest({ writeBootstrapFile: false, expectedMessage: undefined }); }); it("shows the web search hint at the end of onboarding", async () => { const prevBraveKey = process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY; try { const note: WizardPrompter["note"] = vi.fn(async () => {}); const prompter = createWizardPrompter({ note }); const runtime = createRuntime(); await runOnboardingWizard( { acceptRisk: true, flow: "quickstart", authChoice: "skip", installDaemon: false, skipProviders: true, skipSkills: true, skipHealth: true, skipUi: true, }, runtime, prompter, ); const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; expect(calls.length).toBeGreaterThan(0); expect(calls.some((call) => call?.[1] === "Web search (optional)")).toBe(true); } finally { if (prevBraveKey === undefined) { delete process.env.BRAVE_API_KEY; } else { process.env.BRAVE_API_KEY = prevBraveKey; } } }); });