Files
ecm_sync_system/scripts/run_large_scale_auto_test.py
T
strepsiades 4a9c444b10 first commit
2026-03-09 16:31:42 +08:00

444 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
import fnmatch
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from auto_test_lib import PROJECT_ROOT, load_yaml_config, now_slug
DEFAULT_DOC_PATH = PROJECT_ROOT / "docs/auto_test_spec/large_scale_test_control.md"
RESULT_START_MARKER = "<!-- LARGE_SCALE_RESULTS:START -->"
RESULT_END_MARKER = "<!-- LARGE_SCALE_RESULTS:END -->"
@dataclass
class ControlDocument:
path: Path
frontmatter: dict[str, Any]
body: str
def read_control_document(doc_path: str | Path) -> ControlDocument:
path = Path(doc_path)
if not path.is_absolute():
path = PROJECT_ROOT / path
text = path.read_text(encoding="utf-8")
match = re.match(r"^---\n(.*?)\n---\n?", text, re.DOTALL)
if not match:
raise ValueError(f"控制文档缺少 YAML frontmatter: {path}")
payload = yaml.safe_load(match.group(1)) or {}
body = text[match.end() :]
return ControlDocument(path=path, frontmatter=payload, body=body)
def write_control_document(doc: ControlDocument, generated_section: str) -> None:
body = doc.body
if RESULT_START_MARKER in body and RESULT_END_MARKER in body:
body = re.sub(
rf"{re.escape(RESULT_START_MARKER)}.*?{re.escape(RESULT_END_MARKER)}",
f"{RESULT_START_MARKER}\n\n{generated_section}\n\n{RESULT_END_MARKER}",
body,
flags=re.DOTALL,
)
else:
body = (
body.rstrip()
+ "\n\n"
+ RESULT_START_MARKER
+ "\n\n"
+ generated_section
+ "\n\n"
+ RESULT_END_MARKER
+ "\n"
)
frontmatter_text = yaml.safe_dump(doc.frontmatter, allow_unicode=True, sort_keys=False).strip()
final_text = f"---\n{frontmatter_text}\n---\n{body.lstrip()}"
doc.path.write_text(final_text, encoding="utf-8")
def repo_relative(path: str | Path | None) -> str:
if not path:
return ""
resolved = Path(path)
if not resolved.is_absolute():
return str(resolved)
try:
return str(resolved.relative_to(PROJECT_ROOT))
except ValueError:
return str(resolved)
def list_report_dirs(report_base: Path) -> set[Path]:
if not report_base.exists():
return set()
return {item for item in report_base.iterdir() if item.is_dir()}
def extract_report_dir(stdout: str, stderr: str) -> Path | None:
combined = "\n".join(part for part in [stdout, stderr] if part)
match = re.search(r"(?:聚合)?报告已生成:\s*(.+?/report\.md)", combined)
if not match:
return None
return Path(match.group(1).strip()).parent
def build_command(script_dir: Path, config_path: str, round_config: dict[str, Any], control: dict[str, Any]) -> list[str]:
runner = str(round_config.get("runner") or control.get("default_runner") or "aggregate")
init_mode = str(round_config.get("init_mode") or control.get("default_init_mode") or "fresh")
if runner not in {"aggregate", "domains"}:
raise ValueError(f"不支持的 runner: {runner}")
if init_mode not in {"fresh", "reuse", "clear-only"}:
raise ValueError(f"不支持的 init_mode: {init_mode}")
if runner == "aggregate":
command = [sys.executable, str(script_dir / "run_auto_test_all_domains.py"), "--config", config_path]
else:
domains = round_config.get("domains") or []
if not domains:
raise ValueError(f"domains runner 必须显式提供 domains: {round_config.get('id')}")
command = [sys.executable, str(script_dir / "run_auto_test.py"), "--config", config_path]
for domain in domains:
command.extend(["--domain", str(domain)])
if init_mode == "reuse":
command.append("--skip-init")
elif init_mode == "clear-only":
command.append("--clear-only")
return command
def case_map(summary_payload: dict[str, Any]) -> dict[str, dict[str, Any]]:
summary = summary_payload.get("summary", {})
return {
case["nodeid"]: case
for case in summary.get("cases", [])
if isinstance(case, dict) and case.get("nodeid")
}
def match_classification(case_id: str, round_config: dict[str, Any], control: dict[str, Any]) -> str:
merged: dict[str, str] = {}
global_rules = control.get("classification", {}) or {}
round_rules = round_config.get("classification", {}) or {}
merged.update({str(key): str(value) for key, value in global_rules.items()})
merged.update({str(key): str(value) for key, value in round_rules.items()})
for pattern, category in merged.items():
if fnmatch.fnmatch(case_id, pattern):
return category
return "unclassified"
def find_compare_target(previous_rounds: list[dict[str, Any]], current_cases: dict[str, dict[str, Any]]) -> tuple[dict[str, Any] | None, set[str]]:
current_ids = set(current_cases)
for item in reversed(previous_rounds):
previous_cases = item.get("case_results", {})
overlap = current_ids & set(previous_cases)
if overlap:
return item, overlap
return None, set()
def compare_rounds(previous_round: dict[str, Any] | None, overlap: set[str], current_cases: dict[str, dict[str, Any]]) -> dict[str, Any]:
if previous_round is None or not overlap:
return {
"compare_round_id": None,
"overlap_count": 0,
"fixed": [],
"regressed": [],
"persistent_failed": [],
"stable_passed": [],
}
previous_cases = previous_round["case_results"]
fixed: list[str] = []
regressed: list[str] = []
persistent_failed: list[str] = []
stable_passed: list[str] = []
for case_id in sorted(overlap):
old_passed = bool(previous_cases[case_id].get("passed"))
new_passed = bool(current_cases[case_id].get("passed"))
if not old_passed and new_passed:
fixed.append(case_id)
elif old_passed and not new_passed:
regressed.append(case_id)
elif not old_passed and not new_passed:
persistent_failed.append(case_id)
else:
stable_passed.append(case_id)
return {
"compare_round_id": previous_round.get("id"),
"overlap_count": len(overlap),
"fixed": fixed,
"regressed": regressed,
"persistent_failed": persistent_failed,
"stable_passed": stable_passed,
}
def execute_round(
*,
script_dir: Path,
config_path: str,
report_base: Path,
round_config: dict[str, Any],
control: dict[str, Any],
previous_rounds: list[dict[str, Any]],
) -> dict[str, Any]:
command = build_command(script_dir, config_path, round_config, control)
before_dirs = list_report_dirs(report_base)
result = subprocess.run(command, cwd=str(PROJECT_ROOT), text=True, capture_output=True, check=False)
after_dirs = list_report_dirs(report_base)
report_dir = extract_report_dir(result.stdout or "", result.stderr or "")
if report_dir is None:
new_dirs = sorted(after_dirs - before_dirs)
if new_dirs:
report_dir = new_dirs[-1]
summary_payload: dict[str, Any] = {}
if report_dir is not None:
summary_path = report_dir / "summary.json"
if summary_path.exists():
summary_payload = json.loads(summary_path.read_text(encoding="utf-8"))
current_cases = case_map(summary_payload)
compare_round, overlap = find_compare_target(previous_rounds, current_cases)
comparison = compare_rounds(compare_round, overlap, current_cases)
failed_case_details = []
for case_id, item in sorted(current_cases.items()):
if item.get("passed"):
continue
failed_case_details.append(
{
"nodeid": case_id,
"classification": match_classification(case_id, round_config, control),
"issues": item.get("issues", []),
"issue_count": item.get("issue_count", len(item.get("issues", []))),
}
)
summary = summary_payload.get("summary", {})
return {
"id": str(round_config.get("id") or ""),
"title": str(round_config.get("title") or ""),
"objective": str(round_config.get("objective") or ""),
"focus": [str(item) for item in (round_config.get("focus") or [])],
"runner": str(round_config.get("runner") or control.get("default_runner") or "aggregate"),
"init_mode": str(round_config.get("init_mode") or control.get("default_init_mode") or "fresh"),
"domains": [str(item) for item in (round_config.get("domains") or [])],
"next_goal": str(round_config.get("next_goal") or ""),
"returncode": result.returncode,
"command": command,
"report_dir": str(report_dir) if report_dir else None,
"report_md": str(report_dir / "report.md") if report_dir else None,
"summary_json": str(report_dir / "summary.json") if report_dir else None,
"summary": {
"total_domains": int(summary.get("total_domains", 0)),
"passed_domains": int(summary.get("passed_domains", 0)),
"failed_domains": int(summary.get("failed_domains", 0)),
"total_cases": int(summary.get("total_cases", 0)),
"passed_cases": int(summary.get("passed_cases", 0)),
"failed_cases": int(summary.get("failed_cases", 0)),
},
"has_summary": bool(summary_payload),
"case_results": current_cases,
"failed_cases": failed_case_details,
"comparison": comparison,
"stdout_tail": (result.stdout or "")[-4000:],
"stderr_tail": (result.stderr or "")[-4000:],
}
def build_results_markdown(run_id: str, doc_path: Path, results: list[dict[str, Any]]) -> str:
lines = [
f"## 最近一次执行 {run_id}",
"",
f"- 控制文档: {repo_relative(doc_path)}",
f"- 执行轮次数: {len(results)}",
"- 说明: 默认优先使用 fresh 初始化;若使用 aggregate runner,则每个 domain 仍会独立初始化。",
"",
"### 轮次总览",
"",
"| Round | 标题 | Runner | Init | Cases | Failed | 对比轮次 | 报告 |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
for item in results:
summary = item["summary"]
compare_round_id = item["comparison"].get("compare_round_id") or "-"
report_link = repo_relative(item.get("report_md")) or "-"
lines.append(
f"| {item['id']} | {item['title']} | {item['runner']} | {item['init_mode']} | {summary['total_cases']} | {summary['failed_cases']} | {compare_round_id} | {report_link} |"
)
for item in results:
summary = item["summary"]
comparison = item["comparison"]
lines.extend(
[
"",
f"### {item['id']} - {item['title']}",
"",
f"- 目标: {item['objective']}",
f"- 运行方式: {item['runner']}",
f"- 初始化策略: {item['init_mode']}",
f"- 执行命令: {' '.join(item['command'])}",
f"- 单轮结果: {summary['passed_cases']} passed / {summary['failed_cases']} failed / {summary['total_cases']} total cases",
f"- Domain 结果: {summary['passed_domains']} passed / {summary['failed_domains']} failed / {summary['total_domains']} total domains",
f"- 原始报告: {repo_relative(item.get('report_md')) or '-'}",
f"- 原始汇总: {repo_relative(item.get('summary_json')) or '-'}",
]
)
if item["focus"]:
lines.append("- 本轮关注点:")
for focus in item["focus"]:
lines.append(f" - {focus}")
lines.extend(
[
f"- 对比轮次: {comparison.get('compare_round_id') or '无可比较轮次'}",
f"- 可比较 case 数: {comparison.get('overlap_count', 0)}",
]
)
if comparison.get("fixed"):
lines.append("- 本轮改进:")
for case_id in comparison["fixed"]:
lines.append(f" - fixed: {case_id}")
if comparison.get("regressed"):
lines.append("- 本轮回退:")
for case_id in comparison["regressed"]:
lines.append(f" - regressed: {case_id}")
if comparison.get("persistent_failed"):
lines.append("- 本轮遗留:")
for case_id in comparison["persistent_failed"]:
lines.append(f" - persistent_failed: {case_id}")
if not comparison.get("fixed") and not comparison.get("regressed") and not comparison.get("persistent_failed"):
lines.append("- 对比结果: 本轮没有可比改进项,或与此前轮次无重叠 case。")
if item["failed_cases"]:
lines.append("- 失败归因:")
for failed in item["failed_cases"]:
issue_text = "".join(str(issue) for issue in failed.get("issues", [])) or "<empty>"
lines.append(
f" - {failed['nodeid']} | {failed['classification']} | issues={failed['issue_count']} | {issue_text}"
)
else:
lines.append("- 失败归因: 本轮无失败 case。")
if item.get("next_goal"):
lines.append(f"- 下一轮目标: {item['next_goal']}")
if item.get("returncode") not in (0, None):
lines.append(f"- 脚本返回码: {item['returncode']}")
return "\n".join(lines)
def save_archive(output_dir: Path, run_id: str, doc_path: Path, results: list[dict[str, Any]], markdown: str) -> None:
run_dir = output_dir / run_id
run_dir.mkdir(parents=True, exist_ok=True)
payload = {
"run_id": run_id,
"control_doc": repo_relative(doc_path),
"results": results,
}
(run_dir / "summary.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
(run_dir / "report.md").write_text(markdown, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="按控制文档执行大规模自动测试")
parser.add_argument(
"--doc",
default=str(DEFAULT_DOC_PATH),
help="大规模测试控制文档路径",
)
parser.add_argument(
"--round",
action="append",
default=None,
help="只执行指定 round id,可重复传入",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="只解析文档和打印计划,不实际执行",
)
args = parser.parse_args()
control_doc = read_control_document(args.doc)
control = control_doc.frontmatter.get("large_scale_test", {}) or {}
if not control:
raise ValueError("控制文档 frontmatter 缺少 large_scale_test 配置")
config_path = str(control.get("config_path") or "tests/fixtures/auto_sync/auto_test_config.yaml")
auto_test_config = load_yaml_config(config_path)
report_base = Path(auto_test_config["paths"]["report_dir"])
output_dir = Path(control.get("output_dir") or "test_results/auto_test/large_scale")
if not output_dir.is_absolute():
output_dir = PROJECT_ROOT / output_dir
output_dir.mkdir(parents=True, exist_ok=True)
configured_rounds = control.get("rounds") or []
selected_round_ids = set(args.round or [])
rounds: list[dict[str, Any]] = []
for item in configured_rounds:
if not isinstance(item, dict):
continue
if not item.get("enabled", True):
continue
if selected_round_ids and str(item.get("id")) not in selected_round_ids:
continue
rounds.append(item)
if not rounds:
raise ValueError("没有可执行的 round;请检查 enabled 或 --round 参数")
if args.dry_run:
for item in rounds:
print(f"{item.get('id')}: {item.get('title')}")
return 0
run_id = now_slug()
script_dir = PROJECT_ROOT / "scripts"
results: list[dict[str, Any]] = []
for round_config in rounds:
results.append(
execute_round(
script_dir=script_dir,
config_path=config_path,
report_base=report_base,
round_config=round_config,
control=control,
previous_rounds=results,
)
)
markdown = build_results_markdown(run_id, control_doc.path, results)
save_archive(output_dir, run_id, control_doc.path, results, markdown)
write_control_document(control_doc, markdown)
fail_on_failed_cases = bool(control.get("fail_on_failed_cases", False))
if fail_on_failed_cases and any(item["summary"].get("failed_cases", 0) > 0 for item in results):
return 1
if any(item.get("returncode") not in (0, None) and not item.get("has_summary") for item in results):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())