Files
javis_bot/tests/test_setup_wizard.py
javis-bot c4abf63f38
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
Add Discord-native hybrid front-end for Jarvis (bot + bridge)
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.
2026-06-09 14:51:05 +09:00

948 lines
40 KiB
Python

"""
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)