Files
EvoScientist-Multi/tests/test_onboard.py
T
nb213 4ddf7ebe52 Add Atlas Cloud LLM provider (#388)
* Add Atlas Cloud LLM provider

* Add Atlas Cloud onboarding support

* fix(validators): update atlascloud key validation to handle insufficient balance case

---------

Co-authored-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com>
Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
2026-07-29 14:09:44 +01:00

2480 lines
99 KiB
Python

"""Tests for EvoScientist onboarding wizard."""
import subprocess
from contextlib import contextmanager
from unittest.mock import MagicMock, Mock, patch
import pytest
from EvoScientist.config import EvoScientistConfig
from EvoScientist.config.onboard.style import (
CONFIRM_STYLE,
WIZARD_STYLE,
)
from EvoScientist.config.onboard.validators import (
ChoiceValidator,
IntegerValidator,
)
from EvoScientist.config.onboard.wizard import (
STEPS,
render_progress,
)
@contextmanager
def _patch_all_questionary(mock_q):
"""Patch ``questionary`` in every onboard submodule that imports it.
The wizard delegates to step functions across multiple submodules
(steps/helpers/channels/style/wizard), and each submodule binds its own
``questionary`` name at import time. Patching just one location wouldn't
intercept calls made from the others.
Also forces ``_check_npx`` to True so the skills-step easter egg never
probes the host: on runners where npx is missing/slow, ``_ensure_npx``
would otherwise fire an extra ``questionary.confirm`` and shift the
mocked side_effect sequences (platform-dependent StopIteration).
"""
with (
patch("EvoScientist.config.onboard.wizard.questionary", mock_q),
patch("EvoScientist.config.onboard.steps.questionary", mock_q),
patch("EvoScientist.config.onboard.helpers.questionary", mock_q),
patch("EvoScientist.config.onboard.channels.questionary", mock_q),
patch("EvoScientist.config.onboard.style.questionary", mock_q),
patch("EvoScientist.config.onboard.helpers._check_npx", return_value=True),
):
yield mock_q
# =============================================================================
# Test STEPS and WIZARD_STYLE constants
# =============================================================================
class TestConstants:
def test_steps_has_thirteen_items(self):
"""Test that STEPS contains exactly 13 steps."""
assert len(STEPS) == 13
assert STEPS == [
"UI",
"LangGraph Port",
"Provider",
"API Key",
"Model",
"Auxiliary Model",
"Tavily Key",
"Workspace",
"Thinking",
"Skills",
"MCP Servers",
"LaTeX",
"Channels",
]
def test_wizard_style_is_style_instance(self):
"""Test that WIZARD_STYLE is a prompt_toolkit Style."""
from prompt_toolkit.styles import Style
assert isinstance(WIZARD_STYLE, Style)
def test_confirm_style_is_style_instance(self):
"""Test that CONFIRM_STYLE is a prompt_toolkit Style."""
from prompt_toolkit.styles import Style
assert isinstance(CONFIRM_STYLE, Style)
def test_confirm_style_differs_from_wizard(self):
"""Test that CONFIRM_STYLE has a different qmark color (orange)."""
assert CONFIRM_STYLE is not WIZARD_STYLE
class TestSharedConstantsAlignment:
"""Drift guard: the canonical valid-value sets in
``EvoScientist.config.onboard.constants`` must match the actual
``Choice(value=...)`` ids built by the interactive step functions in
``steps.py``. Without this, adding a new provider to one file but not
the other would silently break either CLI flag validation or the
interactive picker.
"""
def test_provider_constants_match_step_choices(self):
"""Every value in `_step_provider`'s Choice list must be in
``VALID_PROVIDERS`` — and vice versa."""
from unittest.mock import MagicMock, patch
from EvoScientist.config.onboard.constants import VALID_PROVIDERS
from EvoScientist.config.onboard.steps import _step_provider
# Intercept questionary.select to capture the choices list before
# any user prompt happens.
captured = {}
def _capture(*args, **kwargs):
captured["choices"] = kwargs.get("choices") or (
args[1] if len(args) > 1 else []
)
fake = MagicMock()
fake.ask.return_value = "anthropic"
return fake
with patch(
"EvoScientist.config.onboard.steps.questionary.select",
side_effect=_capture,
):
_step_provider(EvoScientistConfig())
actual_provider_ids = {c.value for c in captured["choices"]}
assert actual_provider_ids == set(VALID_PROVIDERS), (
"VALID_PROVIDERS in constants.py drifted from _step_provider's "
f"choices. Only-in-constants: {set(VALID_PROVIDERS) - actual_provider_ids}; "
f"only-in-choices: {actual_provider_ids - set(VALID_PROVIDERS)}"
)
def test_ui_constants_match_step_choices(self):
from EvoScientist.config.onboard.constants import VALID_UI_BACKENDS
# _step_ui_backend hard-codes "tui", "cli", and "webui" — small enough
# to check by direct lookup against the canonical set.
assert VALID_UI_BACKENDS == frozenset({"tui", "cli", "webui"})
def test_workspace_mode_constants_match_step_choices(self):
from EvoScientist.config.onboard.constants import VALID_WORKSPACE_MODES
assert VALID_WORKSPACE_MODES == frozenset({"daemon", "run"})
def test_valid_providers_aligns_with_provider_key_attr(self):
"""Every provider in ``VALID_PROVIDERS`` (except ``openai`` and
``ollama``, which the wizard handles via fallbacks) must appear in
``_PROVIDER_KEY_ATTR`` — otherwise the auth-mode flow won't know
which config attribute to write the validated key to."""
from EvoScientist.config.onboard.constants import VALID_PROVIDERS
from EvoScientist.config.onboard.wizard import _PROVIDER_KEY_ATTR
expected = set(VALID_PROVIDERS) - {"openai", "ollama"}
missing = expected - set(_PROVIDER_KEY_ATTR.keys())
assert not missing, (
"VALID_PROVIDERS has providers missing from _PROVIDER_KEY_ATTR "
f"in wizard.py: {missing}"
)
extra = set(_PROVIDER_KEY_ATTR.keys()) - expected
assert not extra, (
f"_PROVIDER_KEY_ATTR in wizard.py has keys not in VALID_PROVIDERS: {extra}"
)
def test_valid_providers_aligns_with_provider_key_info(self):
"""``_provider_key_info`` uses ``mapping.get(provider, <openai
fallback>)``, so an unknown provider silently behaves like OpenAI.
Verify every non-``openai`` provider in ``VALID_PROVIDERS`` has an
explicit entry — detected by checking the returned display name is
not the OpenAI fallback string."""
from EvoScientist.config.onboard.constants import VALID_PROVIDERS
from EvoScientist.config.onboard.helpers import _provider_key_info
cfg = EvoScientistConfig()
for provider in VALID_PROVIDERS:
display_name, _, _ = _provider_key_info(cfg, provider)
if provider == "openai":
assert display_name == "OpenAI"
else:
assert display_name != "OpenAI", (
f"Provider {provider!r} is missing from _provider_key_info "
"in helpers.py — it falls through to the OpenAI default, "
"which would silently send the wrong validator and "
"current-key lookup."
)
class TestOAuthModeReconcile:
def test_reconcile_preserves_auxiliary_openai_oauth(self):
from EvoScientist.config.onboard.wizard import _reconcile_oauth_modes
config = EvoScientistConfig(
provider="minimax",
auxiliary_provider="openai",
auxiliary_model="gpt-5.5",
openai_auth_mode="oauth",
anthropic_auth_mode="oauth",
)
_reconcile_oauth_modes(config)
assert config.openai_auth_mode == "oauth"
assert config.anthropic_auth_mode == "api_key"
def test_reconcile_preserves_auxiliary_provider_without_model(self):
from EvoScientist.config.onboard.wizard import _reconcile_oauth_modes
config = EvoScientistConfig(
provider="minimax",
auxiliary_provider="openai",
auxiliary_model="",
openai_auth_mode="oauth",
)
_reconcile_oauth_modes(config)
assert config.openai_auth_mode == "oauth"
# =============================================================================
# Test render_progress
# =============================================================================
class TestRenderProgress:
def test_renders_first_step_active(self):
"""Test that first step is shown as active."""
panel = render_progress(current_step=0, completed=set())
# Panel should contain the title
assert panel.title is not None
# The renderable content should contain step names
content_str = str(panel.renderable)
assert "Provider" in content_str
def test_renders_completed_steps(self):
"""Test that completed steps are marked differently."""
panel = render_progress(current_step=2, completed={0, 1})
content_str = str(panel.renderable)
# All step names should be present
for step in STEPS:
assert step in content_str
def test_panel_has_title(self):
"""Test that the panel has the expected title."""
panel = render_progress(current_step=0, completed=set())
assert "EvoScientist Setup" in str(panel.title)
def test_panel_has_blue_border(self):
"""Test that the panel has a blue border style."""
panel = render_progress(current_step=0, completed=set())
assert panel.border_style == "blue"
# =============================================================================
# Test Validators
# =============================================================================
class TestIntegerValidator:
def test_accepts_valid_integer(self):
"""Test that valid integers are accepted."""
validator = IntegerValidator(min_value=1, max_value=10)
class Doc:
text = "5"
# Should not raise
validator.validate(Doc())
def test_accepts_empty_for_default(self):
"""Test that empty string is accepted for using default."""
validator = IntegerValidator(min_value=1, max_value=10)
class Doc:
text = ""
# Should not raise
validator.validate(Doc())
def test_rejects_non_integer(self):
"""Test that non-integers are rejected."""
from prompt_toolkit.validation import ValidationError
validator = IntegerValidator(min_value=1, max_value=10)
class Doc:
text = "abc"
with pytest.raises(ValidationError) as exc_info:
validator.validate(Doc())
assert "valid integer" in str(exc_info.value.message)
def test_rejects_below_min(self):
"""Test that values below min are rejected."""
from prompt_toolkit.validation import ValidationError
validator = IntegerValidator(min_value=5, max_value=10)
class Doc:
text = "3"
with pytest.raises(ValidationError) as exc_info:
validator.validate(Doc())
assert "between" in str(exc_info.value.message)
def test_rejects_above_max(self):
"""Test that values above max are rejected."""
from prompt_toolkit.validation import ValidationError
validator = IntegerValidator(min_value=1, max_value=5)
class Doc:
text = "10"
with pytest.raises(ValidationError) as exc_info:
validator.validate(Doc())
assert "between" in str(exc_info.value.message)
class TestChoiceValidator:
def test_accepts_valid_choice(self):
"""Test that valid choices are accepted."""
validator = ChoiceValidator(choices=["apple", "banana", "cherry"])
class Doc:
text = "banana"
# Should not raise
validator.validate(Doc())
def test_accepts_case_insensitive(self):
"""Test that choices are case-insensitive."""
validator = ChoiceValidator(choices=["Apple", "Banana"])
class Doc:
text = "APPLE"
# Should not raise
validator.validate(Doc())
def test_accepts_empty_when_allowed(self):
"""Test that empty is accepted when allow_empty=True."""
validator = ChoiceValidator(choices=["a", "b"], allow_empty=True)
class Doc:
text = ""
# Should not raise
validator.validate(Doc())
def test_rejects_empty_when_not_allowed(self):
"""Test that empty is rejected when allow_empty=False."""
from prompt_toolkit.validation import ValidationError
validator = ChoiceValidator(choices=["a", "b"], allow_empty=False)
class Doc:
text = ""
with pytest.raises(ValidationError):
validator.validate(Doc())
def test_rejects_invalid_choice(self):
"""Test that invalid choices are rejected."""
from prompt_toolkit.validation import ValidationError
validator = ChoiceValidator(choices=["a", "b"])
class Doc:
text = "c"
with pytest.raises(ValidationError) as exc_info:
validator.validate(Doc())
assert "one of" in str(exc_info.value.message)
class TestValidateAtlasCloudKey:
def test_empty_key_skipped(self):
from EvoScientist.config.onboard.validators import validate_atlascloud_key
is_valid, msg = validate_atlascloud_key("")
assert is_valid is True
assert "Skipped" in msg
@pytest.mark.parametrize("status", [200, 404])
def test_accepts_authenticated_sentinel_statuses(self, status):
from EvoScientist.config.onboard.validators import validate_atlascloud_key
with patch("httpx.post") as mock_post:
mock_post.return_value.status_code = status
is_valid, msg = validate_atlascloud_key("atlas-key")
assert is_valid is True
assert msg == "Valid"
payload = mock_post.call_args.kwargs["json"]
assert payload["model"] == "atlascloud/auth-preflight"
assert payload["max_tokens"] == 1
@pytest.mark.parametrize("status", [401, 403])
def test_auth_rejection_is_invalid(self, status):
from EvoScientist.config.onboard.validators import validate_atlascloud_key
with patch("httpx.post") as mock_post:
mock_post.return_value.status_code = status
is_valid, msg = validate_atlascloud_key("bad-key")
assert is_valid is False
assert msg == "Invalid API key"
def test_insufficient_balance_means_valid_key(self):
"""402 fires before model resolution, so auth passed — key is valid."""
from EvoScientist.config.onboard.validators import validate_atlascloud_key
with patch("httpx.post") as mock_post:
mock_post.return_value.status_code = 402
is_valid, msg = validate_atlascloud_key("atlas-key")
assert is_valid is True
assert "insufficient balance" in msg.lower()
@pytest.mark.parametrize("status", [400, 429, 500, 503])
def test_transient_status_is_inconclusive(self, status):
from EvoScientist.config.onboard.validators import validate_atlascloud_key
with patch("httpx.post") as mock_post:
mock_post.return_value.status_code = status
is_valid, msg = validate_atlascloud_key("atlas-key")
assert is_valid is False
assert "inconclusive" in msg.lower()
# =============================================================================
# Test Step Functions (Mocked questionary)
# =============================================================================
class TestStepProvider:
def test_returns_selected_provider(self):
"""Test that _step_provider returns selected provider."""
from EvoScientist.config.onboard.steps import _step_provider
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = "anthropic"
result = _step_provider(config)
assert result == "anthropic"
mock_q.select.assert_called_once()
def test_default_value_and_label_override(self):
"""default_value preselects a provider (re-run co-pilot default) and
label customizes the prompt text."""
from EvoScientist.config.onboard.steps import _step_provider
config = EvoScientistConfig(provider="anthropic")
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = "openai"
_step_provider(config, label="co-pilot", default_value="openrouter")
call = mock_q.select.call_args
assert call.kwargs["default"] == "openrouter" # override, not config.provider
assert "co-pilot" in call.args[0]
def test_raises_keyboard_interrupt_on_cancel(self):
"""Test that _step_provider raises KeyboardInterrupt on cancel."""
from EvoScientist.config.onboard.steps import _step_provider
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = None
with pytest.raises(KeyboardInterrupt):
_step_provider(config)
class TestStepOAuthAuthMode:
@pytest.mark.parametrize(
(
"step_name",
"config_attr",
"provider_label",
"oauth_choice_label",
"ccproxy_provider",
"status_label",
"question_label",
"login_prompt",
),
[
(
"_step_anthropic_auth_mode",
"anthropic_auth_mode",
"Anthropic",
"Claude Code OAuth",
"claude_api",
"OAuth",
"Authentication mode",
"Log in to Claude now?",
),
(
"_step_openai_auth_mode",
"openai_auth_mode",
"OpenAI",
"Codex OAuth",
"codex",
"Codex OAuth",
"OpenAI authentication mode",
"Log in to Codex now?",
),
],
)
def test_oauth_wrappers_use_provider_specific_ccproxy_flow(
self,
step_name,
config_attr,
provider_label,
oauth_choice_label,
ccproxy_provider,
status_label,
question_label,
login_prompt,
):
"""Anthropic/OpenAI wrappers share flow but keep provider-specific IDs."""
from EvoScientist.config.onboard import steps as onboard_steps
config = EvoScientistConfig(**{config_attr: "oauth"})
select_question = MagicMock()
select_question.ask.return_value = "oauth"
confirm_question = MagicMock()
confirm_question.ask.return_value = True
with (
patch(
"EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True
),
patch(
"EvoScientist.ccproxy_manager.check_ccproxy_auth",
return_value=(False, "not authenticated"),
) as mock_check_auth,
patch(
"EvoScientist.config.onboard.prompter.install_navigation_keys"
) as mock_nav,
patch(
"EvoScientist.config.onboard.steps.questionary.select",
return_value=select_question,
) as mock_select,
patch(
"EvoScientist.config.onboard.steps.questionary.confirm",
return_value=confirm_question,
) as mock_confirm,
patch(
"EvoScientist.config.onboard.steps._prompt_ccproxy_port"
) as mock_port,
patch("EvoScientist.config.onboard.steps._run_ccproxy_login") as mock_login,
):
result = getattr(onboard_steps, step_name)(config)
assert result == "oauth"
mock_nav.assert_called_once_with(select_question, with_back=True)
mock_port.assert_called_once_with(config)
mock_check_auth.assert_called_once_with(ccproxy_provider)
mock_login.assert_called_once_with(ccproxy_provider, status_label)
select_call = mock_select.call_args
assert select_call.args[0] == f"{question_label} [Esc/← to go back]:"
assert select_call.kwargs["default"] == "oauth"
choice_titles = [
choice.title
for choice in select_call.kwargs["choices"]
if getattr(choice, "value", None) in {"api_key", "oauth"}
]
assert choice_titles == [
f"API Key (direct {provider_label} access)",
f"{oauth_choice_label} (via ccproxy — no API key needed)",
]
mock_confirm.assert_called_once()
assert mock_confirm.call_args.args[0] == login_prompt
class TestStepModel:
def test_returns_selected_model(self):
"""Test that _step_model returns selected model."""
from EvoScientist.config.onboard.steps import _step_model
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = "claude-sonnet-4-6"
result = _step_model(config, "anthropic")
assert result == "claude-sonnet-4-6"
def test_main_model_not_in_provider_list_defaults_to_first(self):
"""Reset/main flow: a config.model that isn't in the chosen provider's
list (e.g. provider switched to google-genai) defaults to that
provider's first model, NOT the custom 'Type a model name...' entry."""
from EvoScientist.config.onboard.steps import _step_model
from EvoScientist.llm.models import get_models_for_provider
config = EvoScientistConfig(model="claude-sonnet-4-6")
entries = get_models_for_provider("google-genai")
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = entries[0][0]
_step_model(config, "google-genai")
default = mock_q.select.call_args.kwargs["default"]
assert default == entries[0][0]
assert default != "__custom__"
def test_custom_default_value_preselects_and_prefills(self):
"""Co-pilot re-run: a saved custom (non-registry) model preselects and
prefills the 'Type a model name...' entry."""
from EvoScientist.config.onboard.steps import _step_model
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = "__custom__"
mock_q.text.return_value.ask.return_value = "my-private/model"
result = _step_model(config, "openrouter", default_value="my-private/model")
assert mock_q.select.call_args.kwargs["default"] == "__custom__"
assert mock_q.text.call_args.kwargs["default"] == "my-private/model"
assert result == "my-private/model"
def test_raises_keyboard_interrupt_on_cancel(self):
"""Test that _step_model raises KeyboardInterrupt on cancel."""
from EvoScientist.config.onboard.steps import _step_model
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = None
with pytest.raises(KeyboardInterrupt):
_step_model(config, "anthropic")
class TestStepWorkspace:
def test_returns_daemon_mode(self):
"""Test workspace step returns selected mode."""
from EvoScientist.config.onboard.steps import _step_workspace
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = "daemon"
result = _step_workspace(config)
assert result == "daemon"
def test_returns_run_mode(self):
"""Test workspace step returns run mode."""
from EvoScientist.config.onboard.steps import _step_workspace
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = "run"
result = _step_workspace(config)
assert result == "run"
class TestPromptAndValidateApiKey:
def test_keep_existing_key_still_validates(self):
"""Pressing Enter to keep current key should validate the existing key."""
from EvoScientist.config.onboard.helpers import _prompt_and_validate_api_key
validate_fn = Mock(return_value=(True, "Valid"))
with (
patch("EvoScientist.config.onboard.helpers.questionary") as mock_q,
patch("EvoScientist.config.onboard.helpers.console"),
):
mock_q.password.return_value.ask.return_value = "" # keep existing
result = _prompt_and_validate_api_key(
"Enter key:",
current="existing-key",
validate_fn=validate_fn,
skip_validation=False,
)
assert result is None # None means "keep existing, don't overwrite"
validate_fn.assert_called_once_with("existing-key")
def test_new_key_still_validates(self):
"""Entering a new key should still run validation."""
from EvoScientist.config.onboard.helpers import _prompt_and_validate_api_key
validate_fn = Mock(return_value=(True, "valid"))
with patch("EvoScientist.config.onboard.helpers.questionary") as mock_q:
mock_q.password.return_value.ask.return_value = "new-key"
result = _prompt_and_validate_api_key(
"Enter key:",
current="old-key",
validate_fn=validate_fn,
skip_validation=False,
)
assert result == "new-key"
validate_fn.assert_called_once_with("new-key")
class TestValidateImessage:
def test_valid_when_cli_found_with_rpc(self):
"""Test validate_imessage returns valid when imsg CLI found and RPC works."""
from EvoScientist.config.onboard.helpers import validate_imessage
version_result = Mock(returncode=0, stdout="imsg 1.2.3")
rpc_result = Mock(returncode=0)
with (
patch("EvoScientist.config.onboard.helpers.sys") as mock_sys,
patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sub,
):
mock_sys.platform = "darwin"
mock_shutil.which.return_value = "/opt/homebrew/bin/imsg"
mock_sub.run.side_effect = [version_result, rpc_result]
valid, msg = validate_imessage()
assert valid is True
assert "imsg" in msg
assert "1.2.3" in msg
def test_invalid_when_cli_not_found(self):
"""Test validate_imessage returns not_installed when imsg CLI missing."""
from EvoScientist.config.onboard.helpers import validate_imessage
with (
patch("EvoScientist.config.onboard.helpers.sys") as mock_sys,
patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil,
):
mock_sys.platform = "darwin"
mock_shutil.which.return_value = None
valid, msg = validate_imessage()
assert valid is False
assert msg == "not_installed"
def test_invalid_on_non_macos(self):
"""Test validate_imessage returns invalid on non-macOS."""
from EvoScientist.config.onboard.helpers import validate_imessage
with patch("EvoScientist.config.onboard.helpers.sys") as mock_sys:
mock_sys.platform = "linux"
valid, msg = validate_imessage()
assert valid is False
assert "macOS" in msg
def test_invalid_when_rpc_not_supported(self):
"""Test validate_imessage returns invalid when RPC check fails."""
from EvoScientist.config.onboard.helpers import validate_imessage
version_result = Mock(returncode=0, stdout="imsg 0.1.0")
rpc_result = Mock(returncode=1)
with (
patch("EvoScientist.config.onboard.helpers.sys") as mock_sys,
patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sub,
):
mock_sys.platform = "darwin"
mock_shutil.which.return_value = "/usr/local/bin/imsg"
mock_sub.run.side_effect = [version_result, rpc_result]
valid, msg = validate_imessage()
assert valid is False
assert "RPC not supported" in msg
class TestInstallImsg:
def test_install_success(self):
"""Test _install_imsg returns True on success."""
from EvoScientist.config.onboard.helpers import _install_imsg
with patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sub:
mock_sub.run.return_value = Mock(returncode=0)
mock_sub.TimeoutExpired = subprocess.TimeoutExpired
result = _install_imsg()
assert result is True
def test_install_brew_not_found(self):
"""Test _install_imsg handles missing Homebrew."""
from EvoScientist.config.onboard.helpers import _install_imsg
with (
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sub,
patch("EvoScientist.config.onboard.helpers.console"),
):
mock_sub.run.side_effect = FileNotFoundError()
mock_sub.TimeoutExpired = subprocess.TimeoutExpired
result = _install_imsg()
assert result is False
def test_install_failure(self):
"""Test _install_imsg returns False on non-zero exit."""
from EvoScientist.config.onboard.helpers import _install_imsg
with patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sub:
mock_sub.run.return_value = Mock(returncode=1)
mock_sub.TimeoutExpired = subprocess.TimeoutExpired
result = _install_imsg()
assert result is False
class TestSetupImessage:
def test_already_installed(self):
"""Test _setup_imessage returns True when already installed."""
from EvoScientist.config.onboard.helpers import _setup_imessage
with (
patch(
"EvoScientist.config.onboard.helpers.validate_imessage",
return_value=(True, "imsg at /bin/imsg"),
),
patch("EvoScientist.config.onboard.helpers.console"),
):
result = _setup_imessage()
assert result is True
def test_not_macos(self):
"""Test _setup_imessage returns False on non-macOS."""
from EvoScientist.config.onboard.helpers import _setup_imessage
with (
patch(
"EvoScientist.config.onboard.helpers.validate_imessage",
return_value=(False, "iMessage requires macOS"),
),
patch("EvoScientist.config.onboard.helpers.console"),
):
result = _setup_imessage()
assert result is False
def test_install_then_valid(self):
"""Test _setup_imessage installs and re-validates successfully."""
from EvoScientist.config.onboard.helpers import _setup_imessage
with (
patch("EvoScientist.config.onboard.helpers.validate_imessage") as mock_val,
patch(
"EvoScientist.config.onboard.helpers._install_imsg", return_value=True
),
patch("EvoScientist.config.onboard.helpers.questionary") as mock_q,
patch("EvoScientist.config.onboard.helpers.console"),
):
mock_val.side_effect = [
(False, "not_installed"), # First check
(True, "imsg at /bin/imsg"), # After install
]
mock_q.confirm.return_value.ask.return_value = True # Yes, install
result = _setup_imessage()
assert result is True
def test_user_declines_install(self):
"""Test _setup_imessage returns False when user declines install."""
from EvoScientist.config.onboard.helpers import _setup_imessage
with (
patch(
"EvoScientist.config.onboard.helpers.validate_imessage",
return_value=(False, "not_installed"),
),
patch("EvoScientist.config.onboard.helpers.questionary") as mock_q,
patch("EvoScientist.config.onboard.helpers.console"),
):
mock_q.confirm.return_value.ask.return_value = False
result = _setup_imessage()
assert result is False
class TestStepSkills:
def test_returns_empty_when_none_selected(self):
"""Test skills step returns empty list when user selects nothing."""
from EvoScientist.config.onboard.steps import _step_skills
with (
patch("EvoScientist.config.onboard.style.questionary") as mock_q,
patch("EvoScientist.config.onboard.steps.console"),
# Empty selection triggers the npx easter-egg check; without this
# mock the real questionary.confirm in _ensure_npx fires on hosts
# where npx is missing/slow (NoConsoleScreenBufferError on
# headless Windows CI).
patch("EvoScientist.config.onboard.steps._ensure_npx", return_value=True),
):
mock_q.checkbox.return_value.ask.return_value = []
result = _step_skills()
assert result == []
def test_installs_selected_skills(self):
"""Test skills step installs selected skills and returns sources."""
from EvoScientist.config.onboard.steps import _RECOMMENDED_SKILLS, _step_skills
source = _RECOMMENDED_SKILLS[0]["source"]
with (
patch("EvoScientist.config.onboard.style.questionary") as mock_q,
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.tools.skills_manager.install_skill") as mock_install,
):
mock_q.checkbox.return_value.ask.return_value = [source]
mock_install.return_value = {"success": True, "name": "test"}
result = _step_skills()
assert result == [source]
mock_install.assert_called_once_with(source)
def test_handles_install_failure(self):
"""Test skills step handles installation errors gracefully."""
from EvoScientist.config.onboard.steps import _RECOMMENDED_SKILLS, _step_skills
source = _RECOMMENDED_SKILLS[0]["source"]
with (
patch("EvoScientist.config.onboard.style.questionary") as mock_q,
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.tools.skills_manager.install_skill") as mock_install,
):
mock_q.checkbox.return_value.ask.return_value = [source]
mock_install.side_effect = Exception("network error")
result = _step_skills()
assert result == []
def test_raises_keyboard_interrupt_on_cancel(self):
"""Test skills step raises KeyboardInterrupt on cancel."""
from EvoScientist.config.onboard.steps import _step_skills
with patch("EvoScientist.config.onboard.style.questionary") as mock_q:
mock_q.checkbox.return_value.ask.return_value = None
with pytest.raises(KeyboardInterrupt):
_step_skills()
def test_detects_pack_via_manifest(self, tmp_path):
"""A pack source recorded in the manifest is detected as installed
even when none of the unpacked child dir names match the source."""
from EvoScientist.config.onboard.steps import _RECOMMENDED_SKILLS, _step_skills
# Recreate the EvoSkills-style layout: child dirs in GLOBAL_SKILLS_DIR
# whose names share nothing with the pack source URL.
global_dir = tmp_path / "global"
global_dir.mkdir()
for child in ("paper-writing", "evo-memory", "research-survey"):
(global_dir / child).mkdir()
(global_dir / child / "SKILL.md").write_text(
f"---\nname: {child}\ndescription: x\n---\n"
)
pack_source = _RECOMMENDED_SKILLS[0]["source"]
(global_dir / ".installed.yaml").write_text(
f"paper-writing:\n source: {pack_source}\n"
f"evo-memory:\n source: {pack_source}\n"
f"research-survey:\n source: {pack_source}\n"
)
empty_user = tmp_path / "user"
empty_user.mkdir()
captured: list = []
def _capture(choices, _msg, **_kw):
captured.extend(choices)
return []
with (
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", global_dir),
patch("EvoScientist.paths.USER_SKILLS_DIR", empty_user),
patch(
"EvoScientist.config.onboard.steps._checkbox_ask", side_effect=_capture
),
patch("EvoScientist.config.onboard.steps.console"),
# _capture returns [] (empty selection), which would otherwise
# reach the real _ensure_npx — see test_returns_empty_when_none_selected.
patch("EvoScientist.config.onboard.steps._ensure_npx", return_value=True),
):
_step_skills()
pack_choice = next(c for c in captured if c.value == pack_source)
# Detection signal: an "installed" hint appears in the choice title,
# but the choice stays selectable so users can re-sync (important for
# packs where one child was deleted but the rest keep the entry).
title_text = "".join(seg[1] for seg in pack_choice.title)
assert "installed" in title_text, (
"EvoSkills pack should be marked as installed via the manifest "
"even though no child dir name matches the pack source string"
)
assert not pack_choice.disabled, (
"Installed packs must remain selectable so users can re-sync them"
)
def test_surfaces_update_available_when_upstream_moved(self, tmp_path):
"""When a pack records an install-time commit and `git ls-remote`
reports a different SHA, the choice label should call out the update."""
from EvoScientist.config.onboard.steps import _RECOMMENDED_SKILLS, _step_skills
pack_source = _RECOMMENDED_SKILLS[0]["source"]
global_dir = tmp_path / "global"
global_dir.mkdir()
(global_dir / "paper-writing").mkdir()
(global_dir / "paper-writing" / "SKILL.md").write_text(
"---\nname: paper-writing\ndescription: x\n---\n"
)
# Recorded install-time commit.
(global_dir / ".installed.yaml").write_text(
f"paper-writing:\n source: {pack_source}\n commit: aaa111\n"
)
empty_user = tmp_path / "user"
empty_user.mkdir()
captured: list = []
def _capture(choices, _msg, **_kw):
captured.extend(choices)
return []
with (
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", global_dir),
patch("EvoScientist.paths.USER_SKILLS_DIR", empty_user),
# Upstream moved past the recorded commit.
patch(
"EvoScientist.tools.skills_manager.resolve_remote_head",
return_value="bbb222",
),
patch(
"EvoScientist.config.onboard.steps._checkbox_ask", side_effect=_capture
),
patch("EvoScientist.config.onboard.steps.console"),
# _capture returns [] (empty selection), which would otherwise
# reach the real _ensure_npx — see test_returns_empty_when_none_selected.
patch("EvoScientist.config.onboard.steps._ensure_npx", return_value=True),
):
_step_skills()
pack_choice = next(c for c in captured if c.value == pack_source)
title_text = "".join(seg[1] for seg in pack_choice.title)
assert "update available" in title_text, (
f"expected an 'update available' hint in the label, got: {title_text!r}"
)
def test_no_update_hint_when_remote_check_fails(self, tmp_path):
"""If `git ls-remote` returns None (offline, timeout, etc.), the label
must fall back to plain 'installed' — never falsely claim 'update'."""
from EvoScientist.config.onboard.steps import _RECOMMENDED_SKILLS, _step_skills
pack_source = _RECOMMENDED_SKILLS[0]["source"]
global_dir = tmp_path / "global"
global_dir.mkdir()
(global_dir / "paper-writing").mkdir()
(global_dir / "paper-writing" / "SKILL.md").write_text(
"---\nname: paper-writing\ndescription: x\n---\n"
)
(global_dir / ".installed.yaml").write_text(
f"paper-writing:\n source: {pack_source}\n commit: aaa111\n"
)
empty_user = tmp_path / "user"
empty_user.mkdir()
captured: list = []
def _capture(choices, _msg, **_kw):
captured.extend(choices)
return []
with (
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", global_dir),
patch("EvoScientist.paths.USER_SKILLS_DIR", empty_user),
patch(
"EvoScientist.tools.skills_manager.resolve_remote_head",
return_value=None,
),
patch(
"EvoScientist.config.onboard.steps._checkbox_ask", side_effect=_capture
),
patch("EvoScientist.config.onboard.steps.console"),
# _capture returns [] (empty selection), which would otherwise
# reach the real _ensure_npx — see test_returns_empty_when_none_selected.
patch("EvoScientist.config.onboard.steps._ensure_npx", return_value=True),
):
_step_skills()
pack_choice = next(c for c in captured if c.value == pack_source)
title_text = "".join(seg[1] for seg in pack_choice.title)
assert "update available" not in title_text, (
f"must not claim 'update available' when remote check fails: {title_text!r}"
)
assert "installed" in title_text
class TestStepChannels:
def test_returns_disabled_when_skip(self):
"""Test channels step returns empty dict when user selects nothing."""
from EvoScientist.config.onboard.channels import _step_channels
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.channels.questionary") as mock_q:
mock_q.checkbox.return_value.ask.return_value = []
result = _step_channels(config)
assert result == {"channel_enabled": "", "imessage_enabled": False}
def test_returns_enabled_when_setup_passes(self):
"""Test channels step returns enabled when iMessage setup succeeds."""
from EvoScientist.config.onboard.channels import _step_channels
config = EvoScientistConfig()
with (
patch("EvoScientist.config.onboard.channels.questionary") as mock_q,
patch(
"EvoScientist.config.onboard.channels._setup_imessage",
return_value=True,
),
):
mock_q.checkbox.return_value.ask.return_value = ["imessage"]
mock_q.text.return_value.ask.return_value = ""
result = _step_channels(config)
assert result["channel_enabled"] == "imessage"
assert result["imessage_enabled"] is True
def test_returns_enabled_with_senders(self):
"""Test channels step returns enabled with specific senders."""
from EvoScientist.config.onboard.channels import _step_channels
config = EvoScientistConfig()
with (
patch("EvoScientist.config.onboard.channels.questionary") as mock_q,
patch(
"EvoScientist.config.onboard.channels._setup_imessage",
return_value=True,
),
):
mock_q.checkbox.return_value.ask.return_value = ["imessage"]
mock_q.text.return_value.ask.return_value = "+1234567890,+0987654321"
result = _step_channels(config)
assert result["channel_enabled"] == "imessage"
assert result["imessage_enabled"] is True
assert result["imessage_allowed_senders"] == "+1234567890,+0987654321"
def test_setup_fails_user_declines(self):
"""Test channels step skips iMessage when setup fails and user declines."""
from EvoScientist.config.onboard.channels import _step_channels
config = EvoScientistConfig()
with (
patch("EvoScientist.config.onboard.channels.questionary") as mock_q,
patch(
"EvoScientist.config.onboard.channels._setup_imessage",
return_value=False,
),
):
mock_q.checkbox.return_value.ask.return_value = ["imessage"]
mock_q.confirm.return_value.ask.return_value = False
result = _step_channels(config)
assert result["channel_enabled"] == ""
assert result["imessage_enabled"] is False
def test_setup_fails_user_enables_anyway(self):
"""Test channels step enables iMessage when setup fails but user confirms."""
from EvoScientist.config.onboard.channels import _step_channels
config = EvoScientistConfig()
with (
patch("EvoScientist.config.onboard.channels.questionary") as mock_q,
patch(
"EvoScientist.config.onboard.channels._setup_imessage",
return_value=False,
),
):
mock_q.checkbox.return_value.ask.return_value = ["imessage"]
mock_q.confirm.return_value.ask.return_value = True
mock_q.text.return_value.ask.return_value = ""
result = _step_channels(config)
assert result["channel_enabled"] == "imessage"
assert result["imessage_enabled"] is True
def test_raises_keyboard_interrupt_on_cancel(self):
"""Test channels step raises KeyboardInterrupt on cancel."""
from EvoScientist.config.onboard.channels import _step_channels
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.channels.questionary") as mock_q:
mock_q.checkbox.return_value.ask.return_value = None
with pytest.raises(KeyboardInterrupt):
_step_channels(config)
def test_telegram_channel_selected(self):
"""Test channels step handles Telegram selection."""
from EvoScientist.config.onboard.channels import _step_channels
config = EvoScientistConfig()
_real_import = (
__builtins__.__import__
if hasattr(__builtins__, "__import__")
else __import__
)
def _fake_import(name, *args, **kwargs):
if name == "telegram":
return # pretend installed
return _real_import(name, *args, **kwargs)
with (
patch("EvoScientist.config.onboard.channels.questionary") as mock_q,
patch("EvoScientist.config.onboard.channels._probe_channel"),
patch("builtins.__import__", side_effect=_fake_import),
):
mock_q.checkbox.return_value.ask.return_value = ["telegram"]
# Bot token is a secret → prompted via questionary.password.
mock_q.password.return_value.ask.return_value = "test-token"
mock_q.text.return_value.ask.return_value = ""
result = _step_channels(config)
assert result["channel_enabled"] == "telegram"
assert result["telegram_bot_token"] == "test-token"
def test_discord_channel_selected(self):
"""Test channels step handles Discord selection."""
from EvoScientist.config.onboard.channels import _step_channels
config = EvoScientistConfig()
_real_import = (
__builtins__.__import__
if hasattr(__builtins__, "__import__")
else __import__
)
def _fake_import(name, *args, **kwargs):
if name == "discord":
return # pretend installed
return _real_import(name, *args, **kwargs)
with (
patch("EvoScientist.config.onboard.channels.questionary") as mock_q,
patch("EvoScientist.config.onboard.channels._probe_channel"),
patch("builtins.__import__", side_effect=_fake_import),
):
mock_q.checkbox.return_value.ask.return_value = ["discord"]
# Bot token is a secret → prompted via questionary.password.
mock_q.password.return_value.ask.return_value = "discord-token"
mock_q.text.return_value.ask.return_value = ""
result = _step_channels(config)
assert result["channel_enabled"] == "discord"
assert result["discord_bot_token"] == "discord-token"
class TestStepMcpServersNpxFailure:
def _make_test_servers(self):
from EvoScientist.mcp.registry import MCPServerEntry
return [
MCPServerEntry(
name="npx-server",
label="NPX Server",
tags=["onboarding"],
command="npx",
args=["-y", "test-server"],
),
MCPServerEntry(
name="url-server",
label="URL Server",
tags=["onboarding"],
transport="streamable_http",
url="https://example.com/mcp",
),
]
def test_npx_failure_skips_npx_servers(self):
"""When _ensure_npx returns False, npx-dependent servers must be skipped."""
from EvoScientist.config.onboard.steps import _step_mcp_servers
servers = self._make_test_servers()
with (
patch(
"EvoScientist.mcp.registry.fetch_marketplace_index",
return_value=servers,
),
patch(
"EvoScientist.config.onboard.steps._checkbox_ask",
return_value=["npx-server", "url-server"],
),
patch("EvoScientist.config.onboard.steps._ensure_npx", return_value=False),
patch("EvoScientist.config.onboard.helpers._check_npx", return_value=False),
patch("EvoScientist.mcp.client._load_user_config", return_value={}),
patch("EvoScientist.mcp.client.add_mcp_server") as mock_add,
patch("EvoScientist.config.onboard.steps.console"),
):
result = _step_mcp_servers()
# The npx server must NOT have been added
added_names = [call.args[0] for call in mock_add.call_args_list]
assert "npx-server" not in added_names
# The URL server should still be added
assert "url-server" in added_names
assert "url-server" in result
assert "npx-server" not in result
def test_npx_failure_returns_empty_when_all_npx(self):
"""When all selected servers are npx-based and npx fails, return []."""
from EvoScientist.config.onboard.steps import _step_mcp_servers
servers = self._make_test_servers()
npx_names = [s.name for s in servers if s.command == "npx"]
with (
patch(
"EvoScientist.mcp.registry.fetch_marketplace_index",
return_value=servers,
),
patch(
"EvoScientist.config.onboard.steps._checkbox_ask",
return_value=npx_names,
),
patch("EvoScientist.config.onboard.steps._ensure_npx", return_value=False),
patch("EvoScientist.config.onboard.helpers._check_npx", return_value=False),
patch("EvoScientist.mcp.client._load_user_config", return_value={}),
patch("EvoScientist.mcp.client.add_mcp_server") as mock_add,
patch("EvoScientist.config.onboard.steps.console"),
):
result = _step_mcp_servers()
assert result == []
mock_add.assert_not_called()
class TestStepThinking:
def test_returns_show_thinking(self):
"""Test thinking step returns selected value."""
from EvoScientist.config.onboard.steps import _step_thinking
config = EvoScientistConfig()
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = True
result = _step_thinking(config)
assert result is True
def test_returns_false_when_off(self):
"""Test thinking step returns False when user selects Off."""
from EvoScientist.config.onboard.steps import _step_thinking
config = EvoScientistConfig(show_thinking=False)
with patch("EvoScientist.config.onboard.steps.questionary") as mock_q:
mock_q.select.return_value.ask.return_value = False
result = _step_thinking(config)
assert result is False
# =============================================================================
# Test run_onboard (Integration-like test with mocked questionary)
# =============================================================================
class TestRunOnboard:
def test_returns_true_on_save(self):
"""Test that run_onboard returns True when config is saved."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.channels.console"),
patch("EvoScientist.config.onboard.helpers.console"),
patch("EvoScientist.config.onboard.wizard._step_tinytex"),
):
# Setup mock config
mock_load.return_value = EvoScientistConfig()
# Mock all questionary calls — order matches the wizard's select
# sequence: UI → Provider → Anthropic auth_mode → Model →
# Workspace → Thinking → (channels skipped via empty checkbox).
mock_q.select.return_value.ask.side_effect = [
"tui", # UI backend
"anthropic", # Provider
"api_key", # Anthropic auth mode (API key, not OAuth)
"claude-sonnet-4-6", # Model
"skip", # Auxiliary: Skip (single driver)
"daemon", # Workspace mode
True, # Show thinking
]
mock_q.password.return_value.ask.side_effect = [
"", # Provider API key (keep current)
"", # Tavily key (keep current)
]
mock_q.confirm.return_value.ask.side_effect = [
True, # Save config
]
mock_q.text.return_value.ask.side_effect = [
"", # Workspace directory (empty = use cwd)
]
mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip
result = run_onboard(skip_validation=True)
assert result is True
# Config is autosaved between phases + once at the end.
mock_save.assert_called()
# Verify the final saved config has the values we picked — guards
# against silent shift bugs where a missing side_effect entry causes
# downstream prompts to consume the wrong values.
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.provider == "anthropic"
assert final_config.model == "claude-sonnet-4-6"
assert final_config.anthropic_auth_mode == "api_key"
assert final_config.ui_backend == "tui"
assert final_config.default_mode == "daemon"
def test_auxiliary_model_enabled_collects_provider_and_key(self):
"""Enabling the auxiliary step stores its provider, model, and the
chosen provider's API key (a different company than the main agent)."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.channels.console"),
patch("EvoScientist.config.onboard.helpers.console"),
patch("EvoScientist.config.onboard.wizard._step_tinytex"),
):
mock_load.return_value = EvoScientistConfig()
mock_q.select.return_value.ask.side_effect = [
"tui", # UI backend
"anthropic", # Provider
"api_key", # Anthropic auth mode
"claude-sonnet-4-6", # Model
"assemble", # Auxiliary: Assemble
"openai", # Auxiliary provider (a different company)
"api_key", # Auxiliary OpenAI auth mode
"gpt-5.5", # Auxiliary model
"daemon", # Workspace mode
True, # Show thinking
]
mock_q.password.return_value.ask.side_effect = [
"", # Main provider API key (keep current)
"sk-aux-openai", # Auxiliary provider API key
"", # Tavily key (keep current)
]
mock_q.confirm.return_value.ask.side_effect = [
True, # Save config
]
mock_q.text.return_value.ask.side_effect = [
"", # Workspace directory
]
mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip
result = run_onboard(skip_validation=True)
assert result is True
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "openai"
assert final_config.auxiliary_model == "gpt-5.5"
assert final_config.openai_auth_mode == "api_key"
# The auxiliary provider's key is stored in its per-provider field.
assert final_config.openai_api_key == "sk-aux-openai"
# Main agent is untouched.
assert final_config.provider == "anthropic"
assert final_config.model == "claude-sonnet-4-6"
def test_auxiliary_same_provider_reuses_main_credentials(self):
"""Same-provider co-pilot should not imply separate credentials exist."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
):
mock_load.return_value = EvoScientistConfig(
provider="openai",
model="gpt-5.5",
openai_api_key="sk-main-openai",
)
mock_q.select.return_value.ask.side_effect = [
"assemble", # Auxiliary: Assemble
"openai", # Same provider as the main model
"gpt-5.5", # Auxiliary model
]
mock_q.confirm.return_value.ask.side_effect = [True] # Save config
result = run_onboard(
skip_validation=True, only_sections={"auxiliary_model"}
)
assert result is True
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "openai"
assert final_config.auxiliary_model == "gpt-5.5"
assert final_config.openai_api_key == "sk-main-openai"
mock_q.password.assert_not_called()
assert mock_q.select.return_value.ask.call_count == 3
def test_auxiliary_same_provider_prompts_when_shared_key_missing(self):
"""Same-provider reuse should not hide a missing shared API key."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.helpers.console"),
patch(
"EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True
),
):
mock_load.return_value = EvoScientistConfig(
provider="openai",
model="gpt-5.5",
openai_auth_mode="api_key",
openai_api_key="",
)
mock_q.select.return_value.ask.side_effect = [
"assemble", # Auxiliary: Assemble
"openai", # Same provider as the main model
"api_key", # Shared OpenAI auth mode
"gpt-5.5", # Auxiliary model
]
mock_q.password.return_value.ask.side_effect = [
"sk-shared-openai",
]
mock_q.confirm.return_value.ask.side_effect = [True] # Save config
result = run_onboard(
skip_validation=True, only_sections={"auxiliary_model"}
)
assert result is True
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "openai"
assert final_config.auxiliary_model == "gpt-5.5"
assert final_config.openai_auth_mode == "api_key"
assert final_config.openai_api_key == "sk-shared-openai"
mock_q.password.assert_called_once()
assert mock_q.select.return_value.ask.call_count == 4
def test_auxiliary_openai_oauth_skips_api_key(self):
"""Auxiliary OpenAI now uses the shared auth flow and skips keys on OAuth."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.helpers.console"),
patch(
"EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True
),
patch(
"EvoScientist.ccproxy_manager.check_ccproxy_auth",
return_value=(False, "not authenticated"),
) as mock_auth,
):
mock_load.return_value = EvoScientistConfig()
mock_q.select.return_value.ask.side_effect = [
"assemble", # Auxiliary: Assemble
"openai", # Auxiliary provider
"oauth", # OpenAI auth mode
"gpt-5.5", # Auxiliary model
]
mock_q.text.return_value.ask.side_effect = [
"", # ccproxy port (keep default)
]
mock_q.confirm.return_value.ask.side_effect = [
False, # Do not log in to Codex now
True, # Save config
]
result = run_onboard(
skip_validation=True, only_sections={"auxiliary_model"}
)
assert result is True
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "openai"
assert final_config.auxiliary_model == "gpt-5.5"
assert final_config.openai_auth_mode == "oauth"
assert final_config.openai_api_key == ""
mock_q.password.assert_not_called()
mock_auth.assert_called_once_with("codex")
def test_auxiliary_reconfigure_clears_unused_openai_oauth(self):
"""Switching co-pilot away from OpenAI clears stale OpenAI OAuth mode."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.helpers.console"),
):
mock_load.return_value = EvoScientistConfig(
provider="anthropic",
model="claude-sonnet-4-6",
anthropic_auth_mode="oauth",
auxiliary_provider="openai",
auxiliary_model="gpt-5.5",
openai_auth_mode="oauth",
)
mock_q.select.return_value.ask.side_effect = [
"assemble", # Auxiliary: Assemble
"minimax", # Auxiliary provider no longer uses OpenAI
"global", # MiniMax region
"minimax-m2", # Auxiliary model
]
mock_q.password.return_value.ask.side_effect = [
"sk-minimax", # MiniMax API key
]
mock_q.confirm.return_value.ask.side_effect = [True] # Save config
result = run_onboard(
skip_validation=True, only_sections={"auxiliary_model"}
)
assert result is True
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "minimax"
assert final_config.openai_auth_mode == "api_key"
assert final_config.anthropic_auth_mode == "oauth"
def test_auxiliary_custom_provider_collects_base_url(self):
"""Regression for the custom-provider fix: a custom auxiliary provider
collects its base URL (provider -> base URL -> key -> model order)."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.channels.console"),
patch("EvoScientist.config.onboard.helpers.console"),
):
mock_load.return_value = EvoScientistConfig()
mock_q.select.return_value.ask.side_effect = [
"assemble", # Auxiliary: Assemble
"custom-openai", # Auxiliary provider
"gpt-5.5", # Auxiliary model (from the custom-openai registry)
]
mock_q.text.return_value.ask.side_effect = [
"https://my-endpoint/v1", # Auxiliary base URL (custom provider)
]
mock_q.password.return_value.ask.side_effect = [
"sk-aux-custom", # Auxiliary provider API key
]
mock_q.confirm.return_value.ask.side_effect = [True] # Save
result = run_onboard(
skip_validation=True, only_sections={"auxiliary_model"}
)
assert result is True
final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "custom-openai"
assert final_config.auxiliary_model == "gpt-5.5"
# Base URL must be collected for the custom auxiliary provider.
assert final_config.custom_openai_base_url == "https://my-endpoint/v1"
assert final_config.custom_openai_api_key == "sk-aux-custom"
def test_returns_false_on_cancel(self):
"""Test that run_onboard returns False when cancelled."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.channels.console"),
patch("EvoScientist.config.onboard.helpers.console"),
patch("EvoScientist.config.onboard.wizard._step_tinytex"),
):
mock_load.return_value = EvoScientistConfig()
# First selection returns None (Ctrl+C)
mock_q.select.return_value.ask.return_value = None
result = run_onboard(skip_validation=True)
assert result is False
def test_returns_false_when_not_saving(self):
"""Test that run_onboard returns False when user declines to save."""
from EvoScientist.config.onboard.wizard import run_onboard
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch("EvoScientist.config.onboard.wizard.load_config") as mock_load,
patch("EvoScientist.config.onboard.wizard.save_config") as mock_save,
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.channels.console"),
patch("EvoScientist.config.onboard.helpers.console"),
patch("EvoScientist.config.onboard.wizard._step_tinytex"),
):
mock_load.return_value = EvoScientistConfig()
mock_q.select.return_value.ask.side_effect = [
"tui", # UI backend
"anthropic", # Provider
"api_key", # Anthropic auth mode
"claude-sonnet-4-6", # Model
"skip", # Auxiliary: Skip (single driver)
"daemon", # Workspace mode
True, # Show thinking
]
mock_q.password.return_value.ask.side_effect = ["", ""]
mock_q.confirm.return_value.ask.side_effect = [
False, # Save config - NO
]
mock_q.text.return_value.ask.side_effect = [
"", # Workspace directory (empty = use cwd)
]
mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip
result = run_onboard(skip_validation=True)
assert result is False
# The wizard autosaves between phases AND writes the original
# snapshot back when the user declines — so save_config IS called.
mock_save.assert_called()
def test_reset_then_no_restores_pre_wizard_config(self, tmp_path):
"""Regression: Reset → No must restore the original user file,
NOT overwrite it with ``EvoScientistConfig()`` defaults.
Reproduces a bug where the snapshot was refreshed after Reset, so
declining the save silently wiped the user's previous settings.
Verifies the raw-bytes revert path introduced for CodeRabbit's
"file absence on cancel" comment.
"""
from EvoScientist.config.onboard.wizard import run_onboard
from EvoScientist.config.settings import save_config
config_file = tmp_path / "config.yaml"
existing = EvoScientistConfig(
provider="openai",
model="gpt-5",
openai_api_key="sk-existing",
ui_backend="cli",
)
mock_q = MagicMock()
with patch(
"EvoScientist.config.settings.get_config_path", return_value=config_file
):
save_config(existing)
# Append a comment that ``save_config`` would strip on
# re-serialization. The test then proves revert restored the
# EXACT bytes (not just rewrote the snapshot via save_config
# and got lucky because logical content matched).
with config_file.open("ab") as f:
f.write(b"\n# user manual comment - must survive revert\n")
original_bytes = config_file.read_bytes()
with (
_patch_all_questionary(mock_q),
patch(
"EvoScientist.config.onboard.wizard.get_config_path",
return_value=config_file,
),
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.channels.console"),
patch("EvoScientist.config.onboard.helpers.console"),
patch("EvoScientist.config.onboard.wizard._step_tinytex"),
):
mock_q.select.return_value.ask.side_effect = [
"reset", # Keep/Modify/Reset → Reset
"tui",
"anthropic",
"api_key",
"claude-sonnet-4-6",
"skip", # Auxiliary: Skip (single driver)
"daemon",
True,
]
mock_q.password.return_value.ask.side_effect = ["", ""]
mock_q.confirm.return_value.ask.side_effect = [
False, # Save? = No
]
mock_q.text.return_value.ask.side_effect = [""]
mock_q.checkbox.return_value.ask.return_value = []
result = run_onboard(skip_validation=True)
assert result is False
assert config_file.exists(), "file should still exist after revert"
assert config_file.read_bytes() == original_bytes, (
"revert must restore EXACT pre-wizard bytes, not autosaved state"
)
def test_revert_removes_file_when_none_existed(self, tmp_path):
"""Brand-new user (no config.yaml) declines the final save: the file
created by autosaves should be deleted to match the original state.
"""
from EvoScientist.config.onboard.wizard import run_onboard
config_file = tmp_path / "config.yaml"
assert not config_file.exists()
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch(
"EvoScientist.config.settings.get_config_path",
return_value=config_file,
),
patch(
"EvoScientist.config.onboard.wizard.get_config_path",
return_value=config_file,
),
patch("EvoScientist.config.onboard.wizard.console"),
patch("EvoScientist.config.onboard.steps.console"),
patch("EvoScientist.config.onboard.channels.console"),
patch("EvoScientist.config.onboard.helpers.console"),
patch("EvoScientist.config.onboard.wizard._step_tinytex"),
):
mock_q.select.return_value.ask.side_effect = [
"tui",
"anthropic",
"api_key",
"claude-sonnet-4-6",
"skip", # Auxiliary: Skip (single driver)
"daemon",
True,
]
mock_q.password.return_value.ask.side_effect = ["", ""]
mock_q.confirm.return_value.ask.side_effect = [
False, # Save? = No
]
mock_q.text.return_value.ask.side_effect = [""]
mock_q.checkbox.return_value.ask.return_value = []
result = run_onboard(skip_validation=True)
assert result is False
assert not config_file.exists(), (
"revert must delete file that was autosaved into existence "
"during the wizard run"
)
def test_preset_tavily_key_rejected_by_validator_is_fatal(self):
"""``--tavily-key`` preset must be validated like ``--api-key``;
a rejected key raises rather than silently saving."""
import pytest
from EvoScientist.config.onboard.prompter import NonInteractivePrompter
from EvoScientist.config.onboard.wizard import run_onboard
prompter = NonInteractivePrompter(answers={"tavily_key": "tvly-bad"})
with (
patch(
"EvoScientist.config.onboard.wizard.load_config",
return_value=EvoScientistConfig(),
),
patch("EvoScientist.config.onboard.wizard.save_config"),
patch("EvoScientist.config.onboard.wizard.console"),
patch(
"EvoScientist.config.onboard.validators.validate_tavily_key",
return_value=(False, "Invalid API key"),
),
):
with pytest.raises(RuntimeError, match="--tavily-key rejected"):
run_onboard(
skip_validation=False,
prompter=prompter,
only_sections={"tavily"},
)
def test_preset_tavily_key_skips_validation_when_flagged(self):
"""``--skip-validation`` must bypass the preset tavily check."""
from EvoScientist.config.onboard.prompter import NonInteractivePrompter
from EvoScientist.config.onboard.wizard import run_onboard
prompter = NonInteractivePrompter(answers={"tavily_key": "tvly-bad"})
mock_q = MagicMock()
with (
_patch_all_questionary(mock_q),
patch(
"EvoScientist.config.onboard.wizard.load_config",
return_value=EvoScientistConfig(),
),
patch("EvoScientist.config.onboard.wizard.save_config"),
patch("EvoScientist.config.onboard.wizard.console"),
patch(
"EvoScientist.config.onboard.validators.validate_tavily_key"
) as mock_validate,
):
mock_q.confirm.return_value.ask.side_effect = [True] # Save? = Yes
run_onboard(
skip_validation=True,
prompter=prompter,
only_sections={"tavily"},
)
mock_validate.assert_not_called()
class TestOnboardCliErrorPresentation:
"""The CLI must turn the wizard's non-interactive ``RuntimeError`` signals
into a clean message + exit code 1, not a raw Python traceback."""
def test_runtime_error_becomes_clean_typer_exit(self):
import typer
from EvoScientist.cli.commands import _run_onboard_cli
with (
patch(
"EvoScientist.config.onboard.run_onboard",
side_effect=RuntimeError("--tavily-key rejected by validator: nope"),
),
patch("EvoScientist.cli.commands.console") as mock_console,
):
with pytest.raises(typer.Exit) as exc_info:
_run_onboard_cli(skip_validation=False)
assert exc_info.value.exit_code == 1
# The error message reaches the user via console, not a traceback.
printed = " ".join(str(c.args[0]) for c in mock_console.print.call_args_list)
assert "rejected by validator" in printed
# =============================================================================
# Test TinyTeX helpers
# =============================================================================
class TestCheckLatexComponents:
"""Tests for _check_latex_components()."""
def test_all_available(self):
"""All three components found → all True."""
from EvoScientist.config.onboard.helpers import _check_latex_components
with (
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
):
mock_sh.which.return_value = "/usr/local/bin/cmd"
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.return_value = Mock(returncode=0)
result = _check_latex_components()
assert result == {"pdflatex": True, "latexmk": True, "tlmgr": True}
def test_only_pdflatex(self):
"""Only pdflatex available."""
from EvoScientist.config.onboard.helpers import _check_latex_components
with (
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
):
mock_sh.which.side_effect = lambda cmd: (
"/usr/local/bin/pdflatex" if cmd == "pdflatex" else None
)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.return_value = Mock(returncode=0)
result = _check_latex_components()
assert result == {
"pdflatex": True,
"latexmk": False,
"tlmgr": False,
}
def test_none_available(self):
"""Nothing found → all False."""
from EvoScientist.config.onboard.helpers import _check_latex_components
with patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh:
mock_sh.which.return_value = None
result = _check_latex_components()
assert result == {
"pdflatex": False,
"latexmk": False,
"tlmgr": False,
}
class TestAutoInstallLatexmk:
"""Tests for _auto_install_latexmk()."""
def test_success(self):
"""tlmgr install latexmk succeeds."""
from EvoScientist.config.onboard.helpers import _auto_install_latexmk
with (
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
patch("EvoScientist.config.onboard.helpers.console") as mock_con,
):
mock_sh.which.side_effect = lambda cmd: f"/usr/local/bin/{cmd}"
mock_sp.run.return_value = Mock(returncode=0)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
_auto_install_latexmk()
success_printed = any(
"latexmk installed" in str(c) for c in mock_con.print.call_args_list
)
assert success_printed
def test_tlmgr_not_found(self):
"""tlmgr not on PATH → does nothing."""
from EvoScientist.config.onboard.helpers import _auto_install_latexmk
with (
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
patch("EvoScientist.config.onboard.helpers.console"),
):
mock_sh.which.return_value = None
_auto_install_latexmk()
mock_sp.run.assert_not_called()
def test_install_fails(self):
"""tlmgr install returns nonzero → warns."""
from EvoScientist.config.onboard.helpers import _auto_install_latexmk
with (
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
patch("EvoScientist.config.onboard.helpers.console") as mock_con,
):
mock_sh.which.side_effect = lambda cmd: (
"/usr/local/bin/tlmgr" if cmd == "tlmgr" else None
)
mock_sp.run.return_value = Mock(returncode=1)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
_auto_install_latexmk()
warn_printed = any(
"Failed" in str(c) for c in mock_con.print.call_args_list
)
assert warn_printed
class TestCheckTinytex:
"""Tests for _check_tinytex()."""
def test_found_pdflatex(self):
"""pdflatex found and working → True."""
from EvoScientist.config.onboard.helpers import _check_tinytex
with (
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
):
mock_sh.which.return_value = "/usr/local/bin/pdflatex"
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.return_value = Mock(returncode=0)
assert _check_tinytex() is True
def test_tlmgr_only_not_enough(self):
"""pdflatex missing but tlmgr found → False (pdflatex is required)."""
from EvoScientist.config.onboard.helpers import _check_tinytex
with (
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
):
mock_sh.which.side_effect = lambda cmd: (
"/usr/local/bin/tlmgr" if cmd == "tlmgr" else None
)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.return_value = Mock(returncode=0)
assert _check_tinytex() is False
def test_not_found(self):
"""Neither pdflatex nor tlmgr found → False."""
from EvoScientist.config.onboard.helpers import _check_tinytex
with patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh:
mock_sh.which.return_value = None
assert _check_tinytex() is False
def test_version_timeout(self):
"""Command found but --version times out → False."""
from EvoScientist.config.onboard.helpers import _check_tinytex
with (
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
):
mock_sh.which.return_value = "/usr/local/bin/pdflatex"
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.side_effect = subprocess.TimeoutExpired("pdflatex", 10)
assert _check_tinytex() is False
def test_version_nonzero(self):
"""Command found but --version returns nonzero → False."""
from EvoScientist.config.onboard.helpers import _check_tinytex
with (
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
):
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
mock_sp.run.return_value = Mock(returncode=1)
# pdflatex fails, tlmgr not found
mock_sh.which.side_effect = lambda cmd: (
"/usr/local/bin/pdflatex" if cmd == "pdflatex" else None
)
assert _check_tinytex() is False
class TestDetectTinytexInstallMethod:
"""Tests for _detect_tinytex_install_method()."""
def test_macos_with_curl(self):
"""macOS with curl → curl method."""
from EvoScientist.config.onboard.helpers import _detect_tinytex_install_method
with (
patch("EvoScientist.config.onboard.helpers.sys") as mock_sys,
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
):
mock_sys.platform = "darwin"
mock_sh.which.side_effect = lambda cmd: (
"/usr/bin/curl" if cmd == "curl" else None
)
method, command = _detect_tinytex_install_method()
assert method == "curl"
assert "install-bin-unix.sh" in command
def test_linux_wget_fallback(self):
"""Linux without curl, with wget → wget method."""
from EvoScientist.config.onboard.helpers import _detect_tinytex_install_method
with (
patch("EvoScientist.config.onboard.helpers.sys") as mock_sys,
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
):
mock_sys.platform = "linux"
mock_sh.which.side_effect = lambda cmd: (
"/usr/bin/wget" if cmd == "wget" else None
)
method, command = _detect_tinytex_install_method()
assert method == "wget"
assert "install-bin-unix.sh" in command
def test_windows_choco(self):
"""Windows with choco → choco method."""
from EvoScientist.config.onboard.helpers import _detect_tinytex_install_method
with (
patch("EvoScientist.config.onboard.helpers.sys") as mock_sys,
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
):
mock_sys.platform = "win32"
mock_sh.which.side_effect = lambda cmd: (
"C:\\choco\\choco.exe" if cmd == "choco" else None
)
method, command = _detect_tinytex_install_method()
assert method == "choco"
assert "tinytex" in command
def test_windows_scoop(self):
"""Windows with scoop (no choco) → scoop method."""
from EvoScientist.config.onboard.helpers import _detect_tinytex_install_method
with (
patch("EvoScientist.config.onboard.helpers.sys") as mock_sys,
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
):
mock_sys.platform = "win32"
mock_sh.which.side_effect = lambda cmd: (
"C:\\scoop\\scoop.exe" if cmd == "scoop" else None
)
method, command = _detect_tinytex_install_method()
assert method == "scoop"
assert "tinytex" in command
def test_no_tools(self):
"""No tools available → manual method."""
from EvoScientist.config.onboard.helpers import _detect_tinytex_install_method
with (
patch("EvoScientist.config.onboard.helpers.sys") as mock_sys,
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
):
mock_sys.platform = "linux"
mock_sh.which.return_value = None
method, command = _detect_tinytex_install_method()
assert method == "manual"
assert "yihui.org" in command
class TestInstallTinytex:
"""Tests for _install_tinytex()."""
def test_curl_install_success(self):
"""curl install succeeds → True."""
from EvoScientist.config.onboard.helpers import _install_tinytex
with patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp:
mock_sp.run.return_value = Mock(returncode=0)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
assert _install_tinytex("curl", "curl -sL ... | sh") is True
mock_sp.run.assert_called_once()
# Verify shell=True was used for pipe commands
_, kwargs = mock_sp.run.call_args
assert kwargs.get("shell") is True
def test_curl_install_timeout(self):
"""curl install times out → False."""
from EvoScientist.config.onboard.helpers import _install_tinytex
with (
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
patch("EvoScientist.config.onboard.helpers.console"),
):
mock_sp.run.side_effect = subprocess.TimeoutExpired("curl", 300)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
assert _install_tinytex("curl", "curl -sL ... | sh") is False
def test_choco_install_success(self):
"""choco install succeeds → True."""
from EvoScientist.config.onboard.helpers import _install_tinytex
with (
patch("EvoScientist.config.onboard.helpers.subprocess") as mock_sp,
patch("EvoScientist.config.onboard.helpers.shutil") as mock_sh,
):
mock_sh.which.return_value = "C:\\choco\\choco.exe"
mock_sp.run.return_value = Mock(returncode=0)
mock_sp.TimeoutExpired = subprocess.TimeoutExpired
assert _install_tinytex("choco", "choco install tinytex -y") is True
def test_manual_returns_false(self):
"""manual method → False immediately."""
from EvoScientist.config.onboard.helpers import _install_tinytex
assert _install_tinytex("manual", "https://yihui.org/tinytex/") is False
class TestStepTinytex:
"""Tests for _step_tinytex()."""
def test_user_declines_prepare(self):
"""User says No to 'Prepare LaTeX environment?' → skipped."""
from EvoScientist.config.onboard.steps import _step_tinytex
with (
patch("EvoScientist.config.onboard.steps.questionary") as mock_q,
patch("EvoScientist.config.onboard.steps._print_step_skipped") as mock_ps,
patch("EvoScientist.config.onboard.steps.console"),
):
mock_q.select.return_value.ask.return_value = False
_step_tinytex()
mock_ps.assert_called_once_with("LaTeX", "skipped")
def test_already_installed_all_components(self):
"""User says Yes, all components available → prints detailed status."""
from EvoScientist.config.onboard.steps import _step_tinytex
with (
patch("EvoScientist.config.onboard.steps.questionary") as mock_q,
patch(
"EvoScientist.config.onboard.steps._check_latex_components",
return_value={
"pdflatex": True,
"latexmk": True,
"tlmgr": True,
},
),
patch(
"EvoScientist.config.onboard.steps._print_latex_status"
) as mock_status,
patch("EvoScientist.config.onboard.steps.console"),
):
mock_q.select.return_value.ask.return_value = True
_step_tinytex()
mock_status.assert_called_once()
def test_already_installed_missing_latexmk(self):
"""pdflatex + tlmgr present but latexmk missing → auto-installs."""
from EvoScientist.config.onboard.steps import _step_tinytex
with (
patch("EvoScientist.config.onboard.steps.questionary") as mock_q,
patch(
"EvoScientist.config.onboard.steps._check_latex_components",
return_value={
"pdflatex": True,
"latexmk": False,
"tlmgr": True,
},
),
patch("EvoScientist.config.onboard.steps._print_latex_status"),
patch(
"EvoScientist.config.onboard.steps._auto_install_latexmk"
) as mock_auto,
patch("EvoScientist.config.onboard.steps.console"),
):
mock_q.select.return_value.ask.return_value = True
_step_tinytex()
mock_auto.assert_called_once()
def test_user_installs_successfully(self):
"""Yes → not found → confirms install → succeeds → re-check passes."""
from EvoScientist.config.onboard.steps import _step_tinytex
all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False}
all_true = {"pdflatex": True, "latexmk": True, "tlmgr": True}
with (
patch(
"EvoScientist.config.onboard.steps._check_latex_components",
side_effect=[all_false, all_true],
),
patch(
"EvoScientist.config.onboard.steps._detect_tinytex_install_method",
return_value=("curl", "curl ... | sh"),
),
patch(
"EvoScientist.config.onboard.steps._install_tinytex",
return_value=True,
),
patch("EvoScientist.config.onboard.steps.questionary") as mock_q,
patch("EvoScientist.config.onboard.steps._print_step_result") as mock_pr,
patch("EvoScientist.config.onboard.steps._print_latex_status"),
patch("EvoScientist.config.onboard.steps.console"),
):
mock_q.select.return_value.ask.return_value = True
mock_q.confirm.return_value.ask.return_value = True
_step_tinytex()
mock_pr.assert_called_once_with("LaTeX", "TinyTeX installed")
def test_user_declines_install(self):
"""Yes to prepare → not found → declines install → skipped."""
from EvoScientist.config.onboard.steps import _step_tinytex
all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False}
with (
patch(
"EvoScientist.config.onboard.steps._check_latex_components",
return_value=all_false,
),
patch(
"EvoScientist.config.onboard.steps._detect_tinytex_install_method",
return_value=("curl", "curl ... | sh"),
),
patch("EvoScientist.config.onboard.steps.questionary") as mock_q,
patch("EvoScientist.config.onboard.steps._print_step_skipped") as mock_ps,
patch("EvoScientist.config.onboard.steps.console"),
):
mock_q.select.return_value.ask.return_value = True
mock_q.confirm.return_value.ask.return_value = False
_step_tinytex()
mock_ps.assert_called_once_with("LaTeX", "skipped")
def test_install_fails(self):
"""Yes → not found → confirms install → install fails."""
from EvoScientist.config.onboard.steps import _step_tinytex
all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False}
with (
patch(
"EvoScientist.config.onboard.steps._check_latex_components",
return_value=all_false,
),
patch(
"EvoScientist.config.onboard.steps._detect_tinytex_install_method",
return_value=("curl", "curl ... | sh"),
),
patch(
"EvoScientist.config.onboard.steps._install_tinytex",
return_value=False,
),
patch("EvoScientist.config.onboard.steps.questionary") as mock_q,
patch("EvoScientist.config.onboard.steps._print_step_result") as mock_pr,
patch("EvoScientist.config.onboard.steps.console"),
):
mock_q.select.return_value.ask.return_value = True
mock_q.confirm.return_value.ask.return_value = True
_step_tinytex()
mock_pr.assert_called_once_with(
"LaTeX", "installation failed", success=False
)
def test_installed_but_not_in_path(self):
"""Install succeeds but pdflatex not yet in PATH → warns user."""
from EvoScientist.config.onboard.steps import _step_tinytex
all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False}
with (
patch(
"EvoScientist.config.onboard.steps._check_latex_components",
side_effect=[all_false, all_false],
),
patch(
"EvoScientist.config.onboard.steps._detect_tinytex_install_method",
return_value=("curl", "curl ... | sh"),
),
patch(
"EvoScientist.config.onboard.steps._install_tinytex",
return_value=True,
),
patch("EvoScientist.config.onboard.steps.questionary") as mock_q,
patch("EvoScientist.config.onboard.steps._print_step_result") as mock_pr,
patch("EvoScientist.config.onboard.steps.console") as mock_con,
):
mock_q.select.return_value.ask.return_value = True
mock_q.confirm.return_value.ask.return_value = True
_step_tinytex()
path_warning_printed = any(
"PATH" in str(call) for call in mock_con.print.call_args_list
)
assert path_warning_printed
mock_pr.assert_called_once_with(
"LaTeX", "installed (restart terminal for PATH)"
)
def test_manual_method(self):
"""Yes to prepare → not found → manual method → prints URL, no install prompt."""
from EvoScientist.config.onboard.steps import _step_tinytex
all_false = {"pdflatex": False, "latexmk": False, "tlmgr": False}
with (
patch(
"EvoScientist.config.onboard.steps._check_latex_components",
return_value=all_false,
),
patch(
"EvoScientist.config.onboard.steps._detect_tinytex_install_method",
return_value=("manual", "https://yihui.org/tinytex/"),
),
patch("EvoScientist.config.onboard.steps.questionary") as mock_q,
patch("EvoScientist.config.onboard.steps._print_step_skipped") as mock_ps,
patch("EvoScientist.config.onboard.steps.console"),
):
mock_q.select.return_value.ask.return_value = True
_step_tinytex()
mock_ps.assert_called_once_with("LaTeX", "manual install needed")