Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2, AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code changes across packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
184 lines
6.6 KiB
Python
Executable File
184 lines
6.6 KiB
Python
Executable File
"""
|
||
AirTst 测试运行器运行时 — V2 新增组件。
|
||
统一测试执行接口,支持多框架,产出结构化结果。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import subprocess
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
from air_runtime.io import atomic_json_write
|
||
from air_runtime.paths import tst_state_path
|
||
|
||
|
||
@dataclass
|
||
class TestCase:
|
||
name: str
|
||
status: str # passed | failed | skipped
|
||
duration: str = ""
|
||
message: str = ""
|
||
|
||
|
||
@dataclass
|
||
class TestSuite:
|
||
name: str
|
||
total: int = 0
|
||
passed: int = 0
|
||
failed: int = 0
|
||
skipped: int = 0
|
||
cases: list[TestCase] = field(default_factory=list)
|
||
|
||
|
||
@dataclass
|
||
class TestRunResult:
|
||
framework: str
|
||
total: int = 0
|
||
passed: int = 0
|
||
failed: int = 0
|
||
disabled: int = 0
|
||
duration: str = ""
|
||
suites: list[TestSuite] = field(default_factory=list)
|
||
failures: list[dict] = field(default_factory=list)
|
||
|
||
|
||
class TestRunner:
|
||
"""统一测试执行器。"""
|
||
|
||
FRAMEWORKS = {
|
||
"pytest": ["python", "-m", "pytest", "--json-report", "-q"],
|
||
"googletest": ["ctest", "--output-on-failure"],
|
||
"jest": ["npx", "jest", "--json"],
|
||
"vitest": ["npx", "vitest", "run", "--reporter=json"],
|
||
"go": ["go", "test", "-json", "./..."],
|
||
"cargo": ["cargo", "test", "--", "--format=json"],
|
||
}
|
||
|
||
def run(self, task_id: str, project_root: Path, framework: str,
|
||
target_path: Path | None = None,
|
||
extra_args: list[str] | None = None) -> TestRunResult:
|
||
if framework not in self.FRAMEWORKS:
|
||
return TestRunResult(framework=framework, failures=[{"error": f"unsupported framework: {framework}"}])
|
||
|
||
cmd = list(self.FRAMEWORKS[framework])
|
||
if target_path:
|
||
cmd.append(str(target_path))
|
||
if extra_args:
|
||
cmd.extend(extra_args)
|
||
|
||
try:
|
||
result = subprocess.run(cmd, capture_output=True, text=True,
|
||
timeout=600, cwd=str(project_root))
|
||
except subprocess.TimeoutExpired:
|
||
return TestRunResult(framework=framework, failures=[{"error": "timeout"}])
|
||
|
||
run_result = self._parse_result(framework, result.stdout)
|
||
self._save_report(task_id, project_root, run_result)
|
||
return run_result
|
||
|
||
def _parse_result(self, framework: str, stdout: str) -> TestRunResult:
|
||
if framework == "pytest":
|
||
return self._parse_pytest(stdout)
|
||
if framework in ("jest", "vitest"):
|
||
return self._parse_jest(stdout)
|
||
if framework == "googletest":
|
||
return self._parse_googletest(stdout)
|
||
if framework == "go":
|
||
return self._parse_go(stdout)
|
||
if framework == "cargo":
|
||
return self._parse_cargo(stdout)
|
||
return TestRunResult(framework=framework, total=0)
|
||
|
||
def _parse_pytest(self, stdout: str) -> TestRunResult:
|
||
try:
|
||
data = json.loads(stdout)
|
||
except json.JSONDecodeError:
|
||
return TestRunResult(framework="pytest", failures=[{"error": "json parse failed"}])
|
||
return TestRunResult(
|
||
framework="pytest",
|
||
total=data.get("summary", {}).get("total", 0),
|
||
passed=data.get("summary", {}).get("passed", 0),
|
||
failed=data.get("summary", {}).get("failed", 0),
|
||
duration=str(data.get("duration", "")),
|
||
)
|
||
|
||
def _parse_jest(self, stdout: str) -> TestRunResult:
|
||
try:
|
||
data = json.loads(stdout)
|
||
except json.JSONDecodeError:
|
||
return TestRunResult(framework="jest", failures=[{"error": "json parse failed"}])
|
||
return TestRunResult(
|
||
framework="jest",
|
||
total=data.get("numTotalTests", 0),
|
||
passed=data.get("numPassedTests", 0),
|
||
failed=data.get("numFailedTests", 0),
|
||
)
|
||
|
||
def _parse_googletest(self, stdout: str) -> TestRunResult:
|
||
"""解析 ctest 输出。ctest 不输出 JSON,从文本提取统计。"""
|
||
import re
|
||
total = passed = failed = disabled = 0
|
||
for line in stdout.splitlines():
|
||
m = re.match(r"(\d+)% tests passed, (\d+) tests failed out of (\d+)", line)
|
||
if m:
|
||
failed = int(m.group(2))
|
||
total = int(m.group(3))
|
||
passed = total - failed
|
||
# GoogleTest 也支持 --gtest_output=json
|
||
try:
|
||
data = json.loads(stdout)
|
||
if isinstance(data, dict):
|
||
total = sum(s.get("tests", 0) for s in data.get("testsuites", []))
|
||
failed = sum(s.get("failures", 0) for s in data.get("testsuites", []))
|
||
passed = total - failed
|
||
disabled = sum(s.get("disabled", 0) for s in data.get("testsuites", []))
|
||
except json.JSONDecodeError:
|
||
pass
|
||
return TestRunResult(
|
||
framework="googletest", total=total, passed=passed, failed=failed, disabled=disabled,
|
||
)
|
||
|
||
def _parse_go(self, stdout: str) -> TestRunResult:
|
||
"""解析 go test -json 输出(JSONL 格式,每行一个事件)。"""
|
||
total = passed = failed = 0
|
||
for line in stdout.splitlines():
|
||
try:
|
||
ev = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
action = ev.get("Action", "")
|
||
if action == "pass":
|
||
passed += 1
|
||
total += 1
|
||
elif action == "fail":
|
||
failed += 1
|
||
total += 1
|
||
elif action == "skip":
|
||
total += 1
|
||
return TestRunResult(framework="go", total=total, passed=passed, failed=failed)
|
||
|
||
def _parse_cargo(self, stdout: str) -> TestRunResult:
|
||
"""解析 cargo test --format=json 输出(JSONL 格式)。"""
|
||
total = passed = failed = 0
|
||
for line in stdout.splitlines():
|
||
try:
|
||
ev = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if ev.get("type") == "test":
|
||
total += 1
|
||
if ev.get("event") == "ok":
|
||
passed += 1
|
||
elif ev.get("event") == "failed":
|
||
failed += 1
|
||
return TestRunResult(framework="cargo", total=total, passed=passed, failed=failed)
|
||
|
||
def _save_report(self, task_id: str, project_root: Path, result: TestRunResult) -> None:
|
||
report_dir = tst_state_path(project_root).parent / "reports"
|
||
report_dir.mkdir(parents=True, exist_ok=True)
|
||
from air_runtime.utils import session_stamp
|
||
report_path = report_dir / f"{task_id}-{session_stamp()}.json"
|
||
atomic_json_write(report_path, result.__dict__)
|