Files
.pouch/skills/tester/scripts/generate_run_csv.py
T

234 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""从 runs/*.yaml 生成给人看的成绩单 CSV。不要手改 CSV。"""
from __future__ import annotations
import argparse
import csv
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError: # pragma: no cover
print("需要 PyYAML: python3 -m pip install pyyaml", file=sys.stderr)
raise SystemExit(2)
from generate_cases_csv import load_cases, one_line, resolve_root, tester_dir
from journey import suite as case_suite, test_type
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
EXECUTED_YES = frozenset({"pass", "fail", "environment-fail"})
EXECUTED_NO = frozenset({"skipped", "not-run"})
FIELDS = (
"id",
"test_type",
"suite",
"client_suite",
"platform",
"result",
"executed",
"evidence",
"notes",
"category",
"title",
"tag",
"risk",
"driver",
"maps_to",
"blocked_by",
"issue_id",
"issue_type",
"expected",
"observed",
"artifact_hash",
"environment",
"evidence_path",
"reproduction",
"next_step",
)
def executed_cell(result: str) -> str:
if result in EXECUTED_YES:
return "yes"
if result in EXECUTED_NO:
return "no"
return ""
def run_yaml_files(root: Path) -> list[Path]:
runs = tester_dir(root) / "runs"
if not runs.is_dir():
return []
return sorted(path for path in runs.glob("*.yaml") if path.is_file())
def latest_run_yaml(root: Path) -> Path | None:
files = run_yaml_files(root)
return files[-1] if files else None
def new_run_id(scope: str, suffix: str | None = None) -> str:
"""Return a collision-resistant filename/id for a new run snapshot."""
from datetime import datetime, timezone
from secrets import token_hex
clean_scope = re.sub(r"[^A-Za-z0-9._-]+", "-", scope).strip("-") or "run"
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
clean_suffix = re.sub(r"[^A-Za-z0-9._-]+", "-", suffix or "").strip("-")
random_part = token_hex(3)
parts = [stamp, clean_scope]
if clean_suffix:
parts.append(clean_suffix)
parts.append(random_part)
return "-".join(parts)
def load_run(path: Path) -> dict[str, Any]:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"{path} 必须是 mapping")
results = data.get("results")
if not isinstance(results, list):
raise SystemExit(f"{path} 缺少 results 列表")
run_id = data.get("runId")
if run_id is not None and (
not isinstance(run_id, str) or RUN_ID_RE.fullmatch(run_id) is None
):
raise SystemExit(f"{path} 的 runId 格式无效")
return data
def results_by_id(run: dict[str, Any]) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for item in run.get("results") or []:
if not isinstance(item, dict) or not item.get("id"):
continue
out[str(item["id"])] = item
return out
def case_index(root: Path) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for case in load_cases(root):
cid = str(case.get("id") or "")
if cid:
out[cid] = case
return out
def issue_index(run: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Index canonical defects and compatibility test_issues by testcase."""
indexed: dict[str, dict[str, Any]] = {}
raw = run.get("defects")
if not isinstance(raw, list):
raw = run.get("test_issues")
if not isinstance(raw, list):
return indexed
for issue in raw:
if not isinstance(issue, dict):
continue
testcase = str(issue.get("testcase") or issue.get("id") or "")
if testcase:
indexed.setdefault(testcase, issue)
return indexed
def export_run_csv(
root: Path,
run_path: Path | None = None,
out: Path | None = None,
) -> Path:
path = run_path or latest_run_yaml(root)
if path is None:
raise SystemExit(f"找不到运行快照: {tester_dir(root) / 'runs'}/*.yaml")
path = path.resolve()
run = load_run(path)
cases = case_index(root)
issues = issue_index(run)
dest = out or path.with_suffix(".csv")
dest.parent.mkdir(parents=True, exist_ok=True)
with dest.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(FIELDS), extrasaction="ignore")
writer.writeheader()
for item in run.get("results") or []:
if not isinstance(item, dict) or not item.get("id"):
continue
cid = str(item["id"])
result = str(item.get("result") or "")
case = cases.get(cid) or {}
issue = dict(issues.get(cid, {}))
if isinstance(item.get("issue"), dict):
issue.update(item["issue"])
client_suite_value = case.get("client_suite") or (
case.get("suite") if test_type(case) == "journey" else ""
)
writer.writerow(
{
"id": cid,
"test_type": test_type(case),
"suite": case_suite(case),
"client_suite": client_suite_value or "",
"platform": one_line(case.get("platform")),
"result": result,
"executed": executed_cell(result),
"evidence": one_line(item.get("evidence")),
"notes": one_line(item.get("notes")),
"category": case.get("category") or "",
"title": case.get("title") or "",
"tag": case.get("tag") or "",
"risk": case.get("risk") or "",
"driver": case.get("driver") or "",
"maps_to": case.get("maps_to") or "",
"blocked_by": one_line(item.get("blocked_by")),
"issue_id": issue.get("id") or item.get("issue_id") or "",
"issue_type": issue.get("issue_type") or item.get("issue_type") or "",
"expected": one_line(issue.get("expected") or item.get("expected")),
"observed": one_line(issue.get("observed") or item.get("observed")),
"artifact_hash": issue.get("artifact_hash") or item.get("artifact_hash") or "",
"environment": one_line(issue.get("environment") or item.get("environment")),
"evidence_path": one_line(issue.get("evidence_path") or item.get("evidence_path")),
"reproduction": one_line(issue.get("reproduction") or item.get("reproduction")),
"next_step": one_line(issue.get("next_step") or item.get("next_step")),
}
)
return dest
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="从 runs/*.yaml 生成成绩单 CSV")
parser.add_argument("--root", default=".", help="项目根")
parser.add_argument("--run", help="运行快照 yaml,默认 runs/ 里文件名最新的一份")
parser.add_argument("--out", help="输出路径,默认与 yaml 同名 .csv")
parser.add_argument(
"--no-refresh-cases",
action="store_true",
help="兼容旧命令;cases.csv 已不会被运行结果刷新",
)
args = parser.parse_args(argv)
root = resolve_root(args.root)
run_path = Path(args.run).resolve() if args.run else None
out = Path(args.out).resolve() if args.out else None
path = export_run_csv(
root,
run_path=run_path,
out=out,
)
counts: Counter[str] = Counter()
with path.open(encoding="utf-8-sig", newline="") as handle:
rows = list(csv.DictReader(handle))
for row in rows:
counts[row.get("result") or ""] += 1
bits = " ".join(f"{name}={counts.get(name, 0)}" for name in RESULTS)
print(f"wrote {path} rows={len(rows)} {bits}")
return 0
if __name__ == "__main__":
raise SystemExit(main())