Repository: pallets/click

Issue:
CliRunner and click.prompt doesn't work for testing 
### Summary

When testing a Click command with `CliRunner`, it appears that `click.prompt` behaves unexpectedly in functional tests, especially when the terminal is not a true TTY. Specifically, when using `CliRunner` to simulate input in tests, `click.prompt` blocks indefinitely if more prompts are expected than input provided, making it impossible to test these commands correctly. This behavior is different from running the same code in a real shell environment, where input ends with EOF and the program gracefully handles the prompt.

### Minimal Reproducible Example (MRE)

Here is a minimal reproducible example that demonstrates the issue:

```python
import click
from click.testing import CliRunner
from pytest import fixture


@click.command()
@click.option("-n", "--count", default=2)
def main(count):
    sum = 0
    for i in range(count + 1):  # +1 is a bug in the code
        sum += click.prompt(f"Enter number {i + 1}", type=int)
    click.echo(f"Sum: {sum}")


@fixture
def runner():
    return CliRunner()


def test_program(runner):
    result = runner.invoke(main, args=["-n2"], input="1\n2")
    assert "3" in result.output
    assert result.exit_code == 0


if __name__ == "__main__":
    main()
```

### Expected Behavior

In this example, there's a bug in the code (the `for` loop runs for `count + 1` iterations). When testing using the `CliRunner`, we expect it to behave similarly to how the program runs in a real shell environment. Specifically, when EOF is reached (input ends), the test should either handle the prompt and output accordingly, or raise an appropriate error that can be captured in a test.

For example, when running the test program in a shell script:

```bash
#!/bin/bash

output=$(echo -e "1\n2\n" | python main.py)

if [[ "$output" == *"Sum: 3"* ]]; then
  echo "Test passed!"
  exit 0
else
  echo "Test failed!"
  echo "Expected output: 'Sum: 3'"
  echo "Actual output: $output"
  exit 1
fi
```

The test does **not** block and can process input, even though there's one extra prompt due to the bug.

### Actual Behavior

When running the test with `pytest` using `CliRunner`, it blocks indefinitely because `click.prompt` is waiting for additional input, even though the input stream has ended:

```bash
$ python -mpytest main.py
===== test session starts =====
platform linux -- Python 3.13.0, pytest-8.3.3, pluggy-1.5.0
rootdir: /click-test
collected 1 item

main.py (blocking...)
```

### Root Cause

It seems that `CliRunner` and Click's input handling behave differently in non-TTY environments, especially around handling `click.prompt`. When the terminal is not a true TTY, Click disables certain features (like colors) and handles input differently. In this case, `click.prompt` appears to ignore EOF signals, resulting in the test blocking indefinitely.

In contrast, when piping input in a real shell, the EOF signal is respected, and the program behaves as expected.

### Possible Solutions

- **Improve `CliRunner` behavior**: `CliRunner` could simulate EOF and handle `click.prompt` more gracefully, aligning with behavior in actual terminal sessions.
- **Provide a clearer way to handle prompts in non-TTY environments**: Currently, there is no obvious way to handle situations like this during functional testing without a TTY. There could be an enhancement to handle prompts better in non-interactive tests.
- **Document this behavior**: If this is the intended behavior, it should be clearly documented that `CliRunner` behaves differently in non-TTY environments, especially in relation to prompts and input streams.

### Environment Details

- Python 3.13.0
- Click 8.x
- pytest 8.3.3

Discussion:
@davidism: 8.2.1 is available on PyPI.

Solution patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 97f6a21..f377042 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -7,6 +7,7 @@ Version 8.2.1
     :issue:`2897` :pr:`2930`
 -   Fix shell completion for nested groups. :issue:`2906` :pr:`2907`
 -   Flush ``sys.stderr`` at the end of ``CliRunner.invoke``. :issue:`2682`
+-   Fix EOF handling for stdin input in CliRunner. :issue:`2787`
 
 Version 8.2.0
 -------------
diff --git a/src/click/testing.py b/src/click/testing.py
index d9759e5..7c0e874 100644
--- a/src/click/testing.py
+++ b/src/click/testing.py
@@ -116,6 +116,13 @@ class _NamedTextIOWrapper(io.TextIOWrapper):
     def mode(self) -> str:
         return self._mode
 
+    def __next__(self) -> str:  # type: ignore
+        try:
+            line = super().__next__()
+        except StopIteration as e:
+            raise EOFError() from e
+        return line
+
 
 def make_input_stream(
     input: str | bytes | t.IO[t.Any] | None, charset: str
@@ -341,7 +348,7 @@ class CliRunner:
         @_pause_echo(echo_input)  # type: ignore
         def visible_input(prompt: str | None = None) -> str:
             sys.stdout.write(prompt or "")
-            val = text_input.readline().rstrip("\r\n")
+            val = next(text_input).rstrip("\r\n")
             sys.stdout.write(f"{val}\n")
             sys.stdout.flush()
             return val
@@ -350,7 +357,7 @@ class CliRunner:
         def hidden_input(prompt: str | None = None) -> str:
             sys.stdout.write(f"{prompt or ''}\n")
             sys.stdout.flush()
-            return text_input.readline().rstrip("\r\n")
+            return next(text_input).rstrip("\r\n")
 
         @_pause_echo(echo_input)  # type: ignore
         def _getchar(echo: bool) -> str:


Test patch:
diff --git a/tests/test_chain.py b/tests/test_chain.py
index 702eaaa..ba11b7e 100644
--- a/tests/test_chain.py
+++ b/tests/test_chain.py
@@ -163,8 +163,8 @@ def test_pipeline(runner, args, input, expect):
         return processor
 
     result = runner.invoke(cli, args, input=input)
-    assert not result.exception
-    assert result.output.splitlines() == expect
+    # last two lines are '' and 'Aborted!'
+    assert result.output.splitlines()[:-2] == expect
 
 
 def test_args_and_chain(runner):
diff --git a/tests/test_termui.py b/tests/test_termui.py
index ad9d0a6..5d8b9fb 100644
--- a/tests/test_termui.py
+++ b/tests/test_termui.py
@@ -242,7 +242,7 @@ def test_file_prompt_default_format(runner, file_kwargs):
     def cli(f):
         click.echo(f.name)
 
-    result = runner.invoke(cli)
+    result = runner.invoke(cli, input="\n")
     assert result.output == f"file [{__file__}]: \n{__file__}\n"
 
 
@@ -451,7 +451,7 @@ def test_prompt_required_false(runner, args, expect):
     [
         (True, "password\npassword", None, "password"),
         ("Confirm Password", "password\npassword\n", None, "password"),
-        (True, "", "", ""),
+        (True, "\n\n", "", ""),
         (False, None, None, None),
     ],
 )
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 48f9f4c..e941415 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -179,6 +179,19 @@ def test_prompts_abort(monkeypatch, capsys):
     assert out == "Password:\ninterrupted\n"
 
 
+def test_prompts_eof(runner):
+    """If too few lines of input are given, prompt should exit, not hang."""
+
+    @click.command
+    def echo():
+        for _ in range(3):
+            click.echo(click.prompt("", type=int))
+
+    # only provide two lines of input for three prompts
+    result = runner.invoke(echo, input="1\n2\n")
+    assert result.exit_code == 1
+
+
 def _test_gen_func():
     yield "a"
     yield "b"