Files
voculinux/tests/test_installer_upgrade.py
T
be690f0bd8 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>
2026-10-26 20:55:39 -07:00

465 lines
16 KiB
Python

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