File size: 3,397 Bytes
b51092e
 
 
 
8e26883
16ef9ea
f8b17b7
007ba8f
b51092e
 
 
 
 
 
 
 
007ba8f
f8b17b7
b51092e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b92050
007ba8f
b51092e
 
 
 
 
 
 
 
3c64c1d
 
8e26883
3c64c1d
 
 
 
decea2b
3c64c1d
 
 
 
b51092e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
007ba8f
b51092e
decea2b
 
 
 
 
 
 
b51092e
 
 
 
 
 
 
 
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
"""
Runs pytest against a cloned repository inside a resource-limited sandbox.
"""
import os
import sys
import subprocess   # nosec B404
from typing import Any

from app.core.logger import get_logger
from app.tools.sandbox import run_sandboxed

logger = get_logger(__name__)

# Hard cap on how many test output lines we forward to the LLM
_MAX_OUTPUT_LINES = 100


def run_tests(local_path: str) -> dict[str, Any]:
    """
    Execute pytest in local_path inside the sandbox and return a summary.

    Returns:
        {
            "passed":    int,
            "failed":    int,
            "errors":    int,
            "output":    str,   # truncated pytest stdout/stderr
            "timed_out": bool,
            "oom_killed": bool,
        }
    """
    summary: dict[str, Any] = {
        "passed":    0,
        "failed":    0,
        "errors":    0,
        "output":    "",
        "timed_out": False,
        "oom_killed": False,
        "success":   False,
    }

    # Minimal safe env: inherit PATH so pytest is findable, but strip
    # secrets that might be present in the parent environment
    safe_env = {
        "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
        "HOME": "/tmp",  # nosec B108 — restrict home dir
        "PYTHONDONTWRITEBYTECODE": "1",
    }
    try:
        result = run_sandboxed(
            cmd=[sys.executable, "-m", "pytest", "--tb=short", "-q", "--no-header"],
            cwd=local_path,
            timeout=120,
            env=safe_env,
        )
    
    except subprocess.TimeoutExpired:
        summary["output"] = "pytest timed out"
        summary["timed_out"] = True
        return summary

    summary["timed_out"]  = result["timed_out"]
    summary["oom_killed"] = result["oom_killed"]

    raw_output = (result["stdout"] + result["stderr"]).strip()

    # Truncate to avoid flooding the LLM context
    lines = raw_output.splitlines()
    if len(lines) > _MAX_OUTPUT_LINES:
        lines = lines[:_MAX_OUTPUT_LINES] + [f"... ({len(lines) - _MAX_OUTPUT_LINES} lines truncated)"]
    summary["output"] = "\n".join(lines)

    # Parse pytest's summary line: "3 passed, 1 failed, 2 errors"
    for line in lines:
        lower = line.lower()
        if "passed" in lower or "failed" in lower or "error" in lower:
            summary["passed"] += _parse_count(line, "passed")
            summary["failed"] += _parse_count(line, "failed")
            summary["errors"] += _parse_count(line, "error")

    if result["timed_out"]:
        logger.warning("pytest timed out", extra={"path": local_path})
    elif result["oom_killed"]:
        logger.warning("pytest was OOM-killed", extra={"path": local_path})
    else:
        logger.info(
            "pytest complete",
            extra={
                "passed": summary["passed"],
                "failed": summary["failed"],
                "errors": summary["errors"],
            },
        )

    summary["success"] = (
        not summary["timed_out"]
        and not summary["oom_killed"]
        and summary["failed"] == 0
        and summary["errors"] == 0
        and summary["passed"] > 0
    )
    return summary


def _parse_count(line: str, keyword: str) -> int:
    """Extract the integer before `keyword` in a pytest summary line."""
    import re
    match = re.search(rf"(\d+)\s+{keyword}", line.lower())
    return int(match.group(1)) if match else 0