Files
voculinux/tests/test_installer_engine_config.py
T
f43f9dcd72 fix: leftover engine repair, just --no-sync, and ggml verify from #713 (#732)
* fix: leftover engine repair, just --no-sync, and ggml verify from #713

#713 only rewrote a broken config.json toward whisper_cpp, so a reinstall
that kept the old engine still died when vosk was the extra that installed.
verify_configured_engine now prefers SELECTED_ENGINE, then any working extra,
and the vosk fallback rewrites an existing file.

just recipes use uv run --no-sync so they do not prune extras from
just deps-all. CONTRIBUTING lists libgirepository-2.0-dev for Ubuntu 24.04.
An unverified ggml file is not loaded if unlink fails, and an unpinned name
is refused rather than deleted.

* fix(appimage): retry tooling downloads on curl reset

* fix(build): bootstrap .venv for --no-sync just recipes and cover the contributor path in CI

---------

Co-authored-by: Sergey Silaev <hello@8am.dev>
2026-08-24 10:42:52 -07:00

192 lines
6.1 KiB
Python

"""Behavioral tests for the installer's speech-engine sanity check.
config.json survives reinstalls, so it can name an engine the current venv
cannot import — vosk is an optional extra since #705. Vocalinux then dies at
startup with "No module named 'vosk'" instead of the installer saying so.
"""
import json
import subprocess
from pathlib import Path
INSTALL_SH = Path(__file__).resolve().parents[1] / "install.sh"
PRELUDE = """
set -uo pipefail
print_info() { echo "INFO: $*"; }
print_warning() { echo "WARNING: $*"; }
print_error() { echo "ERROR: $*"; }
print_success() { echo "SUCCESS: $*"; }
"""
def _run(script: str) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", PRELUDE + script], capture_output=True, text=True, timeout=30
)
def _venv_python(venv_dir: Path, importable: "set[str] | None" = None) -> Path:
"""A stub venv interpreter: real python3, with chosen imports forced.
``importable`` names the engine modules that succeed; every other engine
module fails. Anything else (the json probes) runs on the real interpreter.
"""
modules = {"vosk", "whisper", "pywhispercpp.model"}
ok = importable if importable is not None else set()
python = venv_dir / "bin" / "python"
python.parent.mkdir(parents=True, exist_ok=True)
branches = "".join(
'if [[ "$*" == *"import {module}"* ]]; then\n'
" exit {code}\n"
"fi\n".format(module=module, code=0 if module in ok else 1)
for module in sorted(modules, key=len, reverse=True)
)
python.write_text("#!/bin/bash\n" + branches + 'exec python3 "$@"\n')
python.chmod(0o755)
return python
def _engine_of(config_file: Path) -> str:
return json.loads(config_file.read_text())["speech_recognition"]["engine"]
def _config(config_dir: Path, engine: str) -> Path:
config_dir.mkdir(parents=True, exist_ok=True)
path = config_dir / "config.json"
path.write_text(json.dumps({"speech_recognition": {"engine": engine}}))
return path
def _check(
config_dir: Path, venv_dir: Path, selected_engine: str = ""
) -> subprocess.CompletedProcess:
script = f"""
CONFIG_DIR="{config_dir}"
VENV_DIR="{venv_dir}"
SELECTED_ENGINE="{selected_engine}"
for fn in engine_import_module engine_pip_name venv_can_import set_configured_engine verify_configured_engine; do
source <(sed -n "/^$fn() {{$/,/^}}$/p" "{INSTALL_SH}")
done
verify_configured_engine
echo "EXIT=$?"
"""
return _run(script)
def test_repairs_a_config_naming_an_uninstallable_engine(tmp_path):
"""Don't leave a config the app cannot start with."""
venv = tmp_path / "venv"
_venv_python(venv, importable={"pywhispercpp.model"})
config = _config(tmp_path / "config", "vosk")
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "whisper_cpp"
assert "Switched" in result.stdout
def test_repairs_a_leftover_whisper_engine_too(tmp_path):
"""The check was vosk-only; whisper is an optional extra as well."""
venv = tmp_path / "venv"
_venv_python(venv, importable={"pywhispercpp.model"})
config = _config(tmp_path / "config", "whisper")
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "whisper_cpp"
def test_fails_when_no_engine_works(tmp_path):
"""Nothing to fall back to: the install must not report success."""
venv = tmp_path / "venv"
_venv_python(venv, importable=set())
config = _config(tmp_path / "config", "vosk")
result = _check(tmp_path / "config", venv)
assert "EXIT=1" in result.stdout
assert "cannot start" in result.stdout
# The config is left alone so the user can see what it asked for.
assert _engine_of(config) == "vosk"
def test_leaves_a_working_engine_alone(tmp_path):
venv = tmp_path / "venv"
_venv_python(venv, importable={"vosk"})
config = _config(tmp_path / "config", "vosk")
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "vosk"
assert "WARNING" not in result.stdout
def test_ignores_engines_that_need_no_extra(tmp_path):
venv = tmp_path / "venv"
_venv_python(venv, importable=set())
config = _config(tmp_path / "config", "remote_api")
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "remote_api"
def test_silent_without_a_config_file(tmp_path):
venv = tmp_path / "venv"
_venv_python(venv, importable=set())
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
def test_repairs_whisper_cpp_to_vosk_when_that_is_what_works(tmp_path):
"""Reinstall: config still says whisper_cpp, pywhispercpp is gone, vosk is in."""
venv = tmp_path / "venv"
_venv_python(venv, importable={"vosk"})
config = _config(tmp_path / "config", "whisper_cpp")
result = _check(tmp_path / "config", venv)
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "vosk"
assert "Switched" in result.stdout
def test_honors_selected_engine_ahead_of_the_default_fallback(tmp_path):
"""SELECTED_ENGINE is tried before the default vosk/whisper extras."""
venv = tmp_path / "venv"
_venv_python(venv, importable={"vosk", "whisper"})
config = _config(tmp_path / "config", "whisper_cpp")
result = _check(tmp_path / "config", venv, selected_engine="whisper")
assert "EXIT=0" in result.stdout
assert _engine_of(config) == "whisper"
def test_vosk_fallback_rewrites_an_existing_config_file():
"""The fallback used to write vosk only when config.json was missing."""
text = INSTALL_SH.read_text(encoding="utf-8")
assert 'set_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"' in text
assert '[ ! -f "$FALLBACK_VOSK_CONFIG" ]' not in text
def test_unreadable_config_is_not_treated_as_an_engine(tmp_path):
venv = tmp_path / "venv"
_venv_python(venv, importable=set())
config_dir = tmp_path / "config"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text("{not json")
result = _check(config_dir, venv)
assert "EXIT=0" in result.stdout