"""Post-PM installer stages use the real installation-bound publication.""" import json import os from pathlib import Path import subprocess import sys import pytest from tests.installation_launcher_fixture import publish_fixture_launcher if os.name == 'posix': import fcntl import pty import termios ROOT = Path(__file__).resolve().parents[2] def _with_controlling_terminal(tty_path: str): """preexec_fn: make this PTY the child's controlling terminal, so /dev/tty opens.""" def attach() -> None: os.setsid() # windows-footgun: ok (posix-only test) fd = os.open(tty_path, os.O_RDWR) try: # Opening a PTY slave does not always acquire it on Darwin. fcntl.ioctl(fd, termios.TIOCSCTTY, 0) probe = os.open('/dev/tty', os.O_RDONLY) os.close(probe) finally: os.close(fd) return attach @pytest.mark.platforms('posix') @pytest.mark.parametrize('stage, expected', [('setup', ['setup']), ('gateway', ['gateway', 'install', '--if-missing'])]) def test_installer_post_pm_stages(tmp_path: Path, stage: str, expected: list[str]) -> None: install = tmp_path / 'source tree' calls = tmp_path / 'calls.json' publish_fixture_launcher(install, "import json, os, sys\nfrom pathlib import Path\ndef main():\n Path(os.environ['CALLS']).write_text(json.dumps(sys.argv[1:])); return int(os.environ['STAGE_EXIT'])\n") command = ['bash', '-c', 'source "$1" --manifest >/dev/null; INSTALL_DIR="$2"; NON_INTERACTIVE=false; "stage_$3"', 'test', str(ROOT / 'scripts/install.sh'), str(install), stage] env = {**os.environ, 'HOME': str(tmp_path), 'HERMES_HOME': str(tmp_path / 'home'), 'HERMES_RUNTIME_DIR': str(tmp_path / 'store'), 'CALLS': str(calls)} # `curl | bash` and Docker builds have no terminal: the interactive stage is skipped, not failed. result = subprocess.run(command, cwd=tmp_path, env={**env, 'STAGE_EXIT': '9'}, capture_output=True, text=True, encoding='utf-8', timeout=20, start_new_session=True) assert result.returncode == 0, result.stdout + result.stderr assert not calls.exists() master, slave = pty.openpty() try: tty_path = os.ttyname(slave) finally: os.close(slave) try: for code in [0, 9]: result = subprocess.run(command, cwd=tmp_path, env={**env, 'STAGE_EXIT': str(code)}, capture_output=True, text=True, encoding='utf-8', timeout=20, stdin=subprocess.DEVNULL, preexec_fn=_with_controlling_terminal(tty_path)) assert calls.exists(), (result.returncode, result.stdout, result.stderr) assert (result.returncode == 0) == (code == 0), result.stdout + result.stderr assert json.loads(calls.read_text()) == expected finally: os.close(master) assert not (install / 'venv').exists() @pytest.mark.platforms('posix') @pytest.mark.parametrize('stage, include_desktop, expected_desktop', [ ('products', False, False), ('products', True, True), ('desktop', False, True), ]) def test_products_and_desktop_stages_share_the_completion_tail(tmp_path: Path, stage: str, include_desktop: bool, expected_desktop: bool) -> None: """Both stages hand the checkout to hermes_cli/source_completion.py; --include-desktop (or the external `desktop` stage) only adds --desktop to that one call.""" install = tmp_path / 'source tree' calls = tmp_path / 'calls.json' (install / 'hermes_cli').mkdir(parents=True) (install / 'hermes_cli' / 'source_completion.py').write_text( "import json, os, sys\nfrom pathlib import Path\n" "Path(os.environ['CALLS']).write_text(json.dumps(sys.argv[1:]))\n", encoding='utf-8') flag = 'true' if include_desktop else 'false' command = ['bash', '-c', f'source "$1" --manifest >/dev/null; INSTALL_DIR="$2"; INCLUDE_DESKTOP={flag}; ' 'bootstrap_python() { boot_py="$PYTHON_FOR_TEST"; }; "stage_$3"', 'test', str(ROOT / 'scripts/install.sh'), str(install), stage] env = {**os.environ, 'HOME': str(tmp_path), 'HERMES_HOME': str(tmp_path / 'home'), 'HERMES_RUNTIME_DIR': str(tmp_path / 'store'), 'CALLS': str(calls), 'PYTHON_FOR_TEST': sys.executable} result = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=20) assert result.returncode == 0, result.stdout + result.stderr argv = json.loads(calls.read_text()) assert argv[:2] == ['--source', str(install)] assert ('--desktop' in argv) is expected_desktop @pytest.mark.platforms("posix") def test_desktop_flag_does_not_add_a_stage(tmp_path): """--include-desktop selects the desktop product inside `products`; the manifest is one ladder.""" def manifest(*flags): result = subprocess.run( ["bash", str(ROOT / "scripts/install.sh"), "--manifest", *flags], cwd=tmp_path, capture_output=True, text=True, timeout=30, check=True, ) return [row["name"] for row in json.loads(result.stdout)["stages"]] plain = manifest() assert "desktop" not in plain assert "products" in plain assert plain[-1] == "complete" assert manifest("--include-desktop") == plain