Improve onboard wizard setup flow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' ==="
|
||||
|
||||
Reference in New Issue
Block a user