Improve onboard wizard setup flow

This commit is contained in:
chengyongru
2026-06-22 13:04:05 +08:00
committed by Xubin Ren
parent 9db3dc5e32
commit fc7971b3b6
16 changed files with 483 additions and 93 deletions
+114 -9
View File
@@ -8,7 +8,6 @@ from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from pydantic import BaseModel, Field
from nanobot.cli import onboard as onboard_wizard
@@ -452,12 +451,23 @@ class TestConfigurePydanticModelDrafts:
onboard_wizard, "_input_with_existing", lambda *_args, **_kwargs: text_value
)
def test_discarding_section_keeps_original_model_unchanged(self, monkeypatch):
def test_back_commits_section_draft(self, monkeypatch):
model = _SimpleDraftModel()
self._patch_prompt_helpers(monkeypatch, ["first", "back"])
result = _configure_pydantic_model(model, "Simple")
assert result is not None
updated = cast(_SimpleDraftModel, result)
assert updated.api_key == "secret"
assert model.api_key == ""
def test_cancel_keeps_original_model_unchanged(self, monkeypatch):
model = _SimpleDraftModel()
self._patch_prompt_helpers(monkeypatch, ["first", None])
result = _configure_pydantic_model(model, "Simple")
assert result is None
assert model.api_key == ""
@@ -472,7 +482,7 @@ class TestConfigurePydanticModelDrafts:
assert updated.api_key == "secret"
assert model.api_key == ""
def test_nested_section_back_discards_nested_edits(self, monkeypatch):
def test_nested_section_back_commits_nested_edits(self, monkeypatch):
model = _OuterDraftModel()
self._patch_prompt_helpers(monkeypatch, ["first", "first", "back", "done"])
@@ -480,7 +490,7 @@ class TestConfigurePydanticModelDrafts:
assert result is not None
updated = cast(_OuterDraftModel, result)
assert updated.nested.api_key == ""
assert updated.nested.api_key == "secret"
assert model.nested.api_key == ""
def test_nested_section_done_commits_nested_edits(self, monkeypatch):
@@ -656,8 +666,8 @@ class TestValidateFieldConstraint:
def test_real_send_max_retries_field(self):
"""Validate against the actual ChannelsConfig.send_max_retries field."""
from nanobot.config.schema import ChannelsConfig
from nanobot.cli.onboard import _validate_field_constraint
from nanobot.config.schema import ChannelsConfig
field_info = ChannelsConfig.model_fields["send_max_retries"]
assert _validate_field_constraint(3, field_info) is None
@@ -847,14 +857,108 @@ class TestApiServerRegistration:
class TestMainMenuUpdate:
"""Tests for main menu including new Channel Common and API Server items."""
def test_run_onboard_quick_start_edit(self, monkeypatch):
"""run_onboard should route [Q] to Quick Start."""
initial_config = Config()
responses = iter([
"[Q] Quick Start (recommended)",
"[S] Save and Exit",
])
class FakePrompt:
def __init__(self, response):
self.response = response
def ask(self):
return self.response
def fake_select(*_args, **_kwargs):
return FakePrompt(next(responses))
def fake_quick_start(config):
config.agents.defaults.bot_name = "quickbot"
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
monkeypatch.setattr(onboard_wizard, "_configure_quick_start", fake_quick_start)
result = run_onboard(initial_config=initial_config)
assert result.should_save is True
assert result.config.agents.defaults.bot_name == "quickbot"
def test_quick_start_configures_primary_preset_and_telegram(self, monkeypatch):
"""Quick Start should set only the minimum provider, model and channel fields."""
config = Config()
selections = iter(["Telegram", "OpenRouter"])
def fake_select_with_back(*_args, **_kwargs):
return next(selections)
def fake_input(prompt, *_args, **_kwargs):
if "API key" in prompt:
return "sk-or-test"
if "Telegram bot token" in prompt:
return "123:abc"
raise AssertionError(prompt)
monkeypatch.setattr(onboard_wizard.console, "clear", lambda: None)
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
monkeypatch.setattr(onboard_wizard, "_input_with_existing", fake_input)
monkeypatch.setattr(
onboard_wizard,
"_input_model_with_autocomplete",
lambda *_args, **_kwargs: "anthropic/claude-sonnet-4.5",
)
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_pause", lambda: None)
onboard_wizard._configure_quick_start(config)
assert config.providers.openrouter.api_key == "sk-or-test"
assert config.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
assert config.agents.defaults.model_preset == "primary"
assert config.model_presets["primary"].provider == "openrouter"
assert config.model_presets["primary"].model == "anthropic/claude-sonnet-4.5"
telegram = getattr(config.channels, "telegram")
assert telegram["enabled"] is True
assert telegram["token"] == "123:abc"
def test_quick_start_webui_enables_websocket(self, monkeypatch):
"""The recommended Quick Start target should create a working WebUI channel."""
config = Config()
selections = iter(["WebUI only (recommended)", "OpenRouter"])
def fake_select_with_back(*_args, **_kwargs):
return next(selections)
monkeypatch.setattr(onboard_wizard.console, "clear", lambda: None)
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
monkeypatch.setattr(onboard_wizard, "_input_with_existing", lambda *a, **kw: "sk-or-test")
monkeypatch.setattr(
onboard_wizard,
"_input_model_with_autocomplete",
lambda *_args, **_kwargs: "anthropic/claude-sonnet-4.5",
)
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None)
monkeypatch.setattr(onboard_wizard, "_pause", lambda: None)
onboard_wizard._configure_quick_start(config)
websocket = getattr(config.channels, "websocket")
assert websocket["enabled"] is True
assert websocket["allowFrom"] == ["*"]
def test_main_menu_dispatch_includes_channel_common(self):
"""Main menu dispatch should route [H] to Channel Common."""
from nanobot.cli.onboard import run_onboard
# We verify by checking the dispatch table is set up correctly
# The menu items are defined inline in run_onboard, so we test
# that _configure_general_settings handles the new sections.
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
from nanobot.cli.onboard import _SETTINGS_GETTER, _SETTINGS_SECTIONS, _SETTINGS_SETTER
assert "Channel Common" in _SETTINGS_SECTIONS
assert "Channel Common" in _SETTINGS_GETTER
@@ -862,7 +966,7 @@ class TestMainMenuUpdate:
def test_main_menu_dispatch_includes_api_server(self):
"""Main menu dispatch should route [I] to API Server."""
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
from nanobot.cli.onboard import _SETTINGS_GETTER, _SETTINGS_SECTIONS, _SETTINGS_SETTER
assert "API Server" in _SETTINGS_SECTIONS
assert "API Server" in _SETTINGS_GETTER
@@ -1014,6 +1118,7 @@ class TestIsStrOrNone:
def test_optional_str_true(self):
from typing import Optional
from nanobot.cli.onboard import _is_str_or_none
assert _is_str_or_none(Optional[str]) is True
@@ -1035,7 +1140,7 @@ class TestConfigurePydanticModelEmptyString:
def test_optional_str_empty_string_becomes_none(self, monkeypatch):
"""Entering '' for an optional str field should set it to None."""
from pydantic import BaseModel
from nanobot.cli.onboard import _is_str_or_none
class M(BaseModel):
api_key: str | None = None
+48 -7
View File
@@ -91,12 +91,13 @@ def mock_paths():
def test_onboard_fresh_install(mock_paths):
"""No existing config — should create from scratch."""
"""No existing config in non-TTY mode should fall back to defaults."""
config_file, workspace_dir, mock_ws = mock_paths
result = runner.invoke(app, ["onboard"])
assert result.exit_code == 0
assert "No interactive terminal detected" in result.stdout
assert "Created config" in result.stdout
assert "Created workspace" in result.stdout
assert "nanobot is ready" in result.stdout
@@ -112,7 +113,7 @@ def test_onboard_existing_config_refresh(mock_paths):
config_file, workspace_dir, _ = mock_paths
config_file.write_text('{"existing": true}')
result = runner.invoke(app, ["onboard"], input="n\n")
result = runner.invoke(app, ["onboard", "--defaults"], input="n\n")
assert result.exit_code == 0
assert "Config already exists" in result.stdout
@@ -121,12 +122,26 @@ def test_onboard_existing_config_refresh(mock_paths):
assert (workspace_dir / "AGENTS.md").exists()
def test_onboard_non_tty_existing_config_refreshes_without_prompt(mock_paths):
"""Default onboard should not ask overwrite when falling back outside a TTY."""
config_file, workspace_dir, _ = mock_paths
config_file.write_text("{}")
result = runner.invoke(app, ["onboard"])
assert result.exit_code == 0
assert "No interactive terminal detected" in result.stdout
assert "Config already exists" not in result.stdout
assert "existing values preserved" in result.stdout
assert workspace_dir.exists()
def test_onboard_existing_config_overwrite(mock_paths):
"""Config exists, user confirms overwrite — should reset to defaults."""
config_file, workspace_dir, _ = mock_paths
config_file.write_text('{"existing": true}')
result = runner.invoke(app, ["onboard"], input="y\n")
result = runner.invoke(app, ["onboard", "--defaults"], input="y\n")
assert result.exit_code == 0
assert "Config already exists" in result.stdout
@@ -140,7 +155,7 @@ def test_onboard_existing_workspace_safe_create(mock_paths):
workspace_dir.mkdir(parents=True)
config_file.write_text("{}")
result = runner.invoke(app, ["onboard"], input="n\n")
result = runner.invoke(app, ["onboard", "--defaults"], input="n\n")
assert result.exit_code == 0
assert "Created workspace" not in result.stdout
@@ -164,6 +179,7 @@ def test_onboard_help_shows_workspace_and_config_options():
assert "--config" in stripped_output
assert "-c" in stripped_output
assert "--wizard" in stripped_output
assert "--defaults" in stripped_output
assert "--dir" not in stripped_output
@@ -172,12 +188,13 @@ def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_path
from nanobot.cli.onboard import OnboardResult
monkeypatch.setattr("nanobot.cli.commands._onboard_can_prompt", lambda: True)
monkeypatch.setattr(
"nanobot.cli.onboard.run_onboard",
lambda initial_config: OnboardResult(config=initial_config, should_save=False),
)
result = runner.invoke(app, ["onboard", "--wizard"])
result = runner.invoke(app, ["onboard"])
assert result.exit_code == 0
assert "No changes were saved" in result.stdout
@@ -193,7 +210,14 @@ def test_onboard_uses_explicit_config_and_workspace_paths(tmp_path, monkeypatch)
result = runner.invoke(
app,
["onboard", "--config", str(config_path), "--workspace", str(workspace_path)],
[
"onboard",
"--defaults",
"--config",
str(config_path),
"--workspace",
str(workspace_path),
],
)
assert result.exit_code == 0
@@ -217,11 +241,12 @@ def test_onboard_wizard_preserves_explicit_config_in_next_steps(tmp_path, monkey
"nanobot.cli.onboard.run_onboard",
lambda initial_config: OnboardResult(config=initial_config, should_save=True),
)
monkeypatch.setattr("nanobot.cli.commands._onboard_can_prompt", lambda: True)
monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {})
result = runner.invoke(
app,
["onboard", "--wizard", "--config", str(config_path), "--workspace", str(workspace_path)],
["onboard", "--config", str(config_path), "--workspace", str(workspace_path)],
)
assert result.exit_code == 0
@@ -232,6 +257,22 @@ def test_onboard_wizard_preserves_explicit_config_in_next_steps(tmp_path, monkey
assert f"nanobot gateway --config {resolved_config}" in compact_output
def test_onboard_wizard_non_tty_falls_back_to_defaults(mock_paths, monkeypatch):
config_file, _workspace_dir, _ = mock_paths
monkeypatch.setattr("nanobot.cli.commands._onboard_can_prompt", lambda: False)
monkeypatch.setattr(
"nanobot.cli.onboard.run_onboard",
lambda initial_config: (_ for _ in ()).throw(AssertionError("should not prompt")),
)
result = runner.invoke(app, ["onboard", "--wizard"])
assert result.exit_code == 0
assert "No interactive terminal detected" in result.stdout
assert config_file.exists()
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
config = Config()
config.agents.defaults.model = "github-copilot/gpt-5.3-codex"
+2 -2
View File
@@ -8,8 +8,8 @@ echo "=== Building Docker image ==="
docker build -t "$IMAGE_NAME" .
echo ""
echo "=== Running 'nanobot onboard' ==="
docker run --name nanobot-test-run "$IMAGE_NAME" onboard
echo "=== Running 'nanobot onboard --defaults' ==="
docker run --name nanobot-test-run "$IMAGE_NAME" onboard --defaults
echo ""
echo "=== Running 'nanobot status' ==="