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:
co-authored by
Jatin K Malik
parent
8f220c38f4
commit
be690f0bd8
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user