test_install_sh_setup_wizard_tty_probe.py
1 """Regression for #16746: install.sh /dev/tty gates must actually open /dev/tty. 2 3 In a Docker build, ``/dev/tty`` exists as a device node (so a bare ``-e`` 4 existence test returns true) but opening it fails with ``ENXIO: No such 5 device or address``. Under the old gates the script proceeded past the "no 6 terminal available" skip and then crashed on the ``< /dev/tty`` redirect a 7 few lines later, aborting the entire image build. The fix replaces every 8 existence-based check that guards a subsequent ``< /dev/tty`` redirect with 9 an open-based probe so the skip kicks in correctly. 10 11 This module covers all three affected functions: ``run_setup_wizard()`` 12 (the reproducer in #16746), ``install_system_packages()`` (the apt sudo 13 prompt fallback), and ``maybe_start_gateway()`` (the gateway-install gate). 14 """ 15 16 from __future__ import annotations 17 18 import re 19 from pathlib import Path 20 21 import pytest 22 23 REPO_ROOT = Path(__file__).resolve().parent.parent 24 INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" 25 26 # Every function in scripts/install.sh that previously gated on a bare 27 # ``[ -e /dev/tty ]`` check before redirecting stdin from ``/dev/tty``. 28 GATED_FUNCTIONS = ("run_setup_wizard", "install_system_packages", "maybe_start_gateway") 29 30 31 def _extract_function_body(name: str) -> str: 32 """Return the body of ``<name>()`` as a single string. 33 34 Anchored to ``<name>()`` and a top-of-line ``}`` so the helper keeps 35 working if neighbouring functions are renamed. 36 """ 37 text = INSTALL_SH.read_text() 38 match = re.search( 39 rf"^{re.escape(name)}\(\)\s*\{{\s*\n(?P<body>.*?)^\}}", 40 text, 41 re.MULTILINE | re.DOTALL, 42 ) 43 assert match is not None, f"{name}() not found in scripts/install.sh" 44 return match["body"] 45 46 47 @pytest.mark.parametrize("fn_name", GATED_FUNCTIONS) 48 def test_tty_gate_does_not_use_existence_only_check(fn_name: str) -> None: 49 """The bare ``-e`` test is the bug — no spelling of it should remain.""" 50 body = _extract_function_body(fn_name) 51 # Cover ``[ -e /dev/tty ]``, ``[ -e "/dev/tty" ]``, ``test -e /dev/tty`` 52 # and friends, with arbitrary surrounding whitespace. 53 pattern = re.compile( 54 r"""( 55 \[\s*-e\s+["']?/dev/tty["']?\s*\] 56 | 57 \btest\s+-e\s+["']?/dev/tty["']? 58 )""", 59 re.VERBOSE, 60 ) 61 match = pattern.search(body) 62 assert match is None, ( 63 f"{fn_name} contains an existence-only check on /dev/tty " 64 f"({match.group(0)!r}). Bare `-e` tests pass in Docker builds " 65 "where the device node is in the mount namespace but cannot be " 66 "opened (ENXIO). Use an open-based probe (e.g. " 67 "`(: </dev/tty) 2>/dev/null` or `exec 3</dev/tty`) so the skip " 68 "kicks in before the function tries to read from /dev/tty. " 69 "See #16746." 70 ) 71 72 73 @pytest.mark.parametrize("fn_name", GATED_FUNCTIONS) 74 def test_tty_gate_uses_open_based_probe(fn_name: str) -> None: 75 """The gate must actually attempt to open ``/dev/tty``. 76 77 Any ``if``/``if !``/``elif`` whose condition opens ``/dev/tty`` for 78 input counts: ``(: </dev/tty)``, ``exec 3</dev/tty``, 79 ``{ exec 3</dev/tty; }``, etc. Asserting the higher-level invariant 80 rather than a specific spelling so equivalent refactors stay green. 81 """ 82 body = _extract_function_body(fn_name) 83 gate = re.compile( 84 r"^\s*(?:if|elif)\s+!?\s*[^\n]*<\s*/dev/tty[^\n]*;\s*then", 85 re.MULTILINE, 86 ) 87 assert gate.search(body), ( 88 f"{fn_name} must gate on an open-based probe of /dev/tty " 89 "(an `if`/`if !`/`elif` whose test redirects stdin from /dev/tty), " 90 "not a mere existence check. See #16746." 91 )