"""Tests for repo_read intern.""" import os import subprocess import tempfile from pathlib import Path from unittest.mock import MagicMock, patch from interns_mcp.interns.base import BudgetExceededError, InternResponse from interns_mcp.interns.repo_read import RepoRead, _top_files_by_size def _make_llm_mock(text="The code prints hello", prompt_tokens=100, completion_tokens=50): """Build a mock LLM response.""" mock = MagicMock() mock.choices = [MagicMock()] mock.choices[0].message.content = text mock.usage.prompt_tokens = prompt_tokens mock.usage.completion_tokens = completion_tokens return mock def test_happy_path(tmp_path): """Repomix pack + LLM answer works.""" intern = RepoRead() intern.system_prompt = "You are a helpful assistant." mock_client = MagicMock() mock_client.chat.completions.create.return_value = _make_llm_mock() target = str(tmp_path) with patch("subprocess.run") as mock_run, \ patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[".env"]): def fake_run(args, **kwargs): out_idx = args.index("--output") Path(args[out_idx + 1]).write_text("# Repo\n\n```python\nprint('hello')\n```") return MagicMock(returncode=0, stdout="", stderr="") mock_run.side_effect = fake_run intern.client = mock_client result = intern.run([target], "What does this repo do?") assert isinstance(result, InternResponse) assert "hello" in result.text.lower() assert result.usage["tokens_in"] == 100 assert result.usage["tokens_out"] == 50 # Verify --ignore flag injected call_args = mock_run.call_args[0][0] assert "--ignore" in call_args ignore_idx = call_args.index("--ignore") assert call_args[ignore_idx + 1] == ".env" # Verify --style xml assert "--style" in call_args style_idx = call_args.index("--style") assert call_args[style_idx + 1] == "xml" def test_tempfile_cleaned_up(tmp_path): """Temp file is removed after successful call.""" intern = RepoRead() intern.system_prompt = "assistant" intern.client = MagicMock() intern.client.chat.completions.create.return_value = _make_llm_mock() created_files = [] with patch("subprocess.run") as mock_run, \ patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): original_mkstemp = tempfile.mkstemp def tracking_mkstemp(*args, **kwargs): fd, path = original_mkstemp(*args, **kwargs) created_files.append(path) return fd, path with patch("interns_mcp.interns.repo_read.tempfile.mkstemp", side_effect=tracking_mkstemp): def fake_run(args, **kwargs): out_idx = args.index("--output") Path(args[out_idx + 1]).write_text("packed") return MagicMock(returncode=0, stdout="", stderr="") mock_run.side_effect = fake_run intern.run([str(tmp_path)], "summarize") # Temp file should be cleaned up for f in created_files: assert not os.path.exists(f), f"Temp file not cleaned up: {f}" def test_tempfile_cleaned_up_on_error(tmp_path): """Temp file is removed even when repomix fails.""" intern = RepoRead() created_files = [] with patch("subprocess.run") as mock_run, \ patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): original_mkstemp = tempfile.mkstemp def tracking_mkstemp(*args, **kwargs): fd, path = original_mkstemp(*args, **kwargs) created_files.append(path) return fd, path with patch("interns_mcp.interns.repo_read.tempfile.mkstemp", side_effect=tracking_mkstemp): mock_run.side_effect = subprocess.TimeoutExpired("npx", 120) intern.run([str(tmp_path)], "summarize") for f in created_files: assert not os.path.exists(f), f"Temp file leaked on error: {f}" def test_compress_flag_passed(tmp_path): """compress=True adds --compress to repomix args.""" intern = RepoRead() intern.system_prompt = "assistant" intern.client = MagicMock() intern.client.chat.completions.create.return_value = _make_llm_mock() with patch("subprocess.run") as mock_run, \ patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): def fake_run(args, **kwargs): out_idx = args.index("--output") Path(args[out_idx + 1]).write_text("packed content") return MagicMock(returncode=0, stdout="", stderr="") mock_run.side_effect = fake_run intern.run([str(tmp_path)], "summarize", compress=True) call_args = mock_run.call_args[0][0] assert "--compress" in call_args def test_multiple_paths_passed_to_repomix(tmp_path): """All paths are forwarded to repomix, not just the first.""" intern = RepoRead() intern.system_prompt = "assistant" intern.client = MagicMock() intern.client.chat.completions.create.return_value = _make_llm_mock() dir1 = tmp_path / "a" dir2 = tmp_path / "b" dir1.mkdir() dir2.mkdir() with patch("subprocess.run") as mock_run, \ patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): def fake_run(args, **kwargs): out_idx = args.index("--output") Path(args[out_idx + 1]).write_text("packed") return MagicMock(returncode=0, stdout="", stderr="") mock_run.side_effect = fake_run intern.run([str(dir1), str(dir2)], "overview") call_args = mock_run.call_args[0][0] assert str(dir1) in call_args assert str(dir2) in call_args def test_empty_paths_returns_error(): """Empty paths list returns error.""" intern = RepoRead() result = intern.run([], "summarize") assert isinstance(result, InternResponse) assert "no paths" in result.text.lower() def test_repomix_timeout_returns_error(tmp_path): """Repomix timeout returns graceful error.""" intern = RepoRead() with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("npx", 120)), \ patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): result = intern.run([str(tmp_path)], "summarize") assert isinstance(result, InternResponse) assert "timed out" in result.text.lower() def test_repomix_failure_returns_error(tmp_path): """Repomix non-zero return returns stderr.""" intern = RepoRead() with patch("subprocess.run") as mock_run, \ patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): mock_run.side_effect = subprocess.CalledProcessError( 1, "npx", stderr="package not found" ) result = intern.run([str(tmp_path)], "summarize") assert isinstance(result, InternResponse) assert "failed" in result.text.lower() def test_npx_not_found_returns_error(tmp_path): """Missing npx returns helpful message.""" intern = RepoRead() with patch("subprocess.run", side_effect=FileNotFoundError), \ patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): result = intern.run([str(tmp_path)], "summarize") assert isinstance(result, InternResponse) assert "node.js" in result.text.lower() def test_budget_exceeded_returns_error(tmp_path): """Large packed output triggers BudgetExceededError.""" intern = RepoRead() intern.context_budget = 10 # very low to force overflow with patch("subprocess.run") as mock_run, \ patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): def fake_run(args, **kwargs): out_idx = args.index("--output") Path(args[out_idx + 1]).write_text("x" * 200) return MagicMock(returncode=0, stdout="", stderr="") mock_run.side_effect = fake_run result = intern.run([str(tmp_path)], "summarize") assert isinstance(result, BudgetExceededError) assert result.tokens > result.limit assert result.hint def test_top_files_by_size_sorts_by_content_length(): """_top_files_by_size returns files sorted by content size, not document order.""" packed = ( 'x\n' '' + "y" * 500 + '\n' '' + "z" * 100 + '\n' ) result = _top_files_by_size(packed, n=3) assert result[0] == "large.py", f"Expected large.py first, got {result[0]}" assert result[1] == "medium.py", f"Expected medium.py second, got {result[1]}" assert result[2] == "small.py", f"Expected small.py third, got {result[2]}" def test_repomix_runs_without_pipes(tmp_path): """Anti-deadlock invariant: repomix must NOT be launched with captured pipes. capture_output / stdout=PIPE spawns reader threads that communicate() joins; an orphaned grandchild holding the pipe write-end makes that join hang forever (the real-world 6-minute repo_read wedge). stdout must be DEVNULL, stdin DEVNULL, and stderr a real file (has fileno) — never subprocess.PIPE. """ intern = RepoRead() intern.system_prompt = "assistant" intern.client = MagicMock() intern.client.chat.completions.create.return_value = _make_llm_mock() with patch("subprocess.run") as mock_run, \ patch("interns_mcp.interns.repo_read.always_ask_globs", return_value=[]): def fake_run(args, **kwargs): out_idx = args.index("--output") Path(args[out_idx + 1]).write_text("packed") return MagicMock(returncode=0) mock_run.side_effect = fake_run intern.run([str(tmp_path)], "summarize") kwargs = mock_run.call_args.kwargs assert kwargs.get("capture_output") is not True, "must not capture_output (pipes)" assert kwargs.get("stdout") is subprocess.DEVNULL, "stdout must be DEVNULL" assert kwargs.get("stdin") is subprocess.DEVNULL, "stdin must be DEVNULL" assert kwargs.get("stderr") is not subprocess.PIPE, "stderr must not be a PIPE" def test_top_files_by_size_limits_count(): """_top_files_by_size respects the n parameter.""" packed = ( 'xx\n' 'yy\n' 'zz\n' ) result = _top_files_by_size(packed, n=2) assert len(result) == 2