crackerjack 0.30.3__py3-none-any.whl โ†’ 0.31.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of crackerjack might be problematic. Click here for more details.

Files changed (156) hide show
  1. crackerjack/CLAUDE.md +1005 -0
  2. crackerjack/RULES.md +380 -0
  3. crackerjack/__init__.py +42 -13
  4. crackerjack/__main__.py +227 -299
  5. crackerjack/agents/__init__.py +41 -0
  6. crackerjack/agents/architect_agent.py +281 -0
  7. crackerjack/agents/base.py +170 -0
  8. crackerjack/agents/coordinator.py +512 -0
  9. crackerjack/agents/documentation_agent.py +498 -0
  10. crackerjack/agents/dry_agent.py +388 -0
  11. crackerjack/agents/formatting_agent.py +245 -0
  12. crackerjack/agents/import_optimization_agent.py +281 -0
  13. crackerjack/agents/performance_agent.py +669 -0
  14. crackerjack/agents/proactive_agent.py +104 -0
  15. crackerjack/agents/refactoring_agent.py +788 -0
  16. crackerjack/agents/security_agent.py +529 -0
  17. crackerjack/agents/test_creation_agent.py +657 -0
  18. crackerjack/agents/test_specialist_agent.py +486 -0
  19. crackerjack/agents/tracker.py +212 -0
  20. crackerjack/api.py +560 -0
  21. crackerjack/cli/__init__.py +24 -0
  22. crackerjack/cli/facade.py +104 -0
  23. crackerjack/cli/handlers.py +267 -0
  24. crackerjack/cli/interactive.py +471 -0
  25. crackerjack/cli/options.py +409 -0
  26. crackerjack/cli/utils.py +18 -0
  27. crackerjack/code_cleaner.py +618 -928
  28. crackerjack/config/__init__.py +19 -0
  29. crackerjack/config/hooks.py +218 -0
  30. crackerjack/core/__init__.py +0 -0
  31. crackerjack/core/async_workflow_orchestrator.py +406 -0
  32. crackerjack/core/autofix_coordinator.py +200 -0
  33. crackerjack/core/container.py +104 -0
  34. crackerjack/core/enhanced_container.py +542 -0
  35. crackerjack/core/performance.py +243 -0
  36. crackerjack/core/phase_coordinator.py +585 -0
  37. crackerjack/core/proactive_workflow.py +316 -0
  38. crackerjack/core/session_coordinator.py +289 -0
  39. crackerjack/core/workflow_orchestrator.py +826 -0
  40. crackerjack/dynamic_config.py +94 -103
  41. crackerjack/errors.py +263 -41
  42. crackerjack/executors/__init__.py +11 -0
  43. crackerjack/executors/async_hook_executor.py +431 -0
  44. crackerjack/executors/cached_hook_executor.py +242 -0
  45. crackerjack/executors/hook_executor.py +345 -0
  46. crackerjack/executors/individual_hook_executor.py +669 -0
  47. crackerjack/intelligence/__init__.py +44 -0
  48. crackerjack/intelligence/adaptive_learning.py +751 -0
  49. crackerjack/intelligence/agent_orchestrator.py +551 -0
  50. crackerjack/intelligence/agent_registry.py +414 -0
  51. crackerjack/intelligence/agent_selector.py +502 -0
  52. crackerjack/intelligence/integration.py +290 -0
  53. crackerjack/interactive.py +576 -315
  54. crackerjack/managers/__init__.py +11 -0
  55. crackerjack/managers/async_hook_manager.py +135 -0
  56. crackerjack/managers/hook_manager.py +137 -0
  57. crackerjack/managers/publish_manager.py +433 -0
  58. crackerjack/managers/test_command_builder.py +151 -0
  59. crackerjack/managers/test_executor.py +443 -0
  60. crackerjack/managers/test_manager.py +258 -0
  61. crackerjack/managers/test_manager_backup.py +1124 -0
  62. crackerjack/managers/test_progress.py +114 -0
  63. crackerjack/mcp/__init__.py +0 -0
  64. crackerjack/mcp/cache.py +336 -0
  65. crackerjack/mcp/client_runner.py +104 -0
  66. crackerjack/mcp/context.py +621 -0
  67. crackerjack/mcp/dashboard.py +636 -0
  68. crackerjack/mcp/enhanced_progress_monitor.py +479 -0
  69. crackerjack/mcp/file_monitor.py +336 -0
  70. crackerjack/mcp/progress_components.py +569 -0
  71. crackerjack/mcp/progress_monitor.py +949 -0
  72. crackerjack/mcp/rate_limiter.py +332 -0
  73. crackerjack/mcp/server.py +22 -0
  74. crackerjack/mcp/server_core.py +244 -0
  75. crackerjack/mcp/service_watchdog.py +501 -0
  76. crackerjack/mcp/state.py +395 -0
  77. crackerjack/mcp/task_manager.py +257 -0
  78. crackerjack/mcp/tools/__init__.py +17 -0
  79. crackerjack/mcp/tools/core_tools.py +249 -0
  80. crackerjack/mcp/tools/error_analyzer.py +308 -0
  81. crackerjack/mcp/tools/execution_tools.py +372 -0
  82. crackerjack/mcp/tools/execution_tools_backup.py +1097 -0
  83. crackerjack/mcp/tools/intelligence_tool_registry.py +80 -0
  84. crackerjack/mcp/tools/intelligence_tools.py +314 -0
  85. crackerjack/mcp/tools/monitoring_tools.py +502 -0
  86. crackerjack/mcp/tools/proactive_tools.py +384 -0
  87. crackerjack/mcp/tools/progress_tools.py +217 -0
  88. crackerjack/mcp/tools/utility_tools.py +341 -0
  89. crackerjack/mcp/tools/workflow_executor.py +565 -0
  90. crackerjack/mcp/websocket/__init__.py +14 -0
  91. crackerjack/mcp/websocket/app.py +39 -0
  92. crackerjack/mcp/websocket/endpoints.py +559 -0
  93. crackerjack/mcp/websocket/jobs.py +253 -0
  94. crackerjack/mcp/websocket/server.py +116 -0
  95. crackerjack/mcp/websocket/websocket_handler.py +78 -0
  96. crackerjack/mcp/websocket_server.py +10 -0
  97. crackerjack/models/__init__.py +31 -0
  98. crackerjack/models/config.py +93 -0
  99. crackerjack/models/config_adapter.py +230 -0
  100. crackerjack/models/protocols.py +118 -0
  101. crackerjack/models/task.py +154 -0
  102. crackerjack/monitoring/ai_agent_watchdog.py +450 -0
  103. crackerjack/monitoring/regression_prevention.py +638 -0
  104. crackerjack/orchestration/__init__.py +0 -0
  105. crackerjack/orchestration/advanced_orchestrator.py +970 -0
  106. crackerjack/orchestration/coverage_improvement.py +223 -0
  107. crackerjack/orchestration/execution_strategies.py +341 -0
  108. crackerjack/orchestration/test_progress_streamer.py +636 -0
  109. crackerjack/plugins/__init__.py +15 -0
  110. crackerjack/plugins/base.py +200 -0
  111. crackerjack/plugins/hooks.py +246 -0
  112. crackerjack/plugins/loader.py +335 -0
  113. crackerjack/plugins/managers.py +259 -0
  114. crackerjack/py313.py +8 -3
  115. crackerjack/services/__init__.py +22 -0
  116. crackerjack/services/cache.py +314 -0
  117. crackerjack/services/config.py +358 -0
  118. crackerjack/services/config_integrity.py +99 -0
  119. crackerjack/services/contextual_ai_assistant.py +516 -0
  120. crackerjack/services/coverage_ratchet.py +356 -0
  121. crackerjack/services/debug.py +736 -0
  122. crackerjack/services/dependency_monitor.py +617 -0
  123. crackerjack/services/enhanced_filesystem.py +439 -0
  124. crackerjack/services/file_hasher.py +151 -0
  125. crackerjack/services/filesystem.py +421 -0
  126. crackerjack/services/git.py +176 -0
  127. crackerjack/services/health_metrics.py +611 -0
  128. crackerjack/services/initialization.py +873 -0
  129. crackerjack/services/log_manager.py +286 -0
  130. crackerjack/services/logging.py +174 -0
  131. crackerjack/services/metrics.py +578 -0
  132. crackerjack/services/pattern_cache.py +362 -0
  133. crackerjack/services/pattern_detector.py +515 -0
  134. crackerjack/services/performance_benchmarks.py +653 -0
  135. crackerjack/services/security.py +163 -0
  136. crackerjack/services/server_manager.py +234 -0
  137. crackerjack/services/smart_scheduling.py +144 -0
  138. crackerjack/services/tool_version_service.py +61 -0
  139. crackerjack/services/unified_config.py +437 -0
  140. crackerjack/services/version_checker.py +248 -0
  141. crackerjack/slash_commands/__init__.py +14 -0
  142. crackerjack/slash_commands/init.md +122 -0
  143. crackerjack/slash_commands/run.md +163 -0
  144. crackerjack/slash_commands/status.md +127 -0
  145. crackerjack-0.31.7.dist-info/METADATA +742 -0
  146. crackerjack-0.31.7.dist-info/RECORD +149 -0
  147. crackerjack-0.31.7.dist-info/entry_points.txt +2 -0
  148. crackerjack/.gitignore +0 -34
  149. crackerjack/.libcst.codemod.yaml +0 -18
  150. crackerjack/.pdm.toml +0 -1
  151. crackerjack/crackerjack.py +0 -3805
  152. crackerjack/pyproject.toml +0 -286
  153. crackerjack-0.30.3.dist-info/METADATA +0 -1290
  154. crackerjack-0.30.3.dist-info/RECORD +0 -16
  155. {crackerjack-0.30.3.dist-info โ†’ crackerjack-0.31.7.dist-info}/WHEEL +0 -0
  156. {crackerjack-0.30.3.dist-info โ†’ crackerjack-0.31.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,443 @@
1
+ """Test execution engine with progress tracking and output parsing.
2
+
3
+ This module handles the actual test execution, subprocess management, and real-time
4
+ output parsing. Split from test_manager.py for better separation of concerns.
5
+ """
6
+
7
+ import re
8
+ import subprocess
9
+ import threading
10
+ import time
11
+ import typing as t
12
+ from pathlib import Path
13
+
14
+ from rich.console import Console
15
+ from rich.live import Live
16
+
17
+ from .test_progress import TestProgress
18
+
19
+
20
+ class TestExecutor:
21
+ """Handles test execution with real-time progress tracking."""
22
+
23
+ def __init__(self, console: Console, pkg_path: Path) -> None:
24
+ self.console = console
25
+ self.pkg_path = pkg_path
26
+
27
+ def execute_with_progress(
28
+ self,
29
+ cmd: list[str],
30
+ timeout: int = 600,
31
+ ) -> subprocess.CompletedProcess[str]:
32
+ """Execute test command with live progress display."""
33
+ return self._execute_with_live_progress(cmd, timeout)
34
+
35
+ def execute_with_ai_progress(
36
+ self,
37
+ cmd: list[str],
38
+ progress_callback: t.Callable[[dict[str, t.Any]], None],
39
+ timeout: int = 600,
40
+ ) -> subprocess.CompletedProcess[str]:
41
+ """Execute test command with AI-compatible progress callbacks."""
42
+ return self._run_test_command_with_ai_progress(cmd, progress_callback, timeout)
43
+
44
+ def _execute_with_live_progress(
45
+ self, cmd: list[str], timeout: int
46
+ ) -> subprocess.CompletedProcess[str]:
47
+ """Execute tests with Rich live progress display."""
48
+ progress = self._initialize_progress()
49
+
50
+ with Live(progress.format_progress(), console=self.console) as live:
51
+ env = self._setup_test_environment()
52
+
53
+ process = subprocess.Popen(
54
+ cmd,
55
+ cwd=self.pkg_path,
56
+ stdout=subprocess.PIPE,
57
+ stderr=subprocess.PIPE,
58
+ text=True,
59
+ bufsize=1,
60
+ universal_newlines=True,
61
+ env=env,
62
+ )
63
+
64
+ # Start reader threads
65
+ stdout_thread, stderr_thread, monitor_thread = self._start_reader_threads(
66
+ process, progress, live
67
+ )
68
+
69
+ # Wait for completion
70
+ try:
71
+ process.wait(timeout=timeout)
72
+ except subprocess.TimeoutExpired:
73
+ self._handle_progress_error(
74
+ process, progress, "Test execution timed out"
75
+ )
76
+
77
+ # Cleanup
78
+ self._cleanup_threads([stdout_thread, stderr_thread, monitor_thread])
79
+
80
+ # Return completed process with string output
81
+ stdout_str = process.stdout.read() if process.stdout else ""
82
+ stderr_str = process.stderr.read() if process.stderr else ""
83
+ return subprocess.CompletedProcess(
84
+ cmd, process.returncode, stdout_str, stderr_str
85
+ )
86
+
87
+ def _run_test_command_with_ai_progress(
88
+ self,
89
+ cmd: list[str],
90
+ progress_callback: t.Callable[[dict[str, t.Any]], None],
91
+ timeout: int = 600,
92
+ ) -> subprocess.CompletedProcess[str]:
93
+ """Execute test command with AI progress callbacks."""
94
+ progress = self._initialize_progress()
95
+ env = self._setup_coverage_env()
96
+
97
+ result = self._execute_test_process_with_progress(
98
+ cmd, env, progress, progress_callback, timeout
99
+ )
100
+
101
+ return result
102
+
103
+ def _initialize_progress(self) -> TestProgress:
104
+ """Initialize progress tracker."""
105
+ progress = TestProgress()
106
+ progress.start_time = time.time()
107
+ return progress
108
+
109
+ def _setup_test_environment(self) -> dict[str, str]:
110
+ """Set up environment variables for test execution."""
111
+ import os
112
+
113
+ cache_dir = Path.home() / ".cache" / "crackerjack" / "coverage"
114
+ cache_dir.mkdir(parents=True, exist_ok=True)
115
+
116
+ env = os.environ.copy()
117
+ env["COVERAGE_FILE"] = str(cache_dir / ".coverage")
118
+ env["PYTEST_CURRENT_TEST"] = ""
119
+ return env
120
+
121
+ def _setup_coverage_env(self) -> dict[str, str]:
122
+ """Set up coverage environment for AI mode."""
123
+ import os
124
+ from pathlib import Path
125
+
126
+ cache_dir = Path.home() / ".cache" / "crackerjack" / "coverage"
127
+ cache_dir.mkdir(parents=True, exist_ok=True)
128
+
129
+ env = os.environ.copy()
130
+ env["COVERAGE_FILE"] = str(cache_dir / ".coverage")
131
+ return env
132
+
133
+ def _start_reader_threads(
134
+ self, process: subprocess.Popen[str], progress: TestProgress, live: Live
135
+ ) -> tuple[threading.Thread, threading.Thread, threading.Thread]:
136
+ """Start threads for reading stdout, stderr, and monitoring."""
137
+ stdout_thread = self._create_stdout_reader(process, progress, live)
138
+ stderr_thread = self._create_stderr_reader(process, progress, live)
139
+ monitor_thread = self._create_monitor_thread(progress)
140
+
141
+ stdout_thread.start()
142
+ stderr_thread.start()
143
+ monitor_thread.start()
144
+
145
+ return stdout_thread, stderr_thread, monitor_thread
146
+
147
+ def _create_stdout_reader(
148
+ self, process: subprocess.Popen[str], progress: TestProgress, live: Live
149
+ ) -> threading.Thread:
150
+ """Create thread for reading stdout."""
151
+
152
+ def read_output() -> None:
153
+ if process.stdout:
154
+ for line in iter(process.stdout.readline, ""):
155
+ if line.strip():
156
+ self._process_test_output_line(line.strip(), progress)
157
+ self._update_display_if_needed(progress, live)
158
+ if progress.is_complete:
159
+ break
160
+
161
+ return threading.Thread(target=read_output, daemon=True)
162
+
163
+ def _create_stderr_reader(
164
+ self, process: subprocess.Popen[str], progress: TestProgress, live: Live
165
+ ) -> threading.Thread:
166
+ """Create thread for reading stderr."""
167
+
168
+ def read_stderr() -> None:
169
+ if process.stderr:
170
+ for line in iter(process.stderr.readline, ""):
171
+ if line.strip() and "warning" not in line.lower():
172
+ progress.update(current_test=f"โš ๏ธ {line.strip()}")
173
+ self._update_display_if_needed(progress, live)
174
+
175
+ return threading.Thread(target=read_stderr, daemon=True)
176
+
177
+ def _create_monitor_thread(self, progress: TestProgress) -> threading.Thread:
178
+ """Create thread for monitoring stuck tests."""
179
+
180
+ def monitor_stuck_tests() -> None:
181
+ last_update = time.time()
182
+ last_test = ""
183
+
184
+ while not progress.is_complete:
185
+ time.sleep(5)
186
+ current_time = time.time()
187
+
188
+ if (
189
+ progress.current_test == last_test
190
+ and current_time - last_update > 30
191
+ ):
192
+ self._mark_test_as_stuck(progress, progress.current_test)
193
+ last_update = current_time
194
+ elif progress.current_test != last_test:
195
+ last_test = progress.current_test
196
+ last_update = current_time
197
+
198
+ return threading.Thread(target=monitor_stuck_tests, daemon=True)
199
+
200
+ def _process_test_output_line(self, line: str, progress: TestProgress) -> None:
201
+ """Process a single line of test output."""
202
+ self._parse_test_line(line, progress)
203
+
204
+ def _parse_test_line(self, line: str, progress: TestProgress) -> None:
205
+ """Parse test output line and update progress."""
206
+ # Handle collection completion
207
+ if self._handle_collection_completion(line, progress):
208
+ return
209
+
210
+ # Handle session events
211
+ if self._handle_session_events(line, progress):
212
+ return
213
+
214
+ # Handle collection progress
215
+ if self._handle_collection_progress(line, progress):
216
+ return
217
+
218
+ # Handle test execution
219
+ if self._handle_test_execution(line, progress):
220
+ return
221
+
222
+ def _handle_collection_completion(self, line: str, progress: TestProgress) -> bool:
223
+ """Handle test collection completion."""
224
+ if "collected" in line and ("item" in line or "test" in line):
225
+ match = re.search(r"(\d+) (?:item|test)", line)
226
+ if match:
227
+ progress.update(
228
+ total_tests=int(match.group(1)),
229
+ is_collecting=False,
230
+ collection_status="Collection complete",
231
+ )
232
+ return True
233
+ return False
234
+
235
+ def _handle_session_events(self, line: str, progress: TestProgress) -> bool:
236
+ """Handle pytest session events."""
237
+ if "session starts" in line and progress.collection_status != "Session started":
238
+ progress.update(collection_status="Session started")
239
+ return True
240
+ elif (
241
+ "test session starts" in line
242
+ and progress.collection_status != "Test collection started"
243
+ ):
244
+ progress.update(collection_status="Test collection started")
245
+ return True
246
+ return False
247
+
248
+ def _handle_collection_progress(self, line: str, progress: TestProgress) -> bool:
249
+ """Handle collection progress updates."""
250
+ if progress.is_collecting:
251
+ # Look for file discovery patterns
252
+ if line.endswith(".py") and ("test_" in line or "_test.py" in line):
253
+ with progress._lock:
254
+ if line not in progress._seen_files:
255
+ progress._seen_files.add(line)
256
+ progress.files_discovered += 1
257
+ progress.collection_status = (
258
+ f"Found {progress.files_discovered} test files..."
259
+ )
260
+ return True
261
+ return False
262
+
263
+ def _handle_test_execution(self, line: str, progress: TestProgress) -> bool:
264
+ """Handle test execution progress."""
265
+ # Test result patterns
266
+ if " PASSED " in line:
267
+ progress.update(passed=progress.passed + 1)
268
+ self._extract_current_test(line, progress)
269
+ return True
270
+ elif " FAILED " in line:
271
+ progress.update(failed=progress.failed + 1)
272
+ self._extract_current_test(line, progress)
273
+ return True
274
+ elif " SKIPPED " in line:
275
+ progress.update(skipped=progress.skipped + 1)
276
+ self._extract_current_test(line, progress)
277
+ return True
278
+ elif " ERROR " in line:
279
+ progress.update(errors=progress.errors + 1)
280
+ self._extract_current_test(line, progress)
281
+ return True
282
+ elif "::" in line and any(x in line for x in ("RUNNING", "test_")):
283
+ self._handle_running_test(line, progress)
284
+ return True
285
+
286
+ return False
287
+
288
+ def _handle_running_test(self, line: str, progress: TestProgress) -> None:
289
+ """Handle currently running test indicator."""
290
+ if "::" in line:
291
+ # Extract test name from line
292
+ test_parts = line.split("::")
293
+ if len(test_parts) >= 2:
294
+ test_name = "::".join(test_parts[-2:])
295
+ progress.update(current_test=test_name)
296
+
297
+ def _extract_current_test(self, line: str, progress: TestProgress) -> None:
298
+ """Extract current test name from output line."""
299
+ if "::" in line:
300
+ # Extract test identifier
301
+ parts = line.split(" ")
302
+ for part in parts:
303
+ if "::" in part:
304
+ progress.update(current_test=part)
305
+ break
306
+
307
+ def _update_display_if_needed(self, progress: TestProgress, live: Live) -> None:
308
+ """Update display if enough time has passed or significant change occurred."""
309
+ if self._should_refresh_display(progress):
310
+ live.update(progress.format_progress())
311
+
312
+ def _should_refresh_display(self, progress: TestProgress) -> bool:
313
+ """Determine if display should be refreshed."""
314
+ # Only refresh on significant changes to reduce spam
315
+ return (
316
+ progress.is_complete
317
+ or progress.total_tests > 0
318
+ or len(progress.current_test) > 0
319
+ )
320
+
321
+ def _mark_test_as_stuck(self, progress: TestProgress, test_name: str) -> None:
322
+ """Mark a test as potentially stuck."""
323
+ if test_name:
324
+ progress.update(current_test=f"๐ŸŒ {test_name} (slow)")
325
+
326
+ def _cleanup_threads(self, threads: list[threading.Thread]) -> None:
327
+ """Clean up reader threads."""
328
+ for thread in threads:
329
+ if thread.is_alive():
330
+ thread.join(timeout=1.0)
331
+
332
+ def _handle_progress_error(
333
+ self, process: subprocess.Popen[str], progress: TestProgress, error_msg: str
334
+ ) -> None:
335
+ """Handle progress tracking errors."""
336
+ process.terminate()
337
+ progress.update(is_complete=True, current_test=f"โŒ {error_msg}")
338
+
339
+ def _execute_test_process_with_progress(
340
+ self,
341
+ cmd: list[str],
342
+ env: dict[str, str],
343
+ progress: TestProgress,
344
+ progress_callback: t.Callable[[dict[str, t.Any]], None],
345
+ timeout: int,
346
+ ) -> subprocess.CompletedProcess[str]:
347
+ """Execute test process with AI progress tracking."""
348
+ process = subprocess.Popen(
349
+ cmd,
350
+ cwd=self.pkg_path,
351
+ stdout=subprocess.PIPE,
352
+ stderr=subprocess.PIPE,
353
+ text=True,
354
+ env=env,
355
+ )
356
+
357
+ stdout_lines = self._read_stdout_with_progress(
358
+ process, progress, progress_callback
359
+ )
360
+ stderr_lines = self._read_stderr_lines(process)
361
+
362
+ return_code = self._wait_for_process_completion(process, timeout)
363
+
364
+ return subprocess.CompletedProcess(
365
+ cmd, return_code, "\n".join(stdout_lines), "\n".join(stderr_lines)
366
+ )
367
+
368
+ def _read_stdout_with_progress(
369
+ self,
370
+ process: subprocess.Popen[str],
371
+ progress: TestProgress,
372
+ progress_callback: t.Callable[[dict[str, t.Any]], None],
373
+ ) -> list[str]:
374
+ """Read stdout with progress updates."""
375
+ stdout_lines = []
376
+
377
+ if process.stdout:
378
+ for line in iter(process.stdout.readline, ""):
379
+ if not line:
380
+ break
381
+
382
+ line = line.strip()
383
+ if line:
384
+ stdout_lines.append(line)
385
+ self._process_test_output_line(line, progress)
386
+ self._emit_ai_progress(progress, progress_callback)
387
+
388
+ return stdout_lines
389
+
390
+ def _read_stderr_lines(self, process: subprocess.Popen[str]) -> list[str]:
391
+ """Read stderr lines."""
392
+ stderr_lines = []
393
+
394
+ if process.stderr:
395
+ for line in iter(process.stderr.readline, ""):
396
+ if not line:
397
+ break
398
+ line = line.strip()
399
+ if line:
400
+ stderr_lines.append(line)
401
+
402
+ return stderr_lines
403
+
404
+ def _wait_for_process_completion(
405
+ self, process: subprocess.Popen[str], timeout: int
406
+ ) -> int:
407
+ """Wait for process completion with timeout."""
408
+ try:
409
+ process.wait(timeout=timeout)
410
+ return process.returncode
411
+ except subprocess.TimeoutExpired:
412
+ process.terminate()
413
+ return -1
414
+
415
+ def _emit_ai_progress(
416
+ self,
417
+ progress: TestProgress,
418
+ progress_callback: t.Callable[[dict[str, t.Any]], None],
419
+ ) -> None:
420
+ """Emit progress update for AI consumption."""
421
+ progress_data = {
422
+ "type": "test_progress",
423
+ "total_tests": progress.total_tests,
424
+ "completed": progress.completed,
425
+ "passed": progress.passed,
426
+ "failed": progress.failed,
427
+ "skipped": progress.skipped,
428
+ "errors": progress.errors,
429
+ "current_test": progress.current_test,
430
+ "elapsed_time": progress.elapsed_time,
431
+ "is_collecting": progress.is_collecting,
432
+ "is_complete": progress.is_complete,
433
+ "collection_status": progress.collection_status,
434
+ }
435
+
436
+ if progress.eta_seconds:
437
+ progress_data["eta_seconds"] = progress.eta_seconds
438
+
439
+ from contextlib import suppress
440
+
441
+ with suppress(Exception):
442
+ # Don't let progress callback errors affect test execution
443
+ progress_callback(progress_data)
@@ -0,0 +1,258 @@
1
+ """Refactored test manager with focused responsibilities.
2
+
3
+ This is the new modular test manager that delegates to specialized components:
4
+ - TestExecutor: Handles test execution and progress tracking
5
+ - TestCommandBuilder: Builds pytest commands with appropriate options
6
+ - TestProgress: Tracks and displays test progress
7
+ - CoverageRatchetService: Manages coverage requirements
8
+
9
+ REFACTORING NOTE: Original test_manager.py was 1133 lines with 60+ methods.
10
+ This refactored version is ~200 lines and delegates to focused modules.
11
+ """
12
+
13
+ import subprocess
14
+ import time
15
+ import typing as t
16
+ from pathlib import Path
17
+
18
+ from rich.console import Console
19
+
20
+ from crackerjack.models.protocols import OptionsProtocol
21
+ from crackerjack.services.coverage_ratchet import CoverageRatchetService
22
+
23
+ from .test_command_builder import TestCommandBuilder
24
+ from .test_executor import TestExecutor
25
+
26
+
27
+ class TestManager:
28
+ """Refactored test manager with modular architecture."""
29
+
30
+ def __init__(self, console: Console, pkg_path: Path) -> None:
31
+ self.console = console
32
+ self.pkg_path = pkg_path
33
+
34
+ # Initialize specialized components
35
+ self.executor = TestExecutor(console, pkg_path)
36
+ self.command_builder = TestCommandBuilder(pkg_path)
37
+ self.coverage_ratchet = CoverageRatchetService(pkg_path, console)
38
+
39
+ # State
40
+ self._last_test_failures: list[str] = []
41
+ self._progress_callback: t.Callable[[dict[str, t.Any]], None] | None = None
42
+ self.coverage_ratchet_enabled = True
43
+
44
+ def set_progress_callback(
45
+ self,
46
+ callback: t.Callable[[dict[str, t.Any]], None] | None,
47
+ ) -> None:
48
+ """Set callback for AI mode structured progress updates."""
49
+ self._progress_callback = callback
50
+
51
+ def set_coverage_ratchet_enabled(self, enabled: bool) -> None:
52
+ """Enable or disable the coverage ratchet system."""
53
+ self.coverage_ratchet_enabled = enabled
54
+ if enabled:
55
+ self.console.print(
56
+ "[cyan]๐Ÿ“Š[/cyan] Coverage ratchet enabled - targeting 100% coverage"
57
+ )
58
+ else:
59
+ self.console.print("[yellow]โš ๏ธ[/yellow] Coverage ratchet disabled")
60
+
61
+ def run_tests(self, options: OptionsProtocol) -> bool:
62
+ """Run tests with comprehensive progress tracking and coverage analysis."""
63
+ start_time = time.time()
64
+
65
+ try:
66
+ result = self._execute_test_workflow(options)
67
+ duration = time.time() - start_time
68
+
69
+ if result:
70
+ return self._handle_test_success(result.stdout, duration)
71
+ else:
72
+ return self._handle_test_failure(
73
+ result.stderr if result else "", duration
74
+ )
75
+
76
+ except Exception as e:
77
+ return self._handle_test_error(start_time, e)
78
+
79
+ def run_specific_tests(self, test_pattern: str) -> bool:
80
+ """Run tests matching a specific pattern."""
81
+ self.console.print(f"[cyan]๐Ÿงช[/cyan] Running tests matching: {test_pattern}")
82
+
83
+ cmd = self.command_builder.build_specific_test_command(test_pattern)
84
+ result = self.executor.execute_with_progress(cmd)
85
+
86
+ success = result.returncode == 0
87
+ if success:
88
+ self.console.print("[green]โœ…[/green] Specific tests passed")
89
+ else:
90
+ self.console.print("[red]โŒ[/red] Some specific tests failed")
91
+
92
+ return success
93
+
94
+ def validate_test_environment(self) -> bool:
95
+ """Validate test environment and configuration."""
96
+ if not self.has_tests():
97
+ self.console.print("[yellow]โš ๏ธ[/yellow] No tests found")
98
+ return False
99
+
100
+ # Test basic pytest collection
101
+ cmd = self.command_builder.build_validation_command()
102
+ result = subprocess.run(cmd, cwd=self.pkg_path, capture_output=True, text=True)
103
+
104
+ if result.returncode != 0:
105
+ self.console.print("[red]โŒ[/red] Test environment validation failed")
106
+ self.console.print(result.stderr)
107
+ return False
108
+
109
+ self.console.print("[green]โœ…[/green] Test environment validated")
110
+ return True
111
+
112
+ def get_coverage_ratchet_status(self) -> dict[str, t.Any]:
113
+ """Get comprehensive coverage ratchet status."""
114
+ return self.coverage_ratchet.get_status_report()
115
+
116
+ def get_test_stats(self) -> dict[str, t.Any]:
117
+ """Get comprehensive test execution statistics."""
118
+ return {
119
+ "has_tests": self.has_tests(),
120
+ "coverage_ratchet_enabled": self.coverage_ratchet_enabled,
121
+ "last_failures_count": len(self._last_test_failures),
122
+ }
123
+
124
+ def get_test_failures(self) -> list[str]:
125
+ """Get list of recent test failures."""
126
+ return self._last_test_failures.copy()
127
+
128
+ def get_test_command(self, options: OptionsProtocol) -> list[str]:
129
+ """Get the test command that would be executed."""
130
+ return self.command_builder.build_command(options)
131
+
132
+ def get_coverage_report(self) -> str | None:
133
+ """Get coverage report if available."""
134
+ try:
135
+ return self.coverage_ratchet.get_coverage_report()
136
+ except Exception:
137
+ return None
138
+
139
+ def has_tests(self) -> bool:
140
+ """Check if project has tests."""
141
+ test_directories = ["tests", "test"]
142
+ test_files = ["test_*.py", "*_test.py"]
143
+
144
+ for test_dir in test_directories:
145
+ test_path = self.pkg_path / test_dir
146
+ if test_path.exists() and test_path.is_dir():
147
+ for test_file_pattern in test_files:
148
+ if list(test_path.glob(f"**/{test_file_pattern}")):
149
+ return True
150
+
151
+ # Check for test files in root directory
152
+ for test_file_pattern in test_files:
153
+ if list(self.pkg_path.glob(test_file_pattern)):
154
+ return True
155
+
156
+ return False
157
+
158
+ # Private implementation methods
159
+
160
+ def _execute_test_workflow(
161
+ self, options: OptionsProtocol
162
+ ) -> subprocess.CompletedProcess[str]:
163
+ """Execute the complete test workflow."""
164
+ self._print_test_start_message(options)
165
+
166
+ cmd = self.command_builder.build_command(options)
167
+
168
+ if self._progress_callback:
169
+ return self.executor.execute_with_ai_progress(
170
+ cmd, self._progress_callback, self._get_timeout(options)
171
+ )
172
+ return self.executor.execute_with_progress(cmd, self._get_timeout(options))
173
+
174
+ def _print_test_start_message(self, options: OptionsProtocol) -> None:
175
+ """Print test execution start message."""
176
+ workers = self.command_builder.get_optimal_workers(options)
177
+ timeout = self.command_builder.get_test_timeout(options)
178
+
179
+ self.console.print(
180
+ f"[cyan]๐Ÿงช[/cyan] Running tests (workers: {workers}, timeout: {timeout}s)"
181
+ )
182
+
183
+ def _handle_test_success(self, output: str, duration: float) -> bool:
184
+ """Handle successful test execution."""
185
+ self.console.print(f"[green]โœ…[/green] Tests passed in {duration:.1f}s")
186
+
187
+ if self.coverage_ratchet_enabled:
188
+ return self._process_coverage_ratchet()
189
+
190
+ return True
191
+
192
+ def _handle_test_failure(self, output: str, duration: float) -> bool:
193
+ """Handle failed test execution."""
194
+ self.console.print(f"[red]โŒ[/red] Tests failed in {duration:.1f}s")
195
+
196
+ self._last_test_failures = self._extract_failure_lines(output)
197
+ return False
198
+
199
+ def _handle_test_error(self, start_time: float, error: Exception) -> bool:
200
+ """Handle test execution errors."""
201
+ duration = time.time() - start_time
202
+ self.console.print(
203
+ f"[red]๐Ÿ’ฅ[/red] Test execution error after {duration:.1f}s: {error}"
204
+ )
205
+ return False
206
+
207
+ def _process_coverage_ratchet(self) -> bool:
208
+ """Process coverage ratchet checks."""
209
+ if not self.coverage_ratchet_enabled:
210
+ return True
211
+
212
+ ratchet_result = self.coverage_ratchet.check_and_update_coverage()
213
+ return self._handle_ratchet_result(ratchet_result)
214
+
215
+ def _handle_ratchet_result(self, ratchet_result: dict[str, t.Any]) -> bool:
216
+ """Handle coverage ratchet results."""
217
+ if ratchet_result.get("success", False):
218
+ if ratchet_result.get("improved", False):
219
+ self._handle_coverage_improvement(ratchet_result)
220
+ return True
221
+ else:
222
+ self.console.print(
223
+ f"[red]๐Ÿ“‰[/red] Coverage regression: "
224
+ f"{ratchet_result.get('current_coverage', 0):.2f}% < "
225
+ f"{ratchet_result.get('previous_coverage', 0):.2f}%"
226
+ )
227
+ return False
228
+
229
+ def _handle_coverage_improvement(self, ratchet_result: dict[str, t.Any]) -> None:
230
+ """Handle coverage improvements."""
231
+ improvement = ratchet_result.get("improvement", 0)
232
+ current = ratchet_result.get("current_coverage", 0)
233
+
234
+ self.console.print(
235
+ f"[green]๐Ÿ“ˆ[/green] Coverage improved by {improvement:.2f}% "
236
+ f"to {current:.2f}%"
237
+ )
238
+
239
+ def _extract_failure_lines(self, output: str) -> list[str]:
240
+ """Extract failure information from test output."""
241
+ failures = []
242
+ lines = output.split("\n")
243
+
244
+ for line in lines:
245
+ if any(
246
+ keyword in line for keyword in ("FAILED", "ERROR", "AssertionError")
247
+ ):
248
+ failures.append(line.strip())
249
+
250
+ return failures[:10] # Limit to first 10 failures
251
+
252
+ def _get_timeout(self, options: OptionsProtocol) -> int:
253
+ """Get timeout for test execution."""
254
+ return self.command_builder.get_test_timeout(options)
255
+
256
+
257
+ # For backward compatibility, also export the old class name
258
+ TestManagementImpl = TestManager