feat: AirPlan V2 — 全专家插件强制路由 + 事件系统规范化
P0-8 扩大: do_mode.py finish_worker 全专家插件强制路由 - GUI→XDB, network→NDB, C/C++→SDB, done→Rvr, blocked/failed→Dbg - 证据去重: 已有 xdbSessions/ndbSessions/sdbReports/rvrReviewed 则跳过 P1-GAP17: 事件 emit 规范化 - 新增 7 个事件常量 (TASK_ENTERED, TASK_FINISHED, ENGINE_ENTERED 等) - 全部 emit 调用替换字符串字面量为常量,零残留 - 30 个事件类型常量全部定义且唯一 P1-GAP18: 事件日志原子轮转 - emit 计数器每 128 次检查轮转,避免每次 emit 读文件 - 清除未使用的 _emit_with_completion/_pending_merge_complete - 原子轮转: tempfile+os.replace 保证不损坏 eng 极端接管: 强制调用全部专家插件 (Dbg/XDB/NDB/SDB/Rvr) commands/do.md: 更新为全专家插件路由文档 全量测试: 69 通过, 0 失败 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
183
lib/air_runtime/test_runtime.py
Executable file
183
lib/air_runtime/test_runtime.py
Executable 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