Files
hermes-agent/tests/scripts/test_install_sh_launch_handoff.py
ethernet 746d861504 fix(install): don't ask the gateway install questions twice
The setup stage installs the gateway service through
ensure_gateway_service. On Windows that asks the start-now, Scheduled
Task and UAC questions. The gateway stage then ran `hermes gateway
install`, which asked them all again.

`gateway install --if-missing` does nothing when a service is already
installed. Both installers' gateway stages use it, so they ask only
when setup did not install the service.
2026-09-24 23:49:18 -04:00

107 lines
5.2 KiB
Python

"""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