test_tty.py
1 """Tests for mureo.cli._tty — TTY detection and safe confirm helper. 2 3 Verifies that setup wizards don't hang when run as a subprocess (e.g. 4 from Claude Code's Bash tool) by detecting a missing TTY and taking 5 the caller-supplied default instead of calling ``typer.confirm``. 6 """ 7 8 from __future__ import annotations 9 10 import sys 11 12 import pytest 13 14 # Module under test does not exist yet — this drives RED. 15 from mureo.cli._tty import confirm_or_default, is_tty # noqa: I001 16 17 18 class _FakeStream: 19 """Minimal stream stand-in whose ``isatty()`` returns a fixed value. 20 21 Used to patch both ``sys.stdin`` and ``sys.stdout`` — ``is_tty`` 22 now requires both ends to be terminals. 23 """ 24 25 def __init__(self, tty: bool) -> None: 26 self._tty = tty 27 28 def isatty(self) -> bool: 29 return self._tty 30 31 32 # Back-compat alias so existing test call sites still read naturally. 33 _FakeStdin = _FakeStream 34 35 36 def _set_tty(monkeypatch: pytest.MonkeyPatch, *, tty: bool) -> None: 37 """Patch both stdin and stdout to the same TTY state.""" 38 monkeypatch.setattr(sys, "stdin", _FakeStream(tty=tty)) 39 monkeypatch.setattr(sys, "stdout", _FakeStream(tty=tty)) 40 41 42 class TestIsTty: 43 def test_returns_true_when_stdin_is_tty( 44 self, monkeypatch: pytest.MonkeyPatch 45 ) -> None: 46 _set_tty(monkeypatch, tty=True) 47 assert is_tty() is True 48 49 def test_returns_false_when_stdin_is_not_tty( 50 self, monkeypatch: pytest.MonkeyPatch 51 ) -> None: 52 _set_tty(monkeypatch, tty=False) 53 assert is_tty() is False 54 55 56 class TestConfirmOrDefault: 57 def test_no_tty_returns_default_true( 58 self, monkeypatch: pytest.MonkeyPatch 59 ) -> None: 60 """In non-TTY (subprocess / Claude Bash tool), take the default 61 without any I/O — no typer.confirm, no input() call.""" 62 _set_tty(monkeypatch, tty=False) 63 called = False 64 65 def _sentinel(*_args: object, **_kwargs: object) -> bool: 66 nonlocal called 67 called = True 68 return False 69 70 monkeypatch.setattr("typer.confirm", _sentinel) 71 assert confirm_or_default("Configure?", default=True) is True 72 assert called is False, "typer.confirm must not be called in non-TTY" 73 74 def test_no_tty_returns_default_false( 75 self, monkeypatch: pytest.MonkeyPatch 76 ) -> None: 77 _set_tty(monkeypatch, tty=False) 78 assert confirm_or_default("Configure?", default=False) is False 79 80 def test_tty_delegates_to_typer_confirm( 81 self, monkeypatch: pytest.MonkeyPatch 82 ) -> None: 83 """With a TTY present, the helper must let typer.confirm drive the 84 actual prompt so existing CLI UX is unchanged.""" 85 _set_tty(monkeypatch, tty=True) 86 captured: dict[str, object] = {} 87 88 def _fake_confirm(prompt: str, default: bool = False) -> bool: 89 captured["prompt"] = prompt 90 captured["default"] = default 91 return True 92 93 monkeypatch.setattr("typer.confirm", _fake_confirm) 94 result = confirm_or_default("Configure Google Ads?", default=True) 95 96 assert result is True 97 assert captured == {"prompt": "Configure Google Ads?", "default": True} 98 99 def test_explicit_override_bypasses_prompt( 100 self, monkeypatch: pytest.MonkeyPatch 101 ) -> None: 102 """If the caller already knows the value (e.g. from a CLI flag), 103 passing override=... skips both TTY detection and typer.confirm.""" 104 _set_tty(monkeypatch, tty=True) 105 called = False 106 107 def _sentinel(*_args: object, **_kwargs: object) -> bool: 108 nonlocal called 109 called = True 110 return True 111 112 monkeypatch.setattr("typer.confirm", _sentinel) 113 assert ( 114 confirm_or_default("Configure?", default=True, override=False) is False 115 ) 116 assert called is False, "Explicit override must not call typer.confirm" 117 118 def test_asymmetric_tty_counts_as_non_tty( 119 self, monkeypatch: pytest.MonkeyPatch 120 ) -> None: 121 """stdin TTY but stdout piped (or vice versa) must be treated as 122 non-interactive — prompting would either have no visible output 123 or block on unreachable input.""" 124 monkeypatch.setattr(sys, "stdin", _FakeStream(tty=True)) 125 monkeypatch.setattr(sys, "stdout", _FakeStream(tty=False)) 126 assert is_tty() is False 127 assert confirm_or_default("Configure?", default=True) is True 128 129 def test_eof_from_confirm_falls_back_to_default( 130 self, monkeypatch: pytest.MonkeyPatch 131 ) -> None: 132 """If stdin closes mid-prompt, fall back to default rather than 133 aborting with a stack trace.""" 134 _set_tty(monkeypatch, tty=True) 135 136 def _raise_eof(*_: object, **__: object) -> bool: 137 raise EOFError 138 139 monkeypatch.setattr("typer.confirm", _raise_eof) 140 assert confirm_or_default("Configure?", default=True) is True 141 assert confirm_or_default("Configure?", default=False) is False