Merge pull request 'test: isolate CLI output capture with monkeypatch/redirect to fix stdout corruption in full suite (Issue #178)' (#180) from feat/issue-178-fix-test-suite-stdout-capture into master

This commit was merged in pull request #180.
This commit is contained in:
2026-07-05 14:26:31 -05:00
9 changed files with 101 additions and 34 deletions
+5
View File
@@ -11,6 +11,7 @@ import json
import sys import sys
import tempfile import tempfile
import unittest import unittest
import contextlib
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
# The module under test lives in the repo root, not a package. # The module under test lives in the repo root, not a package.
@@ -38,6 +39,7 @@ class TestArgParsing(unittest.TestCase):
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"}) @patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
@patch("create_issue.get_credentials", return_value=FAKE_CREDS) @patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_minimal_args(self, _cred, _api): def test_minimal_args(self, _cred, _api):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = create_issue.main(["--title", "Hello"]) rc = create_issue.main(["--title", "Hello"])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
@@ -50,6 +52,7 @@ class TestArgParsing(unittest.TestCase):
@patch("create_issue.get_credentials", return_value=FAKE_CREDS) @patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_remote_choices(self, _cred, _api): def test_remote_choices(self, _cred, _api):
for remote in ("dadeschools", "prgs"): for remote in ("dadeschools", "prgs"):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = create_issue.main(["--remote", remote, "--title", "X"]) rc = create_issue.main(["--remote", remote, "--title", "X"])
self.assertEqual(rc, 0, f"--remote {remote} should be accepted") self.assertEqual(rc, 0, f"--remote {remote} should be accepted")
@@ -138,6 +141,7 @@ class TestAuthFailure(unittest.TestCase):
@patch("create_issue.get_credentials", return_value=("", "")) @patch("create_issue.get_credentials", return_value=("", ""))
def test_no_credentials_returns_1(self, _cred): def test_no_credentials_returns_1(self, _cred):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = create_issue.main(["--title", "T"]) rc = create_issue.main(["--title", "T"])
self.assertEqual(rc, 1) self.assertEqual(rc, 1)
@@ -152,6 +156,7 @@ class TestAPIError(unittest.TestCase):
def test_api_error_returns_1(self, _cred): def test_api_error_returns_1(self, _cred):
with patch("create_issue.api_request", with patch("create_issue.api_request",
side_effect=RuntimeError("HTTP 422: duplicate")): side_effect=RuntimeError("HTTP 422: duplicate")):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = create_issue.main(["--title", "Dup"]) rc = create_issue.main(["--title", "Dup"])
self.assertEqual(rc, 1) self.assertEqual(rc, 1)
+2
View File
@@ -7,6 +7,7 @@ import io
import json import json
import sys import sys
import unittest import unittest
import contextlib
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -34,6 +35,7 @@ class TestArgParsing(unittest.TestCase):
@patch("create_pr.urllib.request.urlopen", return_value=_mock_urlopen()) @patch("create_pr.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_pr.get_credentials", return_value=FAKE_CREDS) @patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_minimal_required_args(self, _cred, _url): def test_minimal_required_args(self, _cred, _url):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = create_pr.main(["--title", "PR Title", "--head", "feat/branch"]) rc = create_pr.main(["--title", "PR Title", "--head", "feat/branch"])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
+6
View File
@@ -2,9 +2,11 @@
All API calls are mocked — no real network or keychain access. All API calls are mocked — no real network or keychain access.
""" """
import io
import json import json
import sys import sys
import unittest import unittest
import contextlib
from unittest.mock import MagicMock, call, patch from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -33,6 +35,7 @@ class TestLabelCreation(unittest.TestCase):
# Patch sys.argv to avoid --dry # Patch sys.argv to avoid --dry
with patch.object(sys, "argv", ["manage_labels.py"]): with patch.object(sys, "argv", ["manage_labels.py"]):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main() manage_labels.main()
# The GET call happens, but no POST calls for label creation # The GET call happens, but no POST calls for label creation
@@ -59,6 +62,7 @@ class TestLabelCreation(unittest.TestCase):
mock_api.side_effect = side_effect mock_api.side_effect = side_effect
with patch.object(sys, "argv", ["manage_labels.py"]): with patch.object(sys, "argv", ["manage_labels.py"]):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main() manage_labels.main()
post_calls = [ post_calls = [
@@ -79,6 +83,7 @@ class TestDryRun(unittest.TestCase):
mock_api.return_value = [] # no existing labels mock_api.return_value = [] # no existing labels
with patch.object(sys, "argv", ["manage_labels.py", "--dry"]): with patch.object(sys, "argv", ["manage_labels.py", "--dry"]):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main() manage_labels.main()
# Only the GET call should be made, no POST or PUT # Only the GET call should be made, no POST or PUT
@@ -107,6 +112,7 @@ class TestLabelMapping(unittest.TestCase):
mock_api.side_effect = side_effect mock_api.side_effect = side_effect
with patch.object(sys, "argv", ["manage_labels.py"]): with patch.object(sys, "argv", ["manage_labels.py"]):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main() manage_labels.main()
put_calls = [c for c in mock_api.call_args_list if c[0][0] == "PUT"] put_calls = [c for c in mock_api.call_args_list if c[0][0] == "PUT"]
+6 -2
View File
@@ -45,13 +45,17 @@ class TestMergeDisabled(unittest.TestCase):
mock_api.assert_not_called() mock_api.assert_not_called()
def test_message_points_to_gated_workflow(self): def test_message_points_to_gated_workflow(self):
from _pytest.monkeypatch import MonkeyPatch
import io import io
import contextlib
with patch("merge_pr.get_auth_header", return_value=FAKE_CREDS), \ with patch("merge_pr.get_auth_header", return_value=FAKE_CREDS), \
patch("merge_pr.api_request") as mock_api: patch("merge_pr.api_request") as mock_api:
buf = io.StringIO() buf = io.StringIO()
with contextlib.redirect_stderr(buf): monkeypatch = MonkeyPatch()
monkeypatch.setattr(sys, "stderr", buf)
try:
rc = merge_pr.main(["--pr-number", "81"]) rc = merge_pr.main(["--pr-number", "81"])
finally:
monkeypatch.undo()
self.assertEqual(rc, 2) self.assertEqual(rc, 2)
mock_api.assert_not_called() mock_api.assert_not_called()
msg = buf.getvalue().lower() msg = buf.getvalue().lower()
+15 -3
View File
@@ -9,6 +9,8 @@ import shutil
from unittest.mock import patch from unittest.mock import patch
from io import StringIO from io import StringIO
from _pytest.monkeypatch import MonkeyPatch
# Add project root to sys.path # Add project root to sys.path
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path: if PROJECT_ROOT not in sys.path:
@@ -127,9 +129,12 @@ class TestMigrateProfiles(unittest.TestCase):
v2_data = migrate_profiles.migrate_v1_to_v2(self.v1_content) v2_data = migrate_profiles.migrate_v1_to_v2(self.v1_content)
self.assertTrue(migrate_profiles.validate_v2_data(v2_data)) self.assertTrue(migrate_profiles.validate_v2_data(v2_data))
@patch("sys.stdout", new_callable=StringIO) def test_dry_run_default(self):
def test_dry_run_default(self, mock_stdout):
"""Verify that running without -w prints generated config without modifying files.""" """Verify that running without -w prints generated config without modifying files."""
monkeypatch = MonkeyPatch()
mock_stdout = StringIO()
monkeypatch.setattr(sys, "stdout", mock_stdout)
try:
output_file = os.path.join(self.temp_dir, "migrated_dry.json") output_file = os.path.join(self.temp_dir, "migrated_dry.json")
test_args = [ test_args = [
"migrate_profiles.py", "migrate_profiles.py",
@@ -145,6 +150,8 @@ class TestMigrateProfiles(unittest.TestCase):
self.assertFalse(os.path.exists(f"{self.input_file}.bak")) self.assertFalse(os.path.exists(f"{self.input_file}.bak"))
stdout_output = mock_stdout.getvalue() stdout_output = mock_stdout.getvalue()
finally:
monkeypatch.undo()
self.assertIn("DRY-RUN MODE", stdout_output) self.assertIn("DRY-RUN MODE", stdout_output)
self.assertIn("version", stdout_output) self.assertIn("version", stdout_output)
self.assertIn("identities", stdout_output) self.assertIn("identities", stdout_output)
@@ -165,13 +172,18 @@ class TestMigrateProfiles(unittest.TestCase):
json.dump(sensitive, f) json.dump(sensitive, f)
test_args = ["migrate_profiles.py", "-i", self.input_file] test_args = ["migrate_profiles.py", "-i", self.input_file]
with patch("sys.stdout", new_callable=StringIO) as mock_stdout: monkeypatch = MonkeyPatch()
mock_stdout = StringIO()
monkeypatch.setattr(sys, "stdout", mock_stdout)
try:
with patch.object(sys, "argv", test_args): with patch.object(sys, "argv", test_args):
with self.assertRaises(SystemExit) as cm: with self.assertRaises(SystemExit) as cm:
migrate_profiles.main() migrate_profiles.main()
self.assertEqual(cm.exception.code, 0) self.assertEqual(cm.exception.code, 0)
stdout_output = mock_stdout.getvalue() stdout_output = mock_stdout.getvalue()
finally:
monkeypatch.undo()
self.assertNotIn("super-secret-token-value", stdout_output) self.assertNotIn("super-secret-token-value", stdout_output)
self.assertNotIn("token", stdout_output.lower()) self.assertNotIn("token", stdout_output.lower())
+4
View File
@@ -4,6 +4,8 @@ Mocks api_request and credentials.
""" """
import sys import sys
import unittest import unittest
import io
import contextlib
from unittest.mock import patch from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -24,6 +26,7 @@ class TestListPRs(unittest.TestCase):
mock_api.return_value = [ mock_api.return_value = [
{"number": 1, "title": "PR 1", "head": {"ref": "branch1"}, "base": {"ref": "main"}, "html_url": "http://url1", "mergeable": True} {"number": 1, "title": "PR 1", "head": {"ref": "branch1"}, "base": {"ref": "main"}, "html_url": "http://url1", "mergeable": True}
] ]
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = list_prs.main([]) rc = list_prs.main([])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
mock_api.assert_called_once() mock_api.assert_called_once()
@@ -31,6 +34,7 @@ class TestListPRs(unittest.TestCase):
@patch("list_prs.api_request", return_value=[]) @patch("list_prs.api_request", return_value=[])
@patch("list_prs.get_auth_header", return_value=FAKE_CREDS) @patch("list_prs.get_auth_header", return_value=FAKE_CREDS)
def test_list_prs_empty(self, _auth, mock_api): def test_list_prs_empty(self, _auth, mock_api):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = list_prs.main(["--state", "closed"]) rc = list_prs.main(["--state", "closed"])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
mock_api.assert_called_once() mock_api.assert_called_once()
+4
View File
@@ -4,6 +4,8 @@ All tests mock credentials and API requests so no real network calls are made.
""" """
import sys import sys
import unittest import unittest
import io
import contextlib
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
# The modules under test live in the repo root # The modules under test live in the repo root
@@ -31,6 +33,7 @@ class TestCloseIssueCLI(unittest.TestCase):
@patch("close_issue.api_request") @patch("close_issue.api_request")
@patch("close_issue.get_auth_header", return_value=FAKE_AUTH) @patch("close_issue.get_auth_header", return_value=FAKE_AUTH)
def test_successful_close(self, _auth, mock_api): def test_successful_close(self, _auth, mock_api):
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = close_issue.main(["42"]) rc = close_issue.main(["42"])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
mock_api.assert_called_once() mock_api.assert_called_once()
@@ -69,6 +72,7 @@ class TestMarkIssueCLI(unittest.TestCase):
[{"id": 101, "name": "status:in-progress"}], [{"id": 101, "name": "status:in-progress"}],
[{"name": "status:in-progress"}], [{"name": "status:in-progress"}],
] ]
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = mark_issue.main(["15", "start"]) rc = mark_issue.main(["15", "start"])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
self.assertEqual(mock_api.call_count, 2) self.assertEqual(mock_api.call_count, 2)
+6 -2
View File
@@ -79,15 +79,19 @@ class TestAPIPayload(unittest.TestCase):
self.assertEqual(mock_api.call_count, 0) self.assertEqual(mock_api.call_count, 0)
def test_merge_flag_message_points_to_gated_workflow(self): def test_merge_flag_message_points_to_gated_workflow(self):
from _pytest.monkeypatch import MonkeyPatch
import io import io
import contextlib
with patch("review_pr.get_auth_header", return_value=FAKE_CREDS), \ with patch("review_pr.get_auth_header", return_value=FAKE_CREDS), \
patch("review_pr.api_request") as mock_api: patch("review_pr.api_request") as mock_api:
buf = io.StringIO() buf = io.StringIO()
with contextlib.redirect_stderr(buf): monkeypatch = MonkeyPatch()
monkeypatch.setattr(sys, "stderr", buf)
try:
rc = review_pr.main([ rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE", "--merge", "--pr-number", "81", "--event", "APPROVE", "--merge",
]) ])
finally:
monkeypatch.undo()
self.assertEqual(rc, 2) self.assertEqual(rc, 2)
self.assertEqual(mock_api.call_count, 0) self.assertEqual(mock_api.call_count, 0)
msg = buf.getvalue().lower() msg = buf.getvalue().lower()
+26
View File
@@ -14,6 +14,7 @@ each precondition of a blind queue review:
These are the harness assertions from the issue's Required behavior 7. These are the harness assertions from the issue's Required behavior 7.
""" """
import io
import sys import sys
import unittest import unittest
@@ -463,5 +464,30 @@ class TestFinalReport(unittest.TestCase):
self.assertFalse(report["merge_allowed"]) self.assertFalse(report["merge_allowed"])
class TestStdoutIsolation(unittest.TestCase):
"""Regression test for #178: tests must not close or corrupt stdout/stderr
(prevents need for junitxml workaround in full suite runs and review validation).
"""
def test_stdout_remains_usable(self):
"""After typical test activity (mocks, redirects, prints from mains), stdout should be usable."""
# Verify not closed
self.assertFalse(getattr(sys.stdout, "closed", False))
# Should be able to write (even if captured by pytest)
try:
sys.stdout.write("")
sys.stdout.flush()
except Exception as exc:
self.fail(f"stdout write failed after test activity: {exc}")
def test_stderr_remains_usable(self):
self.assertFalse(getattr(sys.stderr, "closed", False))
try:
sys.stderr.write("")
sys.stderr.flush()
except Exception as exc:
self.fail(f"stderr write failed: {exc}")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()