|
| 1 | +"""Regression tests for subprocess output streaming in dispatch. |
| 2 | +
|
| 3 | +The dispatch command must stream stdout/stderr in real time rather than |
| 4 | +buffering the entire output via capture_output=True. Long-running tools |
| 5 | +(deploydiff, schemaforge, configdrift on large datasets) can produce |
| 6 | +megabytes of output that should reach the user's terminal immediately. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import subprocess |
| 12 | +import sys |
| 13 | +from unittest import mock |
| 14 | + |
| 15 | +from typer.testing import CliRunner |
| 16 | + |
| 17 | +from devforge.cli import app |
| 18 | + |
| 19 | +runner = CliRunner() |
| 20 | + |
| 21 | + |
| 22 | +class TestDispatchStreaming: |
| 23 | + """dispatch must NOT use subprocess.run with capture_output=True. |
| 24 | +
|
| 25 | + Real-time streaming requires subprocess.Popen (or subprocess.run with |
| 26 | + stdout=None, stderr=None) so the child process inherits the parent's |
| 27 | + file descriptors directly. |
| 28 | + """ |
| 29 | + |
| 30 | + @mock.patch("devforge.cli._is_tool_installed", return_value=True) |
| 31 | + def test_dispatch_does_not_buffer_output(self, _mock_installed): |
| 32 | + """subprocess.run must NOT be called with capture_output=True. |
| 33 | +
|
| 34 | + capture_output=True buffers the entire child output in memory before |
| 35 | + the parent can write anything. For tools that produce large or |
| 36 | + long-running output, this is a UX regression: the user sees nothing |
| 37 | + until the tool finishes, and memory usage grows unbounded. |
| 38 | + """ |
| 39 | + with mock.patch("devforge.cli.subprocess.run") as mock_run: |
| 40 | + mock_run.return_value = mock.MagicMock(returncode=0, stdout="", stderr="") |
| 41 | + with mock.patch("devforge.cli.sys.exit"): |
| 42 | + runner.invoke(app, ["guard", "--help"]) |
| 43 | + |
| 44 | + if mock_run.called: |
| 45 | + # If subprocess.run is used, it must NOT capture output |
| 46 | + call_kwargs = mock_run.call_args[1] if mock_run.call_args[1] else {} |
| 47 | + assert call_kwargs.get("capture_output") is not True, ( |
| 48 | + "dispatch uses subprocess.run(capture_output=True) which buffers " |
| 49 | + "all output. Use subprocess.Popen or stdout=None to stream." |
| 50 | + ) |
| 51 | + assert call_kwargs.get("stdout") is not subprocess.PIPE, ( |
| 52 | + "dispatch uses stdout=PIPE which buffers output. " |
| 53 | + "Use stdout=None to inherit the parent's stdout." |
| 54 | + ) |
| 55 | + |
| 56 | + @mock.patch("devforge.cli._is_tool_installed", return_value=True) |
| 57 | + def test_dispatch_uses_popen_or_inherited_fds(self, _mock_installed): |
| 58 | + """dispatch should use subprocess.Popen for real-time streaming, |
| 59 | + or subprocess.run without capture (stdout=None, stderr=None). |
| 60 | + """ |
| 61 | + with mock.patch("devforge.cli.subprocess.Popen") as mock_popen, \ |
| 62 | + mock.patch("devforge.cli.subprocess.run") as mock_run: |
| 63 | + |
| 64 | + # Set up Popen mock to simulate a successful run |
| 65 | + mock_proc = mock.MagicMock() |
| 66 | + mock_proc.wait.return_value = 0 |
| 67 | + mock_popen.return_value = mock_proc |
| 68 | + |
| 69 | + with mock.patch("devforge.cli.sys.exit"): |
| 70 | + runner.invoke(app, ["guard"]) |
| 71 | + |
| 72 | + # Either Popen was used (preferred for streaming) |
| 73 | + # or subprocess.run was used WITHOUT capture_output |
| 74 | + if mock_popen.called: |
| 75 | + # Good: Popen streams by default |
| 76 | + assert True |
| 77 | + elif mock_run.called: |
| 78 | + kwargs = mock_run.call_args[1] if mock_run.call_args[1] else {} |
| 79 | + assert kwargs.get("capture_output") is not True |
| 80 | + assert kwargs.get("stdout") is not subprocess.PIPE |
| 81 | + else: |
| 82 | + raise AssertionError( |
| 83 | + "Neither subprocess.Popen nor subprocess.run was called" |
| 84 | + ) |
| 85 | + |
| 86 | + @mock.patch("devforge.cli._is_tool_installed", return_value=True) |
| 87 | + def test_dispatch_exit_code_propagates(self, _mock_installed): |
| 88 | + """The child process exit code must propagate to the parent.""" |
| 89 | + with mock.patch("devforge.cli.subprocess.Popen") as mock_popen: |
| 90 | + mock_proc = mock.MagicMock() |
| 91 | + mock_proc.wait.return_value = 42 |
| 92 | + mock_popen.return_value = mock_proc |
| 93 | + |
| 94 | + with mock.patch("devforge.cli.sys.exit") as mock_exit: |
| 95 | + runner.invoke(app, ["guard"]) |
| 96 | + |
| 97 | + if mock_popen.called: |
| 98 | + # sys.exit(42) raises SystemExit; CliRunner catches it and |
| 99 | + # may call sys.exit(0) afterward. Check that 42 was among |
| 100 | + # the calls rather than asserting exactly one call. |
| 101 | + exit_codes = [c.args[0] for c in mock_exit.call_args_list] |
| 102 | + assert 42 in exit_codes, f"Expected exit code 42 in {exit_codes}" |
0 commit comments