"""User-cancellation handling in the interactive onboarding flow.""" from __future__ import annotations from typing import Any import pytest from opensquilla.onboarding import flow from opensquilla.onboarding.errors import UserCancelledError class _Prompt: def __init__(self, value: Any) -> None: self._value = value def ask(self) -> Any: return self._value class _Questionary: """Minimal stand-in that returns canned answers per prompt type.""" def __init__( self, *, select_value: Any = None, password_value: Any = None, confirm_value: bool = False, ) -> None: self._select = select_value self._password = password_value self._confirm = confirm_value def select(self, *_args, **_kwargs): return _Prompt(self._select) def password(self, *_args, **_kwargs): return _Prompt(self._password) def confirm(self, *_args, **_kwargs): return _Prompt(self._confirm) def text(self, *_args, **_kwargs): return _Prompt("") def test_search_choice_cancel_raises_user_cancelled(): q = _Questionary(select_value=None) with pytest.raises(UserCancelledError) as exc_info: flow._ask_search_choice(q) assert exc_info.value.section == "search" def test_search_api_key_cancel_raises_user_cancelled(): spec = flow.get_search_provider_setup_spec("brave") q = _Questionary(password_value=None, confirm_value=False) with pytest.raises(UserCancelledError) as exc_info: flow._ask_search_fields(q, spec) assert exc_info.value.section == "search" def test_search_api_key_cancel_aborts_before_followup_prompts(monkeypatch): """Once the user cancels the api_key prompt, the helper must abort immediately. Counting how many times ``.ask()`` is invoked after the password prompt protects against future prompt-label changes without making the test brittle to wording.""" spec = flow.get_search_provider_setup_spec("brave") asks_after_password = {"count": 0} password_seen = {"seen": False} class _CountingPrompt: def __init__(self, kind: str, value: Any) -> None: self._kind = kind self._value = value def ask(self) -> Any: if self._kind == "password": password_seen["seen"] = True elif password_seen["seen"]: asks_after_password["count"] += 1 return self._value class _Tracker: def select(self, *_a, **_kw): return _CountingPrompt("select", None) def password(self, *_a, **_kw): return _CountingPrompt("password", None) def confirm(self, *_a, **_kw): return _CountingPrompt("confirm", False) def text(self, *_a, **_kw): return _CountingPrompt("text", "") monkeypatch.delenv("BRAVE_API_KEY", raising=False) with pytest.raises(UserCancelledError): flow._ask_search_fields(_Tracker(), spec) assert password_seen["seen"], "password prompt should have fired" assert asks_after_password["count"] == 0, ( "no further prompts should run after api_key cancel" ) def test_search_fallback_cancel_raises_user_cancelled(monkeypatch): """A cancel at the fallback-policy select used to leak ``None`` into the enum mapper. With ``_ask_or_cancel``, it must surface as a typed cancel so the optional-section runner can route the user back cleanly.""" spec = flow.get_search_provider_setup_spec("brave") monkeypatch.delenv("BRAVE_API_KEY", raising=False) fired: list[str] = [] class _Tracker: def select(self, label, *_a, **_kw): fired.append(f"select:{label}") # Cancel only the fallback select; let earlier selects pass. if "fallback" in label: return _Prompt(None) return _Prompt(None) def password(self, *_a, **_kw): fired.append("password") return _Prompt("explicit-key") def confirm(self, *_a, **_kw): fired.append("confirm") return _Prompt(False) def text(self, label, *_a, **_kw): fired.append(f"text:{label}") return _Prompt("5" if "Max" in label else "") with pytest.raises(UserCancelledError) as exc_info: flow._ask_search_fields(_Tracker(), spec) assert exc_info.value.section == "search" assert any("fallback" in line for line in fired) def test_provider_choice_cancel_raises_user_cancelled(): """Provider select returning ``None`` previously crashed with ``AttributeError: 'NoneType' object has no attribute 'split'``.""" from opensquilla.onboarding.flow import OnboardOptions q = _Questionary(select_value=None) with pytest.raises(UserCancelledError) as exc_info: flow._ask_provider_choice(q, OnboardOptions()) assert exc_info.value.section == "provider" class _RecordingConsole: """Stand-in for ``opensquilla.ui.console`` that records ``print`` calls. The real ``console`` is a Rich ``Console`` constructed at import time with a captured stdout reference, which makes ``capsys`` brittle under full test-suite execution. Monkeypatching ``flow.console`` keeps the assertion deterministic regardless of how Rich initialised in earlier tests. """ def __init__(self) -> None: self.messages: list[str] = [] def print(self, message: str = "", *_a, **_kw) -> None: self.messages.append(str(message)) def joined(self) -> str: return "\n".join(self.messages) def test_optional_section_runner_swallows_user_cancelled(monkeypatch): recorder = _RecordingConsole() monkeypatch.setattr(flow, "console", recorder) def _runner(): raise UserCancelledError(section="search") flow._run_optional_section( section="search", label="search", runner=_runner ) text = recorder.joined().lower() assert "search setup cancelled" in text assert "opensquilla onboard configure search" in recorder.joined() def test_optional_section_runner_resume_hint_uses_section_slug_not_label(monkeypatch): """For multi-word labels (e.g. "image generation"), the resume hint must use the typed section slug so the command is actually runnable.""" recorder = _RecordingConsole() monkeypatch.setattr(flow, "console", recorder) def _runner(): raise UserCancelledError(section="image-generation") flow._run_optional_section( section="image-generation", label="image generation", runner=_runner, ) assert "opensquilla onboard configure image-generation" in recorder.joined() def test_optional_section_runner_swallows_keyboard_interrupt(monkeypatch): recorder = _RecordingConsole() monkeypatch.setattr(flow, "console", recorder) def _runner(): raise KeyboardInterrupt flow._run_optional_section( section="search", label="search", runner=_runner ) assert "interrupted" in recorder.joined().lower() def test_optional_section_runner_propagates_value_error(): """ValueError must propagate — those indicate real validation or programming errors, not user cancels. Swallowing them would let "Onboarding Complete" print over a broken config. Contract note: interactive numeric prompts now carry ``validate=`` hooks, so a user typo re-prompts at the input line and never surfaces here as a ValueError. Propagation stays pinned for genuine programming errors.""" def _runner(): raise ValueError("search provider 'brave' requires an api_key") with pytest.raises(ValueError): flow._run_optional_section( section="search", label="search", runner=_runner ) def test_optional_section_runner_propagates_unexpected_exception(): class _BoomError(RuntimeError): pass def _runner(): raise _BoomError("unexpected") with pytest.raises(_BoomError): flow._run_optional_section( section="search", label="search", runner=_runner ) # --------------------------------------------------------------------------- # Image generation: cancel must never flow onward as ``None``. # --------------------------------------------------------------------------- def test_image_generation_provider_choice_cancel_raises_user_cancelled(): """A cancel at the provider select used to crash with ``AttributeError: 'NoneType' object has no attribute 'split'`` — which escapes ``_run_optional_section`` and kills the whole wizard.""" from opensquilla.gateway.config import GatewayConfig q = _Questionary(select_value=None) with pytest.raises(UserCancelledError) as exc_info: flow._ask_image_generation_choice(q, GatewayConfig()) assert exc_info.value.section == "image-generation" def test_image_generation_enabled_confirm_cancel_raises_user_cancelled(monkeypatch): """Cancelling the "Image generation enabled?" consent confirm used to store ``None`` and persist ``enabled = false`` under a success banner.""" from opensquilla.gateway.config import GatewayConfig from opensquilla.onboarding.image_generation_specs import ( get_image_generation_provider_setup_spec, ) monkeypatch.setattr(flow, "console", _RecordingConsole()) monkeypatch.setenv("OPENAI_API_KEY", "sk-image-env") class _Prompted: def select(self, message, **_kw): if message == "Image API key source": return _Prompt("Use environment variable OPENAI_API_KEY") raise AssertionError(f"unexpected select prompt: {message}") def text(self, message, **kwargs): return _Prompt(kwargs.get("default")) def confirm(self, message, **_kw): assert message == "Image generation enabled?" return _Prompt(None) def password(self, message, **_kw): raise AssertionError(f"unexpected password prompt: {message}") with pytest.raises(UserCancelledError) as exc_info: flow._ask_image_generation_fields( _Prompted(), get_image_generation_provider_setup_spec("openai"), GatewayConfig(), ) assert exc_info.value.section == "image-generation" def test_image_generation_configure_cancel_persists_nothing(tmp_path, monkeypatch): """An end-to-end cancel at the consent confirm must leave the config untouched instead of writing ``enabled = false`` and printing "Image generation configured.".""" import sys import types target = tmp_path / "c.toml" monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target)) monkeypatch.setenv("OPENAI_API_KEY", "sk-image-env") monkeypatch.setattr(flow, "_is_tty", lambda: True) monkeypatch.setattr(flow, "console", _RecordingConsole()) class _Answer: def __init__(self, value): self.value = value def ask(self): return self.value class _FakeQuestionary(types.SimpleNamespace): def select(self, message, **kwargs): if message == "Image generation provider": return _Answer("openai (OpenAI Images)") if message == "Image API key source": return _Answer("Use environment variable OPENAI_API_KEY") raise AssertionError(f"unexpected select prompt: {message}") def text(self, message, **kwargs): return _Answer(kwargs.get("default")) def confirm(self, message, **_kw): assert message == "Image generation enabled?" return _Answer(None) def password(self, message, **_kw): raise AssertionError(f"unexpected password prompt: {message}") def checkbox(self, message, **_kw): raise AssertionError(f"unexpected checkbox prompt: {message}") monkeypatch.setitem(sys.modules, "questionary", _FakeQuestionary()) with pytest.raises(UserCancelledError): flow.run_interactive_image_generation_configure(config_path=target) assert not target.exists() # --------------------------------------------------------------------------- # Channel wizard cancels. # --------------------------------------------------------------------------- def _channel_questionary_module(select_value): import types class _Answer: def __init__(self, value): self.value = value def ask(self): return self.value class _FakeQuestionary(types.SimpleNamespace): def select(self, message, **_kw): return _Answer(select_value) def text(self, message, **_kw): raise AssertionError(f"unexpected text prompt: {message}") def password(self, message, **_kw): raise AssertionError(f"unexpected password prompt: {message}") def confirm(self, message, **_kw): raise AssertionError(f"unexpected confirm prompt: {message}") def checkbox(self, message, **_kw): raise AssertionError(f"unexpected checkbox prompt: {message}") return _FakeQuestionary() def test_channel_add_type_select_cancel_raises_user_cancelled(tmp_path, monkeypatch): """Cancelling the channel-type select used to crash with ``KeyError: "unknown channel type: None"``.""" import sys target = tmp_path / "c.toml" monkeypatch.setattr(flow, "_is_tty", lambda: True) monkeypatch.setitem(sys.modules, "questionary", _channel_questionary_module(None)) with pytest.raises(UserCancelledError) as exc_info: flow.run_interactive_channel_add(None, config_path=target) assert exc_info.value.section == "channels" assert not target.exists() def test_channel_edit_picker_cancel_raises_user_cancelled(tmp_path, monkeypatch): """Cancelling the channel-to-edit select used to crash with a bare ``StopIteration`` from the entry lookup.""" import sys from opensquilla.onboarding.setup_engine import SetupEngine target = tmp_path / "c.toml" engine = SetupEngine(path=target) engine.apply( "channels", { "type": "slack", "name": "slack-main", "token": "xoxb-stored", "signing_secret": "ss-stored", }, ) engine.persist() monkeypatch.setattr(flow, "_is_tty", lambda: True) monkeypatch.setattr(flow, "console", _RecordingConsole()) monkeypatch.setitem(sys.modules, "questionary", _channel_questionary_module(None)) before = target.read_text() with pytest.raises(UserCancelledError) as exc_info: flow.run_interactive_channel_edit(None, config_path=target) assert exc_info.value.section == "channels" assert target.read_text() == before @pytest.mark.parametrize( ("field_type", "extra"), [ ("select", {"choices": ("webhook", "socket")}), ("bool", {"default": True}), ("password", {"secret": True}), ("int", {"default": 3}), ("float", {"default": 1.5}), ("text", {}), ], ) def test_channel_field_prompt_cancel_raises_user_cancelled(field_type, extra): """Every channel field prompt must convert a cancel into the typed error instead of storing ``None``/coerced garbage for pydantic to reject much later.""" from opensquilla.onboarding.channel_specs import ChannelSetupField field = ChannelSetupField( name="sample", label="Sample field", field_type=field_type, required=True, **extra, ) class _CancellingQuestionary(_Questionary): def text(self, *_a, **_kw): return _Prompt(None) q = _CancellingQuestionary( select_value=None, password_value=None, confirm_value=None ) with pytest.raises(UserCancelledError) as exc_info: flow._ask_channel_field(q, field, field.default) assert exc_info.value.section == "channels" # --------------------------------------------------------------------------- # Router consent prompts. # --------------------------------------------------------------------------- def test_router_mode_cancel_raises_user_cancelled(): """Cancelling the "Router mode" consent select used to map ``None`` to "recommended" and persist ``squilla_router.enabled = true``.""" from opensquilla.gateway.config import GatewayConfig q = _Questionary(select_value=None) with pytest.raises(UserCancelledError) as exc_info: flow._ask_router_fields( q, GatewayConfig(), provider_id="openrouter", requested_mode="recommended", ) assert exc_info.value.section == "router" def test_router_default_tier_cancel_raises_user_cancelled(monkeypatch): """Cancelling the default-tier select must not silently persist c1.""" from opensquilla.gateway.config import GatewayConfig monkeypatch.setattr(flow, "console", _RecordingConsole()) class _Prompted: def select(self, message, **_kw): if message == "Router mode": return _Prompt("SquillaRouter") if message == "Default text model": return _Prompt(None) raise AssertionError(f"unexpected select prompt: {message}") def confirm(self, message, **_kw): raise AssertionError(f"unexpected confirm prompt: {message}") with pytest.raises(UserCancelledError) as exc_info: flow._ask_router_fields( _Prompted(), GatewayConfig(), provider_id="openrouter", requested_mode="recommended", ) assert exc_info.value.section == "router" # --------------------------------------------------------------------------- # Provider key-source select. # --------------------------------------------------------------------------- def test_provider_key_source_cancel_raises_before_password_prompt(monkeypatch): """A cancel at the key-source select used to be indistinguishable from "Paste API key now" and dropped the user into a required password prompt.""" from opensquilla.onboarding.flow import OnboardOptions, _ask_provider_fields from opensquilla.onboarding.provider_specs import get_provider_setup_spec monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) password_fired: list[str] = [] class _Prompted: def select(self, message, **_kw): assert message == "LLM API key source" return _Prompt(None) def password(self, message, **_kw): password_fired.append(message) return _Prompt("sk-should-never-be-asked") with pytest.raises(UserCancelledError) as exc_info: _ask_provider_fields( _Prompted(), get_provider_setup_spec("openrouter"), OnboardOptions(), ) assert exc_info.value.section == "provider" assert password_fired == [] # --------------------------------------------------------------------------- # Required provider stage: cancel propagates out of the wizard entry point. # --------------------------------------------------------------------------- def test_onboard_cancel_at_required_provider_stage_propagates(tmp_path, monkeypatch): """Cancelling the required provider select must surface as a typed cancellation from ``run_interactive_onboard`` (the CLI boundary decides how to render it) and must not write any config.""" import sys import types target = tmp_path / "c.toml" monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target)) monkeypatch.setattr(flow, "_is_tty", lambda: True) monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: None) monkeypatch.setattr(flow, "detect_default_sources", lambda: []) monkeypatch.setattr(flow, "console", _RecordingConsole()) class _Answer: def __init__(self, value): self.value = value def ask(self): return self.value class _FakeQuestionary(types.SimpleNamespace): def select(self, message, **_kw): assert message == "LLM provider" return _Answer(None) def text(self, message, **_kw): raise AssertionError(f"unexpected text prompt: {message}") def password(self, message, **_kw): raise AssertionError(f"unexpected password prompt: {message}") def confirm(self, message, **_kw): raise AssertionError(f"unexpected confirm prompt: {message}") def checkbox(self, message, **_kw): raise AssertionError(f"unexpected checkbox prompt: {message}") monkeypatch.setitem(sys.modules, "questionary", _FakeQuestionary()) with pytest.raises(UserCancelledError) as exc_info: flow.run_interactive_onboard(flow.OnboardOptions()) assert exc_info.value.section == "provider" assert not target.exists() def test_onboard_router_cancel_keeps_provider_and_skips_router(tmp_path, monkeypatch): """Cancelling the router step of the full wizard must skip the router section without persisting a fabricated router answer and without discarding the provider credentials entered just before it.""" import sys import types target = tmp_path / "c.toml" monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target)) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) monkeypatch.setattr(flow, "_is_tty", lambda: True) monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: None) monkeypatch.setattr(flow, "detect_default_sources", lambda: []) # The pre-save connection check is not under test here; degrade silently. monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None) recorder = _RecordingConsole() monkeypatch.setattr(flow, "console", recorder) real_upsert_router = flow.upsert_router router_calls: list[dict[str, Any]] = [] def recording_upsert_router(*args: Any, **kwargs: Any) -> Any: router_calls.append(kwargs) return real_upsert_router(*args, **kwargs) monkeypatch.setattr(flow, "upsert_router", recording_upsert_router) class _Answer: def __init__(self, value): self.value = value def ask(self): return self.value class _FakeQuestionary(types.SimpleNamespace): def select(self, message, **_kw): if message == "LLM provider": return _Answer("openrouter (OpenRouter)") if message == "LLM API key source": return _Answer("Use environment variable OPENROUTER_API_KEY") if message == "Router mode": return _Answer(None) raise AssertionError(f"unexpected select prompt: {message}") def confirm(self, message, **_kw): if message in { "Configure a messaging channel now?", "Configure web search now?", "Enable image generation now?", }: return _Answer(False) raise AssertionError(f"unexpected confirm prompt: {message}") def text(self, message, **_kw): raise AssertionError(f"unexpected text prompt: {message}") def password(self, message, **_kw): raise AssertionError(f"unexpected password prompt: {message}") def checkbox(self, message, **_kw): raise AssertionError(f"unexpected checkbox prompt: {message}") monkeypatch.setitem(sys.modules, "questionary", _FakeQuestionary()) flow.run_interactive_onboard(flow.OnboardOptions()) assert router_calls == [], "a cancelled router step must not call upsert_router" joined = recorder.joined() assert "router setup cancelled" in joined.lower() assert "opensquilla onboard configure router" in joined import tomllib data = tomllib.loads(target.read_text()) # The credentials entered right before the router cancel are kept. assert data["llm"]["api_key_env"] == "OPENROUTER_API_KEY" # --------------------------------------------------------------------------- # Router tier editor: Esc must cancel, not persist (R3). # --------------------------------------------------------------------------- def test_router_tier_editor_select_cancel_raises_user_cancelled(): """Esc at "Tier to edit" used to map to Done and let both callers run upsert_router + persist_config — a destructive re-save on explicit abort. It must raise the typed cancel like every sibling prompt.""" from opensquilla.gateway.config import GatewayConfig q = _Questionary(select_value=None) with pytest.raises(UserCancelledError) as exc_info: flow._router_tier_overrides(q, GatewayConfig()) assert exc_info.value.section == "router" def test_router_tier_editor_text_prompt_cancel_raises_user_cancelled(): """Esc at the tier provider/model text prompts used to silently mean keep-current-and-continue.""" from opensquilla.gateway.config import GatewayConfig class _Prompted: def select(self, message, **_kw): assert message == "Tier to edit" return _Prompt("Route c1") def text(self, message, **_kw): assert message == "c1 provider" return _Prompt(None) with pytest.raises(UserCancelledError) as exc_info: flow._router_tier_overrides(_Prompted(), GatewayConfig()) assert exc_info.value.section == "router" def test_router_configure_tier_editor_cancel_persists_nothing(tmp_path, monkeypatch): """End-to-end: `onboard configure router` -> SquillaRouter -> edit tiers -> Esc at "Tier to edit" must not write config.toml (previously it replaced a hand-edited inline ladder with the packaged profile).""" import sys import types target = tmp_path / "c.toml" monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target)) monkeypatch.setattr(flow, "_is_tty", lambda: True) monkeypatch.setattr(flow, "console", _RecordingConsole()) class _Answer: def __init__(self, value): self.value = value def ask(self): return self.value class _FakeQuestionary(types.SimpleNamespace): def select(self, message, **_kw): if message == "Router mode": return _Answer("SquillaRouter") if message == "Default text model": return _Answer("Route c1") if message == "Tier to edit": return _Answer(None) # Esc raise AssertionError(f"unexpected select prompt: {message}") def confirm(self, message, **_kw): assert message == "Edit router tier models now?" return _Answer(True) def text(self, message, **_kw): raise AssertionError(f"unexpected text prompt: {message}") def password(self, message, **_kw): raise AssertionError(f"unexpected password prompt: {message}") def checkbox(self, message, **_kw): raise AssertionError(f"unexpected checkbox prompt: {message}") monkeypatch.setitem(sys.modules, "questionary", _FakeQuestionary()) with pytest.raises(UserCancelledError) as exc_info: flow.run_interactive_router_configure(config_path=target) assert exc_info.value.section == "router" assert not target.exists()