update
This commit is contained in:
+74
-2
@@ -29,6 +29,7 @@ from prompt_toolkit import PromptSession # type: ignore[import-untyped]
|
||||
from prompt_toolkit.history import FileHistory # type: ignore[import-untyped]
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory # type: ignore[import-untyped]
|
||||
from prompt_toolkit.formatted_text import HTML # type: ignore[import-untyped]
|
||||
from rich.panel import Panel # type: ignore[import-untyped]
|
||||
from rich.text import Text # type: ignore[import-untyped]
|
||||
from rich.table import Table # type: ignore[import-untyped]
|
||||
|
||||
@@ -234,8 +235,6 @@ def print_banner(
|
||||
info.append("/install-skill", style="bold")
|
||||
info.append(", ", style="dim")
|
||||
info.append("/uninstall-skill", style="bold")
|
||||
info.append(", ", style="dim")
|
||||
info.append("/channel", style="bold")
|
||||
console.print(info)
|
||||
console.print()
|
||||
|
||||
@@ -413,6 +412,69 @@ def _cmd_channel_stop() -> None:
|
||||
# CLI commands
|
||||
# =============================================================================
|
||||
|
||||
def _print_channel_panel(channels: list[tuple[str, bool, str]]) -> None:
|
||||
"""Print a summary panel for active channels.
|
||||
|
||||
Args:
|
||||
channels: List of (name, ok, detail) tuples.
|
||||
"""
|
||||
lines: list[Text] = []
|
||||
all_ok = True
|
||||
for name, ok, detail in channels:
|
||||
line = Text()
|
||||
if ok:
|
||||
line.append("● ", style="green")
|
||||
line.append(name, style="bold")
|
||||
else:
|
||||
line.append("✗ ", style="yellow")
|
||||
line.append(name, style="bold yellow")
|
||||
all_ok = False
|
||||
if detail:
|
||||
line.append(f" {detail}", style="dim")
|
||||
lines.append(line)
|
||||
|
||||
body = Text("\n").join(lines)
|
||||
border = "green" if all_ok else "yellow"
|
||||
console.print(Panel(body, title="[bold]Channels[/bold]", border_style=border, expand=False))
|
||||
console.print()
|
||||
|
||||
|
||||
def _auto_start_channel(agent: Any, thread_id: str, allowed_senders_csv: str) -> None:
|
||||
"""Start iMessage channel automatically from config.
|
||||
|
||||
Args:
|
||||
agent: Compiled agent graph.
|
||||
thread_id: Current thread ID.
|
||||
allowed_senders_csv: Comma-separated allowed senders (empty = all).
|
||||
"""
|
||||
try:
|
||||
from .channels.imessage import IMessageConfig
|
||||
from .channels.imessage.serve import IMessageServer
|
||||
|
||||
allowed: set[str] | None = None
|
||||
if allowed_senders_csv.strip():
|
||||
allowed = {s.strip() for s in allowed_senders_csv.split(",") if s.strip()}
|
||||
|
||||
config = IMessageConfig(allowed_senders=allowed if allowed else None)
|
||||
|
||||
_ChannelState.agent = agent
|
||||
_ChannelState.thread_id = thread_id
|
||||
|
||||
server = IMessageServer(config, handler=_create_channel_handler())
|
||||
_ChannelState.server = server
|
||||
_ChannelState.thread = threading.Thread(
|
||||
target=_run_channel_thread,
|
||||
args=(server,),
|
||||
daemon=True,
|
||||
)
|
||||
_ChannelState.thread.start()
|
||||
|
||||
detail = ", ".join(sorted(allowed)) if allowed else "all senders"
|
||||
_print_channel_panel([("iMessage", True, detail)])
|
||||
except Exception as e:
|
||||
_print_channel_panel([("iMessage", False, str(e))])
|
||||
|
||||
|
||||
def cmd_interactive(
|
||||
agent: Any,
|
||||
show_thinking: bool = True,
|
||||
@@ -421,6 +483,8 @@ def cmd_interactive(
|
||||
mode: str | None = None,
|
||||
model: str | None = None,
|
||||
provider: str | None = None,
|
||||
imessage_enabled: bool = False,
|
||||
imessage_allowed_senders: str = "",
|
||||
) -> None:
|
||||
"""Interactive conversation mode with streaming output.
|
||||
|
||||
@@ -432,6 +496,8 @@ def cmd_interactive(
|
||||
mode: Workspace mode ('daemon' or 'run'), displayed in banner
|
||||
model: Model name to display in banner
|
||||
provider: LLM provider name to display in banner
|
||||
imessage_enabled: Whether to auto-start iMessage channel
|
||||
imessage_allowed_senders: Comma-separated allowed senders
|
||||
"""
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
@@ -514,6 +580,10 @@ def cmd_interactive(
|
||||
# Start background queue checker
|
||||
queue_task = asyncio.create_task(_check_channel_queue())
|
||||
|
||||
# Auto-start iMessage channel if enabled in config
|
||||
if imessage_enabled and not _ChannelState.is_running():
|
||||
_auto_start_channel(state["agent"], state["thread_id"], imessage_allowed_senders)
|
||||
|
||||
try:
|
||||
_print_separator()
|
||||
while state["running"]:
|
||||
@@ -939,6 +1009,8 @@ def _main_callback(
|
||||
mode=effective_mode,
|
||||
model=config.model,
|
||||
provider=config.provider,
|
||||
imessage_enabled=config.imessage_enabled,
|
||||
imessage_allowed_senders=config.imessage_allowed_senders,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@ class EvoScientistConfig:
|
||||
# UI Settings
|
||||
show_thinking: bool = True
|
||||
|
||||
# Channel Settings
|
||||
imessage_enabled: bool = False
|
||||
imessage_allowed_senders: str = "" # comma-separated, empty = allow all
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config file operations
|
||||
|
||||
+205
-3
@@ -7,6 +7,8 @@ workspace settings, and agent parameters. Uses flow-style arrow-key selection UI
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import questionary
|
||||
from prompt_toolkit.styles import Style
|
||||
@@ -51,7 +53,7 @@ CONFIRM_STYLE = Style.from_dict({
|
||||
"text": "",
|
||||
})
|
||||
|
||||
STEPS = ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters"]
|
||||
STEPS = ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Channels"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -188,11 +190,15 @@ def validate_google_key(api_key: str) -> tuple[bool, str]:
|
||||
from google import genai
|
||||
client = genai.Client(api_key=api_key)
|
||||
# Make a minimal request to validate the key
|
||||
list(client.models.list(config={"page_size": 1}))
|
||||
pager = client.models.list(config={"page_size": 1})
|
||||
next(iter(pager)) # fetch first model only
|
||||
return True, "Valid"
|
||||
except StopIteration:
|
||||
# Empty result but request succeeded — key is valid
|
||||
return True, "Valid"
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
if "401" in error_str or "403" in error_str or "unauthorized" in error_str or "invalid" in error_str or "api key" in error_str:
|
||||
if "400" in error_str or "401" in error_str or "403" in error_str or "unauthorized" in error_str or "invalid" in error_str or "api key" in error_str:
|
||||
return False, "Invalid API key"
|
||||
return False, f"Error: {e}"
|
||||
|
||||
@@ -611,6 +617,197 @@ def _step_parameters(config: EvoScientistConfig) -> tuple[int, int, bool]:
|
||||
return max_concurrent, max_iterations, show_thinking
|
||||
|
||||
|
||||
def validate_imessage() -> tuple[bool, str]:
|
||||
"""Validate iMessage environment by checking for the imsg CLI.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, message).
|
||||
"""
|
||||
# macOS only
|
||||
if sys.platform != "darwin":
|
||||
return False, "iMessage requires macOS"
|
||||
|
||||
from .channels.imessage.probe import find_cli
|
||||
|
||||
cli_path = find_cli()
|
||||
if not cli_path:
|
||||
return False, "not_installed"
|
||||
|
||||
# Check version
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[cli_path, "--version"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
version = result.stdout.strip() if result.returncode == 0 else None
|
||||
except Exception:
|
||||
version = None
|
||||
|
||||
# Check RPC support
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[cli_path, "rpc", "--help"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
rpc_ok = result.returncode == 0
|
||||
except Exception:
|
||||
rpc_ok = False
|
||||
|
||||
if not rpc_ok:
|
||||
return False, f"imsg found at {cli_path} but RPC not supported (update with: brew upgrade imsg)"
|
||||
|
||||
version_str = f" ({version})" if version else ""
|
||||
return True, f"imsg{version_str} at {cli_path}"
|
||||
|
||||
|
||||
def _install_imsg() -> bool:
|
||||
"""Run brew install for imsg CLI.
|
||||
|
||||
Returns:
|
||||
True if installation succeeded.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["brew", "install", "steipete/tap/imsg"],
|
||||
timeout=120,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
except FileNotFoundError:
|
||||
console.print(" [red]✗ Homebrew not found[/red]")
|
||||
console.print(" [dim]Install Homebrew first: https://brew.sh[/dim]")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
console.print(" [red]✗ Installation timed out[/red]")
|
||||
return False
|
||||
except Exception as e:
|
||||
console.print(f" [red]✗ Installation failed: {e}[/red]")
|
||||
return False
|
||||
|
||||
|
||||
def _setup_imessage() -> bool:
|
||||
"""Guide the user through iMessage setup: install, validate, test.
|
||||
|
||||
Returns:
|
||||
True if iMessage is ready to use.
|
||||
"""
|
||||
# Step 1: Validate
|
||||
console.print(" [dim]Checking iMessage environment...[/dim]")
|
||||
valid, msg = validate_imessage()
|
||||
|
||||
if valid:
|
||||
console.print(f" [green]✓ {msg}[/green]")
|
||||
return True
|
||||
|
||||
if msg == "iMessage requires macOS":
|
||||
console.print(f" [red]✗ {msg}[/red]")
|
||||
return False
|
||||
|
||||
if msg == "not_installed":
|
||||
console.print(" [yellow]✗ imsg CLI not installed[/yellow]")
|
||||
console.print()
|
||||
|
||||
# Step 2: Offer to install
|
||||
install = questionary.confirm(
|
||||
"Install imsg via Homebrew? (brew install steipete/tap/imsg)",
|
||||
default=True,
|
||||
style=WIZARD_STYLE,
|
||||
qmark=" ?",
|
||||
).ask()
|
||||
|
||||
if install is None:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
if install:
|
||||
console.print()
|
||||
if _install_imsg():
|
||||
console.print()
|
||||
# Re-validate after install
|
||||
valid, msg = validate_imessage()
|
||||
if valid:
|
||||
console.print(f" [green]✓ {msg}[/green]")
|
||||
return True
|
||||
else:
|
||||
console.print(f" [red]✗ {msg}[/red]")
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
console.print(" [dim]Skipped. Install manually: brew install steipete/tap/imsg[/dim]")
|
||||
return False
|
||||
else:
|
||||
# RPC not supported or other issue
|
||||
console.print(f" [red]✗ {msg}[/red]")
|
||||
return False
|
||||
|
||||
|
||||
def _step_channels(config: EvoScientistConfig) -> tuple[bool, str]:
|
||||
"""Step 7: Select a channel to enable on startup.
|
||||
|
||||
Presents a single-select list with "Skip for now" as default.
|
||||
Selecting a channel triggers validation and guided installation.
|
||||
|
||||
Args:
|
||||
config: Current configuration.
|
||||
|
||||
Returns:
|
||||
Tuple of (imessage_enabled, imessage_allowed_senders_csv).
|
||||
"""
|
||||
# Determine default based on current config
|
||||
default = "imessage" if config.imessage_enabled else "skip"
|
||||
|
||||
choices = [
|
||||
Choice(title="Skip for now", value="skip"),
|
||||
Choice(title="iMessage", value="imessage"),
|
||||
# Future channels:
|
||||
# Choice(title="Slack", value="slack"),
|
||||
# Choice(title="Email", value="email"),
|
||||
]
|
||||
|
||||
selected = questionary.select(
|
||||
"Select channel to enable on startup:",
|
||||
choices=choices,
|
||||
default=default,
|
||||
style=WIZARD_STYLE,
|
||||
use_indicator=True,
|
||||
).ask()
|
||||
|
||||
if selected is None:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
if selected == "skip":
|
||||
return False, ""
|
||||
|
||||
# iMessage selected — run guided setup
|
||||
ready = _setup_imessage()
|
||||
|
||||
if not ready:
|
||||
# Setup failed — ask if they want to enable anyway
|
||||
console.print()
|
||||
enable_anyway = questionary.confirm(
|
||||
"Enable iMessage anyway? (will try to connect on startup)",
|
||||
default=False,
|
||||
style=WIZARD_STYLE,
|
||||
qmark=" ?",
|
||||
).ask()
|
||||
if enable_anyway is None:
|
||||
raise KeyboardInterrupt()
|
||||
if not enable_anyway:
|
||||
return False, ""
|
||||
|
||||
# Ask for allowed senders (indented to align with ✓ status lines)
|
||||
senders = questionary.text(
|
||||
"Allowed senders (comma-separated, empty = all):",
|
||||
default=config.imessage_allowed_senders,
|
||||
style=WIZARD_STYLE,
|
||||
qmark=" ?",
|
||||
).ask()
|
||||
|
||||
if senders is None:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
return True, senders.strip()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Progress Rendering (for tests and potential future use)
|
||||
# =============================================================================
|
||||
@@ -727,6 +924,11 @@ def run_onboard(skip_validation: bool = False) -> bool:
|
||||
config.max_iterations = max_iterations
|
||||
config.show_thinking = show_thinking
|
||||
|
||||
# Step 7: Channels
|
||||
imessage_enabled, imessage_allowed_senders = _step_channels(config)
|
||||
config.imessage_enabled = imessage_enabled
|
||||
config.imessage_allowed_senders = imessage_allowed_senders
|
||||
|
||||
# Confirm save
|
||||
console.print()
|
||||
save = questionary.confirm(
|
||||
|
||||
@@ -76,6 +76,8 @@ class TestEvoScientistConfig:
|
||||
assert config.max_concurrent == 3
|
||||
assert config.max_iterations == 3
|
||||
assert config.show_thinking is True
|
||||
assert config.imessage_enabled is False
|
||||
assert config.imessage_allowed_senders == ""
|
||||
|
||||
def test_custom_values(self):
|
||||
"""Test that custom values can be set."""
|
||||
@@ -241,6 +243,23 @@ class TestGetSetValues:
|
||||
set_config_value("max_concurrent", "5")
|
||||
assert get_config_value("max_concurrent") == 5
|
||||
|
||||
def test_set_imessage_enabled_coercion(self, temp_config_dir, clean_env):
|
||||
"""Test that imessage_enabled is coerced from string to bool."""
|
||||
save_config(EvoScientistConfig())
|
||||
|
||||
set_config_value("imessage_enabled", "true")
|
||||
assert get_config_value("imessage_enabled") is True
|
||||
|
||||
set_config_value("imessage_enabled", "false")
|
||||
assert get_config_value("imessage_enabled") is False
|
||||
|
||||
def test_set_imessage_allowed_senders(self, temp_config_dir, clean_env):
|
||||
"""Test that imessage_allowed_senders stores comma-separated string."""
|
||||
save_config(EvoScientistConfig())
|
||||
|
||||
set_config_value("imessage_allowed_senders", "+1234567890,+0987654321")
|
||||
assert get_config_value("imessage_allowed_senders") == "+1234567890,+0987654321"
|
||||
|
||||
def test_list_config(self, temp_config_dir, clean_env):
|
||||
"""Test listing all config values."""
|
||||
config = EvoScientistConfig(provider="openai", model="gpt-4o")
|
||||
|
||||
+239
-4
@@ -1,5 +1,6 @@
|
||||
"""Tests for EvoScientist onboarding wizard."""
|
||||
|
||||
import subprocess
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -21,10 +22,10 @@ from EvoScientist.config import EvoScientistConfig
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_steps_has_six_items(self):
|
||||
"""Test that STEPS contains exactly 6 steps."""
|
||||
assert len(STEPS) == 6
|
||||
assert STEPS == ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters"]
|
||||
def test_steps_has_seven_items(self):
|
||||
"""Test that STEPS contains exactly 7 steps."""
|
||||
assert len(STEPS) == 7
|
||||
assert STEPS == ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Channels"]
|
||||
|
||||
def test_wizard_style_is_style_instance(self):
|
||||
"""Test that WIZARD_STYLE is a prompt_toolkit Style."""
|
||||
@@ -293,6 +294,238 @@ class TestStepWorkspace:
|
||||
assert result == ("run", "/custom/path")
|
||||
|
||||
|
||||
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.onboard import validate_imessage
|
||||
|
||||
version_result = mock.Mock(returncode=0, stdout="imsg 1.2.3")
|
||||
rpc_result = mock.Mock(returncode=0)
|
||||
|
||||
with mock.patch("EvoScientist.onboard.sys") as mock_sys, \
|
||||
mock.patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, \
|
||||
mock.patch("EvoScientist.onboard.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.onboard import validate_imessage
|
||||
|
||||
with mock.patch("EvoScientist.onboard.sys") as mock_sys, \
|
||||
mock.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.onboard import validate_imessage
|
||||
|
||||
with mock.patch("EvoScientist.onboard.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.onboard import validate_imessage
|
||||
|
||||
version_result = mock.Mock(returncode=0, stdout="imsg 0.1.0")
|
||||
rpc_result = mock.Mock(returncode=1)
|
||||
|
||||
with mock.patch("EvoScientist.onboard.sys") as mock_sys, \
|
||||
mock.patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, \
|
||||
mock.patch("EvoScientist.onboard.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.onboard import _install_imsg
|
||||
|
||||
with mock.patch("EvoScientist.onboard.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = mock.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.onboard import _install_imsg
|
||||
|
||||
with mock.patch("EvoScientist.onboard.subprocess") as mock_sub, \
|
||||
mock.patch("EvoScientist.onboard.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.onboard import _install_imsg
|
||||
|
||||
with mock.patch("EvoScientist.onboard.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = mock.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.onboard import _setup_imessage
|
||||
|
||||
with mock.patch("EvoScientist.onboard.validate_imessage", return_value=(True, "imsg at /bin/imsg")), \
|
||||
mock.patch("EvoScientist.onboard.console"):
|
||||
result = _setup_imessage()
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_not_macos(self):
|
||||
"""Test _setup_imessage returns False on non-macOS."""
|
||||
from EvoScientist.onboard import _setup_imessage
|
||||
|
||||
with mock.patch("EvoScientist.onboard.validate_imessage", return_value=(False, "iMessage requires macOS")), \
|
||||
mock.patch("EvoScientist.onboard.console"):
|
||||
result = _setup_imessage()
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_install_then_valid(self):
|
||||
"""Test _setup_imessage installs and re-validates successfully."""
|
||||
from EvoScientist.onboard import _setup_imessage
|
||||
|
||||
with mock.patch("EvoScientist.onboard.validate_imessage") as mock_val, \
|
||||
mock.patch("EvoScientist.onboard._install_imsg", return_value=True), \
|
||||
mock.patch("EvoScientist.onboard.questionary") as mock_q, \
|
||||
mock.patch("EvoScientist.onboard.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.onboard import _setup_imessage
|
||||
|
||||
with mock.patch("EvoScientist.onboard.validate_imessage", return_value=(False, "not_installed")), \
|
||||
mock.patch("EvoScientist.onboard.questionary") as mock_q, \
|
||||
mock.patch("EvoScientist.onboard.console"):
|
||||
mock_q.confirm.return_value.ask.return_value = False
|
||||
result = _setup_imessage()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestStepChannels:
|
||||
def test_returns_disabled_when_skip(self):
|
||||
"""Test channels step returns disabled when user selects skip."""
|
||||
from EvoScientist.onboard import _step_channels
|
||||
|
||||
config = EvoScientistConfig()
|
||||
|
||||
with mock.patch("EvoScientist.onboard.questionary") as mock_q:
|
||||
mock_q.select.return_value.ask.return_value = "skip"
|
||||
result = _step_channels(config)
|
||||
|
||||
assert result == (False, "")
|
||||
|
||||
def test_returns_enabled_when_setup_passes(self):
|
||||
"""Test channels step returns enabled when setup succeeds."""
|
||||
from EvoScientist.onboard import _step_channels
|
||||
|
||||
config = EvoScientistConfig()
|
||||
|
||||
with mock.patch("EvoScientist.onboard.questionary") as mock_q, \
|
||||
mock.patch("EvoScientist.onboard._setup_imessage", return_value=True):
|
||||
mock_q.select.return_value.ask.return_value = "imessage"
|
||||
mock_q.text.return_value.ask.return_value = ""
|
||||
result = _step_channels(config)
|
||||
|
||||
assert result == (True, "")
|
||||
|
||||
def test_returns_enabled_with_senders(self):
|
||||
"""Test channels step returns enabled with specific senders."""
|
||||
from EvoScientist.onboard import _step_channels
|
||||
|
||||
config = EvoScientistConfig()
|
||||
|
||||
with mock.patch("EvoScientist.onboard.questionary") as mock_q, \
|
||||
mock.patch("EvoScientist.onboard._setup_imessage", return_value=True):
|
||||
mock_q.select.return_value.ask.return_value = "imessage"
|
||||
mock_q.text.return_value.ask.return_value = "+1234567890,+0987654321"
|
||||
result = _step_channels(config)
|
||||
|
||||
assert result == (True, "+1234567890,+0987654321")
|
||||
|
||||
def test_setup_fails_user_declines(self):
|
||||
"""Test channels step returns disabled when setup fails and user declines."""
|
||||
from EvoScientist.onboard import _step_channels
|
||||
|
||||
config = EvoScientistConfig()
|
||||
|
||||
with mock.patch("EvoScientist.onboard.questionary") as mock_q, \
|
||||
mock.patch("EvoScientist.onboard._setup_imessage", return_value=False):
|
||||
mock_q.select.return_value.ask.return_value = "imessage"
|
||||
mock_q.confirm.return_value.ask.return_value = False
|
||||
result = _step_channels(config)
|
||||
|
||||
assert result == (False, "")
|
||||
|
||||
def test_setup_fails_user_enables_anyway(self):
|
||||
"""Test channels step enables when setup fails but user confirms."""
|
||||
from EvoScientist.onboard import _step_channels
|
||||
|
||||
config = EvoScientistConfig()
|
||||
|
||||
with mock.patch("EvoScientist.onboard.questionary") as mock_q, \
|
||||
mock.patch("EvoScientist.onboard._setup_imessage", return_value=False):
|
||||
mock_q.select.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 == (True, "")
|
||||
|
||||
def test_raises_keyboard_interrupt_on_cancel(self):
|
||||
"""Test channels step raises KeyboardInterrupt on cancel."""
|
||||
from EvoScientist.onboard import _step_channels
|
||||
|
||||
config = EvoScientistConfig()
|
||||
|
||||
with mock.patch("EvoScientist.onboard.questionary") as mock_q:
|
||||
mock_q.select.return_value.ask.return_value = None
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
_step_channels(config)
|
||||
|
||||
|
||||
class TestStepParameters:
|
||||
def test_returns_parameters(self):
|
||||
"""Test parameters step returns all values."""
|
||||
@@ -344,6 +577,7 @@ class TestRunOnboard:
|
||||
"claude-sonnet-4-5", # Model
|
||||
"daemon", # Workspace mode
|
||||
True, # Show thinking
|
||||
"skip", # Channels: skip
|
||||
]
|
||||
mock_q.password.return_value.ask.side_effect = [
|
||||
"", # Provider API key (keep current)
|
||||
@@ -394,6 +628,7 @@ class TestRunOnboard:
|
||||
"claude-sonnet-4-5",
|
||||
"daemon",
|
||||
True, # Show thinking
|
||||
"skip", # Channels: skip
|
||||
]
|
||||
mock_q.password.return_value.ask.side_effect = ["", ""]
|
||||
mock_q.confirm.return_value.ask.side_effect = [
|
||||
|
||||
Reference in New Issue
Block a user