feat: P1-19 P1-20 实现 - 边界测试强制 + 高风险审计 + UI Skill 路由
P1-19.1: Arc 边界测试强制 - TaskNode 新增 test_required 字段 - _inject_boundary_tests() 为每个模块注入接口测试和单元测试任务 - Done When 验证必须包含"测试通过" P1-19.2: AirRvr 高风险审计 - 新增 HighRiskAudit, HighRiskFinding 数据类 - ReviewReport 新增 highRiskAudit 字段,含 lifecycle/nullPointer/danglingPointer/exceptionSafety/concurrency + overallRisk + deliveryVerdict - 序列化/反序列化支持 P1-19.3: block-release 集成 - dispatch_worker_group() 派发前扫描最新审查报告 - deliveryVerdict=block-release 时阻止所有后续派发 - 记录 eng.blocked 事件 P1-20: frontend-design Skill 集成 - is_ui_task() UI 任务检测 - ensure_frontend_design_skill() 自动安装 Skill - route_ui_task() UI 任务路由决策 - enter_worker() 集成 UI 检测,skill 不可用时阻止执行 - commands/do.md 更新 UI 处理说明 - SKILL.md 新增 INV-12/INV-13/INV-14 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
183
lib/air_runtime/test_runtime.py
Normal file
183
lib/air_runtime/test_runtime.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
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__)
|
||||
Reference in New Issue
Block a user