Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
This commit is contained in:
947
tests/test_setup_wizard.py
Normal file
947
tests/test_setup_wizard.py
Normal file
@@ -0,0 +1,947 @@
|
||||
"""
|
||||
Tests for setup wizard detection functions.
|
||||
|
||||
These tests verify the Ollama detection logic without touching the UI.
|
||||
They treat the detection functions as black boxes, verifying inputs produce correct outputs.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
|
||||
from desktop_app.setup_wizard import (
|
||||
check_ollama_cli,
|
||||
check_ollama_server,
|
||||
get_required_models,
|
||||
check_installed_models,
|
||||
check_ollama_status,
|
||||
resolve_ollama_path,
|
||||
should_show_setup_wizard,
|
||||
OllamaStatus,
|
||||
MCPPage,
|
||||
SearchProvidersPage,
|
||||
)
|
||||
from desktop_app.mcp_catalogue import get_wizard_entries
|
||||
from jarvis.config import DEFAULT_CHAT_MODEL
|
||||
from jarvis.utils.location import (
|
||||
get_location_context,
|
||||
is_location_available,
|
||||
_is_private_ip,
|
||||
)
|
||||
|
||||
|
||||
class TestCheckOllamaCli:
|
||||
"""Tests for Ollama CLI detection."""
|
||||
|
||||
def test_detects_ollama_in_path(self):
|
||||
"""When ollama is in PATH, returns True with path."""
|
||||
with patch("shutil.which", return_value="/usr/local/bin/ollama"):
|
||||
is_installed, path = check_ollama_cli()
|
||||
|
||||
assert is_installed is True
|
||||
assert path == "/usr/local/bin/ollama"
|
||||
|
||||
def test_returns_false_when_not_installed(self):
|
||||
"""When ollama is not installed anywhere, returns False."""
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch("os.path.isfile", return_value=False):
|
||||
is_installed, path = check_ollama_cli()
|
||||
|
||||
assert is_installed is False
|
||||
assert path is None
|
||||
|
||||
def test_checks_macos_homebrew_path(self):
|
||||
"""On macOS, checks Homebrew installation path."""
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch("os.path.isfile") as mock_isfile:
|
||||
with patch("os.access", return_value=True):
|
||||
# First call for /usr/local/bin/ollama returns False
|
||||
# Second call for /opt/homebrew/bin/ollama returns True
|
||||
mock_isfile.side_effect = lambda p: p == "/opt/homebrew/bin/ollama"
|
||||
|
||||
is_installed, path = check_ollama_cli()
|
||||
|
||||
assert is_installed is True
|
||||
assert path == "/opt/homebrew/bin/ollama"
|
||||
|
||||
|
||||
class TestCheckOllamaServer:
|
||||
"""Tests for Ollama server detection."""
|
||||
|
||||
def test_detects_running_server(self):
|
||||
"""When server is running, returns True with version."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"version": "0.1.23"}
|
||||
|
||||
with patch("requests.get", return_value=mock_response):
|
||||
is_running, version = check_ollama_server()
|
||||
|
||||
assert is_running is True
|
||||
assert version == "0.1.23"
|
||||
|
||||
def test_returns_false_when_server_not_running(self):
|
||||
"""When server is not responding, returns False."""
|
||||
with patch("requests.get", side_effect=Exception("Connection refused")):
|
||||
is_running, version = check_ollama_server()
|
||||
|
||||
assert is_running is False
|
||||
assert version is None
|
||||
|
||||
def test_handles_timeout(self):
|
||||
"""When request times out, returns False."""
|
||||
import requests
|
||||
with patch("requests.get", side_effect=requests.exceptions.Timeout):
|
||||
is_running, version = check_ollama_server()
|
||||
|
||||
assert is_running is False
|
||||
assert version is None
|
||||
|
||||
|
||||
class TestGetRequiredModels:
|
||||
"""Tests for getting required models from config."""
|
||||
|
||||
def test_returns_models_from_config(self):
|
||||
"""Returns chat and embed models from config."""
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.ollama_chat_model = "llama2:7b"
|
||||
mock_settings.ollama_embed_model = "nomic-embed-text"
|
||||
mock_settings.intent_judge_model = "gemma4:e2b"
|
||||
|
||||
with patch("desktop_app.setup_wizard.load_settings", return_value=mock_settings):
|
||||
models = get_required_models()
|
||||
|
||||
assert "llama2:7b" in models
|
||||
assert "nomic-embed-text" in models
|
||||
|
||||
def test_includes_intent_judge_model_when_different_from_chat(self):
|
||||
"""Includes intent judge model when it differs from chat model."""
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.ollama_chat_model = "gpt-oss:20b" # Different from intent judge
|
||||
mock_settings.ollama_embed_model = "nomic-embed-text"
|
||||
mock_settings.intent_judge_model = "gemma4:e2b"
|
||||
|
||||
with patch("desktop_app.setup_wizard.load_settings", return_value=mock_settings):
|
||||
models = get_required_models()
|
||||
|
||||
# Should have 3 models: chat, embed, and intent judge
|
||||
assert len(models) == 3
|
||||
assert "gpt-oss:20b" in models
|
||||
assert "nomic-embed-text" in models
|
||||
assert "gemma4:e2b" in models # Intent judge model is always required
|
||||
|
||||
def test_returns_defaults_on_config_error(self):
|
||||
"""Returns default models if config can't be loaded."""
|
||||
with patch("desktop_app.setup_wizard.load_settings", side_effect=Exception("Config error")):
|
||||
models = get_required_models()
|
||||
|
||||
assert len(models) == 2
|
||||
assert "gemma4:e2b" in models
|
||||
assert "nomic-embed-text" in models
|
||||
|
||||
|
||||
class TestCheckInstalledModels:
|
||||
"""Tests for checking installed Ollama models."""
|
||||
|
||||
def test_parses_ollama_list_output(self):
|
||||
"""Correctly parses 'ollama list' output."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = """NAME ID SIZE MODIFIED
|
||||
llama2:7b abc123 3.8 GB 2 days ago
|
||||
nomic-embed-text:latest def456 274 MB 1 week ago
|
||||
"""
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
models = check_installed_models("/usr/bin/ollama")
|
||||
|
||||
assert "llama2:7b" in models
|
||||
assert "nomic-embed-text:latest" in models
|
||||
|
||||
def test_returns_empty_on_error(self):
|
||||
"""Returns empty list if ollama list fails."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
models = check_installed_models()
|
||||
|
||||
assert models == []
|
||||
|
||||
def test_handles_subprocess_exception(self):
|
||||
"""Returns empty list if subprocess raises exception."""
|
||||
with patch("subprocess.run", side_effect=Exception("Command not found")):
|
||||
models = check_installed_models()
|
||||
|
||||
assert models == []
|
||||
|
||||
def test_falls_back_to_check_ollama_cli_when_path_unset(self):
|
||||
"""When PATH does not contain ollama (e.g. frozen macOS .app launch),
|
||||
falls back to check_ollama_cli() so the resolved binary is invoked
|
||||
instead of plain "ollama" which would fail with FileNotFoundError."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "NAME ID SIZE MODIFIED\nllama2:7b abc 3.8 GB 1d\n"
|
||||
|
||||
with patch("desktop_app.setup_wizard.shutil.which", return_value=None):
|
||||
with patch(
|
||||
"desktop_app.setup_wizard.check_ollama_cli",
|
||||
return_value=(True, "/usr/local/bin/ollama"),
|
||||
):
|
||||
with patch("subprocess.run", return_value=mock_result) as run:
|
||||
models = check_installed_models()
|
||||
|
||||
assert "llama2:7b" in models
|
||||
args, _ = run.call_args
|
||||
assert args[0][0] == "/usr/local/bin/ollama"
|
||||
|
||||
|
||||
class TestResolveOllamaPath:
|
||||
"""Tests for the ollama CLI path resolver."""
|
||||
|
||||
def test_prefers_path_lookup(self):
|
||||
with patch("desktop_app.setup_wizard.shutil.which", return_value="/opt/homebrew/bin/ollama"):
|
||||
assert resolve_ollama_path() == "/opt/homebrew/bin/ollama"
|
||||
|
||||
def test_falls_back_to_check_ollama_cli(self):
|
||||
with patch("desktop_app.setup_wizard.shutil.which", return_value=None):
|
||||
with patch(
|
||||
"desktop_app.setup_wizard.check_ollama_cli",
|
||||
return_value=(True, "/usr/local/bin/ollama"),
|
||||
):
|
||||
assert resolve_ollama_path() == "/usr/local/bin/ollama"
|
||||
|
||||
def test_returns_literal_when_nothing_resolves(self):
|
||||
with patch("desktop_app.setup_wizard.shutil.which", return_value=None):
|
||||
with patch(
|
||||
"desktop_app.setup_wizard.check_ollama_cli",
|
||||
return_value=(False, None),
|
||||
):
|
||||
assert resolve_ollama_path() == "ollama"
|
||||
|
||||
|
||||
class TestCheckOllamaStatus:
|
||||
"""Tests for complete Ollama status check."""
|
||||
|
||||
def test_fully_setup_status(self):
|
||||
"""Returns correct status when everything is set up."""
|
||||
with patch("desktop_app.setup_wizard.check_ollama_cli", return_value=(True, "/usr/bin/ollama")):
|
||||
with patch("desktop_app.setup_wizard.check_ollama_server", return_value=(True, "0.1.23")):
|
||||
with patch("desktop_app.setup_wizard.get_required_models", return_value=["llama2:7b"]):
|
||||
with patch("desktop_app.setup_wizard.check_installed_models", return_value=["llama2:7b"]):
|
||||
status = check_ollama_status()
|
||||
|
||||
assert status.is_cli_installed is True
|
||||
assert status.is_server_running is True
|
||||
assert status.missing_models == []
|
||||
assert status.is_fully_setup is True
|
||||
|
||||
def test_missing_cli_status(self):
|
||||
"""Returns correct status when CLI is not installed."""
|
||||
with patch("desktop_app.setup_wizard.check_ollama_cli", return_value=(False, None)):
|
||||
with patch("desktop_app.setup_wizard.check_ollama_server", return_value=(False, None)):
|
||||
with patch("desktop_app.setup_wizard.get_required_models", return_value=["llama2:7b"]):
|
||||
status = check_ollama_status()
|
||||
|
||||
assert status.is_cli_installed is False
|
||||
assert status.is_fully_setup is False
|
||||
assert "llama2:7b" in status.missing_models
|
||||
|
||||
def test_missing_models_status(self):
|
||||
"""Returns correct status when models are missing."""
|
||||
with patch("desktop_app.setup_wizard.check_ollama_cli", return_value=(True, "/usr/bin/ollama")):
|
||||
with patch("desktop_app.setup_wizard.check_ollama_server", return_value=(True, "0.1.23")):
|
||||
with patch("desktop_app.setup_wizard.get_required_models", return_value=["llama2:7b", "codellama"]):
|
||||
with patch("desktop_app.setup_wizard.check_installed_models", return_value=["llama2:7b"]):
|
||||
status = check_ollama_status()
|
||||
|
||||
assert status.is_cli_installed is True
|
||||
assert status.is_server_running is True
|
||||
assert "codellama" in status.missing_models
|
||||
assert status.is_fully_setup is False
|
||||
|
||||
|
||||
class TestShouldShowSetupWizard:
|
||||
"""Tests for wizard display logic."""
|
||||
|
||||
def test_returns_false_when_fully_setup(self):
|
||||
"""Returns False when everything is configured."""
|
||||
mock_status = OllamaStatus(
|
||||
is_cli_installed=True,
|
||||
cli_path="/usr/bin/ollama",
|
||||
is_server_running=True,
|
||||
server_version="0.1.23",
|
||||
installed_models=["llama2:7b"],
|
||||
missing_models=[],
|
||||
)
|
||||
|
||||
with patch("desktop_app.setup_wizard.check_ollama_status", return_value=mock_status):
|
||||
assert should_show_setup_wizard() is False
|
||||
|
||||
def test_returns_true_when_cli_missing(self):
|
||||
"""Returns True when CLI is not installed."""
|
||||
mock_status = OllamaStatus(
|
||||
is_cli_installed=False,
|
||||
is_server_running=False,
|
||||
missing_models=["llama2:7b"],
|
||||
)
|
||||
|
||||
with patch("desktop_app.setup_wizard.check_ollama_status", return_value=mock_status):
|
||||
assert should_show_setup_wizard() is True
|
||||
|
||||
def test_returns_false_when_server_not_running_but_cli_installed(self):
|
||||
"""Returns False when server is not running but CLI is installed.
|
||||
|
||||
The app can auto-start the server, so no wizard needed.
|
||||
"""
|
||||
mock_status = OllamaStatus(
|
||||
is_cli_installed=True,
|
||||
cli_path="/usr/bin/ollama",
|
||||
is_server_running=False,
|
||||
missing_models=[],
|
||||
)
|
||||
|
||||
with patch("desktop_app.setup_wizard.check_ollama_status", return_value=mock_status):
|
||||
assert should_show_setup_wizard() is False
|
||||
|
||||
def test_returns_true_when_models_missing(self):
|
||||
"""Returns True when required models are missing."""
|
||||
mock_status = OllamaStatus(
|
||||
is_cli_installed=True,
|
||||
cli_path="/usr/bin/ollama",
|
||||
is_server_running=True,
|
||||
server_version="0.1.23",
|
||||
installed_models=[],
|
||||
missing_models=["llama2:7b"],
|
||||
)
|
||||
|
||||
with patch("desktop_app.setup_wizard.check_ollama_status", return_value=mock_status):
|
||||
assert should_show_setup_wizard() is True
|
||||
|
||||
|
||||
class TestOllamaStatusDataclass:
|
||||
"""Tests for OllamaStatus dataclass behavior."""
|
||||
|
||||
def test_is_fully_setup_property(self):
|
||||
"""is_fully_setup returns True only when all conditions are met."""
|
||||
# All good
|
||||
status = OllamaStatus(
|
||||
is_cli_installed=True,
|
||||
is_server_running=True,
|
||||
missing_models=[],
|
||||
)
|
||||
assert status.is_fully_setup is True
|
||||
|
||||
# Missing CLI
|
||||
status = OllamaStatus(
|
||||
is_cli_installed=False,
|
||||
is_server_running=True,
|
||||
missing_models=[],
|
||||
)
|
||||
assert status.is_fully_setup is False
|
||||
|
||||
# Server not running
|
||||
status = OllamaStatus(
|
||||
is_cli_installed=True,
|
||||
is_server_running=False,
|
||||
missing_models=[],
|
||||
)
|
||||
assert status.is_fully_setup is False
|
||||
|
||||
# Missing models
|
||||
status = OllamaStatus(
|
||||
is_cli_installed=True,
|
||||
is_server_running=True,
|
||||
missing_models=["some-model"],
|
||||
)
|
||||
assert status.is_fully_setup is False
|
||||
|
||||
def test_default_values(self):
|
||||
"""Dataclass initializes with correct defaults."""
|
||||
status = OllamaStatus()
|
||||
|
||||
assert status.is_cli_installed is False
|
||||
assert status.cli_path is None
|
||||
assert status.is_server_running is False
|
||||
assert status.server_version is None
|
||||
assert status.installed_models == []
|
||||
assert status.missing_models == []
|
||||
|
||||
|
||||
class TestLocationDetectionForWizard:
|
||||
"""Tests for location detection utilities used in setup wizard."""
|
||||
|
||||
def test_private_ip_detection(self):
|
||||
"""Private IPs are correctly identified."""
|
||||
# RFC 1918 private ranges
|
||||
assert _is_private_ip("10.0.0.1") is True
|
||||
assert _is_private_ip("10.255.255.255") is True
|
||||
assert _is_private_ip("172.16.0.1") is True
|
||||
assert _is_private_ip("172.31.255.255") is True
|
||||
assert _is_private_ip("192.168.0.1") is True
|
||||
assert _is_private_ip("192.168.255.255") is True
|
||||
|
||||
# Loopback
|
||||
assert _is_private_ip("127.0.0.1") is True
|
||||
|
||||
# Public IPs (8.8.8.8 is Google DNS, 1.1.1.1 is Cloudflare)
|
||||
assert _is_private_ip("8.8.8.8") is False
|
||||
assert _is_private_ip("1.1.1.1") is False
|
||||
|
||||
def test_location_context_returns_unknown_when_unavailable(self):
|
||||
"""Location context returns 'Unknown' when detection fails."""
|
||||
# Disable auto-detect to avoid network calls, no config IP
|
||||
with patch("jarvis.utils.location._get_external_ip_automatically", return_value=None):
|
||||
with patch("jarvis.utils.location._get_local_network_ip", return_value="192.168.1.1"):
|
||||
context = get_location_context(config_ip=None, auto_detect=True)
|
||||
# Should return Unknown since 192.168.x.x can't be geolocated
|
||||
assert "Unknown" in context or "error" in context.lower()
|
||||
|
||||
def test_location_availability_check(self):
|
||||
"""is_location_available checks for GeoIP2 and database."""
|
||||
with patch("jarvis.utils.location.GEOIP2_AVAILABLE", False):
|
||||
# When library not available, should return False
|
||||
# Note: We can't easily patch the constant after import,
|
||||
# so we test the behavior indirectly
|
||||
pass
|
||||
|
||||
# With patched database path
|
||||
with patch("jarvis.utils.location._get_database_path") as mock_path:
|
||||
mock_path_obj = MagicMock()
|
||||
mock_path_obj.exists.return_value = False
|
||||
mock_path.return_value = mock_path_obj
|
||||
|
||||
# Can't easily test due to import-time GEOIP2_AVAILABLE check
|
||||
# but the function should return False if DB doesn't exist
|
||||
|
||||
def test_location_context_with_config_ip(self):
|
||||
"""When config IP is provided and valid, uses it for location."""
|
||||
mock_location = {
|
||||
"city": "San Francisco",
|
||||
"region": "California",
|
||||
"country": "United States",
|
||||
"timezone": "America/Los_Angeles",
|
||||
}
|
||||
|
||||
with patch("jarvis.utils.location.get_location_info", return_value=mock_location):
|
||||
context = get_location_context(config_ip="203.0.113.45")
|
||||
|
||||
assert "San Francisco" in context
|
||||
assert "California" in context
|
||||
assert "United States" in context
|
||||
|
||||
|
||||
class TestModelOptions:
|
||||
"""Tests for model selection options in setup wizard."""
|
||||
|
||||
def test_model_options_available(self):
|
||||
"""Model options include both recommended and lightweight options."""
|
||||
from desktop_app.setup_wizard import ModelsPage
|
||||
|
||||
assert "gpt-oss:20b" in ModelsPage.MODEL_OPTIONS
|
||||
assert DEFAULT_CHAT_MODEL in ModelsPage.MODEL_OPTIONS
|
||||
|
||||
def test_model_options_have_required_fields(self):
|
||||
"""Each model option has required info fields."""
|
||||
from desktop_app.setup_wizard import ModelsPage
|
||||
|
||||
for model_id, info in ModelsPage.MODEL_OPTIONS.items():
|
||||
assert "name" in info, f"Model {model_id} missing 'name'"
|
||||
assert "description" in info, f"Model {model_id} missing 'description'"
|
||||
assert "size" in info, f"Model {model_id} missing 'size'"
|
||||
assert "vram" in info, f"Model {model_id} missing 'vram'"
|
||||
|
||||
def test_model_options_uses_centralized_config(self):
|
||||
"""ModelsPage.MODEL_OPTIONS should reference the centralized config."""
|
||||
from desktop_app.setup_wizard import ModelsPage
|
||||
from jarvis.config import SUPPORTED_CHAT_MODELS
|
||||
|
||||
# Verify they're the same object (not just equal values)
|
||||
assert ModelsPage.MODEL_OPTIONS is SUPPORTED_CHAT_MODELS
|
||||
|
||||
|
||||
class TestDefaultModelDetection:
|
||||
"""Regression tests: the default small model must be detected as missing when not
|
||||
installed, triggering the setup wizard install prompt.
|
||||
|
||||
Uses DEFAULT_CHAT_MODEL from config so these tests stay valid when the default
|
||||
model changes — no hardcoded model names here.
|
||||
"""
|
||||
|
||||
EMBED_MODEL = "nomic-embed-text"
|
||||
|
||||
def test_small_model_missing_detected_in_status(self):
|
||||
"""When the default chat model is not installed, check_ollama_status reports it as missing."""
|
||||
required = [DEFAULT_CHAT_MODEL, self.EMBED_MODEL]
|
||||
with patch("desktop_app.setup_wizard.check_ollama_cli", return_value=(True, "/usr/bin/ollama")):
|
||||
with patch("desktop_app.setup_wizard.check_ollama_server", return_value=(True, "0.3.0")):
|
||||
with patch("desktop_app.setup_wizard.get_required_models", return_value=required):
|
||||
with patch("desktop_app.setup_wizard.check_installed_models", return_value=[self.EMBED_MODEL]):
|
||||
status = check_ollama_status()
|
||||
|
||||
assert DEFAULT_CHAT_MODEL in status.missing_models
|
||||
assert status.is_fully_setup is False
|
||||
|
||||
def test_small_model_installed_not_in_missing(self):
|
||||
"""When the default chat model is installed, check_ollama_status does not list it as missing."""
|
||||
required = [DEFAULT_CHAT_MODEL, self.EMBED_MODEL]
|
||||
with patch("desktop_app.setup_wizard.check_ollama_cli", return_value=(True, "/usr/bin/ollama")):
|
||||
with patch("desktop_app.setup_wizard.check_ollama_server", return_value=(True, "0.3.0")):
|
||||
with patch("desktop_app.setup_wizard.get_required_models", return_value=required):
|
||||
with patch("desktop_app.setup_wizard.check_installed_models", return_value=required):
|
||||
status = check_ollama_status()
|
||||
|
||||
assert status.missing_models == []
|
||||
assert status.is_fully_setup is True
|
||||
|
||||
def test_wizard_shown_when_small_model_missing(self):
|
||||
"""should_show_setup_wizard returns True when the default chat model is not installed."""
|
||||
mock_status = OllamaStatus(
|
||||
is_cli_installed=True,
|
||||
cli_path="/usr/bin/ollama",
|
||||
is_server_running=True,
|
||||
server_version="0.3.0",
|
||||
installed_models=[self.EMBED_MODEL],
|
||||
missing_models=[DEFAULT_CHAT_MODEL],
|
||||
)
|
||||
|
||||
with patch("desktop_app.setup_wizard.check_ollama_status", return_value=mock_status):
|
||||
assert should_show_setup_wizard() is True
|
||||
|
||||
def test_wizard_not_shown_when_small_model_installed(self):
|
||||
"""should_show_setup_wizard returns False when the default chat model is present."""
|
||||
mock_status = OllamaStatus(
|
||||
is_cli_installed=True,
|
||||
cli_path="/usr/bin/ollama",
|
||||
is_server_running=True,
|
||||
server_version="0.3.0",
|
||||
installed_models=[DEFAULT_CHAT_MODEL, self.EMBED_MODEL],
|
||||
missing_models=[],
|
||||
)
|
||||
|
||||
with patch("desktop_app.setup_wizard.check_ollama_status", return_value=mock_status):
|
||||
assert should_show_setup_wizard() is False
|
||||
|
||||
def test_latest_tag_stripped_before_comparison(self):
|
||||
"""Ollama appends ':latest' to model names; the status check must strip it so
|
||||
'<model>:latest' is not incorrectly treated as missing when '<model>' is required."""
|
||||
required = [DEFAULT_CHAT_MODEL, self.EMBED_MODEL]
|
||||
with patch("desktop_app.setup_wizard.check_ollama_cli", return_value=(True, "/usr/bin/ollama")):
|
||||
with patch("desktop_app.setup_wizard.check_ollama_server", return_value=(True, "0.3.0")):
|
||||
with patch("desktop_app.setup_wizard.get_required_models", return_value=required):
|
||||
# Simulate Ollama reporting "<model>:latest" in its model list
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = (
|
||||
"NAME ID SIZE MODIFIED\n"
|
||||
f"{DEFAULT_CHAT_MODEL}:latest abc123 2.0 GB 1 day ago\n"
|
||||
f"{self.EMBED_MODEL}:latest def456 274 MB 1 week ago\n"
|
||||
)
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
status = check_ollama_status()
|
||||
|
||||
assert DEFAULT_CHAT_MODEL not in status.missing_models
|
||||
assert status.is_fully_setup is True
|
||||
|
||||
|
||||
class TestWhisperModelOptions:
|
||||
"""Tests for whisper model selection options in setup wizard."""
|
||||
|
||||
def test_whisper_multilingual_model_options_available(self):
|
||||
"""Multilingual whisper model options include recommended and lightweight options."""
|
||||
from desktop_app.setup_wizard import WhisperSetupPage
|
||||
|
||||
model_ids = [m[0] for m in WhisperSetupPage.WHISPER_MODEL_OPTIONS]
|
||||
assert "small" in model_ids
|
||||
assert "tiny" in model_ids
|
||||
assert "large-v3-turbo" in model_ids
|
||||
|
||||
def test_whisper_english_model_options_available(self):
|
||||
"""English-only whisper model options include recommended and lightweight options."""
|
||||
from desktop_app.setup_wizard import WhisperSetupPage
|
||||
|
||||
model_ids = [m[0] for m in WhisperSetupPage.WHISPER_MODEL_OPTIONS_EN]
|
||||
assert "small.en" in model_ids
|
||||
assert "tiny.en" in model_ids
|
||||
assert "medium.en" in model_ids
|
||||
# Note: large models don't have .en variants
|
||||
assert not any("large" in m for m in model_ids)
|
||||
|
||||
def test_whisper_multilingual_model_options_have_required_fields(self):
|
||||
"""Each multilingual whisper model option has required info fields."""
|
||||
from desktop_app.setup_wizard import WhisperSetupPage
|
||||
|
||||
for model_tuple in WhisperSetupPage.WHISPER_MODEL_OPTIONS:
|
||||
assert len(model_tuple) == 5, f"Whisper model tuple should have 5 elements: {model_tuple}"
|
||||
model_id, name, file_size, ram, desc = model_tuple
|
||||
assert model_id, "Model ID should not be empty"
|
||||
assert name, "Model name should not be empty"
|
||||
assert file_size, "Model file size should not be empty"
|
||||
assert ram, "Model RAM requirement should not be empty"
|
||||
assert desc, "Model description should not be empty"
|
||||
# Multilingual models should NOT have .en suffix
|
||||
assert not model_id.endswith(".en"), f"Multilingual model should not end with .en: {model_id}"
|
||||
|
||||
def test_turbo_hidden_when_faster_whisper_unsupported(self):
|
||||
"""large-v3-turbo is filtered from options when faster-whisper is too old."""
|
||||
from desktop_app.setup_wizard import WhisperSetupPage
|
||||
|
||||
page = MagicMock(spec=WhisperSetupPage)
|
||||
page._is_english_only = False
|
||||
page._is_apple_silicon = False
|
||||
page.WHISPER_MODEL_OPTIONS = WhisperSetupPage.WHISPER_MODEL_OPTIONS
|
||||
page.WHISPER_MODEL_OPTIONS_EN = WhisperSetupPage.WHISPER_MODEL_OPTIONS_EN
|
||||
|
||||
with patch("desktop_app.setup_wizard._is_faster_whisper_turbo_supported", return_value=False):
|
||||
options = WhisperSetupPage._get_current_model_options(page)
|
||||
model_ids = [m[0] for m in options]
|
||||
assert "large-v3-turbo" not in model_ids
|
||||
assert "small" in model_ids
|
||||
|
||||
def test_turbo_shown_when_faster_whisper_supported(self):
|
||||
"""large-v3-turbo is available when faster-whisper supports it."""
|
||||
from desktop_app.setup_wizard import WhisperSetupPage
|
||||
|
||||
page = MagicMock(spec=WhisperSetupPage)
|
||||
page._is_english_only = False
|
||||
page._is_apple_silicon = False
|
||||
page.WHISPER_MODEL_OPTIONS = WhisperSetupPage.WHISPER_MODEL_OPTIONS
|
||||
page.WHISPER_MODEL_OPTIONS_EN = WhisperSetupPage.WHISPER_MODEL_OPTIONS_EN
|
||||
|
||||
with patch("desktop_app.setup_wizard._is_faster_whisper_turbo_supported", return_value=True):
|
||||
options = WhisperSetupPage._get_current_model_options(page)
|
||||
model_ids = [m[0] for m in options]
|
||||
assert "large-v3-turbo" in model_ids
|
||||
|
||||
def test_turbo_always_shown_on_apple_silicon(self):
|
||||
"""large-v3-turbo is always available on Apple Silicon (MLX backend)."""
|
||||
from desktop_app.setup_wizard import WhisperSetupPage
|
||||
|
||||
page = MagicMock(spec=WhisperSetupPage)
|
||||
page._is_english_only = False
|
||||
page._is_apple_silicon = True
|
||||
page.WHISPER_MODEL_OPTIONS = WhisperSetupPage.WHISPER_MODEL_OPTIONS
|
||||
page.WHISPER_MODEL_OPTIONS_EN = WhisperSetupPage.WHISPER_MODEL_OPTIONS_EN
|
||||
|
||||
with patch("desktop_app.setup_wizard._is_faster_whisper_turbo_supported", return_value=False):
|
||||
options = WhisperSetupPage._get_current_model_options(page)
|
||||
model_ids = [m[0] for m in options]
|
||||
assert "large-v3-turbo" in model_ids
|
||||
|
||||
def test_whisper_english_model_options_have_required_fields(self):
|
||||
"""Each English-only whisper model option has required info fields."""
|
||||
from desktop_app.setup_wizard import WhisperSetupPage
|
||||
|
||||
for model_tuple in WhisperSetupPage.WHISPER_MODEL_OPTIONS_EN:
|
||||
assert len(model_tuple) == 5, f"Whisper model tuple should have 5 elements: {model_tuple}"
|
||||
model_id, name, file_size, ram, desc = model_tuple
|
||||
assert model_id, "Model ID should not be empty"
|
||||
assert name, "Model name should not be empty"
|
||||
assert file_size, "Model file size should not be empty"
|
||||
assert ram, "Model RAM requirement should not be empty"
|
||||
assert desc, "Model description should not be empty"
|
||||
# English-only models should have .en suffix
|
||||
assert model_id.endswith(".en"), f"English model should end with .en: {model_id}"
|
||||
|
||||
|
||||
class TestWhisperSetupPageSliderRebuild:
|
||||
"""Regression tests for WhisperSetupPage slider rebuild lifecycle.
|
||||
|
||||
On macOS, promoting a child QLabel to a top-level widget (via
|
||||
setParent(None)) during a QWizard page transition could trigger
|
||||
a SIGABRT ('Fatal Python error: Aborted') while the next page
|
||||
was being shown. These tests guarantee that the slider labels
|
||||
stay parented to their containers throughout rebuilds — the
|
||||
safe pattern for clearing items out of a layout.
|
||||
"""
|
||||
|
||||
def test_slider_labels_keep_container_parent_after_rebuild(self, qapp):
|
||||
"""Newly-built slider labels must remain children of their containers.
|
||||
|
||||
If any label ends up reparented to None it becomes a top-level
|
||||
widget, which on macOS triggers a native window creation that
|
||||
can abort during wizard page transitions.
|
||||
"""
|
||||
from desktop_app.setup_wizard import WhisperSetupPage
|
||||
|
||||
page = WhisperSetupPage()
|
||||
|
||||
# Toggle language mode — this fires _rebuild_slider_ui which
|
||||
# clears the old labels and inserts a new set.
|
||||
page._on_language_changed(True)
|
||||
page._on_language_changed(False)
|
||||
|
||||
labels_container = page._labels_container
|
||||
size_container = page._size_container
|
||||
|
||||
for i in range(page._labels_layout.count()):
|
||||
item = page._labels_layout.itemAt(i)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
assert w.parent() is labels_container, (
|
||||
"Slider name labels must stay parented to their "
|
||||
"container — a None parent promotes them to top-level "
|
||||
"widgets, which crashes QWizard transitions on macOS."
|
||||
)
|
||||
|
||||
for i in range(page._size_layout.count()):
|
||||
item = page._size_layout.itemAt(i)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
assert w.parent() is size_container, (
|
||||
"Slider size labels must stay parented to their "
|
||||
"container — a None parent promotes them to top-level "
|
||||
"widgets, which crashes QWizard transitions on macOS."
|
||||
)
|
||||
|
||||
def test_initialize_page_can_be_called_multiple_times(self, qapp):
|
||||
"""initializePage must be safely re-callable.
|
||||
|
||||
QWizard calls initializePage each time a page is shown. The
|
||||
first call (right after construction) has to clear the initial
|
||||
labels that __init__ built, and subsequent calls must not
|
||||
crash or leak top-level widgets.
|
||||
"""
|
||||
from desktop_app.setup_wizard import WhisperSetupPage
|
||||
|
||||
page = WhisperSetupPage()
|
||||
|
||||
# Re-initialise a few times — this mirrors back/forward
|
||||
# navigation between wizard pages.
|
||||
for _ in range(3):
|
||||
page.initializePage()
|
||||
|
||||
# All remaining labels in the layouts are still properly
|
||||
# parented (not promoted to top-level).
|
||||
for layout, container in [
|
||||
(page._labels_layout, page._labels_container),
|
||||
(page._size_layout, page._size_container),
|
||||
]:
|
||||
for i in range(layout.count()):
|
||||
item = layout.itemAt(i)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
assert w.parent() is container
|
||||
|
||||
|
||||
class TestMCPPage:
|
||||
"""Tests for the MCP servers wizard page."""
|
||||
|
||||
def test_mcp_page_is_always_complete(self):
|
||||
"""MCP page should always be completeable (nothing is required)."""
|
||||
# MCPPage.isComplete is hardcoded to True — the page is always optional
|
||||
page = MCPPage.__new__(MCPPage)
|
||||
assert page.isComplete() is True
|
||||
|
||||
def test_is_already_configured_returns_false_on_empty_config(self):
|
||||
"""When config has no mcps key, returns False."""
|
||||
with patch("jarvis.config._load_json", return_value={}):
|
||||
assert MCPPage._is_already_configured("filesystem") is False
|
||||
|
||||
def test_is_already_configured_returns_true_when_present(self):
|
||||
"""When the server name exists in config.mcps, returns True."""
|
||||
mock_config = {"mcps": {"filesystem": {"transport": "stdio"}}}
|
||||
with patch("jarvis.config._load_json", return_value=mock_config):
|
||||
assert MCPPage._is_already_configured("filesystem") is True
|
||||
|
||||
def test_is_already_configured_handles_exception(self):
|
||||
"""Returns False if config loading fails."""
|
||||
with patch("jarvis.config._load_json", side_effect=Exception("boom")):
|
||||
assert MCPPage._is_already_configured("filesystem") is False
|
||||
|
||||
def test_wizard_entries_available(self):
|
||||
"""Wizard-featured catalogue entries are available for the MCP page."""
|
||||
entries = get_wizard_entries()
|
||||
assert len(entries) >= 1
|
||||
# All entries should have display names and descriptions
|
||||
for e in entries:
|
||||
assert e.display_name
|
||||
assert e.description
|
||||
|
||||
def test_validate_page_saves_selected_mcps(self):
|
||||
"""validatePage writes selected MCPs to config."""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from jarvis.config import _load_json
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump({}, f)
|
||||
cfg_path = Path(f.name)
|
||||
|
||||
try:
|
||||
page = MCPPage.__new__(MCPPage)
|
||||
entries = get_wizard_entries()
|
||||
# Simulate checkboxes: first entry checked, rest unchecked
|
||||
page._checkboxes = {}
|
||||
for i, entry in enumerate(entries):
|
||||
cb = MagicMock()
|
||||
cb.isChecked.return_value = (i == 0)
|
||||
page._checkboxes[entry.name] = cb
|
||||
|
||||
with patch("jarvis.config.default_config_path", return_value=cfg_path):
|
||||
result = page.validatePage()
|
||||
|
||||
assert result is True
|
||||
saved = _load_json(cfg_path)
|
||||
first_entry = entries[0]
|
||||
assert first_entry.name in saved.get("mcps", {})
|
||||
assert saved["mcps"][first_entry.name]["command"] == first_entry.command
|
||||
finally:
|
||||
cfg_path.unlink(missing_ok=True)
|
||||
|
||||
def test_is_node_available_returns_true_when_npx_found(self):
|
||||
"""_is_node_available returns True when _resolve_command succeeds."""
|
||||
with patch("jarvis.tools.external.mcp_client._resolve_command", return_value="/usr/bin/npx"):
|
||||
assert MCPPage._is_node_available() is True
|
||||
|
||||
def test_is_node_available_returns_false_when_npx_missing(self):
|
||||
"""_is_node_available returns False when _resolve_command raises."""
|
||||
with patch("jarvis.tools.external.mcp_client._resolve_command", side_effect=FileNotFoundError("not found")):
|
||||
assert MCPPage._is_node_available() is False
|
||||
|
||||
def test_validate_page_preserves_existing_non_wizard_mcps(self):
|
||||
"""validatePage must not remove MCPs that aren't in the wizard catalogue."""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from jarvis.config import _load_json
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
json.dump({"mcps": {"custom-server": {"transport": "stdio", "command": "node", "args": []}}}, f)
|
||||
cfg_path = Path(f.name)
|
||||
|
||||
try:
|
||||
page = MCPPage.__new__(MCPPage)
|
||||
entries = get_wizard_entries()
|
||||
page._checkboxes = {}
|
||||
for entry in entries:
|
||||
cb = MagicMock()
|
||||
cb.isChecked.return_value = False
|
||||
page._checkboxes[entry.name] = cb
|
||||
|
||||
with patch("jarvis.config.default_config_path", return_value=cfg_path):
|
||||
page.validatePage()
|
||||
|
||||
saved = _load_json(cfg_path)
|
||||
assert "custom-server" in saved.get("mcps", {}), "Custom MCP server was removed"
|
||||
finally:
|
||||
cfg_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestSearchProvidersPage:
|
||||
"""Tests for the Search Providers wizard page (Brave + Wikipedia)."""
|
||||
|
||||
def _make_page(self, brave_key: str, wiki_enabled: bool) -> SearchProvidersPage:
|
||||
page = SearchProvidersPage.__new__(SearchProvidersPage)
|
||||
brave_input = MagicMock()
|
||||
brave_input.text.return_value = brave_key
|
||||
wiki_check = MagicMock()
|
||||
wiki_check.isChecked.return_value = wiki_enabled
|
||||
page._brave_input = brave_input
|
||||
page._wiki_check = wiki_check
|
||||
return page
|
||||
|
||||
def test_page_is_always_complete(self):
|
||||
page = SearchProvidersPage.__new__(SearchProvidersPage)
|
||||
assert page.isComplete() is True
|
||||
|
||||
def test_validate_writes_brave_key_when_provided(self):
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from jarvis.config import _load_json
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump({}, f)
|
||||
cfg_path = Path(f.name)
|
||||
try:
|
||||
page = self._make_page(brave_key="BSA-abc123", wiki_enabled=True)
|
||||
with patch("jarvis.config.default_config_path", return_value=cfg_path):
|
||||
assert page.validatePage() is True
|
||||
saved = _load_json(cfg_path)
|
||||
# Default non-default-only write: Brave present, Wikipedia omitted.
|
||||
assert saved.get("brave_search_api_key") == "BSA-abc123"
|
||||
assert "wikipedia_fallback_enabled" not in saved
|
||||
finally:
|
||||
cfg_path.unlink(missing_ok=True)
|
||||
|
||||
def test_validate_omits_empty_brave_key(self):
|
||||
"""Empty Brave key must NOT write an empty-string entry — matches
|
||||
the settings-window minimal-diff invariant."""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from jarvis.config import _load_json
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump({}, f)
|
||||
cfg_path = Path(f.name)
|
||||
try:
|
||||
page = self._make_page(brave_key=" ", wiki_enabled=True)
|
||||
with patch("jarvis.config.default_config_path", return_value=cfg_path):
|
||||
page.validatePage()
|
||||
saved = _load_json(cfg_path)
|
||||
assert "brave_search_api_key" not in saved
|
||||
assert "wikipedia_fallback_enabled" not in saved
|
||||
finally:
|
||||
cfg_path.unlink(missing_ok=True)
|
||||
|
||||
def test_validate_persists_wikipedia_disable_only(self):
|
||||
"""Wikipedia defaults to True, so only write it when user disables it."""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from jarvis.config import _load_json
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump({}, f)
|
||||
cfg_path = Path(f.name)
|
||||
try:
|
||||
page = self._make_page(brave_key="", wiki_enabled=False)
|
||||
with patch("jarvis.config.default_config_path", return_value=cfg_path):
|
||||
page.validatePage()
|
||||
saved = _load_json(cfg_path)
|
||||
assert saved.get("wikipedia_fallback_enabled") is False
|
||||
finally:
|
||||
cfg_path.unlink(missing_ok=True)
|
||||
|
||||
def test_validate_removes_existing_brave_key_when_cleared(self):
|
||||
"""If user blanks the Brave key, the entry must be removed, not kept."""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from jarvis.config import _load_json
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump({"brave_search_api_key": "old-key"}, f)
|
||||
cfg_path = Path(f.name)
|
||||
try:
|
||||
page = self._make_page(brave_key="", wiki_enabled=True)
|
||||
with patch("jarvis.config.default_config_path", return_value=cfg_path):
|
||||
page.validatePage()
|
||||
saved = _load_json(cfg_path)
|
||||
assert "brave_search_api_key" not in saved
|
||||
finally:
|
||||
cfg_path.unlink(missing_ok=True)
|
||||
|
||||
def test_validate_preserves_unrelated_keys(self):
|
||||
"""validatePage must not clobber unrelated config entries."""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from jarvis.config import _load_json
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||
json.dump({"ollama_chat_model": "gpt-oss:20b", "mcps": {"x": {}}}, f)
|
||||
cfg_path = Path(f.name)
|
||||
try:
|
||||
page = self._make_page(brave_key="BSA-key", wiki_enabled=False)
|
||||
with patch("jarvis.config.default_config_path", return_value=cfg_path):
|
||||
page.validatePage()
|
||||
saved = _load_json(cfg_path)
|
||||
assert saved["ollama_chat_model"] == "gpt-oss:20b"
|
||||
assert saved["mcps"] == {"x": {}}
|
||||
finally:
|
||||
cfg_path.unlink(missing_ok=True)
|
||||
|
||||
Reference in New Issue
Block a user