Files
voculinux/tests/test_appimage_packaging.py
T
Jatin K MalikandGitHub fb9166ed9e feat(settings): run local VocaGateway from Advanced (podman-first) (#774)
* feat(settings): run local VocaGateway from Speech Engine (podman-first)

Add an optional Local VocaGateway panel next to Remote Server so Linux users
can start the pinned v0.1.0 compose project with podman (docker fallback),
watch Live/Pairable/Ready status, pair a phone without loopback QR URLs, and
preset remote_api to the gateway transcriptions endpoint. Stop preserves
volumes; Flatpak fails closed; tray offers Stop only when this session manages
the gateway. Default on-device engine stays whisper.cpp.

* fix(gateway-embed): drop nonexistent compose cpu profile

VocaGateway v0.1.0 exposes default service gateway plus native/cuda/vulkan
profiles, not cpu. Start and stop the default service without --profile, keep
the latest image pin rejection, and prefer save_config for the remote_api preset.

* fix(gateway-embed): keep compose --profile cpu per embed contract

v0.1.0 has no cpu profile entry, but Compose still starts the unprofiled
gateway service when --profile cpu is set, and keeps cuda/vulkan/native off.
That matches the frozen desktop embed contract and stays forward-compatible.

* fix(gateway-embed): harden embed path for settings and compose

Keep podman/docker probing off the GTK thread, reject link-local and
loopback public URLs for phone QR, sanitize compose .env image overrides,
and cap bootstrap token reads so a hostile file cannot blow memory.

* fix(gateway-embed): keep LAN toggle and phone QR in sync

When Allow LAN flips while compose is already managed, rewrite publish
host and PUBLIC_URL and recreate the gateway before Pairable comes back.
Also reject default docker/podman bridge addresses for phone QR.

* test(gateway-embed): pin bridge and LAN-gate pairing cases

Cover 172.17.0.2 / link-local not becoming Pairable, and the mid-run
LAN toggle path that must wait for compose republish before advertising
a phone URL.

* style(gateway-embed): black format embed modules

* fix(gateway-embed): reject short-form loopback and localhost.* for QR

Align Pairable/QR host checks with vocagateway desktop-embed policy so
127.1 / localhost.* never become phone-reachable display URLs.

* fix(gateway-embed): detach Settings status listener on destroy

Gateway poll callbacks were left registered after Settings closed, so
idle_add could touch destroyed GTK widgets while compose stayed up.

* fix(gateway-embed): pass host_env to bridge ip probe

_bridge_iface_ips shells out to host ip; without host_env the AST
gate fails and AppImage bundles can hand the wrong GLib to ip.

* fix(packaging): ship gateway_embed in the AppImage

The explicit setuptools packages list omitted the new package, so the
bundle booted from source trees but died with ModuleNotFoundError on
every distro boot test.

* style(packaging): black the gateway_embed package guard

* fix(settings): pack local VocaGateway into the Advanced island

The rebase onto main auto-merged textually onto the simple Speech Model
page. Keep the gateway card on advanced_box, next to Remote Server.

* fix(gateway-embed): treat missing Compose as unavailable

When podman or docker is present but Compose is not, stop advertising Run.
Return empty compose_args, show an install-Compose hint, and cover it with a unit test.

Also add module loggers on the new gateway_embed modules and annotate the
gateway Settings/tray callbacks Greptile flagged. just lock found no lockfile drift.

* fix(gateway-embed): keep Allow LAN bind matched to the switch

Snapshot the LAN flag for start/republish, record what compose actually
got, then republish again if the switch moved mid-flight so 0.0.0.0 never
lingers while Allow LAN reads off.

* fix(gateway-embed): detect leftover compose after Quit

Quit does not stop compose, so a LAN-published gateway can keep running.
Settings and the tray treated that as Stopped because managed_by_us is
only process memory. Probe compose after runtime detection, show Stop
when it is actually up, and let Stop work even if this session did not
start it.

* fix(gateway-embed): restore LAN switch from leftover compose

Quit leaves compose running, so the next launch now reads
VOCAGATEWAY_PUBLISH_HOST from the embed .env, adopts the project as
managed, and keeps Allow LAN honest so republish can close 0.0.0.0.

* fix(gateway-embed): restore .env if LAN republish fails

compose recreate can fail after write_env_file already pointed
VOCAGATEWAY_PUBLISH_HOST at loopback. Restore the previous .env so
orphan adopt still sees a live 0.0.0.0 bind, and snap Allow LAN back
to the running publish mode.

* style(gateway-embed): type helper args and black MagicMock

* refactor(types): move Readable protocol to common_types

AGENTS.md keeps Protocol interfaces in common_types. Pairing now imports
the shared Readable surface instead of a local _Readable helper.

* fix(gateway-embed): satisfy mypy on rebased embed code

* fix(audio): keep duck restore point when a set reports failure
2026-09-29 15:33:27 -07:00

327 lines
13 KiB
Python

"""Regression guards for AppImage packaging."""
import re
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
APPIMAGE = REPO_ROOT / "packaging" / "appimage"
BUILD_SH = APPIMAGE / "build.sh"
BOOT_TEST_SH = APPIMAGE / "boot-test.sh"
#: Image name prefix -> the token boot-test.sh matches on ($ID / $ID_LIKE).
BOOT_FAMILIES = {
"debian": "debian",
"ubuntu": "ubuntu",
"fedora": "fedora",
"archlinux": "arch",
"opensuse": "suse",
}
PINS = APPIMAGE / "tool_checksums.txt"
REQUIREMENTS = REPO_ROOT / "requirements"
PYPROJECT = REPO_ROOT / "pyproject.toml"
WORKFLOWS = REPO_ROOT / ".github" / "workflows"
#: Workflows that build an AppImage, and so decide which distros it runs on.
APPIMAGE_WORKFLOWS = ("unified-pipeline.yml", "release.yml", "nightly.yml")
def _pins() -> dict:
"""name -> (kind, value, source) from tool_checksums.txt."""
pins = {}
for line in PINS.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
name, kind, value, source = line.split(None, 3)
pins[name] = (kind, value, source.strip())
return pins
def _exported_versions(path: Path) -> dict:
"""package -> version out of a hash-pinned requirements export."""
return {
match.group(1).lower(): match.group(2)
for match in (
re.match(r"^([A-Za-z0-9._-]+)==([^\s;\\]+)", line)
for line in path.read_text(encoding="utf-8").splitlines()
)
if match
}
# Typelibs that must ship in the AppImage. xlib/Dbusmenu/GModule/fontconfig are
# transitive GIR deps — pruning them broke Gtk on openSUSE (#585).
REQUIRED_TYPELIBS = (
"Gtk-3.0",
"Gdk-3.0",
"xlib-2.0",
"GModule-2.0",
"Dbusmenu-0.4",
"fontconfig-2.0",
"Notify-0.7",
)
# Tray stacks are alternates (same order as tray_indicator.py).
INDICATOR_TYPELIBS = (
"AyatanaAppIndicator3-0.1",
"AyatanaAppindicator3-0.1",
"AppIndicator3-0.1",
)
def test_appimage_build_ships_transitive_typelibs():
text = BUILD_SH.read_text()
assert "TYPELIBS=(" in text
# Must not wipe linuxdeploy's typelib set down to a partial allowlist.
assert 'rm -rf "$APPDIR/usr/lib/girepository-1.0"' not in text
assert "keeping linuxdeploy extras" in text or "Ensuring required typelibs" in text
for typelib in REQUIRED_TYPELIBS:
assert typelib in text, f"missing typelib seed {typelib} in {BUILD_SH}"
for typelib in INDICATOR_TYPELIBS:
assert typelib in text, f"missing indicator typelib seed {typelib}"
assert "INDICATOR_TYPELIBS=" in text
assert "indicator_found" in text
assert "Need at least one of:" in text
def test_every_typelib_the_app_requires_is_seeded():
"""A required typelib the bundle does not seed sends GI to the host's search
path — the openSUSE/Fedora breakage from #585."""
seeded = BUILD_SH.read_text(encoding="utf-8")
required = set()
for path in (REPO_ROOT / "src" / "vocalinux").rglob("*.py"):
for namespace, version in re.findall(
r"require_version\(\s*[\"']([\w]+)[\"']\s*,\s*[\"']([\d.]+)[\"']", path.read_text()
):
required.add(f"{namespace}-{version}")
assert required, "no gi.require_version calls found; has the app stopped using GI?"
missing = sorted(name for name in required if name not in seeded)
assert not missing, f"the app requires these typelibs; TYPELIBS does not seed them: {missing}"
def test_bundled_typelibs_come_with_the_library_they_load():
"""Ship a typelib without the library it dlopens and GI loads the host's
copy against the bundle's older GLib. IBus did: on any host with GLib 2.76+
the app died on `undefined symbol: g_task_set_static_name`. The build host
cannot see it — its libraries match what it bundled."""
text = BUILD_SH.read_text(encoding="utf-8")
assert "libibus-1.0.so.5" in text, "the IBus typelib is seeded but its library is not bundled"
assert "verify_typelib_libraries" in text
assert "HOST_PROVIDED_LIBS" in text, (
"the check needs its list of libraries the excludelist keeps on the host, "
"or harfbuzz fails the build"
)
def test_appimage_build_bundles_gi_runtime_libs():
text = BUILD_SH.read_text()
for lib in (
"libappindicator3.so.1",
"libayatana-appindicator3.so.1",
"libdbusmenu-glib.so.4",
"libdbusmenu-gtk3.so.4",
"libnotify.so.4",
):
assert lib in text, f"missing GI runtime lib {lib} in {BUILD_SH}"
def test_appimage_build_smokes_gi_without_host_typelibs():
text = BUILD_SH.read_text()
assert "smoke_gi_imports" in text
assert "unshare --user --mount" in text
def test_appimage_build_rebuilds_pywhispercpp_with_vulkan():
text = BUILD_SH.read_text()
assert "GGML_VULKAN=1" in text
assert "libggml-vulkan" in text
assert "VOCALINUX_APPIMAGE_REQUIRE_VULKAN" in text
assert "VOCALINUX_APPIMAGE_SKIP_VULKAN" in text
assert "rebuild_pywhispercpp_vulkan" in text
assert 'CC="${CC:-gcc}"' in text
assert 'CXX="${CXX:-g++}"' in text
def test_pywhispercpp_is_pinned_once_and_the_pins_agree():
"""1.5.1 landed mid-review and broke both AppImage jobs with nothing in the
repo changed. The bundle and the Vulkan rebuild install the same sdist, so
install.sh and the lock export have to agree on which one."""
installer = (REPO_ROOT / "install.sh").read_text(encoding="utf-8")
declared = re.search(r'^PYWHISPERCPP_VERSION="([^"]+)"', installer, re.M)
assert declared, "install.sh no longer declares the version build.sh reads"
exported = _exported_versions(REQUIREMENTS / "vad.txt").get("pywhispercpp")
assert exported == declared.group(1), (
"install.sh and requirements/vad.txt pin different pywhispercpp releases; "
"change pyproject.toml and run `just lock`"
)
build = BUILD_SH.read_text(encoding="utf-8")
assert 'PYWHISPERCPP_VERSION="$(sed' in build, "the pin must come from install.sh"
assert "--no-binary pywhispercpp" in build, "the Vulkan rebuild must build from source"
def test_every_download_goes_through_the_verifying_helper():
"""`continuous` and `master` move under us, and nothing noticed."""
text = BUILD_SH.read_text(encoding="utf-8")
assert (
text.count("curl_retry -o") == 1
), "downloads must go through fetch_pinned, which checks the digest first"
assert "/continuous/" not in text
assert "linuxdeploy-plugin-gtk/master/" not in text
pinned = set(_pins())
for name in set(re.findall(r'fetch_pinned "([^"]+)"', text)):
wanted = (
[name.replace("$ARCH", arch) for arch in ("x86_64", "aarch64")]
if "$ARCH" in name
else [name]
)
for entry in wanted:
assert entry in pinned, f"build.sh fetches '{entry}', which {PINS.name} does not pin"
def test_the_pins_are_digests_rather_than_names():
for name, (kind, value, source) in _pins().items():
if kind == "sha256":
assert re.fullmatch(r"[0-9a-f]{64}", value), f"{name}: not a sha256"
assert source.startswith("https://"), f"{name}: {source}"
assert (
"/continuous/" not in source and "/master/" not in source
), f"{name} points at a moving ref, so its digest is a coincidence"
elif kind == "git":
assert re.fullmatch(r"[0-9a-f]{40}", value), f"{name}: not a commit"
elif kind == "docker":
assert "@sha256:" in value, f"{name}: a tag is not a pin"
elif kind == "version":
assert re.fullmatch(r"\d+(\.\d+)+", value), f"{name}: {value}"
else:
raise AssertionError(f"{name}: unknown pin kind {kind!r}")
def test_the_appimage_is_built_in_the_pinned_base_image():
"""An AppImage cannot run on a glibc older than the one that built it, and
ubuntu-latest (2.39) rules out Debian 12, Ubuntu 22.04 and RHEL 9 — which is
what we shipped as the universal option."""
for workflow in APPIMAGE_WORKFLOWS:
text = (WORKFLOWS / workflow).read_text(encoding="utf-8")
assert (
"packaging/appimage/build.sh" not in text
), f"{workflow} runs build.sh on the runner; go through docker-build.sh"
assert "packaging/appimage/docker-build.sh" in text, workflow
image = _pins()["base-image"][1]
assert "22.04" in _pins()["base-image"][2], (
"raising the base image raises the glibc floor and silently drops distros; "
"if that is intended, update docs/INSTALL.md in the same change"
)
assert image.startswith("docker.io/library/ubuntu@sha256:")
def test_the_bundle_is_installed_from_the_lock():
text = BUILD_SH.read_text(encoding="utf-8")
assert "-m pip install" not in text, "pip resolves at build time; install from the exports"
assert "--require-hashes" in text
for export in ("requirements/vad.txt", "requirements/appimage.txt"):
assert export in text, f"{export} is not what the bundle is built from"
def test_the_vad_export_still_covers_the_runtime_export():
"""build.sh installs vad.txt alone, on the assumption it carries everything
runtime.txt does. Two resolutions that drift would ship a third one."""
runtime = _exported_versions(REQUIREMENTS / "runtime.txt")
vad = _exported_versions(REQUIREMENTS / "vad.txt")
missing = {name: ver for name, ver in runtime.items() if vad.get(name) != ver}
assert not missing, f"requirements/vad.txt no longer covers runtime.txt: {missing}"
def test_pygobject_stays_on_the_line_the_base_image_can_build():
"""3.52 moved to girepository-2.0 (glib 2.80+) and the base image has 2.72,
so bumping this to the lock's version fails the build."""
version = _exported_versions(REQUIREMENTS / "appimage.txt").get("pygobject")
assert version, "requirements/appimage.txt no longer pins PyGObject"
assert tuple(int(part) for part in version.split(".")[:2]) <= (3, 50), version
def test_vulkan_builds_against_pinned_headers_not_the_base_image_ones():
"""ggml-vulkan.cpp does not compile against the base image's headers
(1.3.204): PipelineRobustnessCreateInfoEXT and friends are younger. Only the
compile sees them — the bundle carries no loader."""
assert "vulkan-headers" in _pins(), "the Vulkan headers are not pinned"
assert "-DVulkan_INCLUDE_DIR=" in BUILD_SH.read_text(
encoding="utf-8"
), "the rebuild does not point ggml at the pinned headers"
def test_vulkan_shaders_get_the_compiler_the_base_image_lacks():
"""ggml demands glslc specifically, and Ubuntu 22.04 packages none."""
text = BUILD_SH.read_text(encoding="utf-8")
assert "ensure_glslc" in text
assert "shaderc" in _pins()
for workflow in APPIMAGE_WORKFLOWS:
content = (WORKFLOWS / workflow).read_text(encoding="utf-8")
assert (
"vocalinux-appimage" in content
), f"{workflow} does not cache the built glslc; every run rebuilds it"
def _boot_matrix() -> list:
"""The distros the boot job runs the finished AppImage on."""
workflow = (WORKFLOWS / "unified-pipeline.yml").read_text(encoding="utf-8")
block = re.search(r"\n distro:\n((?:\s+- \S+\n)+)", workflow)
assert block, "unified-pipeline.yml has no boot-test distro matrix"
return re.findall(r"- (\S+)", block.group(1))
def test_the_boot_matrix_covers_both_ends_of_the_range():
"""Built on 22.04, the AppImage can fail in either direction.
Too new a glibc and the old distros cannot start it; too old a GLib and the
new distros' own binaries break when the app spawns them. Both #743
regressions were the second kind, and only a new distro shows them.
"""
distros = _boot_matrix()
assert any(
distro.startswith(("debian:12", "ubuntu:22.04")) for distro in distros
), f"nothing older than the build image in {distros}"
assert any(
distro.startswith(("archlinux", "opensuse", "fedora")) for distro in distros
), f"nothing newer than the build image in {distros}"
def test_boot_test_has_a_package_recipe_for_every_distro_it_runs_on():
"""A matrix entry the script cannot install prerequisites for fails late."""
script = BOOT_TEST_SH.read_text(encoding="utf-8")
case_block = re.search(r"case \"\$\{ID\}.*?esac", script, re.S)
assert case_block, "boot-test.sh no longer dispatches on the distro"
missing = []
for distro in _boot_matrix():
family = next(
(token for prefix, token in BOOT_FAMILIES.items() if distro.startswith(prefix)),
None,
)
if family is None or family not in case_block.group(0):
missing.append(distro)
assert not missing, f"boot-test.sh installs nothing for: {missing}"
def test_setuptools_packages_cover_every_python_subpackage():
"""AppImage and Flatpak install the wheel built from an explicit packages
list. A new subpackage left off that list boots fine from a source tree and
dies in the bundle with ModuleNotFoundError (#774).
"""
declared = set(
tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["tool"]["setuptools"]["packages"]
)
src_root = REPO_ROOT / "src" / "vocalinux"
discovered = {
(
"vocalinux"
if init.parent == src_root
else "vocalinux." + ".".join(init.parent.relative_to(src_root).parts)
)
for init in src_root.rglob("__init__.py")
if "__pycache__" not in init.parts
}
missing = sorted(discovered - declared)
assert not missing, f"add these to tool.setuptools.packages in pyproject.toml: {missing}"