* fix(installer): add upgrade mode that keeps the existing configuration Re-running install.sh on an existing install replayed first-time setup: the mode menu offered only interactive or automatic, automatic mode forced whisper.cpp regardless of the configured engine, and nothing said config.json would be kept, even though docs/UPDATE.md promised the installer updates in place and preserves configuration. Detect an existing install early (venv activate + config.json) and offer an upgrade path: - The curl|bash mode menu gains 'Upgrade (keep current settings)' as the default when an install is detected; the guided setup offers it first too. - A --upgrade flag runs the same path non-interactively for scripts, and falls back to a normal automatic install when nothing is installed. - Upgrade mode reads speech_recognition.engine from the existing config.json instead of forcing whisper.cpp, reuses the venv, updates that engine's dependencies, and rebuilds pywhispercpp only when the installed version drifts from the pinned release or GPU support is missing. - config.json is never modified: every engine path that may rewrite it (parakeet, faster_whisper, the whisper.cpp -> vosk fallback) goes through a wrapper that is a logged no-op in upgrade mode, and verify_configured_engine reports an unimportable configured engine instead of repairing the file. - The installer prints 'Existing configuration found at ~/.config/vocalinux/config.json - it will be kept.' when it detects one. Update docs/UPDATE.md with the upgrade behavior and add tests/test_installer_upgrade.py covering detection, the mode menu default, engine resolution from config, config immutability, the guided offer, --upgrade parsing, and an end-to-end pty run of the piped installer. Fixes #1009 * test(installer): isolate the upgrade pty test from host changes Skip system-dependency and model steps in the end-to-end installer run so the only host-mutating calls are gated out entirely; the timed interrupt is no longer the only thing between the marker and sudo. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
239 lines
7.8 KiB
Python
239 lines
7.8 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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[str]:
|
|
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", "sherpa_onnx", "faster_whisper"}
|
|
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'
|
|
" if [ {code} -eq 0 ]; then exit 0; fi\n"
|
|
" echo \"ModuleNotFoundError: No module named '{module}'\" >&2\n"
|
|
" exit 1\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[str]:
|
|
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=$?"
|
|
echo "SELECTED=$SELECTED_ENGINE"
|
|
"""
|
|
return _run(script)
|
|
|
|
|
|
def _check_engine_import(venv_dir: Path, engine: str) -> subprocess.CompletedProcess[str]:
|
|
script = f"""
|
|
VENV_DIR="{venv_dir}"
|
|
for fn in engine_import_module engine_import_error; do
|
|
source <(sed -n "/^$fn() {{$/,/^}}$/p" "{INSTALL_SH}")
|
|
done
|
|
engine_import_error "{engine}"
|
|
echo "EXIT=$?"
|
|
"""
|
|
return _run(script)
|
|
|
|
|
|
def test_engine_import_error_reports_success_and_failure(tmp_path: Path) -> None:
|
|
venv = tmp_path / "venv"
|
|
_venv_python(venv, importable={"sherpa_onnx"})
|
|
|
|
imported = _check_engine_import(venv, "parakeet")
|
|
|
|
assert "EXIT=0" in imported.stdout
|
|
_venv_python(venv, importable=set())
|
|
|
|
failed = _check_engine_import(venv, "parakeet")
|
|
|
|
assert "EXIT=1" in failed.stdout
|
|
assert "ERROR: parakeet installed but `import sherpa_onnx` failed:" in failed.stdout
|
|
assert "ERROR: ModuleNotFoundError: No module named 'sherpa_onnx'" in failed.stdout
|
|
|
|
|
|
def test_repairs_a_config_naming_an_uninstallable_engine(tmp_path: Path) -> None:
|
|
"""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: Path) -> None:
|
|
"""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: Path) -> None:
|
|
"""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: Path) -> None:
|
|
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: Path) -> None:
|
|
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: Path) -> None:
|
|
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: Path) -> None:
|
|
"""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: Path) -> None:
|
|
"""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_selected_engine_tracks_a_parakeet_config_repair(tmp_path: Path) -> None:
|
|
venv = tmp_path / "venv"
|
|
_venv_python(venv, importable={"pywhispercpp.model"})
|
|
config = _config(tmp_path / "config", "parakeet")
|
|
|
|
result = _check(tmp_path / "config", venv, selected_engine="parakeet")
|
|
|
|
assert "EXIT=0" in result.stdout
|
|
assert "SELECTED=whisper_cpp" in result.stdout
|
|
assert _engine_of(config) == "whisper_cpp"
|
|
|
|
|
|
def test_vosk_fallback_rewrites_an_existing_config_file() -> None:
|
|
"""The fallback used to write vosk only when config.json was missing."""
|
|
text = INSTALL_SH.read_text(encoding="utf-8")
|
|
# write_configured_engine wraps the rewrite with the upgrade-mode no-op.
|
|
assert 'write_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: Path) -> None:
|
|
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
|