Files
openclaw/src/wizard/setup.test.ts
Peter Steinberger e2a112a556 feat(onboard): guided CLI onboarding with live AI verification and classic fallback (#101880)
* feat(onboard): guided CLI onboarding with live AI verification and classic fallback

Interactive `openclaw onboard` (and bare `openclaw` on a fresh install) now
runs a guided flow with macOS-app parity: detect existing AI access, live-test
candidates with a real completion before persisting anything, walk down the
ladder on failure with mapped reasons, and offer verified manual API-key entry
from installed provider manifests (masked input). In-flow escapes: classic
wizard, Crestodian chat, skip-AI. Classic wizard gains an optional post-auth
live verification step. `--classic`, `--modern`, and `--non-interactive`
contracts unchanged. Docs corrected for post-#99935 routing.

Closes #101851

* improve(onboard): quiet probe diagnostics in wizard TTY, carry risk ack into classic escape

Candidate live-tests during guided setup are probes: rename their run id and
lane to the existing probe conventions (logging/subsystem.ts console
suppression, command-queue quiet probe lanes) so expected failures stop
leaking raw diagnostics into the Clack UI; file diagnostics unchanged. The
classic-wizard escape now passes the already-collected risk acknowledgement
through instead of re-prompting in the same session.

* fix(onboard): quiet the session-derived setup-inference probe lane too

The live-test run enqueues on two lanes: the explicit probe lane and one
derived from its temp session key. Extend the shared quiet-probe predicate to
cover the derived lane so a failing candidate cannot leak lane-task
diagnostics into the wizard TTY.

* improve(onboard): suppress subsystem console output during wizard live tests

Provider-transport subsystem loggers (model-fetch start/response, transport
errors) carry no run id, so probe suppression cannot catch them and a failing
candidate printed raw log lines into the Clack TTY. Reuse the TUI console
subsystem-filter seam via a finally-safe scoped helper around guided
activation and the classic live-verify; file logging is unchanged and the
gateway (macOS app) surface is unaffected.

* fix(onboard): never auto-replace a configured model when its live check fails

The re-run verification probe executes outside the configured workspace (setup
never runs workspace plugins), so a workspace-backed current model can fail
the check while working fine in the agent. Stop the auto ladder on an
existing-model failure and hand the decision to the manual stage instead of
silently persisting a different candidate as the default. Docs note the
fail-safe and the workspace caveat.

* feat(onboard): two-way switching between Crestodian chat and the menu wizards

From the chat, `open setup wizard`, `open classic wizard`, and `open channel
wizard for <channel>` hand off to the guided flow, the classic wizard, or the
masked `channels add` wizard after the chat TUI tears down (mirrors the
open-tui handoff; gateway surface gets a text pointer instead). The hosted
channel wizard no longer dead-ends at sensitive steps — it offers the switch
and remembers the channel. New read-only `channel info <channel>` operation
and ring-zero action surface label, blurb, configured state, and the real
docs URL from channel-setup discovery so the assistant can explain Slack or
Telegram prerequisites instead of guessing; both prompts instruct it to use
them. `channels add --channel <id>` now preselects the channel. Docs cover
the interchangeable flows.

* fix(onboard): avoid param reassignment in open-setup handoff

* improve(onboard): separate ask-about vs connect intent in channel prompt guidance

Live test showed the agent detouring an explicit connect request through
channel_info because the guidance said to consult it first. Both prompts now
distinguish asking about a channel (channel info + docs link) from asking to
connect (connect right away).

* fix(channels): mark channel token entry as sensitive input

The shared single-token prompt lacked sensitive:true, so terminal wizards
echoed pasted channel tokens and the Crestodian chat bridge (which refuses
plain-text secrets based on this flag) hosted the Telegram token step in
visible chat. Found live-testing the chat-to-wizard switch; pre-existing on
main but load-bearing for the masked-wizard contract this PR documents.

* fix(onboard): restore terminal state around the guided flow's TUI launch

Mirror the classic finalize handoff so the chat TUI never inherits the wizard
prompter's raw/paused terminal state on the default first-run path.

* fix(channels): type the token prompter mock for the sensitive-flag assertion

* fix(gateway): map the TUI-only open-setup action to none for app clients

Engine-side surface gating already prevents open-setup replies on the gateway
surface; this keeps the client-visible action enum stable even if that gate
ever regresses. (Reviewed with the switching round; missed in its commit.)

* docs: regenerate docs map for onboarding page changes
2026-07-09 12:40:55 +01:00

1831 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Setup wizard tests cover end-to-end onboarding prompt flows.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js";
import type { PluginCompatibilityNotice } from "../plugins/status.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter, 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;
const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} })));
const keepCurrentAuthChoice = vi.hoisted(() => "__keep-current" as const);
const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn(async () => "skip"));
const applyAuthChoice = vi.hoisted(() =>
vi.fn<ApplyAuthChoice>(async (args) => ({ config: args.config })),
);
const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider"));
const resolveManifestProviderAuthChoice = vi.hoisted(() =>
vi.fn<ResolveManifestProviderAuthChoice>(() => undefined),
);
const resolvePluginSetupProvider = vi.hoisted(() =>
vi.fn<ResolvePluginSetupProvider>(() => undefined),
);
const resolveProviderPluginChoice = vi.hoisted(() =>
vi.fn<ResolveProviderPluginChoice>(() => null),
);
const resolvePluginProvidersRuntime = vi.hoisted(() =>
vi.fn<ResolvePluginProvidersRuntime>(() => []),
);
const warnIfModelConfigLooksOff = vi.hoisted(() => vi.fn(async () => {}));
const applyPrimaryModel = vi.hoisted(() => vi.fn((cfg) => cfg));
const promptDefaultModel = vi.hoisted(() => vi.fn<PromptDefaultModel>(async () => ({})));
const promptCustomApiConfig = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config })));
const configureGatewayForSetup = vi.hoisted(() =>
vi.fn(async (args) => ({
nextConfig: args.nextConfig,
settings: {
port: args.localPort ?? 18789,
bind: "loopback",
authMode: "token",
gatewayToken: "test-token",
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
})),
);
const finalizeSetupWizard = vi.hoisted(() =>
vi.fn(async (options) => {
if (!options.nextConfig?.tools?.web?.search?.provider) {
await options.prompter.note("Web search was skipped.", "Web search");
}
if (options.opts.skipUi) {
return { launchedTui: false };
}
const hatch = await options.prompter.select({
message: "How do you want to hatch your agent?",
options: [],
});
if (hatch !== "tui") {
return { launchedTui: false };
}
let message: string | undefined;
try {
await fs.stat(path.join(options.workspaceDir, DEFAULT_BOOTSTRAP_FILENAME));
message = "Wake up, my friend!";
} catch {
message = undefined;
}
await runTui({ local: true, deliver: false, message });
return { launchedTui: true };
}),
);
const listChannelPlugins = vi.hoisted(() => vi.fn(() => []));
const logConfigUpdated = vi.hoisted(() => vi.fn(() => {}));
const setupInternalHooks = vi.hoisted(() => vi.fn(async (cfg) => cfg));
const enableDefaultOnboardingInternalHooks = vi.hoisted(() =>
vi.fn((cfg) => ({
...cfg,
hooks: {
...(cfg as { hooks?: Record<string, unknown> }).hooks,
internal: {
...(cfg as { hooks?: { internal?: Record<string, unknown> } }).hooks?.internal,
entries: {
...(cfg as { hooks?: { internal?: { entries?: Record<string, unknown> } } }).hooks
?.internal?.entries,
"session-memory": { enabled: true },
},
},
},
})),
);
const detectSetupMigrationSources = vi.hoisted(() => vi.fn(async () => []));
const listSetupMigrationOptions = vi.hoisted(() => vi.fn(async () => []));
const runSetupMigrationImport = vi.hoisted(() => vi.fn(async () => {}));
const verifySetupInference = vi.hoisted(() =>
vi.fn<() => Promise<import("../crestodian/setup-inference.js").VerifySetupInferenceResult>>(
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));
function providerPluginStub(
overrides: Partial<ProviderPlugin> & Pick<ProviderPlugin, "id">,
): ProviderPlugin {
const { id, ...rest } = overrides;
return {
id,
label: id || "provider",
auth: [],
...rest,
};
}
const healthCommand = vi.hoisted(() => vi.fn(async () => {}));
const ensureWorkspaceAndSessions = vi.hoisted(() => vi.fn(async () => {}));
const replaceConfigFile = vi.hoisted(() => vi.fn(async () => ({ config: {} })));
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: {},
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 requireRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`expected ${label} to be an object`);
}
return value as Record<string, unknown>;
}
function expectRecordFields(
value: unknown,
expected: Record<string, unknown>,
label: string,
): Record<string, unknown> {
const record = requireRecord(value, label);
for (const [key, expectedValue] of Object.entries(expected)) {
expect(record[key], `${label}.${key}`).toEqual(expectedValue);
}
return record;
}
function getMockCallArg(
mock: { mock: { calls: readonly unknown[][] } },
callIndex: number,
argIndex: number,
label: string,
): unknown {
const call = (mock.mock.calls as unknown[][])[callIndex];
if (!call) {
throw new Error(`expected ${label} call ${callIndex}`);
}
return call[argIndex];
}
function expectMockCallArgNotNull(
mock: { mock: { calls: readonly unknown[][] } },
callIndex: number,
argIndex: number,
label: string,
): void {
const value = getMockCallArg(mock, callIndex, argIndex, label);
if (value === null) {
throw new Error(`expected ${label} arg ${argIndex} to be non-null`);
}
}
vi.mock("../commands/onboard-channels.js", () => ({
setupChannels,
}));
vi.mock("../commands/onboard-skills.js", () => ({
setupSkills,
}));
vi.mock("../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,
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("../crestodian/setup-inference.js", () => ({
verifySetupInference,
}));
vi.mock("../config/config.js", () => ({
DEFAULT_GATEWAY_PORT: 18789,
createConfigIO,
resolveGatewayPort,
replaceConfigFile,
}));
vi.mock("../commands/onboard-helpers.js", () => ({
DEFAULT_WORKSPACE: "/tmp/openclaw-workspace",
applyWizardMetadata: (cfg: unknown) => cfg,
summarizeExistingConfig: () => "summary",
handleReset: async () => {},
randomToken: () => "test-token",
normalizeGatewayTokenInput: (value: unknown) => ({
ok: true,
token: typeof value === "string" ? value.trim() : "",
error: null,
}),
validateGatewayPasswordInput: () => ({ ok: true, error: null }),
ensureWorkspaceAndSessions,
detectBrowserOpenSupport: vi.fn(async () => ({ ok: false })),
openUrl: vi.fn(async () => true),
printWizardHeader: vi.fn(),
probeGatewayReachable,
waitForGatewayReachable: vi.fn(async () => {}),
formatControlUiSshHint: vi.fn(() => "ssh hint"),
resolveControlUiLinks: vi.fn(() => ({
httpUrl: "http://127.0.0.1:18789",
wsUrl: "ws://127.0.0.1:18789",
})),
}));
vi.mock("../commands/systemd-linger.js", () => ({
ensureSystemdUserLingerInteractive,
}));
vi.mock("../daemon/systemd.js", () => ({
isSystemdUserServiceAvailable,
}));
vi.mock("../infra/control-ui-assets.js", () => ({
ensureControlUiAssetsBuilt,
}));
vi.mock("../plugins/status.js", () => ({
buildPluginCompatibilitySnapshotNotices,
formatPluginCompatibilityNotice,
}));
vi.mock("../channels/plugins/index.js", () => ({
listChannelPlugins,
}));
vi.mock("../config/logging.js", () => ({
logConfigUpdated,
}));
vi.mock("../tui/tui.js", () => ({
runTui,
}));
vi.mock("./setup.gateway-config.js", () => ({
configureGatewayForSetup,
}));
vi.mock("./setup.finalize.js", () => ({
finalizeSetupWizard,
}));
vi.mock("./setup.completion.js", () => ({
setupWizardShellCompletion,
}));
function createRuntime(opts?: { throwsOnExit?: boolean }): RuntimeEnv {
if (opts?.throwsOnExit) {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
}
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
describe("runSetupWizard", () => {
let suiteRoot = "";
let suiteCase = 0;
beforeAll(async () => {
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-onboard-suite-"));
});
afterAll(async () => {
await fs.rm(suiteRoot, { recursive: true, force: true });
suiteRoot = "";
suiteCase = 0;
});
async function makeCaseDir(prefix: string): Promise<string> {
const dir = path.join(suiteRoot, `${prefix}${++suiteCase}`);
await fs.mkdir(dir, { recursive: true });
return dir;
}
beforeEach(() => {
vi.clearAllMocks();
promptAuthChoiceGrouped.mockReset();
promptAuthChoiceGrouped.mockResolvedValue("skip");
applyAuthChoice.mockReset();
applyAuthChoice.mockImplementation(async (args) => ({ config: args.config }));
setupChannels.mockReset();
setupChannels.mockImplementation(async (cfg) => cfg);
setupSkills.mockReset();
setupSkills.mockImplementation(async (cfg) => cfg);
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({
path: "/tmp/.openclaw/openclaw.json",
exists: false,
raw: null,
parsed: {},
resolved: {},
valid: true,
config: {},
issues: [],
warnings: [],
legacyIssues: [],
});
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([]);
verifySetupInference.mockReset();
verifySetupInference.mockResolvedValue({
ok: true,
modelRef: "openai/gpt-5.5",
latencyMs: 250,
});
});
it("exits successfully after the auto-launched TUI returns", async () => {
const caseDir = await makeCaseDir("tui-success-exit-");
await fs.writeFile(path.join(caseDir, DEFAULT_BOOTSTRAP_FILENAME), "");
const select = vi.fn(async ({ message }: WizardSelectParams<unknown>) => {
if (message === "How do you want to hatch your agent?") {
return "tui";
}
return "__skip__";
}) as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ select });
const runtime = createRuntime({ throwsOnExit: true });
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: false,
workspace: caseDir,
},
runtime,
prompter,
),
).rejects.toThrow("exit:0");
expect(runTui).toHaveBeenCalledWith({
local: true,
deliver: false,
message: "Wake up, my friend!",
});
});
it("skips provider entries without an id during preferred-provider lookup", async () => {
setupChannels.mockClear();
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
valid: true,
config: {},
issues: [],
warnings: [],
legacyIssues: [],
});
resolvePreferredProviderForAuthChoice.mockResolvedValueOnce("demo-provider");
resolvePluginProvidersRuntime.mockReturnValueOnce([
providerPluginStub({ id: "" }),
providerPluginStub({ id: "demo-provider", wizard: { setup: {} } }),
]);
const caseDir = await makeCaseDir("provider-missing-id-");
const select = vi.fn(async ({ message }: WizardSelectParams<unknown>) => {
if (message === "Setup mode") {
return "quickstart";
}
if (message === "Select channel (QuickStart)") {
return "__skip__";
}
if (message === "How do you want to hatch your agent?") {
return "skip";
}
return "skip";
}) as unknown as WizardPrompter["select"];
const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"];
const prompter = buildWizardPrompter({ select, confirm });
const runtime = createRuntime({ throwsOnExit: true });
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "ollama",
installDaemon: false,
skipProviders: 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: {},
valid: false,
config: {},
issues: [{ path: "routing.allowFrom", message: "Legacy key" }],
warnings: [],
legacyIssues: [{ path: "routing.allowFrom", message: "Legacy key" }],
});
const select = vi.fn(
async (_params: WizardSelectParams<unknown>) => "quickstart",
) as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ select });
const runtime = createRuntime({ throwsOnExit: true });
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipProviders: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
runtime,
prompter,
),
).rejects.toThrow("exit:1");
expect(select).not.toHaveBeenCalled();
expect(prompter.outro).toHaveBeenCalled();
});
it("skips prompts and setup steps when flags are set", async () => {
const select = vi.fn(
async (_params: WizardSelectParams<unknown>) => "quickstart",
) as unknown as WizardPrompter["select"];
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
const plain: WizardPrompter["plain"] = vi.fn(async () => {});
const prompter = buildWizardPrompter({ select, multiselect, plain });
const runtime = createRuntime({ throwsOnExit: true });
createConfigIO.mockClear();
ensureAuthProfileStore.mockClear();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipProviders: 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("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,
skipProviders: 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,
skipProviders: 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,
skipProviders: 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: {},
valid: true,
config: { wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" } },
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,
skipProviders: 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("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: {},
valid: true,
config: {
plugins: {
installs: {
demo: { source: "npm", spec: "@openclaw/demo-plugin" },
},
},
},
issues: [],
warnings: [],
legacyIssues: [],
});
const workspaceDir = await makeCaseDir("plugin-install-migration-");
const select = vi.fn(async ({ options }: WizardSelectParams<unknown>) => {
const values = options.map((option) => option.value);
if (values.includes("keep")) {
return "keep";
}
if (values.includes("quickstart")) {
return "quickstart";
}
if (values.includes("__skip__")) {
return "__skip__";
}
return values[0];
}) as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ select });
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "skip",
installDaemon: false,
skipBootstrap: true,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
workspace: workspaceDir,
},
runtime,
prompter,
);
// Migration write + pre-channels persist + post-channels write + final write.
expect(replaceConfigFile).toHaveBeenCalledTimes(4);
const migrationParams = requireRecord(
getMockCallArg(replaceConfigFile, 0, 0, "migration config replacement"),
"migration config replacement params",
);
expect(
requireRecord(migrationParams.nextConfig, "migration next config").plugins,
).toBeUndefined();
const migrationWriteOptions = expectRecordFields(
migrationParams.writeOptions,
{ allowConfigSizeDrop: true },
"migration config replacement write options",
);
expect(migrationWriteOptions.unsetPaths).toContainEqual(["plugins", "installs"]);
const replaceParams = requireRecord(
getMockCallArg(replaceConfigFile, 3, 0, "config replacement"),
"config replacement params",
);
expect(requireRecord(replaceParams.nextConfig, "next config").plugins).toBeUndefined();
expectRecordFields(
replaceParams.writeOptions,
{ allowConfigSizeDrop: false },
"config replacement write options",
);
});
it("fails fast if the auth choice prompt returns nothing", async () => {
promptAuthChoiceGrouped.mockImplementationOnce(async () => undefined as never);
const prompter = buildWizardPrompter();
const runtime = createRuntime();
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
installDaemon: false,
skipProviders: 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: {},
valid: true,
config: {
wizard: { securityAcknowledgedAt: "2026-06-30T00:00:00.000Z" },
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5",
},
},
},
},
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");
});
async function runTuiHatchTestAndExpectLaunch(params: {
writeBootstrapFile: boolean;
expectedMessage: string | undefined;
}) {
runTui.mockClear();
const workspaceDir = await makeCaseDir("workspace-");
if (params.writeBootstrapFile) {
await fs.writeFile(path.join(workspaceDir, DEFAULT_BOOTSTRAP_FILENAME), "{}");
}
const select = vi.fn(async (opts: WizardSelectParams<unknown>) => {
if (opts.message === "How do you want to hatch your agent?") {
return "tui";
}
return "quickstart";
}) as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ select });
const runtime = createRuntime({ throwsOnExit: true });
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
mode: "local",
workspace: workspaceDir,
authChoice: "skip",
skipProviders: 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,
skipProviders: 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("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,
skipProviders: true,
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
.mockResolvedValueOnce({
config: {
plugins: {
entries: {
"demo-provider-plugin": {
enabled: true,
},
},
},
},
retrySelection: true,
})
.mockResolvedValueOnce({
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);
expectRecordFields(
getMockCallArg(applyAuthChoice, 1, 0, "retry auth choice"),
{
authChoice: "demo-provider-two",
config: {
plugins: {
entries: {
"demo-provider-plugin": {
enabled: true,
},
},
},
},
},
"retry auth choice params",
);
});
it("forwards provider-specific auth flags to applyAuthChoice opts", async () => {
applyAuthChoice.mockReset();
applyAuthChoice.mockResolvedValueOnce({
config: {
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5",
},
},
},
},
});
const prompter = buildWizardPrompter({});
const runtime = createRuntime();
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "openai-chatgpt-api-key",
openaiApiKey: "sk-flag-value",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
skipHooks: true,
},
runtime,
prompter,
);
expect(applyAuthChoice).toHaveBeenCalledTimes(1);
const call = getMockCallArg(applyAuthChoice, 0, 0, "openai auth choice");
const opts = (call as { opts?: Record<string, unknown> }).opts ?? {};
expect(opts.openaiApiKey).toBe("sk-flag-value");
});
it("passes preserveExistingDefaultModel to applyAuthChoice to protect existing default model", async () => {
applyAuthChoice.mockReset();
applyAuthChoice.mockResolvedValueOnce({
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: "legacy-before-agent-start",
compatCode: "legacy-before-agent-start",
severity: "warn",
message:
"still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve/before_prompt_build for new work.",
},
]);
readConfigFileSnapshot.mockResolvedValueOnce({
path: "/tmp/.openclaw/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
valid: true,
config: {
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,
skipProviders: 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: {},
valid: true,
config: {
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,
skipProviders: 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,
skipProviders: 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("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,
skipProviders: 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,
skipProviders: 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.mockResolvedValueOnce({
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(verifySetupInference).toHaveBeenCalledOnce();
});
it("continues classic setup when live AI verification fails", async () => {
applyAuthChoice.mockResolvedValueOnce({
config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } },
});
verifySetupInference.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(verifySetupInference).toHaveBeenCalledOnce();
});
it("re-enters model/auth setup once and re-verifies after a failed AI check", async () => {
applyAuthChoice.mockResolvedValue({
config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } },
});
promptAuthChoiceGrouped.mockResolvedValue("demo-provider");
verifySetupInference
.mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" })
.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 });
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(verifySetupInference).toHaveBeenCalledTimes(2);
});
});