Files
voculinux/tests/test_installer_upgrade.py
T
xavierk 80a3792576
CI / security (pull_request) Failing after 1m25s
CI / lint (pull_request) Failing after 34s
CI / ai-review (pull_request) Successful in 25s
chore: import vocalinux project
Import the vocalinux source tree (tray app, Next.js site in web/,
packaging, installers, docs and tests) on top of the Gitea template
commit. The project README replaces the template README, and the
template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
2026-11-06 11:51:44 +05:30

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