Files
voculinux/tests/test_installer_gate.py
T

233 lines
9.9 KiB
Python

"""Regression guards for the install.sh build gate.
That the matrix job runs the installer, that it is allowed to fail a build, and
that the two lists which have to agree (the matrix's containers and the gate's
bootstrap arms) actually do.
These match the constructs themselves rather than bare substrings: every path
and flag worth guarding also appears in a comment or in a `fail` message in the
same file, so `"…" in text` passes on a script that no longer does the thing.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
INSTALLER = REPO_ROOT / "install.sh"
GATE_SH = REPO_ROOT / "scripts" / "install-test.sh"
MATRIX = REPO_ROOT / ".github" / "workflows" / "distro-test-matrix.yml"
JUSTFILE = REPO_ROOT / "justfile"
#: Container image -> the ID its /etc/os-release reports, which is what the
#: gate's bootstrap `case "$ID"` switches on. A new container needs an entry
#: here and an arm there.
CONTAINER_OS_IDS = {
"ubuntu:24.04": "ubuntu",
"ubuntu:26.04": "ubuntu",
"debian:12": "debian",
"fedora:42": "fedora",
"archlinux:latest": "arch",
"opensuse/tumbleweed": "opensuse-tumbleweed",
}
def _matrix_text() -> str:
return MATRIX.read_text(encoding="utf-8")
def _gate_text() -> str:
return GATE_SH.read_text(encoding="utf-8")
def _without_comments(text: str) -> str:
"""Drop whole-line comments, so a guard cannot be satisfied by prose."""
return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
def _job_block(workflow_text: str, job: str) -> str:
"""The job's lines, from its key to the next key at the same indent."""
lines = workflow_text.splitlines(keepends=True)
start = next(
(i for i, line in enumerate(lines) if line.rstrip("\n") == f" {job}:"),
None,
)
assert start is not None, f"no {job} job in the workflow"
block = [lines[start]]
for line in lines[start + 1 :]:
if re.match(r"^ \S", line):
break
block.append(line)
return "".join(block)
def _bootstrap_case() -> str:
"""The gate's bootstrap `case "$ID"`, which is not the only case on $ID."""
blocks = re.findall(r'^case "\$ID" in\n(.*?)^esac', _gate_text(), re.M | re.S)
bootstrap = [block for block in blocks if "apt-get" in block]
assert len(bootstrap) == 1, 'no single `case "$ID"` installs the bootstrap packages'
return bootstrap[0]
def _matrix_containers() -> list:
"""The container images the matrix runs, one per line by convention."""
block = _job_block(_matrix_text(), "install")
listing = re.search(r"^ container:\n((?:\s+- \S+\n)+)", block, re.M)
assert listing, "the install job has no container list"
return [line.strip()[2:] for line in listing.group(1).splitlines()]
def _paths_filters() -> list:
"""Every quoted entry under a `paths:` filter, push and pull_request alike."""
return re.findall(r"^\s+- '([^']+)'$", _matrix_text(), re.M)
def test_the_matrix_runs_the_installer():
"""Checking for a function name or creating a venv by hand tests the
container, not the installer."""
block = _job_block(_matrix_text(), "install")
assert re.search(r"^\s*docker run", block, re.M), "the matrix job must run a container"
assert re.search(
r'^\s*bash "\$PWD/scripts/install-test\.sh"$', block, re.M
), "the matrix job no longer runs the gate script"
assert re.search(r"&& bash install\.sh --auto", _gate_text()), (
"the gate no longer invokes install.sh; it would be testing the"
" container rather than the installer again"
)
def test_the_matrix_can_fail_a_build():
"""`continue-on-error: true` makes the whole matrix advisory."""
block = _job_block(_matrix_text(), "install")
assert not re.search(r"^\s*continue-on-error:\s*true", block, re.M), (
"the install matrix is advisory again; a gate that cannot fail a build"
" is documentation, not a gate"
)
def test_every_matrix_container_has_a_bootstrap_arm():
"""install.sh needs sudo and a non-root user before it will start, and the
gate provides them per package manager. A container with no arm fails at the
`case "$ID"` fallthrough, 40 minutes into a CI run rather than here."""
bootstrap = _bootstrap_case()
for container in _matrix_containers():
assert container in CONTAINER_OS_IDS, (
f"{container} is in the matrix but not in CONTAINER_OS_IDS; add its"
" /etc/os-release ID so this guard can check the gate handles it"
)
os_id = CONTAINER_OS_IDS[container]
assert re.search(rf"^ [\w |-]*\b{re.escape(os_id)}\b[\w |-]*\)", bootstrap, re.M), (
f"{container} reports ID={os_id}, which scripts/install-test.sh has"
" no bootstrap arm for"
)
def test_the_gate_installs_unattended_without_skipping_system_deps():
"""--auto because there is no TTY, --skip-models because a model is 40-75 MB
per container per run and the download path is checksum-verified elsewhere.
Never --skip-system-deps: the per-distro package installation is the point."""
gate = _without_comments(_gate_text())
assert re.search(r"bash install\.sh --auto --skip-models", gate)
assert "--skip-system-deps" not in gate, (
"the gate skips system dependency installation, which is the one part"
" of install.sh the old matrix already failed to exercise"
)
def test_the_gate_checks_the_model_manifest_ships():
"""--skip-models means install.sh never reads model_checksums.txt here, so
the manifest leaving the tree would pass every other check in the gate. That
is the shape of #736, minus the release-tag skew a local-mode gate cannot
reach."""
assert re.search(
r'\[ -f "\$TREE/src/vocalinux/utils/model_checksums\.txt" \]', _gate_text()
), "the gate no longer checks the model manifest ships"
assert (REPO_ROOT / "src" / "vocalinux" / "utils" / "model_checksums.txt").is_file()
def test_the_gate_runs_as_an_unprivileged_user():
"""install.sh refuses to run as root and calls sudo directly. Running the
gate as root would test a path no user takes; without sudo it would not get
past the first package install."""
gate = _without_comments(_gate_text())
assert "useradd" in gate
assert re.search(r'su - "\$INSTALL_USER"', gate)
assert "NOPASSWD" in gate
def test_the_installer_still_refuses_root():
"""The assumption the gate's user setup exists for. If install.sh starts
allowing root, that setup is dead weight and should go deliberately."""
assert re.search(r"\$EUID.{0,12}-eq 0", INSTALLER.read_text(encoding="utf-8")), (
"install.sh no longer refuses to run as root; scripts/install-test.sh"
" creates an unprivileged user only because of that check"
)
def test_the_gate_takes_this_commit_from_git_not_the_working_directory():
"""git archive rather than cp: a stray venv, build artefact or uncommitted
fix must not be able to make the gate pass, and it is why the checkout can be
mounted read-only while local mode writes a venv. Running from the extracted
tree is what puts install.sh in local mode, and so what makes the gate answer
for this commit rather than for the last tag."""
assert re.search(
r'git -C "\$REPO" archive HEAD \| tar -x', _gate_text()
), "the gate no longer extracts the tree from git"
installer = INSTALLER.read_text(encoding="utf-8").splitlines()
assert any(
"grep" in line and "pyproject.toml" in line and "vocalinux" in line for line in installer
), (
"local mode is no longer detected from pyproject.toml in the working"
" directory; the gate may now be testing a downloaded tag instead of"
" this commit"
)
def test_installer_changes_reach_the_gate():
"""A path that matches no filter starts no job at all, and the push and
pull_request filters have to carry it independently."""
filters = _paths_filters()
for needed in ("install.sh", "scripts/**", "pyproject.toml"):
assert (
filters.count(needed) == 2
), f"{needed} is missing from a paths filter; a change there starts no gate"
def test_the_gate_is_runnable_locally():
"""CI and the local entry point must run the same script; a gate only CI
can run is one nobody iterates on."""
justfile = JUSTFILE.read_text(encoding="utf-8")
assert re.search(r"^install-gate distro=", justfile, re.M)
assert "scripts/install-test.sh" in justfile
def test_the_smoke_checks_what_the_installer_claims_to_have_created():
"""Asserting exit 0 alone would pass on an installer that silently
installed nothing. These are the artefacts install.sh reports creating."""
gate = _gate_text()
for construct in (
r'\[ -x "\$VENV/bin/python" \]',
r'\[ -x "\$INSTALL_HOME/\.local/bin/vocalinux" \]',
r'\[ -f "\$INSTALL_HOME/\.local/share/applications/vocalinux\.desktop" \]',
r"'\$INSTALL_HOME/\.local/bin/vocalinux' --version",
):
assert re.search(construct, gate), f"the smoke no longer asserts {construct}"
def test_the_smoke_checks_what_an_import_walk_cannot_see():
"""Two blind spots of a Python-only smoke: the injection backends are
reached through subprocess, and keyboard_backends imports evdev and pynput
under `except ImportError`, so both stay green when the binaries are absent
or neither backend built."""
gate = _gate_text()
assert re.search(
r"^for tool in xclip xsel wl-copy; do", gate, re.M
), "the smoke no longer checks the injection binaries install.sh promises"
assert re.search(r"^\s*command -v ibus-daemon\b", gate, re.M), (
"the smoke no longer checks the IBus runtime, so trading the openSUSE"
" or arch package for a typelib-only one would pass again"
)
assert re.search(r"^assert EVDEV_AVAILABLE or PYNPUT_AVAILABLE", gate, re.M), (
"the smoke imports keyboard_backends without asserting a backend is"
" available, which is the failure the suse compiler packages fix"
)