Files
voculinux/tests/test_void_app_template.py
T
soubarnak bd7b624f50 feat(void): add package smoke gate across all templates
Ticket xavierk/voculinux#28, needs #27. One documented command
(packaging/void/smoke.sh, just void-smoke) lints, builds, installs
and smoke-tests every template under packaging/void/srcpkgs in
dependency order in the pinned Void container via podman, failing
fast naming the package with the harness echoing each stage.

The gate caught three latent breaks in the per-package harness and
the app template, fixed here:
- container-main.sh scoped the libggml scan to python3-pywhispercpp
  and gave the leaf packages their own import smokes (pynput by
  presence: no display in the container), failing loudly for
  packages without a smoke recipe;
- voculinux template drops the deprecated wrksrc override (xbps-src
  renames the single-top-dir archive itself; xlint rejects wrksrc)
  and builds the PEP 517 wheel (the sdist ships no setup.py);
- post_install promotes the pre-rename sdist names (desktop entry
  content, icons, /usr/bin launchers) to the renamed identity, and
  the payload smoke rejects pre-rename leftovers in OS-visible paths.

Offline policy tests (tests/test_void_smoke_gate.py) pin the gate to
the template set, the app-last order, podman-only pinned inputs and
per-package failure identity; the #27 policy tests and the rename
sweep cover the harness and template contracts.
2026-11-06 05:57:37 +05:30

399 lines
17 KiB
Python

"""Offline policy guards for the Void application template (ticket #27).
Spec #16 ships Voculinux itself as a Void package: an in-repo xbps
template in void-packages layout under ``packaging/void`` builds the app
from the canonical, checksum-verified release source distribution,
depends on Void's own interpreter, GTK, desktop bindings and packaged
Python libraries plus the dependency templates from tickets #25 and #26,
and installs the renamed desktop entry and icons. Speech models are not
package payload, nothing is vendored, and no pip runs at build or
install time.
Independent sources of truth: ``src/vocalinux/version.py`` owns the
version, ``pyproject.toml`` owns the dependency ranges, ``MANIFEST.in``
plus the renamed ``voculinux.desktop`` own the install paths, and the
GitHub release publish path (``releases/download``) plus ``xbps-src``
conventions own the fetch shape. The template must agree with all of
them; the container smoke gate (ticket #28) builds on top.
"""
import re
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
VOID = REPO_ROOT / "packaging" / "void"
SRC = VOID / "srcpkgs"
TEMPLATE = SRC / "voculinux" / "template"
INSTALL_MSG = SRC / "voculinux" / "INSTALL.msg"
PINS = VOID / "tool_checksums.txt"
BUILD_SH = VOID / "build.sh"
CONTAINER_MAIN = VOID / "container-main.sh"
VERSION_PY = REPO_ROOT / "src" / "vocalinux" / "version.py"
PYPROJECT = REPO_ROOT / "pyproject.toml"
DISTRO_MAP = REPO_ROOT / "scripts" / "distro-package-map.yaml"
DISTRO_DOC = REPO_ROOT / "docs" / "DISTRO_COMPATIBILITY.md"
#: pip name (lowercase, dashes) -> the Void binary package providing it.
#: pywhispercpp/pynput/PyAudio come from the ticket #25/#26 templates in
#: this same tree; the rest are Void's own packages.
PIP_TO_VOID = {
"pywhispercpp": "python3-pywhispercpp",
"pynput": "python3-pynput",
"evdev": "python3-evdev",
"requests": "python3-requests",
"pysocks": "python3-pysocks",
"numpy": "python3-numpy",
"pyaudio": "python3-PyAudio",
"pygobject": "python3-gobject",
"psutil": "python3-psutil",
}
#: Void packages beyond the direct pip mapping: pywhispercpp's own
#: install_requires (tqdm, platformdirs), pynput's X11 backend (xlib),
#: the directly imported cairo binding, and the desktop substrate.
EXTRA_VOID_DEPS = (
"python3-tqdm",
"python3-platformdirs",
"python3-xlib",
"python3-cairo",
"python3",
"gtk+3",
"libayatana-appindicator",
"ibus",
"gobject-introspection",
)
#: Every package name the app template may reference, verified to exist in
#: void-packages master on 2026-11-06 via the srcpkgs/<name>/template
#: paths (python3-devel is a subpackage of the python3 template, as
#: recorded for ticket #25). Re-verify with `xbps-query -Rs <name>` on a
#: Void system before extending this set.
VERIFIED_VOID_PACKAGES = frozenset(
{
"python3",
"python3-devel",
"python3-setuptools",
"python3-gobject",
"python3-cairo",
"gtk+3",
"libayatana-appindicator",
"ibus",
"gobject-introspection",
"python3-numpy",
"python3-requests",
"python3-pysocks",
"python3-psutil",
"python3-evdev",
"python3-xlib",
"python3-tqdm",
"python3-platformdirs",
"python3-PyAudio",
"python3-pynput",
"python3-pywhispercpp",
}
)
#: Template options that switch off dependency verification. The app
#: resolves every dependency deliberately through the mapping above,
#: never by hiding it behind one of these.
FORBIDDEN_VARS = ("noverifypydeps", "nopyprovides")
def _template_text() -> str:
"""Read the raw template file."""
assert TEMPLATE.is_file(), f"missing Void app template: {TEMPLATE}"
return TEMPLATE.read_text(encoding="utf-8")
def _template_vars() -> dict:
"""Parse top-level VAR=value assignments from the template file."""
variables = {}
in_function = False
for line in _template_text().splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if re.fullmatch(r"[A-Za-z0-9_+-]+\(\)\s*\{", stripped):
in_function = True
continue
if in_function:
if stripped == "}":
in_function = False
continue
key, separator, value = stripped.partition("=")
if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
variables[key] = value
return variables
def _code_lines() -> str:
"""Template with comment lines stripped; behaviour lives in code."""
return "\n".join(
line for line in _template_text().splitlines() if not line.lstrip().startswith("#")
)
def _post_install_body() -> str:
"""Shell lines inside post_install(), without comments."""
lines = _template_text().splitlines()
start = next(i for i, line in enumerate(lines) if line.strip().startswith("post_install()"))
body = []
for line in lines[start + 1 :]:
if line.strip() == "}":
break
if line.strip() and not line.lstrip().startswith("#"):
body.append(line.strip())
return "\n".join(body)
def _app_version() -> str:
"""Version owned by src/vocalinux/version.py."""
match = re.search(r'^__version__ = "([^"]+)"', VERSION_PY.read_text(encoding="utf-8"), re.M)
assert match, "version.py no longer defines __version__"
return match.group(1)
def _project_dependencies() -> list:
"""Full dependency clauses from pyproject.toml."""
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
return list(data["project"]["dependencies"])
def test_app_template_lives_in_void_packages_layout() -> None:
"""The app template sits where xbps-src expects it: srcpkgs/<pkgname>."""
assert TEMPLATE.is_file(), f"{TEMPLATE} is missing"
assert INSTALL_MSG.is_file(), f"{INSTALL_MSG} is missing"
def test_pkgname_matches_template_directory() -> None:
"""Void requires pkgname to match srcpkgs/<pkgname>, using the new name."""
assert _template_vars()["pkgname"] == TEMPLATE.parent.name == "voculinux"
def test_mandatory_void_variables_are_present() -> None:
"""The Manual's mandatory variables plus fetch inputs must be set."""
variables = _template_vars()
for key in (
"pkgname",
"version",
"revision",
"short_desc",
"maintainer",
"license",
"homepage",
"build_style",
"hostmakedepends",
"makedepends",
"depends",
"distfiles",
"checksum",
):
assert variables.get(key), f"template is missing {key}"
# The release sdist ships no setup.py (setuptools pyproject-only), so
# xbps-src must build the PEP 517 wheel, not run setup.py directly.
assert variables["build_style"] == "python3-pep517"
assert "python3-setuptools" in variables["hostmakedepends"].split()
assert "python3-devel" in variables["makedepends"].split()
assert variables["revision"] == "1", "new templates start at revision 1"
def test_template_metadata_shape() -> None:
"""Maintainer, license, homepage and description follow the Manual."""
variables = _template_vars()
assert re.fullmatch(r".+ <[^@<>\s]+@[^@<>\s]+>", variables["maintainer"])
assert "noreply" not in variables["maintainer"]
assert variables["license"] == "AGPL-3.0-only"
assert variables["homepage"] == "https://github.com/VocaHQ/vocalinux"
assert len(variables["short_desc"]) <= 72
assert re.fullmatch(r"[0-9][A-Za-z0-9.]*", variables["version"])
def test_template_version_matches_version_py() -> None:
"""The template builds the current source version, not a stale one."""
assert _template_vars()["version"] == _app_version()
def test_distfiles_sources_the_canonical_release_by_checksum() -> None:
"""One release-asset distfile, checksum-pinned; never a git snapshot."""
variables = _template_vars()
distfiles = variables["distfiles"]
version = variables["version"]
assert (
"https://github.com/VocaHQ/vocalinux/releases/download/v${version}/" in distfiles
), "distfiles must use the canonical release distribution for this version"
assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball"
for moving in ("/master", "/main", "/archive/", "git+"):
assert moving not in distfiles, f"distfiles must not track a moving ref: {moving}"
assert re.fullmatch(r"[0-9a-f]{64}", variables["checksum"]), "checksum must be sha256"
# The unpacked top-level directory must be where the template builds.
# xbps-src moves a single-top-dir archive to pkgname-version itself
# (common/hooks/do-extract/00-distfiles.sh), so the pre-rename archive
# root needs no override — and wrksrc is deprecated, xlint rejects it.
root = Path(distfiles.rsplit("/", 1)[1].replace("${version}", version))
assert root.suffixes[-2:] == [".tar", ".gz"]
assert (
root.name[: -len(".tar.gz")] != f"{variables['pkgname']}-{version}"
), "the release asset is still rooted under the pre-rename archive name"
assert "wrksrc" not in variables, "deprecated wrksrc override: xlint fails the template"
def test_every_project_dependency_is_covered() -> None:
"""pyproject.toml runtime deps plus substrate must all resolve to depends."""
depends = set(_template_vars()["depends"].split())
uncovered = []
for clause in _project_dependencies():
pip_name = re.match(r"^([A-Za-z0-9._-]+)", clause).group(1).lower().replace("_", "-")
void_name = PIP_TO_VOID.get(pip_name)
assert void_name, f"no Void mapping recorded for {pip_name}"
if void_name not in depends:
uncovered.append(f"{pip_name} -> {void_name}")
missing_extra = [name for name in EXTRA_VOID_DEPS if name not in depends]
assert not uncovered, f"deps with no Void coverage: {uncovered}"
assert not missing_extra, f"substrate missing from depends: {missing_extra}"
def test_template_dependencies_exist_in_void_packages() -> None:
"""Template dependency lists name only verified void-packages names."""
variables = _template_vars()
referenced = set()
for key in ("depends", "hostmakedepends", "makedepends", "checkdepends"):
for token in variables.get(key, "").split():
if token.startswith("${"):
continue
referenced.add(token)
unknown = sorted(referenced - VERIFIED_VOID_PACKAGES)
assert not unknown, f"template references unverified packages: {unknown}"
def test_no_stale_indicator_library_name() -> None:
"""The pre-rename libappindicator names do not exist in void-packages."""
variables = _template_vars()
for key in ("depends", "makedepends", "hostmakedepends"):
for token in variables.get(key, "").split():
assert token not in ("libappindicator", "libappindicator-gtk3"), (
f"{key} names {token}, which has no void-packages template; "
"the Ayatana fork (libayatana-appindicator) is the provider"
)
def test_no_pip_or_vendored_payload_at_build_or_install() -> None:
"""The build installs from the sdist; pip never runs, nothing is vendored."""
code = _code_lines().lower()
for forbidden in ("pip install", "pip3 install", "pip download", "requirements", ".whl"):
assert forbidden not in code, f"template runs pip or vendors wheels: {forbidden}"
assert "venv" not in code
body = _post_install_body().lower()
assert "pip" not in body, "post_install must not invoke pip"
def test_package_ships_no_speech_models() -> None:
"""Speech models download on first run; they are not package payload."""
code = _code_lines().lower()
assert "model" not in code, "models are not package payload"
def test_post_install_installs_the_renamed_paths_from_the_old_sdist() -> None:
"""The 0.18.5 sdist predates the rename, so post_install promotes the
old names at install time: the entry lands as voculinux.desktop with
rewritten Name/Exec/Icon, and old-prefixed icons land renamed."""
body = _post_install_body()
assert "vlicense LICENSE" in body
assert "vocalinux.desktop" in body, "the sdist entry still carries the old name"
assert "voculinux.desktop" in body and "usr/share/applications" in body
assert "s/Vocalinux/Voculinux/" in body, "the entry Name must be rewritten"
assert "s/vocalinux/voculinux/" in body, "the entry Exec/Icon must be rewritten"
assert "resources/icons/scalable" in body
assert "usr/share/icons/hicolor/scalable/apps" in body
assert "s/^vocalinux/voculinux/" in body, "old-prefixed icons must land renamed"
assert "usr/bin/vocalinux" in body, "the sdist launchers still carry the old names"
assert "usr/bin/voculinux" in body, "the launchers must land under the new names"
assert "usr/share/voculinux/packaged" in body
def test_build_fetches_nothing_beyond_the_sdist() -> None:
"""Only the homepage and the declared distfile may appear as URLs."""
text = _template_text()
assert text.count("https://") == 2, "homepage + distfiles are the only URLs a template needs"
assert "FetchContent" not in text and "ExternalProject" not in text
def test_no_verification_disabling_options() -> None:
"""Dependency verification stays on; distfiles stay checksum-pinned."""
variables = _template_vars()
for key in FORBIDDEN_VARS:
assert key not in variables, f"template sets {key}"
assert re.fullmatch(r"[0-9a-f]{64}", variables["checksum"])
def test_template_style_is_lint_clean() -> None:
"""Trailing whitespace and a missing final newline fail xlint."""
raw = TEMPLATE.read_bytes().decode("utf-8")
for lineno, line in enumerate(raw.splitlines(), 1):
assert line == line.rstrip(), f"trailing whitespace on line {lineno}"
assert "\t" not in line or line.startswith("\t"), f"stray tab on line {lineno}"
assert raw.endswith("\n"), "template must end with a newline"
def test_install_msg_covers_the_xbps_only_facts() -> None:
"""INSTALL.msg tells the user what xbps cannot express."""
assert INSTALL_MSG.is_file(), f"missing install message: {INSTALL_MSG}"
text = INSTALL_MSG.read_text(encoding="utf-8").lower()
assert "input" in text, "the evdev hotkey backend needs the input group"
assert "model" in text, "models download on first run"
for engine in ("vosk", "whisper", "parakeet", "faster-whisper", "onnxruntime"):
assert engine in text, f"optional-engine status must name {engine}"
assert "ydotool" in text, "the absent injection helper must be named"
def test_install_msg_line_length() -> None:
"""The Manual asks for at most 80 columns per line in INSTALL.msg."""
raw = INSTALL_MSG.read_bytes().decode("utf-8")
for lineno, line in enumerate(raw.splitlines(), 1):
assert len(line) <= 80, f"line {lineno} exceeds 80 columns"
assert raw.endswith("\n"), "INSTALL.msg must end with a newline"
def test_container_harness_covers_the_app_package() -> None:
"""build.sh stays podman-only; container-main smokes the installed app."""
build = BUILD_SH.read_text(encoding="utf-8")
assert "podman" in build, "the build harness must use podman"
assert "docker" not in build.lower().replace("never docker", "")
harness = CONTAINER_MAIN.read_text(encoding="utf-8")
assert "voculinux --version" in harness, "the app smoke checks the entry point"
assert "vocalinux.ui.tray_indicator" in harness, "the smoke imports the desktop modules"
assert (
"vocalinux.speech_recognition.recognition_manager" in harness
), "the smoke imports the speech modules"
assert "usr/share/applications/voculinux.desktop" in harness
assert "usr/share/icons/hicolor/scalable/apps/voculinux.svg" in harness
assert "usr/share/voculinux/packaged" in harness
assert (
"no pre-rename leftovers" in harness
), "the payload check must reject pre-rename file names in the package"
assert "python3-pywhispercpp" in harness or "PKGNAME" in harness
def test_void_distro_inventory_matches_void_packages_reality() -> None:
"""The installer inventory names the Ayatana provider and marks ydotool absent."""
text = DISTRO_MAP.read_text(encoding="utf-8")
void_block = text.split(" void:", 1)[1].split("\n solus:", 1)[0]
code = "\n".join(line for line in void_block.splitlines() if not line.lstrip().startswith("#"))
assert "libayatana-appindicator" in code
assert "libappindicator-gtk3" not in code
assert "libappindicator " not in code.replace("libayatana-appindicator", "")
assert "absent" in void_block, "the ydotool absence must be marked in the inventory"
def test_void_install_docs_name_the_ayatana_provider() -> None:
"""The Void install snippet must install a package that exists."""
text = DISTRO_DOC.read_text(encoding="utf-8")
void_section = text.split("### Void Linux", 1)[1].split("###", 1)[0]
assert "libayatana-appindicator" in void_section
assert "libappindicator-gtk3" not in void_section
assert "libappindicator " not in void_section.replace("libayatana-appindicator", "")