* fix(installer): keep piped script on stdin and prompt via /dev/tty `curl | bash` left bash reading the installer itself from stdin, and the detection block's `exec < /dev/tty` (from #315) closed that pipe about 300 lines into a 3000-line script. curl died with error 23 writing to the closed pipe, and bash read the rest of the installer from the terminal as commands instead of finishing the script. stdin now stays on the pipe and a reachable terminal is recorded in TERMINAL_PROMPTS. Every interactive prompt goes through read_prompt, which reads /dev/tty explicitly only for that read, so user input still works in a terminal while the script body keeps flowing through the pipe. The interactive-mode gate now checks TERMINAL_PROMPTS rather than `-t 0`, which also lets `--interactive` work under a pipe. Regression coverage: the remote-install gate's second run now pipes the bootstrap in under a `script` pty (the literal `cat | bash` shape a curl user takes), and new tests run install.sh in a pty with the script on a pipe to assert the mode and engine prompts still answer on the terminal. Fixes #933 * fix(installer): drain the piped bootstrap before the handoff exec A curl|bash bootstrap leaves its unread tail on stdin; the tagged installer reads its commands from a file, so nothing drained the pipe and a still- streaming curl reported error 23 when the installer exited. Drain stdin to EOF before exec (Greptile P2 on #935). Gate: drip-feed the piped run so the producer is mid-write at the handoff like real curl, and run it under pipefail so a producer killed early fails the run. Tests: pass --tag so a slow release lookup cannot eat the window, wait for the engine prompt before Ctrl+C, and quote the installer path. * test(installer): propagate a killed producer's status in the piped gate A cat dying mid-drip (SIGPIPE when the consumer abandons the pipe) was swallowed by the loop and the final rm, so drip returned success and pipefail could not see the download failure the arm exists to catch. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""
|
|
Test cross-distro compatibility features.
|
|
|
|
This module tests the cross-distribution compatibility features including:
|
|
- Dynamic GI_TYPELIB_PATH detection
|
|
- Wrapper script generation with correct paths
|
|
- Desktop entry configuration
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
def get_repo_root() -> Path:
|
|
"""Get the repository root directory."""
|
|
# Start from current file and traverse up to find repo root
|
|
current = Path(__file__).resolve()
|
|
for parent in [current] + list(current.parents):
|
|
# Check for common repo indicators
|
|
if (parent / ".git").exists() or (parent / "install.sh").exists():
|
|
return parent
|
|
# Also check if we're in a 'vocalinux' subdirectory (CI structure)
|
|
if parent.name == "vocalinux" and (parent.parent / "vocalinux" / "install.sh").exists():
|
|
return parent.parent / "vocalinux"
|
|
# Fallback: use parent of tests directory
|
|
return current.parent.parent.parent
|
|
|
|
|
|
REPO_ROOT = get_repo_root()
|
|
|
|
|
|
class TestCrossDistroCompatibility:
|
|
"""Test cross-distro compatibility features."""
|
|
|
|
@pytest.fixture
|
|
def install_sh_content(self):
|
|
"""Fixture to load the installer entry point and sourced modules."""
|
|
install_sh_path = REPO_ROOT / "install.sh"
|
|
if not install_sh_path.exists():
|
|
pytest.skip(f"install.sh not found at {install_sh_path}")
|
|
parts = [install_sh_path, *sorted((REPO_ROOT / "install.d").glob("*.sh"))]
|
|
return "\n".join(path.read_text(encoding="utf-8") for path in parts)
|
|
|
|
@pytest.fixture
|
|
def workflow_content(self):
|
|
"""Fixture to load workflow content."""
|
|
workflow_path = REPO_ROOT / ".github" / "workflows" / "unified-pipeline.yml"
|
|
if not workflow_path.exists():
|
|
pytest.skip(f"Workflow file not found at {workflow_path}")
|
|
return workflow_path.read_text()
|
|
|
|
def test_detect_typelib_path_function_exists(self, install_sh_content):
|
|
"""Test that detect_typelib_path function exists in install.sh."""
|
|
assert (
|
|
"detect_typelib_path()" in install_sh_content
|
|
), "detect_typelib_path function should exist"
|
|
assert (
|
|
"pkg-config --variable=typelibdir" in install_sh_content
|
|
), "Should use pkg-config for detection"
|
|
|
|
def test_no_hardcoded_gi_typelib_path_in_verification(self, install_sh_content):
|
|
"""Test that package verification uses dynamic GI_TYPELIB_PATH."""
|
|
# The verification function should use the detected path, not hardcoded
|
|
assert (
|
|
"verify_package_installed()" in install_sh_content
|
|
), "verify_package_installed function should exist"
|
|
|
|
# Check that it uses GI_TYPELIB_DETECTED variable
|
|
verify_section = install_sh_content[
|
|
install_sh_content.find("verify_package_installed()") : install_sh_content.find(
|
|
"verify_package_installed()"
|
|
)
|
|
+ 500
|
|
]
|
|
assert (
|
|
"GI_TYPELIB_DETECTED" in verify_section or '"$GI_TYPELIB_DETECTED"' in verify_section
|
|
), "verify_package_installed should use GI_TYPELIB_DETECTED variable"
|
|
|
|
def test_wrapper_script_uses_detected_path(self, install_sh_content):
|
|
"""Test that wrapper scripts use the detected GI_TYPELIB_PATH."""
|
|
# Check that wrapper script creation uses GI_TYPELIB_DETECTED
|
|
assert (
|
|
"export GI_TYPELIB_PATH=$GI_TYPELIB_DETECTED" in install_sh_content
|
|
), "Wrapper script should export GI_TYPELIB_PATH with detected value"
|
|
|
|
def test_desktop_entry_uses_detected_path(self, install_sh_content):
|
|
"""Test that desktop entry uses detected GI_TYPELIB_PATH."""
|
|
# The sed command for desktop entry should use the detected path
|
|
assert (
|
|
"GI_TYPELIB_PATH=$GI_TYPELIB_DETECTED" in install_sh_content
|
|
or r"GI_TYPELIB_PATH=${GI_TYPELIB_DETECTED}" in install_sh_content
|
|
), "Desktop entry sed command should use GI_TYPELIB_DETECTED"
|
|
|
|
# Should NOT have hardcoded /usr/lib/girepository-1.0 in desktop entry section
|
|
desktop_start = install_sh_content.find("install_desktop_entry()")
|
|
if desktop_start == -1:
|
|
pytest.skip("install_desktop_entry function not found")
|
|
desktop_section = install_sh_content[desktop_start : desktop_start + 1000]
|
|
if "sed -i" in desktop_section and "vocalinux.desktop" in desktop_section:
|
|
assert (
|
|
"/usr/lib/girepository-1.0"
|
|
not in desktop_section.split("sed -i")[1].split("vocalinux.desktop")[0]
|
|
), "Desktop entry should not hardcode /usr/lib/girepository-1.0 path"
|
|
|
|
def test_detect_typelib_path_with_pkgconfig(self, install_sh_content):
|
|
"""Test that detect_typelib_path uses pkg-config when available."""
|
|
# Extract the detect_typelib_path function
|
|
func_start = install_sh_content.find("detect_typelib_path() {")
|
|
if func_start == -1:
|
|
pytest.skip("detect_typelib_path function not found")
|
|
func_end = install_sh_content.find("}", func_start)
|
|
func_content = install_sh_content[func_start:func_end]
|
|
|
|
# Should check for pkg-config first
|
|
assert "pkg-config" in func_content, "Should check for pkg-config"
|
|
assert "gobject-introspection-1.0" in func_content, "Should query gobject-introspection-1.0"
|
|
|
|
def test_detect_typelib_path_fallback_paths(self, install_sh_content):
|
|
"""Test that detect_typelib_path has fallback paths for various distros."""
|
|
# Should have fallback paths for different distros
|
|
expected_paths = [
|
|
"/usr/lib/x86_64-linux-gnu/girepository-1.0", # Ubuntu/Debian x86_64
|
|
"/usr/lib/aarch64-linux-gnu/girepository-1.0", # ARM64
|
|
"/usr/lib64/girepository-1.0", # Fedora/RHEL
|
|
"/usr/lib/girepository-1.0", # Arch and fallback
|
|
]
|
|
|
|
for path in expected_paths:
|
|
assert path in install_sh_content, f"Should have fallback path: {path}"
|
|
|
|
def test_opensuse_dependency_mapping_uses_tumbleweed_package_names(self, install_sh_content):
|
|
"""Test that openSUSE uses Tumbleweed-compatible dependency resolution."""
|
|
assert "suse_python_package_prefix()" in install_sh_content
|
|
assert "suse_python_package_candidates()" in install_sh_content
|
|
assert "typelib-1_0-AyatanaAppIndicator3-0_1" in install_sh_content
|
|
assert "typelib-1_0-Notify-0_7" in install_sh_content
|
|
assert "libnotify4" in install_sh_content
|
|
assert "suse_install_appindicator_runtime" in install_sh_content
|
|
zypper_line = next(
|
|
line
|
|
for line in install_sh_content.splitlines()
|
|
if "SYSTEM_PACKAGES=(gtk3 ibus " in line
|
|
)
|
|
zypper_packages = zypper_line.removeprefix("SYSTEM_PACKAGES=(").rstrip(")").split()
|
|
# ibus, not the typelib alone: ibus_engine.py spawns `ibus-daemon -x -d -r`
|
|
# and shells out to `ibus engine`, neither of which libibus provides.
|
|
assert "ibus" in zypper_packages
|
|
assert "typelib-1_0-IBus-1_0" in zypper_packages
|
|
assert "ibus-devel" not in zypper_packages
|
|
assert "python3-devel" not in zypper_line
|
|
assert "python3-virtualenv" not in zypper_line
|
|
assert "libappindicator-gtk3" not in zypper_line
|
|
assert "glslang" not in zypper_line
|
|
|
|
def test_rpm_arch_install_glslc_not_glslang(self, install_sh_content):
|
|
"""ggml Vulkan needs glslc (shaderc). glslangValidator is not a substitute (#604)."""
|
|
dnf_line = next(
|
|
line
|
|
for line in install_sh_content.splitlines()
|
|
if "SYSTEM_PACKAGES=(python3-pip python3-gobject gtk3 " in line
|
|
)
|
|
pacman_line = next(
|
|
line
|
|
for line in install_sh_content.splitlines()
|
|
if "SYSTEM_PACKAGES=(python-pip python-gobject gtk3 " in line
|
|
)
|
|
assert "glslc" in dnf_line
|
|
assert "patchelf" in dnf_line
|
|
assert "glslang" not in dnf_line
|
|
assert "shaderc" in pacman_line
|
|
assert "patchelf" in pacman_line
|
|
assert "glslang" not in pacman_line
|
|
assert (
|
|
"Fedora: sudo dnf install vulkan-loader-devel vulkan-tools glslc patchelf"
|
|
in install_sh_content
|
|
)
|
|
assert (
|
|
"Arch: sudo pacman -S vulkan-headers vulkan-tools shaderc patchelf"
|
|
in install_sh_content
|
|
)
|
|
emerge_line = next(
|
|
line
|
|
for line in install_sh_content.splitlines()
|
|
if "SYSTEM_PACKAGES=(dev-python/pygobject:3 " in line
|
|
)
|
|
assert "media-libs/shaderc" in emerge_line
|
|
assert "dev-util/patchelf" in emerge_line
|
|
assert "sys-devel/patchelf" not in emerge_line
|
|
|
|
def test_skip_system_deps_option_is_implemented(self, install_sh_content):
|
|
"""Test that the documented --skip-system-deps option is parsed and honored."""
|
|
assert "SKIP_SYSTEM_DEPS" in install_sh_content
|
|
assert "--skip-system-deps" in install_sh_content
|
|
assert "Skipping system dependency installation" in install_sh_content
|
|
|
|
def test_noninteractive_tty_fallback_does_not_abort(self, install_sh_content):
|
|
"""Test that missing controlling TTY falls back to non-interactive mode."""
|
|
assert "{ true < /dev/tty; } 2>/dev/null" in install_sh_content
|
|
# stdin must stay on the pipe while bash reads the script from it;
|
|
# prompts reach the terminal through read_prompt instead (#933).
|
|
code = "\n".join(
|
|
line for line in install_sh_content.splitlines() if not line.lstrip().startswith("#")
|
|
)
|
|
assert "exec < /dev/tty" not in code
|
|
assert "exec 0<" not in code
|
|
assert "read_prompt" in install_sh_content
|
|
assert 'NON_INTERACTIVE="yes"' in install_sh_content
|
|
|
|
def test_ci_workflow_uses_dynamic_detection(self, workflow_content):
|
|
"""Test that CI workflow uses dynamic GI_TYPELIB_PATH detection."""
|
|
# Should use pkg-config or dynamic detection
|
|
assert (
|
|
"pkg-config --variable=typelibdir" in workflow_content
|
|
), "CI workflow should use pkg-config for GI_TYPELIB_PATH"
|
|
|
|
|
|
class TestResourceManagerCrossDistro:
|
|
"""Test ResourceManager cross-distro path handling."""
|
|
|
|
def test_resource_manager_has_xdg_support(self):
|
|
"""Test that ResourceManager supports XDG directories."""
|
|
from vocalinux.utils.resource_manager import ResourceManager
|
|
|
|
rm = ResourceManager()
|
|
resources_dir = rm.resources_dir
|
|
|
|
# Should be a valid path string
|
|
assert isinstance(resources_dir, str), "resources_dir should be a string"
|
|
assert len(resources_dir) > 0, "resources_dir should not be empty"
|
|
|
|
|
|
class TestRecognitionManagerCrossDistro:
|
|
"""Test RecognitionManager cross-distro model path handling."""
|
|
|
|
def test_system_model_paths_exists(self):
|
|
"""Test that system model paths configuration exists."""
|
|
from vocalinux.speech_recognition.recognition_manager import SYSTEM_MODELS_DIRS
|
|
|
|
# Should be a list of paths
|
|
assert isinstance(SYSTEM_MODELS_DIRS, list), "SYSTEM_MODELS_DIRS should be a list"
|
|
assert len(SYSTEM_MODELS_DIRS) > 0, "SYSTEM_MODELS_DIRS should not be empty"
|
|
|
|
# Should include XDG paths
|
|
has_xdg = any("/share/" in path for path in SYSTEM_MODELS_DIRS)
|
|
assert has_xdg, "SYSTEM_MODELS_DIRS should include XDG paths"
|
|
|
|
|
|
class TestDocumentation:
|
|
"""Test that documentation is accurate."""
|
|
|
|
def test_distro_compatibility_documentation_exists(self):
|
|
"""Test that DISTRO_COMPATIBILITY.md exists and is accurate."""
|
|
docs_path = REPO_ROOT / "docs" / "DISTRO_COMPATIBILITY.md"
|
|
|
|
if not docs_path.exists():
|
|
pytest.skip(f"DISTRO_COMPATIBILITY.md not found at {docs_path}")
|
|
|
|
content = docs_path.read_text()
|
|
|
|
# Should mention the dynamic path detection
|
|
assert (
|
|
"pkg-config" in content or "detect_typelib_path" in content
|
|
), "Should document dynamic path detection"
|
|
|
|
# Should mention supported distros
|
|
expected_distros = ["Ubuntu", "Debian", "Fedora", "Arch", "openSUSE"]
|
|
for distro in expected_distros:
|
|
assert distro in content, f"Should mention {distro} support"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|