fix(installer): add upgrade mode that keeps the existing configuration (#1026)

* 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>
This commit is contained in:
devin-ai-integration[bot]
2026-10-26 20:55:39 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 8f220c38f4
commit be690f0bd8
5 changed files with 664 additions and 26 deletions
+10
View File
@@ -15,6 +15,16 @@ bash /tmp/vl.sh
The installer detects a running instance, updates in place, preserves configuration and models, and pulls new dependencies (including neural VAD when available).
On an existing install (a virtual environment plus `~/.config/vocalinux/config.json`), the installer reports the saved configuration and defaults to **Upgrade (keep current settings)**: the configured engine stays selected, `config.json` is never rewritten, the virtual environment is reused, downloaded models are kept, and pywhispercpp is rebuilt only when this release pins a new version or GPU support is missing. Pick the interactive or automatic modes instead if you want to reconfigure the install.
For a scripted, non-interactive upgrade:
```bash
bash /tmp/vl.sh --upgrade
```
`--upgrade` on a machine with no existing install falls back to a normal automatic installation.
### Installed from source
```bash
+25
View File
@@ -34,6 +34,31 @@ EOF
echo "Optional Remote API is off unless you choose it; then audio goes only to the server you configure."
echo ""
# An existing install is an upgrade by default: keep the saved configuration
# and update in place instead of redoing first-time setup. Choosing the full
# guided setup continues below and can re-point the engine deliberately.
if [[ "${EXISTING_INSTALL:-no}" == "yes" ]]; then
echo "An existing Vocalinux installation was detected:"
echo " Configuration: $CONFIG_DIR/config.json (kept unchanged by an upgrade)"
echo " Virtual env: $VENV_DIR (reused by an upgrade)"
echo ""
echo " 1. Upgrade (keep current settings) - update Vocalinux only"
echo " 2. Full guided setup - choose engine, backend, and model options"
echo ""
read_prompt -p "Choose [1-2] (default: 1): " UPGRADE_CHOICE
UPGRADE_CHOICE=${UPGRADE_CHOICE:-1}
if [[ "$UPGRADE_CHOICE" != "2" ]]; then
UPGRADE_MODE="yes"
INTERACTIVE_MODE="no"
NON_INTERACTIVE="yes"
echo ""
return 0
fi
echo ""
fi
# Step 1: Detect and display system info
print_header "Step 1: Your System"
echo "Detected: $DISTRO_NAME $DISTRO_VERSION"
+163 -25
View File
@@ -280,6 +280,7 @@ SKIP_SYSTEM_DEPS="no"
NON_INTERACTIVE="no"
INTERACTIVE_MODE="yes" # Default to interactive mode
AUTO_MODE="no"
UPGRADE_MODE="no"
REBUILD_WHISPERCPP="ask"
# Pinned pywhispercpp release (sdist, built from source). Keep in sync with
# uv.lock and the requirements/ exports.
@@ -364,6 +365,13 @@ while [[ $# -gt 0 ]]; do
NON_INTERACTIVE="yes"
shift
;;
--upgrade)
UPGRADE_MODE="yes"
AUTO_MODE="yes"
INTERACTIVE_MODE="no"
NON_INTERACTIVE="yes"
shift
;;
--help)
echo "Vocalinux Installer"
echo ""
@@ -372,11 +380,15 @@ while [[ $# -gt 0 ]]; do
echo "Installation Modes:"
echo " (no flags) Interactive mode - guided setup with recommendations"
echo " --auto Automatic mode - install with defaults (whisper.cpp)"
echo " --upgrade Upgrade mode - update an existing install, keep its settings"
echo " --auto --engine=whisper Auto mode with specific engine"
echo ""
echo "Options:"
echo " --interactive, -i Force interactive mode (default)"
echo " --auto Non-interactive automatic installation"
echo " --upgrade Non-interactive upgrade of an existing install: reuse the"
echo " venv, update the configured engine's dependencies, never"
echo " modify config.json"
echo " --engine=NAME Speech engine: whisper_cpp (default), whisper, vosk, parakeet, faster_whisper, remote_api"
echo " --dev Install in development mode with all dev dependencies"
echo " --test Run tests after installation"
@@ -391,6 +403,7 @@ while [[ $# -gt 0 ]]; do
echo ""
echo "Examples:"
echo " $0 # Interactive mode (recommended)"
echo " $0 --upgrade # Update an existing install, keep settings"
echo " $0 --auto # Auto-install with whisper.cpp"
echo " $0 --auto --engine=vosk # Auto-install VOSK only"
echo " $0 --auto --engine=faster_whisper # Auto-install faster-whisper"
@@ -1286,28 +1299,132 @@ case "$DISTRO_FAMILY" in
;;
esac
# Handle installation mode selection
if [[ "$INTERACTIVE_MODE" == "ask" ]]; then
# Running via curl pipe but we have a terminal - ask user preference
# Define XDG directories early: existing-install detection and the mode menu
# need CONFIG_DIR before system dependency installation asks anything.
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vocalinux"
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux"
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
# An existing install is a usable venv plus a saved configuration: re-running
# the installer on one is an upgrade, not a first-time setup. A leftover
# config.json without a venv (from an aborted install) is not an install.
detect_existing_install() {
[ -f "$VENV_DIR/bin/activate" ] && [ -f "$CONFIG_DIR/config.json" ]
}
# Running via curl pipe but with a terminal: ask which mode to run. An existing
# install gets an upgrade entry as the default so re-running the installer is a
# non-event instead of a guided redo of the first-time setup.
choose_installation_mode() {
[[ "$INTERACTIVE_MODE" == "ask" ]] || return 0
echo ""
echo "Installation Mode:"
echo " 1. Interactive (recommended) - guided setup with recommendations"
echo " 2. Automatic - quick install with defaults (whisper.cpp)"
echo ""
read_prompt -p "Choose mode [1-2] (default: 1): " MODE_CHOICE
MODE_CHOICE=${MODE_CHOICE:-1}
if [[ "${EXISTING_INSTALL:-no}" == "yes" ]]; then
echo " 1. Upgrade (keep current settings) - update Vocalinux, keep your setup"
echo " 2. Interactive - guided setup with recommendations"
echo " 3. Automatic - quick install with defaults (whisper.cpp)"
echo ""
read_prompt -p "Choose mode [1-3] (default: 1): " MODE_CHOICE
MODE_CHOICE=${MODE_CHOICE:-1}
if [[ "$MODE_CHOICE" == "2" ]]; then
AUTO_MODE="yes"
INTERACTIVE_MODE="no"
NON_INTERACTIVE="yes"
case "$MODE_CHOICE" in
2)
INTERACTIVE_MODE="yes"
NON_INTERACTIVE="no"
;;
3)
AUTO_MODE="yes"
INTERACTIVE_MODE="no"
NON_INTERACTIVE="yes"
;;
*)
UPGRADE_MODE="yes"
AUTO_MODE="yes"
INTERACTIVE_MODE="no"
NON_INTERACTIVE="yes"
;;
esac
else
INTERACTIVE_MODE="yes"
NON_INTERACTIVE="no"
echo " 1. Interactive (recommended) - guided setup with recommendations"
echo " 2. Automatic - quick install with defaults (whisper.cpp)"
echo ""
read_prompt -p "Choose mode [1-2] (default: 1): " MODE_CHOICE
MODE_CHOICE=${MODE_CHOICE:-1}
if [[ "$MODE_CHOICE" == "2" ]]; then
AUTO_MODE="yes"
INTERACTIVE_MODE="no"
NON_INTERACTIVE="yes"
else
INTERACTIVE_MODE="yes"
NON_INTERACTIVE="no"
fi
fi
echo ""
}
# --upgrade on a machine with nothing installed cannot keep settings that do
# not exist: warn and proceed as a normal automatic install instead of failing.
resolve_upgrade_request() {
if [[ "${UPGRADE_MODE:-no}" == "yes" && "${EXISTING_INSTALL:-no}" != "yes" ]]; then
print_warning "--upgrade was given, but no existing install was found"
print_warning "(expected a venv in $VENV_DIR plus $CONFIG_DIR/config.json)."
print_info "Continuing with a normal automatic installation."
UPGRADE_MODE="no"
fi
}
# Print speech_recognition.engine from an existing config.json. Empty output
# when the file or the key is missing or unreadable.
read_configured_engine() {
local config_file="$1" python=""
[ -f "$config_file" ] || return 1
if [ -x "$VENV_DIR/bin/python" ]; then
python="$VENV_DIR/bin/python"
elif command_exists python3; then
python="python3"
else
return 1
fi
"$python" - "$config_file" <<'PY' 2>/dev/null
import json
import sys
try:
with open(sys.argv[1]) as handle:
print(json.load(handle).get("speech_recognition", {}).get("engine", ""))
except Exception:
pass
PY
}
# Upgrade mode installs dependencies for the engine the existing configuration
# already selected instead of forcing the automatic default.
apply_upgrade_engine() {
[[ "${UPGRADE_MODE:-no}" == "yes" && -z "${SELECTED_ENGINE:-}" ]] || return 0
SELECTED_ENGINE=$(read_configured_engine "$CONFIG_DIR/config.json" || true)
if [ -n "$SELECTED_ENGINE" ]; then
print_info "Upgrade mode: keeping configured engine '$SELECTED_ENGINE'."
else
print_warning "No engine readable in $CONFIG_DIR/config.json; using the default (whisper.cpp)."
SELECTED_ENGINE="whisper_cpp"
fi
}
EXISTING_INSTALL="no"
if detect_existing_install; then
EXISTING_INSTALL="yes"
print_success "Existing configuration found at $CONFIG_DIR/config.json — it will be kept."
fi
resolve_upgrade_request
# Handle installation mode selection
choose_installation_mode
# Run interactive installation if selected
if [[ "$INTERACTIVE_MODE" == "yes" ]]; then
# Check if we have a TTY (required for interactive mode)
@@ -1322,6 +1439,8 @@ if [[ "$INTERACTIVE_MODE" == "yes" ]]; then
run_interactive_install
fi
apply_upgrade_engine
# Set default engine for auto/non-interactive mode
if [[ "$NON_INTERACTIVE" == "yes" ]] && [[ -z "$SELECTED_ENGINE" ]]; then
SELECTED_ENGINE="whisper_cpp"
@@ -1338,12 +1457,6 @@ else
install_system_dependencies
fi
# Define XDG directories
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vocalinux"
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux"
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
# Install text input tools based on session type
install_text_input_tools
@@ -1828,6 +1941,12 @@ should_rebuild_whispercpp() {
esac
if [[ "$NON_INTERACTIVE" == "yes" ]]; then
# An upgrade should pick up the pinned pywhispercpp: a version drift is
# the one case where reusing the existing build is not an update.
if [ -n "$INSTALLED_VERSION" ] && [ "$INSTALLED_VERSION" != "$PYWHISPERCPP_VERSION" ]; then
print_info "pywhispercpp $INSTALLED_VERSION installed; this release pins $PYWHISPERCPP_VERSION. Rebuilding..."
return 0
fi
if [[ "$HAS_NVIDIA_GPU" == "yes" || "$HAS_VULKAN" == "yes" ]] && ! is_pywhispercpp_gpu_capable; then
print_info "Non-interactive mode: GPU detected but pywhispercpp lacks GPU support. Rebuilding..."
return 0
@@ -1914,6 +2033,16 @@ with open(path, "w") as handle:
PY
}
# config.json is user data. Upgrade mode never modifies it: the write becomes
# a logged no-op so the configured engine survives the update byte-for-byte.
write_configured_engine() {
if [[ "${UPGRADE_MODE:-no}" == "yes" ]]; then
print_info "Upgrade mode: $1 is kept unchanged."
return 0
fi
set_configured_engine "$1" "$2"
}
install_whispercpp_with_gpu_support() {
local PIP_LOG_FILE="$1"
@@ -2083,12 +2212,13 @@ install_whispercpp_with_gpu_support() {
local FALLBACK_VOSK_CONFIG="$CONFIG_DIR/config.json"
if [ -f "$FALLBACK_VOSK_CONFIG" ]; then
if set_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"; then
print_success "Switched $FALLBACK_VOSK_CONFIG to the vosk engine."
if write_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"; then
[[ "${UPGRADE_MODE:-no}" != "yes" ]] &&
print_success "Switched $FALLBACK_VOSK_CONFIG to the vosk engine."
else
print_warning "Could not rewrite $FALLBACK_VOSK_CONFIG to vosk."
fi
else
elif [[ "${UPGRADE_MODE:-no}" != "yes" ]]; then
mkdir -p "$CONFIG_DIR"
cat > "$FALLBACK_VOSK_CONFIG" << 'FALLBACK_VOSK_CONFIG'
{
@@ -2464,7 +2594,7 @@ VOSK_CONFIG
}
PARAKEET_CONFIG
fi
set_configured_engine "$CONFIG_DIR/config.json" parakeet ||
write_configured_engine "$CONFIG_DIR/config.json" parakeet ||
print_warning "Could not point $CONFIG_DIR/config.json at the parakeet engine."
;;
@@ -2554,7 +2684,7 @@ REMOTE_CONFIG
}
FASTER_WHISPER_CONFIG
fi
set_configured_engine "$CONFIG_DIR/config.json" faster_whisper ||
write_configured_engine "$CONFIG_DIR/config.json" faster_whisper ||
print_warning "Could not point $CONFIG_DIR/config.json at the faster_whisper engine."
else
print_error "Failed to install the faster-whisper engine"
@@ -2775,6 +2905,14 @@ PY
print_warning "$CONFIG_FILE selects the $CONFIGURED_ENGINE engine, but it is not importable in $VENV_DIR."
if [[ "${UPGRADE_MODE:-no}" == "yes" ]]; then
# Upgrade mode never rewrites config.json: report instead of repairing.
print_error "Vocalinux cannot start with this configuration and the engine is not installed."
print_error "Install the engine: $VENV_DIR/bin/pip install $(engine_pip_name "$CONFIGURED_ENGINE")"
print_error "or edit $CONFIG_FILE and set speech_recognition.engine to an installed engine."
return 1
fi
# Leaving it means the app dies at startup with ModuleNotFoundError. Prefer
# SELECTED_ENGINE (set by the whisper.cpp -> vosk fallback), then any extra
# the venv can actually import.
+2 -1
View File
@@ -221,7 +221,8 @@ def test_selected_engine_tracks_a_parakeet_config_repair(tmp_path: Path) -> None
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")
assert 'set_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"' in text
# 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
+464
View File
@@ -0,0 +1,464 @@
"""Behavioral tests for the installer's existing-install upgrade path.
Re-running install.sh used to replay first-time setup with no way to keep the
existing configuration (issue #1009): 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. The upgrade path detects a
venv + config.json pair, offers "Upgrade (keep current settings)" as the
default, installs dependencies for the configured engine, and never rewrites
the file.
"""
from __future__ import annotations
import json
import os
import shlex
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
INSTALL_SH = REPO_ROOT / "install.sh"
INTERACTIVE_SH = REPO_ROOT / "install.d" / "interactive.sh"
PRELUDE = """
set -uo pipefail
print_info() { echo "INFO: $*"; }
print_warning() { echo "WARNING: $*"; }
print_error() { echo "ERROR: $*"; }
print_success() { echo "SUCCESS: $*"; }
command_exists() { command -v "$1" >/dev/null 2>&1; }
"""
#: read_prompt writes its answer into the variable named by its last argument,
#: but some calls ("Press Enter to continue...") take no variable name at all:
#: only assign when the last arg is a legal identifier. Answers come from
#: PROMPT_QUEUE so each prompt can be answered differently.
STUBS = """
read_prompt() {
local last="${@: -1}" ans=""
if [ ${#PROMPT_QUEUE[@]} -gt 0 ]; then
ans="${PROMPT_QUEUE[0]}"
PROMPT_QUEUE=("${PROMPT_QUEUE[@]:1}")
fi
if [[ "$last" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
printf -v "$last" '%s' "$ans"
fi
}
PROMPT_QUEUE=()
"""
def _run(script: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", "-c", PRELUDE + script],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, **(env or {})},
)
def _source(*functions: str) -> str:
"""Source the named function definitions out of install.sh."""
return "\n".join(
f"source <(sed -n '/^{name}() {{$/,/^}}$/p' {shlex.quote(str(INSTALL_SH))})"
for name in functions
)
def _make_install(tmp_path: Path, engine: str = "vosk") -> tuple[Path, Path]:
"""Seed the pair detect_existing_install looks for: venv + config.json."""
venv = tmp_path / "venv"
(venv / "bin").mkdir(parents=True)
(venv / "bin" / "activate").touch()
# set_configured_engine invokes "$VENV_DIR/bin/python"; point it at a real
# interpreter so the non-upgrade write path can run.
(venv / "bin" / "python").symlink_to(shutil.which("python3"))
config_dir = tmp_path / "config"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"speech_recognition": {"engine": engine}}))
return venv, config_dir
@pytest.mark.parametrize(
("with_venv", "with_config", "expected"),
[(True, True, "0"), (True, False, "1"), (False, True, "1"), (False, False, "1")],
)
def test_existing_install_requires_venv_and_config(
tmp_path: Path, with_venv: bool, with_config: bool, expected: str
) -> None:
venv = tmp_path / "venv"
config_dir = tmp_path / "config"
if with_venv:
(venv / "bin").mkdir(parents=True)
(venv / "bin" / "activate").touch()
if with_config:
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text("{}")
result = _run(f"""
VENV_DIR={shlex.quote(str(venv))}
CONFIG_DIR={shlex.quote(str(config_dir))}
{_source("detect_existing_install")}
detect_existing_install
echo "EXIT=$?"
""")
assert f"EXIT={expected}" in result.stdout
def test_upgrade_flag_parses_to_noninteractive_upgrade() -> None:
"""--upgrade selects upgrade semantics without any prompts."""
text = INSTALL_SH.read_text(encoding="utf-8")
start = text.index("while [[ $# -gt 0 ]]; do")
end = text.index("\ndone", start) + len("\ndone")
arg_parse = text[start:end]
result = _run(f"""
AUTO_MODE="no"; INTERACTIVE_MODE="yes"; NON_INTERACTIVE="no"; UPGRADE_MODE="no"
INSTALLER_ARGS=()
set -- --upgrade
{arg_parse}
echo "U=$UPGRADE_MODE A=$AUTO_MODE I=$INTERACTIVE_MODE N=$NON_INTERACTIVE"
""")
assert "U=yes A=yes I=no N=yes" in result.stdout
def test_help_documents_the_upgrade_flag() -> None:
result = subprocess.run(
["bash", str(INSTALL_SH), "--help"],
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode == 0
assert "--upgrade" in result.stdout
@pytest.mark.parametrize(
("answer", "expected"),
[
("", "U=yes A=yes I=no N=yes"), # default: Upgrade
("1", "U=yes A=yes I=no N=yes"),
("2", "U=no A=no I=yes N=no"), # Interactive
("3", "U=no A=yes I=no N=yes"), # Automatic
],
)
def test_mode_menu_defaults_to_upgrade_on_existing_install(answer: str, expected: str) -> None:
result = _run(STUBS + f"""
INTERACTIVE_MODE="ask"; AUTO_MODE="no"; NON_INTERACTIVE="no"; UPGRADE_MODE="no"
EXISTING_INSTALL="yes"
{_source("choose_installation_mode")}
PROMPT_QUEUE=("{answer}")
choose_installation_mode
echo "U=$UPGRADE_MODE A=$AUTO_MODE I=$INTERACTIVE_MODE N=$NON_INTERACTIVE"
""")
assert "Upgrade (keep current settings)" in result.stdout
assert expected in result.stdout
@pytest.mark.parametrize(
("answer", "expected"),
[("", "U=no A=no I=yes N=no"), ("2", "U=no A=yes I=no N=yes")],
)
def test_mode_menu_is_unchanged_without_an_install(answer: str, expected: str) -> None:
result = _run(STUBS + f"""
INTERACTIVE_MODE="ask"; AUTO_MODE="no"; NON_INTERACTIVE="no"; UPGRADE_MODE="no"
EXISTING_INSTALL="no"
{_source("choose_installation_mode")}
PROMPT_QUEUE=("{answer}")
choose_installation_mode
echo "U=$UPGRADE_MODE A=$AUTO_MODE I=$INTERACTIVE_MODE N=$NON_INTERACTIVE"
""")
assert "Upgrade (keep current settings)" not in result.stdout
assert expected in result.stdout
def test_upgrade_flag_without_an_install_falls_back_to_auto(tmp_path: Path) -> None:
result = _run(f"""
UPGRADE_MODE="yes"
EXISTING_INSTALL="no"
VENV_DIR={shlex.quote(str(tmp_path / 'venv'))}
CONFIG_DIR={shlex.quote(str(tmp_path / 'config'))}
{_source("resolve_upgrade_request")}
resolve_upgrade_request
echo "U=$UPGRADE_MODE"
""")
assert "no existing install was found" in result.stdout
assert "U=no" in result.stdout
@pytest.mark.parametrize("engine", ["vosk", "parakeet", "faster_whisper", "remote_api"])
def test_upgrade_keeps_the_engine_from_config(tmp_path: Path, engine: str) -> None:
"""The configured engine wins over the automatic default (whisper.cpp)."""
venv, config_dir = _make_install(tmp_path, engine=engine)
result = _run(f"""
UPGRADE_MODE="yes"; SELECTED_ENGINE=""
VENV_DIR={shlex.quote(str(venv))}
CONFIG_DIR={shlex.quote(str(config_dir))}
{_source("read_configured_engine", "apply_upgrade_engine")}
apply_upgrade_engine
echo "SELECTED=$SELECTED_ENGINE"
""")
assert f"SELECTED={engine}" in result.stdout
assert f"keeping configured engine '{engine}'" in result.stdout
def test_upgrade_falls_back_to_default_when_engine_is_unreadable(tmp_path: Path) -> None:
venv, config_dir = _make_install(tmp_path)
(config_dir / "config.json").write_text("{not json")
result = _run(f"""
UPGRADE_MODE="yes"; SELECTED_ENGINE=""
VENV_DIR={shlex.quote(str(venv))}
CONFIG_DIR={shlex.quote(str(config_dir))}
{_source("read_configured_engine", "apply_upgrade_engine")}
apply_upgrade_engine
echo "SELECTED=$SELECTED_ENGINE"
""")
assert "SELECTED=whisper_cpp" in result.stdout
def test_explicit_engine_flag_wins_over_the_configured_one(tmp_path: Path) -> None:
venv, config_dir = _make_install(tmp_path, engine="vosk")
result = _run(f"""
UPGRADE_MODE="yes"; SELECTED_ENGINE="whisper"
VENV_DIR={shlex.quote(str(venv))}
CONFIG_DIR={shlex.quote(str(config_dir))}
{_source("read_configured_engine", "apply_upgrade_engine")}
apply_upgrade_engine
echo "SELECTED=$SELECTED_ENGINE"
""")
assert "SELECTED=whisper" in result.stdout
def test_upgrade_never_rewrites_config_json(tmp_path: Path) -> None:
"""write_configured_engine must leave the file byte-for-byte identical."""
venv, config_dir = _make_install(tmp_path, engine="whisper")
config = config_dir / "config.json"
original = config.read_bytes()
result = _run(f"""
UPGRADE_MODE="yes"
VENV_DIR={shlex.quote(str(venv))}
{_source("set_configured_engine", "write_configured_engine")}
write_configured_engine {shlex.quote(str(config))} vosk
""")
assert result.returncode == 0, result.stderr
assert "kept unchanged" in result.stdout
assert config.read_bytes() == original
def test_write_configured_engine_still_writes_outside_upgrade(tmp_path: Path) -> None:
venv, config_dir = _make_install(tmp_path, engine="whisper")
config = config_dir / "config.json"
result = _run(f"""
UPGRADE_MODE="no"
VENV_DIR={shlex.quote(str(venv))}
{_source("set_configured_engine", "write_configured_engine")}
write_configured_engine {shlex.quote(str(config))} vosk
""")
assert result.returncode == 0, result.stderr
assert json.loads(config.read_text())["speech_recognition"]["engine"] == "vosk"
def _verify(config_dir: Path, venv_dir: Path, upgrade: bool) -> subprocess.CompletedProcess[str]:
script = f"""
CONFIG_DIR={shlex.quote(str(config_dir))}
VENV_DIR={shlex.quote(str(venv_dir))}
UPGRADE_MODE="{'yes' if upgrade else 'no'}"
SELECTED_ENGINE=""
{_source(
"engine_import_module",
"engine_pip_name",
"venv_can_import",
"set_configured_engine",
"write_configured_engine",
"verify_configured_engine",
)}
verify_configured_engine
echo "EXIT=$?"
"""
return _run(script)
def _stub_venv_python(venv_dir: Path, importable: set[str]) -> None:
"""Real python3 whose chosen engine imports succeed; the rest fail."""
modules = {"vosk", "whisper", "pywhispercpp.model", "sherpa_onnx", "faster_whisper"}
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 importable else 1)
for module in sorted(modules, key=len, reverse=True)
)
python.write_text("#!/bin/bash\n" + branches + 'exec python3 "$@"\n')
python.chmod(0o755)
def test_upgrade_reports_an_unimportable_engine_instead_of_repairing(
tmp_path: Path,
) -> None:
"""The repair path rewrites config.json; upgrade mode must not take it."""
venv = tmp_path / "venv"
_stub_venv_python(venv, importable=set())
config_dir = tmp_path / "config"
config_dir.mkdir()
config = config_dir / "config.json"
config.write_text(json.dumps({"speech_recognition": {"engine": "vosk"}}))
result = _verify(config_dir, venv, upgrade=True)
assert "EXIT=1" in result.stdout
assert "pip install vosk" in result.stdout
assert json.loads(config.read_text())["speech_recognition"]["engine"] == "vosk"
def test_upgrade_leaves_a_working_configured_engine_alone(tmp_path: Path) -> None:
venv = tmp_path / "venv"
_stub_venv_python(venv, importable={"vosk"})
config_dir = tmp_path / "config"
config_dir.mkdir()
config = config_dir / "config.json"
config.write_text(json.dumps({"speech_recognition": {"engine": "vosk"}}))
result = _verify(config_dir, venv, upgrade=True)
assert "EXIT=0" in result.stdout
assert "WARNING" not in result.stdout
assert json.loads(config.read_text())["speech_recognition"]["engine"] == "vosk"
def test_guided_setup_offers_upgrade_first_on_existing_install() -> None:
"""run_interactive_install returns before Step 1 when upgrade is chosen."""
result = _run(STUBS + f"""
EXISTING_INSTALL="yes"
UPGRADE_MODE="no"; INTERACTIVE_MODE="yes"; NON_INTERACTIVE="no"
CONFIG_DIR=/tmp/unused-config; VENV_DIR=/tmp/unused-venv
clear_screen() {{ :; }}
print_header() {{ echo "HEADER: $*"; }}
get_engine_recommendation() {{ echo "REACHED_GUIDED_SETUP"; }}
source {shlex.quote(str(INTERACTIVE_SH))}
PROMPT_QUEUE=("")
run_interactive_install
echo "U=$UPGRADE_MODE N=$NON_INTERACTIVE I=$INTERACTIVE_MODE"
""")
assert "existing Vocalinux installation was detected" in result.stdout
assert "REACHED_GUIDED_SETUP" not in result.stdout
assert "U=yes N=yes I=no" in result.stdout
def test_guided_setup_still_runs_when_the_user_declines_upgrade() -> None:
result = _run(STUBS + f"""
EXISTING_INSTALL="yes"
UPGRADE_MODE="no"; INTERACTIVE_MODE="yes"; NON_INTERACTIVE="no"
SELECTED_ENGINE=""
CONFIG_DIR=/tmp/unused-config; VENV_DIR=/tmp/unused-venv
DISTRO_NAME="Test"; DISTRO_VERSION="1"
clear_screen() {{ :; }}
print_header() {{ echo "HEADER: $*"; }}
get_engine_recommendation() {{ printf 'whisper_cpp:star:reason'; }}
detect_whispercpp_backends() {{ printf 'cpu:reason:false:no:no:false:false:na:na'; }}
source {shlex.quote(str(INTERACTIVE_SH))}
# "2" declines the upgrade offer; every later prompt gets the default.
PROMPT_QUEUE=("2")
run_interactive_install
echo "U=$UPGRADE_MODE SELECTED=$SELECTED_ENGINE"
""")
assert "Step 2: Choose Speech Recognition Engine" in result.stdout
assert "U=no" in result.stdout
assert "SELECTED=whisper_cpp" in result.stdout
SCRIPT = shutil.which("script")
needs_pty = pytest.mark.skipif(
SCRIPT is None,
reason="script(1) is needed to give a piped installer a controlling terminal",
)
@needs_pty
@pytest.mark.timeout(90)
def test_piped_installer_defaults_to_upgrade_on_existing_install(tmp_path: Path) -> None:
"""End to end: `cat install.sh | bash` on an existing install picks upgrade.
The venv and config.json are seeded under a scratch HOME; --venv-dir points
the installer at them and --tag skips the release lookup. A bare Enter at
the mode menu must land on "Upgrade (keep current settings)" and then name
the configured engine, not the automatic default.
"""
venv = tmp_path / "venv"
(venv / "bin").mkdir(parents=True)
(venv / "bin" / "activate").touch()
config_dir = tmp_path / ".config" / "vocalinux"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"speech_recognition": {"engine": "vosk"}}))
transcript = tmp_path / "upgrade.typescript"
feeder = tmp_path / "feed-input.sh"
feeder.write_text(
"#!/bin/sh\n"
# Queued until the "Choose mode" prompt reads /dev/tty; a bare Enter
# exercises the default, which must be the upgrade entry.
"sleep 1; printf '\\n'\n"
# Interrupt only once the upgrade marker is on screen: a blind delay
# loses to a slow runner and Ctrl+C's a healthy installer mid-prompt.
"for _ in $(seq 1 60); do\n"
f" grep -q 'keeping configured engine' {shlex.quote(str(transcript))} 2>/dev/null && break\n"
" sleep 1\n"
"done\n"
"printf '\\003'\n",
encoding="utf-8",
)
feeder.chmod(0o755)
env = os.environ.copy()
env["HOME"] = str(tmp_path)
# conftest points XDG_CONFIG_HOME at an isolation dir; the installer reads
# config.json from $XDG_CONFIG_HOME/vocalinux, not straight from $HOME.
env["XDG_CONFIG_HOME"] = str(tmp_path / ".config")
env["XDG_STATE_HOME"] = str(tmp_path / ".local" / "state")
env["TMPDIR"] = str(tmp_path)
inner = (
f"cat {shlex.quote(str(INSTALL_SH))} | bash -s --"
f" --tag=v0.0.0-test --venv-dir={shlex.quote(str(venv))}"
# The marker sits just ahead of the host-mutating steps; skipping them
# means the timed Ctrl+C can never lose a race against sudo or the
# package manager on a runner that happens to have either.
" --skip-system-deps --skip-models"
)
subprocess.run(
f"{shlex.quote(str(feeder))} | {shlex.quote(str(SCRIPT))} -ec "
+ shlex.quote(f"bash -c {shlex.quote(inner)}")
+ f" {shlex.quote(str(transcript))}",
shell=True,
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=75,
check=False,
cwd=REPO_ROOT,
env=env,
)
output = transcript.read_text(encoding="utf-8", errors="replace")
assert "Existing configuration found at" in output
assert "it will be kept" in output
assert "Upgrade (keep current settings)" in output
assert "keeping configured engine 'vosk'" in output