lovranran-mcp-test-runner 0.1.0__tar.gz
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.
- lovranran_mcp_test_runner-0.1.0/.github/workflows/ci.yml +21 -0
- lovranran_mcp_test_runner-0.1.0/.gitignore +5 -0
- lovranran_mcp_test_runner-0.1.0/LICENSE +21 -0
- lovranran_mcp_test_runner-0.1.0/PKG-INFO +99 -0
- lovranran_mcp_test_runner-0.1.0/README.md +82 -0
- lovranran_mcp_test_runner-0.1.0/pyproject.toml +46 -0
- lovranran_mcp_test_runner-0.1.0/pyrightconfig.json +16 -0
- lovranran_mcp_test_runner-0.1.0/server.json +21 -0
- lovranran_mcp_test_runner-0.1.0/src/mcp_test_runner/__init__.py +5 -0
- lovranran_mcp_test_runner-0.1.0/src/mcp_test_runner/coverage.py +55 -0
- lovranran_mcp_test_runner-0.1.0/src/mcp_test_runner/jest_runner.py +31 -0
- lovranran_mcp_test_runner-0.1.0/src/mcp_test_runner/parsers.py +109 -0
- lovranran_mcp_test_runner-0.1.0/src/mcp_test_runner/pytest_runner.py +36 -0
- lovranran_mcp_test_runner-0.1.0/src/mcp_test_runner/runner.py +92 -0
- lovranran_mcp_test_runner-0.1.0/src/mcp_test_runner/schemas.py +28 -0
- lovranran_mcp_test_runner-0.1.0/src/mcp_test_runner/server.py +95 -0
- lovranran_mcp_test_runner-0.1.0/src/mcp_test_runner/single_test.py +31 -0
- lovranran_mcp_test_runner-0.1.0/tests/test_coverage.py +40 -0
- lovranran_mcp_test_runner-0.1.0/tests/test_jest_runner.py +73 -0
- lovranran_mcp_test_runner-0.1.0/tests/test_parsers.py +110 -0
- lovranran_mcp_test_runner-0.1.0/tests/test_pytest_runner.py +94 -0
- lovranran_mcp_test_runner-0.1.0/tests/test_runner.py +72 -0
- lovranran_mcp_test_runner-0.1.0/tests/test_server.py +150 -0
- lovranran_mcp_test_runner-0.1.0/tests/test_single_test.py +30 -0
- lovranran_mcp_test_runner-0.1.0/uv.lock +1873 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["main"]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: ["main"]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.11"
|
|
17
|
+
- uses: astral-sh/setup-uv@v5
|
|
18
|
+
- run: uv sync --extra dev
|
|
19
|
+
- run: uv run ruff check .
|
|
20
|
+
- run: uv run mypy
|
|
21
|
+
- run: uv run pytest
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Haichuan Zhou
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lovranran-mcp-test-runner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for running and normalizing local test results.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: fastmcp>=2.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-json-report>=1.5; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# mcp-test-runner
|
|
19
|
+
|
|
20
|
+
<!-- mcp-name: io.github.LovRanRan/mcp-test-runner -->
|
|
21
|
+
|
|
22
|
+
MCP server for deterministic local test execution and normalized test result reporting.
|
|
23
|
+
|
|
24
|
+
`mcp-test-runner` is the verification layer for codebase onboarding agents. It exposes focused MCP tools for running pytest and Jest, parsing test output, and summarizing coverage so downstream agents can mark claims as verified, unverified, or contradicted by real execution.
|
|
25
|
+
|
|
26
|
+
## Codebase Onboarding Stack
|
|
27
|
+
|
|
28
|
+
`mcp-test-runner` is the verification layer in a three-server MCP tool stack for Project 6 `wayfinder`, a codebase onboarding agent.
|
|
29
|
+
|
|
30
|
+
- [`mcp-repo-mapper`](https://github.com/LovRanRan/mcp-repo-mapper) maps repository structure, languages, entry points, framework evidence, and Python dependency edges.
|
|
31
|
+
- [`mcp-ast-explorer`](https://github.com/LovRanRan/mcp-ast-explorer) provides symbol-grounded Python definition, signature, reference, call-chain, and class-hierarchy lookups.
|
|
32
|
+
- [`mcp-test-runner`](https://github.com/LovRanRan/mcp-test-runner) runs local pytest/Jest checks and coverage summaries so agent claims can be verified against execution.
|
|
33
|
+
|
|
34
|
+
In `wayfinder`, this server turns high-risk code understanding claims into `verified`, `unverified`, or `contradicted` evidence from real test execution.
|
|
35
|
+
|
|
36
|
+
## Status
|
|
37
|
+
|
|
38
|
+
This repository is a Python-first v1 MCP test runner. It supports bounded pytest execution, Jest command execution, single-test targeting, pytest coverage summaries, and normalized pytest/Jest JSON parsing.
|
|
39
|
+
|
|
40
|
+
The server does not use an LLM. Test results come from subprocess execution and structured parser output.
|
|
41
|
+
|
|
42
|
+
## Tools
|
|
43
|
+
|
|
44
|
+
| Tool | Purpose |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `health()` | Returns `ok` for smoke checks. |
|
|
47
|
+
| `run_pytest(path, test_filter?, timeout_seconds?, cpu_seconds?, memory_mb?)` | Run pytest in a bounded working directory and return raw command output. |
|
|
48
|
+
| `run_jest(path, test_filter?, timeout_seconds?, cpu_seconds?, memory_mb?)` | Run Jest through `npx jest` and return raw command output. |
|
|
49
|
+
| `run_single_test(path, test_id, framework?, timeout_seconds?, cpu_seconds?, memory_mb?)` | Run one pytest node id or one Jest test-name pattern. |
|
|
50
|
+
| `parse_test_output(stdout, framework)` | Normalize test runner JSON output. |
|
|
51
|
+
| `get_coverage_summary(path, framework?, timeout_seconds?, cpu_seconds?, memory_mb?)` | Return pytest-cov JSON coverage totals. |
|
|
52
|
+
|
|
53
|
+
## Supported Scope
|
|
54
|
+
|
|
55
|
+
- Python 3.11+ package.
|
|
56
|
+
- FastMCP 2.x server.
|
|
57
|
+
- pytest execution with JSON report output via `pytest-json-report`.
|
|
58
|
+
- Jest command execution via `npx jest --json --outputFile`.
|
|
59
|
+
- pytest and Jest JSON normalization into one `TestRunResult` schema.
|
|
60
|
+
- Single pytest node id execution and Jest test-name targeting.
|
|
61
|
+
- pytest-cov coverage summary from `.coverage.json`.
|
|
62
|
+
- Subprocess timeout plus POSIX `resource.setrlimit` CPU / memory caps.
|
|
63
|
+
|
|
64
|
+
## Current Limitations
|
|
65
|
+
|
|
66
|
+
- v1 does not use Docker. It uses cwd validation, subprocess timeouts, and POSIX resource limits.
|
|
67
|
+
- Resource limits require a POSIX platform that supports `resource.setrlimit`.
|
|
68
|
+
- Jest execution expects Node.js plus project-local or `npx`-resolvable Jest.
|
|
69
|
+
- Jest single-test targeting uses `--testNamePattern`; it does not parse Jest file-specific node ids.
|
|
70
|
+
- Coverage summary is pytest-only in v1.
|
|
71
|
+
- Tools return raw command output for execution; call `parse_test_output` to normalize framework JSON.
|
|
72
|
+
|
|
73
|
+
## Local Development
|
|
74
|
+
|
|
75
|
+
Install dependencies:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv sync --extra dev
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The PyPI distribution name is `lovranran-mcp-test-runner` because `mcp-test-runner` is already taken on PyPI. The installed console script remains `mcp-test-runner`.
|
|
82
|
+
|
|
83
|
+
Run the MCP server:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uv run mcp-test-runner
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Run verification:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uv run ruff check .
|
|
93
|
+
uv run mypy
|
|
94
|
+
uv run pytest
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# mcp-test-runner
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.LovRanRan/mcp-test-runner -->
|
|
4
|
+
|
|
5
|
+
MCP server for deterministic local test execution and normalized test result reporting.
|
|
6
|
+
|
|
7
|
+
`mcp-test-runner` is the verification layer for codebase onboarding agents. It exposes focused MCP tools for running pytest and Jest, parsing test output, and summarizing coverage so downstream agents can mark claims as verified, unverified, or contradicted by real execution.
|
|
8
|
+
|
|
9
|
+
## Codebase Onboarding Stack
|
|
10
|
+
|
|
11
|
+
`mcp-test-runner` is the verification layer in a three-server MCP tool stack for Project 6 `wayfinder`, a codebase onboarding agent.
|
|
12
|
+
|
|
13
|
+
- [`mcp-repo-mapper`](https://github.com/LovRanRan/mcp-repo-mapper) maps repository structure, languages, entry points, framework evidence, and Python dependency edges.
|
|
14
|
+
- [`mcp-ast-explorer`](https://github.com/LovRanRan/mcp-ast-explorer) provides symbol-grounded Python definition, signature, reference, call-chain, and class-hierarchy lookups.
|
|
15
|
+
- [`mcp-test-runner`](https://github.com/LovRanRan/mcp-test-runner) runs local pytest/Jest checks and coverage summaries so agent claims can be verified against execution.
|
|
16
|
+
|
|
17
|
+
In `wayfinder`, this server turns high-risk code understanding claims into `verified`, `unverified`, or `contradicted` evidence from real test execution.
|
|
18
|
+
|
|
19
|
+
## Status
|
|
20
|
+
|
|
21
|
+
This repository is a Python-first v1 MCP test runner. It supports bounded pytest execution, Jest command execution, single-test targeting, pytest coverage summaries, and normalized pytest/Jest JSON parsing.
|
|
22
|
+
|
|
23
|
+
The server does not use an LLM. Test results come from subprocess execution and structured parser output.
|
|
24
|
+
|
|
25
|
+
## Tools
|
|
26
|
+
|
|
27
|
+
| Tool | Purpose |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `health()` | Returns `ok` for smoke checks. |
|
|
30
|
+
| `run_pytest(path, test_filter?, timeout_seconds?, cpu_seconds?, memory_mb?)` | Run pytest in a bounded working directory and return raw command output. |
|
|
31
|
+
| `run_jest(path, test_filter?, timeout_seconds?, cpu_seconds?, memory_mb?)` | Run Jest through `npx jest` and return raw command output. |
|
|
32
|
+
| `run_single_test(path, test_id, framework?, timeout_seconds?, cpu_seconds?, memory_mb?)` | Run one pytest node id or one Jest test-name pattern. |
|
|
33
|
+
| `parse_test_output(stdout, framework)` | Normalize test runner JSON output. |
|
|
34
|
+
| `get_coverage_summary(path, framework?, timeout_seconds?, cpu_seconds?, memory_mb?)` | Return pytest-cov JSON coverage totals. |
|
|
35
|
+
|
|
36
|
+
## Supported Scope
|
|
37
|
+
|
|
38
|
+
- Python 3.11+ package.
|
|
39
|
+
- FastMCP 2.x server.
|
|
40
|
+
- pytest execution with JSON report output via `pytest-json-report`.
|
|
41
|
+
- Jest command execution via `npx jest --json --outputFile`.
|
|
42
|
+
- pytest and Jest JSON normalization into one `TestRunResult` schema.
|
|
43
|
+
- Single pytest node id execution and Jest test-name targeting.
|
|
44
|
+
- pytest-cov coverage summary from `.coverage.json`.
|
|
45
|
+
- Subprocess timeout plus POSIX `resource.setrlimit` CPU / memory caps.
|
|
46
|
+
|
|
47
|
+
## Current Limitations
|
|
48
|
+
|
|
49
|
+
- v1 does not use Docker. It uses cwd validation, subprocess timeouts, and POSIX resource limits.
|
|
50
|
+
- Resource limits require a POSIX platform that supports `resource.setrlimit`.
|
|
51
|
+
- Jest execution expects Node.js plus project-local or `npx`-resolvable Jest.
|
|
52
|
+
- Jest single-test targeting uses `--testNamePattern`; it does not parse Jest file-specific node ids.
|
|
53
|
+
- Coverage summary is pytest-only in v1.
|
|
54
|
+
- Tools return raw command output for execution; call `parse_test_output` to normalize framework JSON.
|
|
55
|
+
|
|
56
|
+
## Local Development
|
|
57
|
+
|
|
58
|
+
Install dependencies:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv sync --extra dev
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The PyPI distribution name is `lovranran-mcp-test-runner` because `mcp-test-runner` is already taken on PyPI. The installed console script remains `mcp-test-runner`.
|
|
65
|
+
|
|
66
|
+
Run the MCP server:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uv run mcp-test-runner
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Run verification:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv run ruff check .
|
|
76
|
+
uv run mypy
|
|
77
|
+
uv run pytest
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[tool.hatch.build.targets.wheel]
|
|
6
|
+
packages = ["src/mcp_test_runner"]
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "lovranran-mcp-test-runner"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
description = "MCP server for running and normalizing local test results."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
requires-python = ">=3.11"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"fastmcp>=2.0",
|
|
17
|
+
"pydantic>=2.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"mypy>=1.10",
|
|
23
|
+
"pytest>=8.0",
|
|
24
|
+
"pytest-cov>=5.0",
|
|
25
|
+
"pytest-json-report>=1.5",
|
|
26
|
+
"ruff>=0.5",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
mcp-test-runner = "mcp_test_runner.server:main"
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 100
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
37
|
+
|
|
38
|
+
[tool.mypy]
|
|
39
|
+
python_version = "3.11"
|
|
40
|
+
strict = true
|
|
41
|
+
mypy_path = "src"
|
|
42
|
+
packages = ["mcp_test_runner"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.LovRanRan/mcp-test-runner",
|
|
4
|
+
"title": "MCP Test Runner",
|
|
5
|
+
"description": "Local test execution and coverage summaries for onboarding agents.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/LovRanRan/mcp-test-runner",
|
|
8
|
+
"source": "github"
|
|
9
|
+
},
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"packages": [
|
|
12
|
+
{
|
|
13
|
+
"registryType": "pypi",
|
|
14
|
+
"identifier": "lovranran-mcp-test-runner",
|
|
15
|
+
"version": "0.1.0",
|
|
16
|
+
"transport": {
|
|
17
|
+
"type": "stdio"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from mcp_test_runner.parsers import _as_mapping, _int_count
|
|
5
|
+
from mcp_test_runner.runner import ResourceLimits, run_command
|
|
6
|
+
from mcp_test_runner.schemas import CoverageSummary
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _float_count(value: object) -> float:
|
|
10
|
+
if isinstance(value, int | float):
|
|
11
|
+
return float(value)
|
|
12
|
+
if isinstance(value, str):
|
|
13
|
+
return float(value)
|
|
14
|
+
return 0.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_coverage_command(framework: str = "pytest") -> list[str]:
|
|
18
|
+
if framework != "pytest":
|
|
19
|
+
raise ValueError(f"Unsupported framework: {framework}")
|
|
20
|
+
|
|
21
|
+
return [
|
|
22
|
+
"pytest",
|
|
23
|
+
"--cov=.",
|
|
24
|
+
"--cov-report=json:.coverage.json",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_coverage_summary(
|
|
29
|
+
path: str | Path,
|
|
30
|
+
framework: str = "pytest",
|
|
31
|
+
timeout_seconds: float = 10.0,
|
|
32
|
+
resource_limits: ResourceLimits | None = None,
|
|
33
|
+
) -> CoverageSummary:
|
|
34
|
+
root = Path(path).resolve()
|
|
35
|
+
result = run_command(
|
|
36
|
+
build_coverage_command(framework),
|
|
37
|
+
cwd=root,
|
|
38
|
+
timeout_seconds=timeout_seconds,
|
|
39
|
+
resource_limits=resource_limits,
|
|
40
|
+
)
|
|
41
|
+
if result.timed_out:
|
|
42
|
+
raise RuntimeError("Coverage command timed out")
|
|
43
|
+
if result.exit_code != 0:
|
|
44
|
+
raise RuntimeError(result.stderr or result.stdout or "Coverage command failed")
|
|
45
|
+
|
|
46
|
+
report_path = root / ".coverage.json"
|
|
47
|
+
payload = _as_mapping(json.loads(report_path.read_text(encoding="utf-8")))
|
|
48
|
+
totals = _as_mapping(payload.get("totals"))
|
|
49
|
+
return CoverageSummary(
|
|
50
|
+
framework=framework,
|
|
51
|
+
covered_lines=_int_count(totals.get("covered_lines")),
|
|
52
|
+
total_lines=_int_count(totals.get("num_statements")),
|
|
53
|
+
percent_covered=_float_count(totals.get("percent_covered")),
|
|
54
|
+
report_path=str(report_path),
|
|
55
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from mcp_test_runner.runner import CommandResult, ResourceLimits, run_command
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_jest_command(test_filter: str | None = None) -> list[str]:
|
|
7
|
+
command = [
|
|
8
|
+
"npx",
|
|
9
|
+
"jest",
|
|
10
|
+
"--json",
|
|
11
|
+
"--outputFile=.jest-report.json",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
if test_filter:
|
|
15
|
+
command.extend(["--testNamePattern", test_filter])
|
|
16
|
+
|
|
17
|
+
return command
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_jest(
|
|
21
|
+
path: str | Path,
|
|
22
|
+
test_filter: str | None = None,
|
|
23
|
+
timeout_seconds: float = 10.0,
|
|
24
|
+
resource_limits: ResourceLimits | None = None,
|
|
25
|
+
) -> CommandResult:
|
|
26
|
+
return run_command(
|
|
27
|
+
build_jest_command(test_filter),
|
|
28
|
+
cwd=path,
|
|
29
|
+
timeout_seconds=timeout_seconds,
|
|
30
|
+
resource_limits=resource_limits,
|
|
31
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from mcp_test_runner.schemas import TestFailure, TestRunResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _as_mapping(value: object) -> dict[str, object]:
|
|
9
|
+
if isinstance(value, Mapping):
|
|
10
|
+
mapping = cast(Mapping[object, object], value)
|
|
11
|
+
return {str(key): item for key, item in mapping.items()}
|
|
12
|
+
return {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _as_list(value: object) -> list[object]:
|
|
16
|
+
if isinstance(value, list):
|
|
17
|
+
return cast(list[object], value)
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _optional_str(value: object) -> str | None:
|
|
22
|
+
if value is None:
|
|
23
|
+
return None
|
|
24
|
+
return str(value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _optional_int(value: object) -> int | None:
|
|
28
|
+
if isinstance(value, int):
|
|
29
|
+
return value
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _int_count(value: object) -> int:
|
|
34
|
+
if isinstance(value, int):
|
|
35
|
+
return value
|
|
36
|
+
if isinstance(value, str):
|
|
37
|
+
return int(value)
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_pytest_output(stdout: str) -> TestRunResult:
|
|
42
|
+
payload = _as_mapping(json.loads(stdout))
|
|
43
|
+
summary = _as_mapping(payload.get("summary"))
|
|
44
|
+
tests = _as_list(payload.get("tests"))
|
|
45
|
+
|
|
46
|
+
failures: list[TestFailure] = []
|
|
47
|
+
for test in tests:
|
|
48
|
+
test_payload = _as_mapping(test)
|
|
49
|
+
if test_payload.get("outcome") != "failed":
|
|
50
|
+
continue
|
|
51
|
+
call = _as_mapping(test_payload.get("call"))
|
|
52
|
+
crash = _as_mapping(call.get("crash"))
|
|
53
|
+
|
|
54
|
+
failures.append(
|
|
55
|
+
TestFailure(
|
|
56
|
+
test_id=str(test_payload.get("nodeid", "")),
|
|
57
|
+
message=str(crash.get("message", "")),
|
|
58
|
+
file=_optional_str(test_payload.get("file")),
|
|
59
|
+
line=_optional_int(test_payload.get("line")),
|
|
60
|
+
traceback=_optional_str(call.get("longrepr")),
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return TestRunResult(
|
|
65
|
+
passed=_int_count(summary.get("passed")),
|
|
66
|
+
failed=_int_count(summary.get("failed")),
|
|
67
|
+
skipped=_int_count(summary.get("skipped")),
|
|
68
|
+
failures=failures,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_jest_output(stdout: str) -> TestRunResult:
|
|
73
|
+
payload = _as_mapping(json.loads(stdout))
|
|
74
|
+
test_results = _as_list(payload.get("testResults"))
|
|
75
|
+
|
|
76
|
+
failures: list[TestFailure] = []
|
|
77
|
+
for test_file in test_results:
|
|
78
|
+
test_file_payload = _as_mapping(test_file)
|
|
79
|
+
file_name = _optional_str(test_file_payload.get("name"))
|
|
80
|
+
assertions = _as_list(test_file_payload.get("assertionResults"))
|
|
81
|
+
for assertion in assertions:
|
|
82
|
+
assertion_payload = _as_mapping(assertion)
|
|
83
|
+
if assertion_payload.get("status") != "failed":
|
|
84
|
+
continue
|
|
85
|
+
messages = _as_list(assertion_payload.get("failureMessages"))
|
|
86
|
+
message = "\n".join(str(message_part) for message_part in messages)
|
|
87
|
+
failures.append(
|
|
88
|
+
TestFailure(
|
|
89
|
+
test_id=str(assertion_payload.get("fullName", "")),
|
|
90
|
+
message=message,
|
|
91
|
+
file=file_name,
|
|
92
|
+
traceback=message or None,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return TestRunResult(
|
|
97
|
+
passed=_int_count(payload.get("numPassedTests")),
|
|
98
|
+
failed=_int_count(payload.get("numFailedTests")),
|
|
99
|
+
skipped=_int_count(payload.get("numPendingTests")),
|
|
100
|
+
failures=failures,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_test_output(stdout: str, framework: str) -> TestRunResult:
|
|
105
|
+
if framework == "pytest":
|
|
106
|
+
return _parse_pytest_output(stdout)
|
|
107
|
+
if framework == "jest":
|
|
108
|
+
return _parse_jest_output(stdout)
|
|
109
|
+
raise ValueError(f"Unsupported framework: {framework}")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from mcp_test_runner.runner import CommandResult, ResourceLimits, run_command
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_pytest_command(
|
|
7
|
+
test_filter: str | None = None,
|
|
8
|
+
test_id: str | None = None,
|
|
9
|
+
) -> list[str]:
|
|
10
|
+
command = [
|
|
11
|
+
"pytest",
|
|
12
|
+
"--json-report",
|
|
13
|
+
"--json-report-file=.pytest-report.json",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
if test_id:
|
|
17
|
+
command.append(test_id)
|
|
18
|
+
|
|
19
|
+
if test_filter:
|
|
20
|
+
command.extend(["-k", test_filter])
|
|
21
|
+
|
|
22
|
+
return command
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_pytest(
|
|
26
|
+
path: str | Path,
|
|
27
|
+
test_filter: str | None = None,
|
|
28
|
+
timeout_seconds: float = 10.0,
|
|
29
|
+
resource_limits: ResourceLimits | None = None,
|
|
30
|
+
) -> CommandResult:
|
|
31
|
+
return run_command(
|
|
32
|
+
build_pytest_command(test_filter),
|
|
33
|
+
cwd=path,
|
|
34
|
+
timeout_seconds=timeout_seconds,
|
|
35
|
+
resource_limits=resource_limits,
|
|
36
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommandResult(BaseModel):
|
|
10
|
+
exit_code: int
|
|
11
|
+
stdout: str
|
|
12
|
+
stderr: str
|
|
13
|
+
timed_out: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ResourceLimits(BaseModel):
|
|
17
|
+
cpu_seconds: int | None = None
|
|
18
|
+
memory_mb: int | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _output_to_text(output: str | bytes | None) -> str:
|
|
22
|
+
if output is None:
|
|
23
|
+
return ""
|
|
24
|
+
if isinstance(output, bytes):
|
|
25
|
+
return output.decode("utf-8", errors="replace")
|
|
26
|
+
return output
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _apply_resource_limits(limits: ResourceLimits) -> None:
|
|
30
|
+
import resource
|
|
31
|
+
|
|
32
|
+
if limits.cpu_seconds is not None:
|
|
33
|
+
resource.setrlimit(
|
|
34
|
+
resource.RLIMIT_CPU,
|
|
35
|
+
(limits.cpu_seconds, limits.cpu_seconds),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if limits.memory_mb is not None:
|
|
39
|
+
memory_bytes = limits.memory_mb * 1024 * 1024
|
|
40
|
+
# RLIMIT_AS is platform-dependent; keep CPU limits and timeouts active.
|
|
41
|
+
with suppress(OSError, ValueError):
|
|
42
|
+
resource.setrlimit(
|
|
43
|
+
resource.RLIMIT_AS,
|
|
44
|
+
(memory_bytes, memory_bytes),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_preexec_fn(limits: ResourceLimits | None) -> Callable[[], None] | None:
|
|
49
|
+
if limits is None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def apply_limits() -> None:
|
|
53
|
+
_apply_resource_limits(limits)
|
|
54
|
+
|
|
55
|
+
return apply_limits
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_command(
|
|
59
|
+
command: list[str],
|
|
60
|
+
cwd: str | Path,
|
|
61
|
+
timeout_seconds: float = 10.0,
|
|
62
|
+
resource_limits: ResourceLimits | None = None,
|
|
63
|
+
) -> CommandResult:
|
|
64
|
+
resolved_cwd = Path(cwd).resolve()
|
|
65
|
+
|
|
66
|
+
if not resolved_cwd.exists() or not resolved_cwd.is_dir():
|
|
67
|
+
raise ValueError(f"cwd must be an existing directory: {resolved_cwd}")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
completed = subprocess.run(
|
|
71
|
+
command,
|
|
72
|
+
cwd=resolved_cwd,
|
|
73
|
+
timeout=timeout_seconds,
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
check=False,
|
|
77
|
+
preexec_fn=_build_preexec_fn(resource_limits),
|
|
78
|
+
)
|
|
79
|
+
except subprocess.TimeoutExpired as exc:
|
|
80
|
+
return CommandResult(
|
|
81
|
+
exit_code=-1,
|
|
82
|
+
stdout=_output_to_text(exc.stdout),
|
|
83
|
+
stderr=_output_to_text(exc.stderr),
|
|
84
|
+
timed_out=True,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return CommandResult(
|
|
88
|
+
exit_code=completed.returncode,
|
|
89
|
+
stdout=completed.stdout,
|
|
90
|
+
stderr=completed.stderr,
|
|
91
|
+
timed_out=False,
|
|
92
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TestFailure(BaseModel):
|
|
5
|
+
test_id: str
|
|
6
|
+
message: str
|
|
7
|
+
file: str | None = None
|
|
8
|
+
line: int | None = None
|
|
9
|
+
traceback: str | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def empty_failures() -> list[TestFailure]:
|
|
13
|
+
return []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestRunResult(BaseModel):
|
|
17
|
+
passed: int = 0
|
|
18
|
+
failed: int = 0
|
|
19
|
+
skipped: int = 0
|
|
20
|
+
failures: list[TestFailure] = Field(default_factory=empty_failures)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CoverageSummary(BaseModel):
|
|
24
|
+
framework: str
|
|
25
|
+
covered_lines: int
|
|
26
|
+
total_lines: int
|
|
27
|
+
percent_covered: float
|
|
28
|
+
report_path: str | None = None
|