The fork takes its own name everywhere a user can see it: binaries (voculinux, voculinux-gui), desktop entry, autostart, tray, D-Bus namespace (com.voculinux.Voculinux, no alias for the old name), config/data/cache dirs, single-instance lock, IBus engine identity, tray icons, resource prefixes, VOCULINUX_* environment names, and the update-checker user agent. The internal Python module stays vocalinux and the distribution metadata keeps the upstream URLs as provenance. On first run a previous Vocalinux install's config.json, custom dictionary, and portal restore token are copied once into the new locations (new config always wins, no merge, no write-back); models re-resolve through the pinned-checksum download path. An offline naming-sweep test proves no old-name strings remain on user-visible surfaces outside the allowlist. Refs #18
235 lines
10 KiB
Python
235 lines
10 KiB
Python
"""Structural tests for Snap packaging (issue #48)."""
|
|
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
SNAPCRAFT_YAML = REPO_ROOT / "snap" / "snapcraft.yaml"
|
|
DESKTOP_FILE = REPO_ROOT / "snap" / "gui" / "voculinux.desktop"
|
|
SNAP_PNG = REPO_ROOT / "snap" / "gui" / "voculinux.png"
|
|
|
|
|
|
def test_snapcraft_recipe_and_gui_assets() -> None:
|
|
assert SNAPCRAFT_YAML.is_file()
|
|
doc = yaml.safe_load(SNAPCRAFT_YAML.read_text(encoding="utf-8"))
|
|
assert doc["name"] == "voculinux"
|
|
assert doc["base"] == "core24"
|
|
assert doc["summary"] == "Free offline voice dictation for Linux"
|
|
assert doc["license"] == "AGPL-3.0-only"
|
|
assert doc["icon"] == "snap/gui/voculinux.png"
|
|
assert doc["website"] == "https://voculinux.com"
|
|
assert doc["confinement"] == "strict"
|
|
assert doc["grade"] == "stable"
|
|
|
|
plugs = set((doc.get("apps") or {}).get("voculinux", {}).get("plugs") or [])
|
|
assert "raw-input" in plugs
|
|
assert "hardware-observe" in plugs
|
|
assert "audio-record" in plugs
|
|
# The Store denied the super-privileged uinput grant; the plug (and the
|
|
# uinput-only ydotool) must stay out or every revision is held in review.
|
|
assert "uinput" not in plugs
|
|
stage = doc["parts"]["voculinux"].get("stage-packages") or []
|
|
assert "ydotool" not in stage
|
|
|
|
recipe_text = SNAPCRAFT_YAML.read_text(encoding="utf-8")
|
|
assert "sudo snap connect voculinux:raw-input" in recipe_text
|
|
assert "sudo snap connect voculinux:hardware-observe" in recipe_text
|
|
|
|
assert DESKTOP_FILE.is_file()
|
|
assert SNAP_PNG.is_file()
|
|
assert SNAP_PNG.stat().st_size > 0
|
|
|
|
|
|
def test_snap_puts_gnome_platform_first_on_ld_library_path() -> None:
|
|
"""core24 gdk-pixbuf finds libpixbufloader_svg.so only if gnome-platform wins.
|
|
|
|
Stage-packages pull a second gdk-pixbuf (no SVG loader) into
|
|
$SNAP/usr/lib/<triplet>. desktop-launch's query-loaders walks the first
|
|
LD_LIBRARY_PATH entry that ends in that triplet, so gnome-platform must
|
|
come first or About-page SVGs fail with "Image type svg is not supported".
|
|
"""
|
|
doc = yaml.safe_load(SNAPCRAFT_YAML.read_text(encoding="utf-8"))
|
|
env = (doc.get("apps") or {}).get("voculinux", {}).get("environment") or {}
|
|
ld_path = env.get("LD_LIBRARY_PATH")
|
|
assert isinstance(ld_path, str)
|
|
assert ld_path.startswith("$SNAP/gnome-platform/usr/lib/$CRAFT_ARCH_TRIPLET:")
|
|
assert ld_path.endswith(":$LD_LIBRARY_PATH")
|
|
|
|
|
|
def test_snap_docs_tell_users_to_connect_hardware_observe() -> None:
|
|
"""Hotkeys need hardware-observe for /proc/bus/input/devices (#857)."""
|
|
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
|
install = (REPO_ROOT / "docs" / "INSTALL.md").read_text(encoding="utf-8")
|
|
snapcraft = SNAPCRAFT_YAML.read_text(encoding="utf-8")
|
|
for text in (readme, install, snapcraft):
|
|
assert "sudo snap connect voculinux:raw-input" in text
|
|
assert "sudo snap connect voculinux:hardware-observe" in text
|
|
|
|
|
|
def test_snap_docs_explain_typing_without_uinput_plug() -> None:
|
|
"""The Store denied the uinput grant; docs must point at the portal, not a connect."""
|
|
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
|
install = (REPO_ROOT / "docs" / "INSTALL.md").read_text(encoding="utf-8")
|
|
update = (REPO_ROOT / "docs" / "UPDATE.md").read_text(encoding="utf-8")
|
|
changelog = (REPO_ROOT / "web" / "src" / "app" / "changelog" / "page.tsx").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
snapcraft = SNAPCRAFT_YAML.read_text(encoding="utf-8")
|
|
for text in (readme, install, update, changelog, snapcraft):
|
|
uinput_lines = "\n".join(line for line in text.splitlines() if "uinput" in line.lower())
|
|
lowered = uinput_lines.lower()
|
|
assert "uinput" in lowered
|
|
assert "plug" in lowered and "no" in lowered
|
|
assert "portal" in lowered
|
|
|
|
|
|
def test_snap_preserves_metadata_and_uses_gnome_gi() -> None:
|
|
"""GI comes from GNOME without rewriting project dependencies."""
|
|
text = SNAPCRAFT_YAML.read_text(encoding="utf-8")
|
|
doc = yaml.safe_load(text)
|
|
part = doc["parts"]["voculinux"]
|
|
assert part["plugin"] == "nil"
|
|
assert "python-packages" not in part
|
|
assert "sed -i" not in part["override-pull"]
|
|
assert "snap/local/build.py" in part["override-build"]
|
|
assert "craftctl default" not in part["override-build"]
|
|
stage = doc["parts"]["voculinux"].get("stage-packages") or []
|
|
assert "python3-gi" not in stage
|
|
assert "python3-gi-cairo" not in stage
|
|
|
|
|
|
def test_release_attaches_snap_then_tries_the_store() -> None:
|
|
"""A Store human review must not swallow the GitHub .snap (v0.17.0, uinput)."""
|
|
text = (REPO_ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
|
|
assert " build-snap:\n" in text
|
|
assert " attach-snap:\n" in text
|
|
assert " publish-snap:\n" in text
|
|
|
|
build = text.split(" build-snap:\n", 1)[1].split("\n attach-snap:", 1)[0]
|
|
assert "snapcore/action-build@" in build
|
|
assert "upload-artifact" in build
|
|
assert "name: snap-amd64" in build
|
|
assert "gh release upload" not in build
|
|
assert "action-publish" not in build
|
|
|
|
attach = text.split(" attach-snap:\n", 1)[1].split("\n publish-snap:", 1)[0]
|
|
assert "needs: [build-and-release, build-snap]" in attach
|
|
assert "gh release upload" in attach
|
|
assert "dist/*.snap" in attach
|
|
assert "--clobber" in attach
|
|
assert "contents: write" in attach
|
|
assert "TAG: ${{ github.ref_name }}" in attach
|
|
assert 'gh release upload "$TAG"' in attach
|
|
assert '"${{ github.ref_name }}"' not in attach
|
|
|
|
store = text.split(" publish-snap:\n", 1)[1].split("\n deploy-website:", 1)[0]
|
|
assert "needs: [build-snap, build-and-release]" in store
|
|
assert "--release edge,candidate" in store
|
|
assert "release: stable" not in store
|
|
assert "SNAPCRAFT_STORE_CREDENTIALS is unset; cannot publish the snap" in store
|
|
assert "will need manual review" in store
|
|
assert "allow-installation" in store
|
|
assert "continue-on-error" not in store
|
|
assert "snapcore/action-publish" not in store
|
|
assert "SNAP_FILE:" in store
|
|
assert 'snapcraft upload "$SNAP_FILE"' in store
|
|
assert "gh release upload" not in store
|
|
assert "action-gh-release" not in store
|
|
|
|
|
|
def test_release_notes_document_snap_sideload() -> None:
|
|
body = (REPO_ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
|
|
assert "snap install --dangerous" in body
|
|
assert "RemoteDesktop portal" in body
|
|
assert "no `uinput` plug" in body
|
|
assert "voculinux_${{ steps.get_version.outputs.VERSION }}_amd64.snap" in body
|
|
|
|
|
|
def test_snap_backfill_workflow_attests_only_the_snap() -> None:
|
|
"""Existing tags (v0.17.0) need a dispatch that does not re-attest AppImages."""
|
|
path = REPO_ROOT / ".github" / "workflows" / "snap-backfill.yml"
|
|
text = path.read_text(encoding="utf-8")
|
|
assert "workflow_dispatch:" in text
|
|
assert "snapcore/action-build@" in text
|
|
assert "gh release upload" in text
|
|
assert "SHA256SUMS" in text
|
|
assert "actions/attest-build-provenance@" in text
|
|
assert "subject-path: dist/${{ steps.stage.outputs.name }}" in text
|
|
assert "subject-checksums:" not in text
|
|
assert " verify:\n" in text
|
|
assert "scripts/verify_release.py" in text
|
|
assert 'python3 scripts/verify_release.py "$TAG"' in text
|
|
|
|
|
|
def test_snap_backfill_dispatch_input_never_reaches_the_shell() -> None:
|
|
"""Same rule as verify-release.yml: interpolating the tag into run: is injection."""
|
|
import re
|
|
|
|
text = (REPO_ROOT / ".github" / "workflows" / "snap-backfill.yml").read_text(encoding="utf-8")
|
|
run_lines = [line for line in text.splitlines() if re.match(r"^\s*-?\s*run:", line)]
|
|
assert run_lines, "found no run: step, so this guard is scanning nothing"
|
|
for line in run_lines:
|
|
assert "${{" not in line, f"a run: step interpolates a template expression: {line.strip()}"
|
|
assert "TAG: ${{ inputs.tag }}" in text
|
|
assert 'gh release upload "$TAG"' in text
|
|
|
|
|
|
def test_snap_promote_dispatch_gates_stable_on_candidate() -> None:
|
|
"""stable is the gated option of #783: a manual dispatch releasing the candidate revision."""
|
|
text = (REPO_ROOT / ".github" / "workflows" / "snap-promote.yml").read_text(encoding="utf-8")
|
|
assert "workflow_dispatch:" in text
|
|
assert 'snapcraft release voculinux "$REVISION" latest/stable' in text
|
|
assert "SNAPCRAFT_STORE_CREDENTIALS is unset; cannot promote the snap" in text
|
|
assert "environment:" in text
|
|
# The promote must prove the candidate revision carries the tag's version.
|
|
assert "snapcraft status voculinux" in text
|
|
assert 'if [ "$seen_version" != "$VERSION" ]' in text
|
|
|
|
|
|
def test_snap_promote_dispatch_input_never_reaches_the_shell() -> None:
|
|
"""Same rule as snap-backfill.yml: interpolating the tag into run: is injection."""
|
|
import re
|
|
|
|
text = (REPO_ROOT / ".github" / "workflows" / "snap-promote.yml").read_text(encoding="utf-8")
|
|
run_lines = [line for line in text.splitlines() if re.match(r"^\s*-?\s*run:", line)]
|
|
assert run_lines, "found no run: step, so this guard is scanning nothing"
|
|
for line in run_lines:
|
|
assert "${{" not in line, f"a run: step interpolates a template expression: {line.strip()}"
|
|
assert "TAG: ${{ inputs.tag }}" in text
|
|
assert "REVISION: ${{ steps.candidate.outputs.revision }}" in text
|
|
|
|
|
|
#: ggml SIMD options pinned off for the portable x86-64 baseline in every
|
|
#: packaged pywhispercpp build (#957). GGML_NATIVE=OFF alone leaves each of
|
|
#: these on while ggml's native default is on.
|
|
PORTABLE_GGML_OPTIONS = (
|
|
"GGML_NATIVE",
|
|
"GGML_SSE42",
|
|
"GGML_AVX",
|
|
"GGML_AVX2",
|
|
"GGML_BMI2",
|
|
"GGML_FMA",
|
|
"GGML_F16C",
|
|
)
|
|
|
|
|
|
def test_snap_builds_pywhispercpp_at_the_portable_baseline() -> None:
|
|
"""pywhispercpp's PyPI wheel ships a libggml-cpu compiled for AVX2+FMA,
|
|
which dies with SIGILL at model load on pre-Haswell CPUs (#957). The
|
|
python plugin installs that wheel by default, so the recipe must force an
|
|
sdist build with every ggml SIMD option pinned off — GGML_NATIVE=OFF
|
|
alone leaves them all on."""
|
|
doc = yaml.safe_load(SNAPCRAFT_YAML.read_text(encoding="utf-8"))
|
|
env: dict = {}
|
|
for entry in doc["parts"]["voculinux"].get("build-environment") or []:
|
|
env.update(entry)
|
|
build = (REPO_ROOT / "snap/local/build.py").read_text()
|
|
assert '"--no-binary=pywhispercpp"' in build
|
|
assert '"--no-cache-dir"' in build
|
|
cmake_args = env.get("CMAKE_ARGS") or ""
|
|
for flag in PORTABLE_GGML_OPTIONS:
|
|
assert (
|
|
f"-D{flag}=OFF" in cmake_args
|
|
), f"the snap's pywhispercpp build does not pin {flag} off"
|