Spaces:
Sleeping
Sleeping
File size: 9,958 Bytes
8edee29 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 | """
executors/test_runner.py
------------------------
Test Runner for AutoDevAgent.
Executes the test suite produced by the TestGeneratorAgent against
the generated code. Returns a typed TestResult with pass/fail counts,
individual failure messages, and full output.
Design:
- Python tests: runs the test file in a subprocess using
`python -m unittest` so tests are fully isolated from the
parent process. Parses the unittest output to extract counts
and individual failure messages.
- SQL tests: the generated SQL test file is also a Python file
(using sqlite3 + unittest), so it runs through the same
subprocess path. No special SQL handling needed here.
- If tests fail, the debug loop re-engages β the TestResult
failure messages are passed back to the DebugAgent as context.
- Uses the same timeout as Python execution to prevent runaway tests.
Usage:
from executors.test_runner import TestRunner
from pipeline.state import PipelineState
runner = TestRunner()
# state.generated_tests must be populated by TestGeneratorAgent
updated = runner.run(state)
print(updated["test_result"].passed)
print(updated["test_result"].failures)
"""
import logging
import os
import re
import subprocess
import sys
import tempfile
import time
from typing import Any
from config import settings
from pipeline.state import (
PipelineState,
PipelineStatus,
TestResult,
)
logger = logging.getLogger(__name__)
class TestRunner:
"""
Runs generated test suites in a sandboxed subprocess.
Writes the test code to a temp file, executes it with
`python -m unittest`, parses the output into a typed TestResult,
and cleans up.
Attributes:
timeout: Maximum seconds allowed for the test run.
"""
def __init__(self) -> None:
"""Initialise with timeout from central config."""
# Tests get the same generous timeout as code execution
self.timeout: int = settings.python_execution_timeout
def run(self, state: PipelineState) -> dict[str, Any]:
"""
Execute the generated tests and return a typed TestResult.
Args:
state: Current PipelineState. Reads: generated_tests.
Returns:
Partial state dict with keys:
- "test_result": TestResult
- "status": PipelineStatus.TESTING
"""
test_code = state.generated_tests
if not test_code or not test_code.strip():
logger.warning("TestRunner received empty test code.")
return {
"test_result": TestResult(
passed=False,
output="No test code was provided.",
failures=["No test code was provided to run."],
),
"status": PipelineStatus.TESTING,
}
logger.info(
"TestRunner executing %d lines of test code",
len(test_code.splitlines()),
)
tmp_path = None
try:
# Write test code to a named temp file
tmp_file = tempfile.NamedTemporaryFile(
mode="w",
suffix=".py",
delete=False,
encoding="utf-8",
)
tmp_file.write(test_code)
tmp_file.close()
tmp_path = tmp_file.name
result = self._execute_tests(tmp_path)
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
return {
"test_result": result,
"status": PipelineStatus.TESTING,
}
# ---------------------------------------------------------------- #
# Internal execution #
# ---------------------------------------------------------------- #
def _execute_tests(self, file_path: str) -> TestResult:
"""
Run the test file directly with python file.py and parse output.
The test file must call unittest.main(verbosity=2) so we get
per-test pass/fail status in the output, which we parse into
structured counts and failure messages.
Args:
file_path: Absolute path to the temporary test file.
Returns:
TestResult with pass/fail counts, output, and failures list.
"""
start = time.perf_counter()
try:
proc = subprocess.run(
[sys.executable, file_path],
capture_output=True,
text=True,
timeout=self.timeout,
)
elapsed = round(time.perf_counter() - start, 3)
# unittest writes results to stderr, any extra output to stdout
raw_output = (proc.stderr.strip() + "\n" + proc.stdout.strip()).strip()
# Clean the temp path from output for cleaner display
clean_output = raw_output.replace(file_path, "test_suite.py")
logger.info(
"TestRunner finished in %.3fs β returncode: %d",
elapsed,
proc.returncode,
)
return _parse_unittest_output(clean_output, proc.returncode)
except subprocess.TimeoutExpired:
elapsed = round(time.perf_counter() - start, 3)
msg = f"Test suite timed out after {self.timeout} seconds."
logger.warning("TestRunner timeout after %.3fs", elapsed)
return TestResult(
passed=False,
output=msg,
failures=[msg],
)
except Exception as e:
elapsed = round(time.perf_counter() - start, 3)
msg = f"TestRunner internal error: {e}"
logger.error(msg)
return TestResult(
passed=False,
output=msg,
failures=[msg],
)
# ------------------------------------------------------------------ #
# Helpers #
# ------------------------------------------------------------------ #
def _parse_unittest_output(output: str, returncode: int) -> TestResult:
"""
Parse the output of a unittest run (verbosity=2) into a TestResult.
The verbose unittest output looks like:
test_empty_input (test_suite.TestReverse) ... ok
test_normal_input (test_suite.TestReverse) ... ok
test_single_char (test_suite.TestReverse) ... FAIL
...
FAILED (failures=1) or OK
We extract:
- Total tests run from the final summary line
- Pass/fail counts by scanning each test line
- Failure messages from the FAIL/ERROR blocks
Args:
output: Raw stdout/stderr from the unittest subprocess.
returncode: Process return code (0 = all passed).
Returns:
Typed TestResult with counts and structured failure list.
"""
if not output:
# No output at all β something went wrong before tests ran
return TestResult(
passed=returncode == 0,
output="(no output captured)",
failures=[] if returncode == 0 else ["No test output captured."],
)
lines = output.splitlines()
# ββ Count individual test outcomes ββββββββββββββββββββββββββββββ #
passed_tests = 0
failed_tests = 0
error_tests = 0
for line in lines:
line_lower = line.lower()
if " ... ok" in line_lower:
passed_tests += 1
elif " ... fail" in line_lower:
failed_tests += 1
elif " ... error" in line_lower:
error_tests += 1
total_tests = passed_tests + failed_tests + error_tests
# ββ Try to extract total from the summary line βββββββββββββββββββ #
# e.g. "Ran 3 tests in 0.001s"
ran_match = re.search(r"Ran (\d+) test", output)
if ran_match:
total_tests = int(ran_match.group(1))
# ββ Extract individual failure/error messages ββββββββββββββββββββ #
failures: list[str] = []
# Failure blocks start with "FAIL: test_name" or "ERROR: test_name"
# and end before the next "===" separator or end of output
fail_pattern = re.compile(
r"((?:FAIL|ERROR): .+?)\n" # Header line
r"[-=]+\n" # Separator
r"(.*?)" # Failure body
r"(?=\n[-=]+|\Z)", # Stop at next separator or end
re.DOTALL,
)
for match in fail_pattern.finditer(output):
header = match.group(1).strip()
body = match.group(2).strip()
# Keep the most useful part β last few lines of the traceback
body_lines = body.splitlines()
excerpt = "\n".join(body_lines[-5:]) if len(body_lines) > 5 else body
failures.append(f"{header}\n{excerpt}")
# Fallback: if no structured failures found but returncode != 0
if not failures and returncode != 0:
# Surface the last 10 lines of output as the failure message
tail = "\n".join(lines[-10:])
failures.append(tail)
# Objective: ALL tests must pass. Even a single failure or error
# sends the code back through the debug loop for a rewrite.
all_passed = (failed_tests == 0) and (returncode == 0) and (total_tests > 0)
if all_passed:
logger.info("TestRunner: all %d test(s) passed", total_tests)
else:
logger.info(
"TestRunner: %d passed, %d failed, %d errors",
passed_tests, failed_tests, error_tests,
)
return TestResult(
passed = all_passed,
total_tests = total_tests,
passed_tests = passed_tests,
failed_tests = failed_tests + error_tests,
output = output,
failures = failures,
)
|