/ tests / test_tty.py
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