#!/usr/bin/env python3 # -*- coding: utf-8 -*- """M1b 测试执行器:真实跑 unittest 并把完整日志落盘(QC #9 取证)。 对应 QC 退回意见 #9:「仅 py_compile 通过,无真实执行通过证据」。 本工具用 unittest.TextTestRunner 真实执行 tests/test_m1b_*.py, 把 stdout/stderr 全量写入 projects/pbls/deliverables/m1b/test_logs/{suite}.log 并产出汇总 JSON projects/pbls/deliverables/m1b/test_report.json 退出码 = 失败数 + 错误数(0 表示全绿)。 用法: python3 tools/m1b_run_tests.py # 跑全部 M1b 测试 python3 tools/m1b_run_tests.py --suite ext_ref python3 tools/m1b_run_tests.py -v 3 """ import argparse import io import json import os import sys # --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path --- _M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) _M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, "..")) _M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR] try: for _d in sorted(os.listdir(_M1B_MODULES_DIR)): _sub = os.path.join(_M1B_MODULES_DIR, _d) if os.path.isdir(_sub) and not _d.startswith("."): _M1B_CANDIDATES.append(_sub) except OSError: pass for _p in _M1B_CANDIDATES: if _p not in sys.path: sys.path.insert(0, _p) # --- end bootstrap --- import time import traceback import unittest HERE = os.path.dirname(os.path.abspath(__file__)) MOD_ROOT = os.path.dirname(HERE) REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", "..")) TESTS_DIR = os.path.join(MOD_ROOT, "tests") LOG_DIR = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables", "m1b", "test_logs") REPORT = os.path.join(REPO_ROOT, "projects", "pbls", "deliverables", "m1b", "test_report.json") SUITES = { "ext_ref": "test_m1b_ext_ref.py", "realdb": "test_m1b_realdb.py", "closure": "test_m1b_import_closure.py", } for p in (REPO_ROOT, MOD_ROOT, TESTS_DIR): if p not in sys.path: sys.path.insert(0, p) def run_suite(name, filename, verbosity=2): """执行单个测试文件,返回 (result_dict, log_text)。""" buf = io.StringIO() old_out, old_err = sys.stdout, sys.stderr started = time.time() tests_run = failures = errors = skipped = 0 case_results = [] fatal = None try: sys.stdout = buf sys.stderr = buf loader = unittest.TestLoader() suite = loader.discover(TESTS_DIR, pattern=filename, top_level_dir=TESTS_DIR) runner = unittest.TextTestRunner(stream=buf, verbosity=verbosity) res = runner.run(suite) tests_run = res.testsRun failures = len(res.failures) errors = len(res.errors) skipped = len(res.skipped) for case, tb in res.failures: case_results.append({"case": str(case), "status": "FAIL", "trace": tb}) for case, tb in res.errors: case_results.append({"case": str(case), "status": "ERROR", "trace": tb}) for case, reason in res.skipped: case_results.append({"case": str(case), "status": "SKIP", "trace": str(reason)}) ok = res.wasSuccessful() except Exception: # noqa: BLE001 - 装载失败也要留证据 ok = False fatal = traceback.format_exc() buf.write("\n[FATAL] suite load/run error:\n" + fatal) finally: sys.stdout, sys.stderr = old_out, old_err elapsed = round(time.time() - started, 3) log = buf.getvalue() return { "suite": name, "file": "tests/%s" % filename, "ok": bool(ok), "tests_run": tests_run, "failures": failures, "errors": errors, "skipped": skipped, "passed": max(tests_run - failures - errors - skipped, 0), "elapsed_sec": elapsed, "fatal": fatal, "cases": case_results, }, log def main(argv=None): ap = argparse.ArgumentParser(description="M1b test runner (real execution)") ap.add_argument("--suite", choices=list(SUITES.keys()), help="只跑指定套件(缺省跑全部)") ap.add_argument("-v", "--verbosity", type=int, default=2) args = ap.parse_args(argv) os.makedirs(LOG_DIR, exist_ok=True) targets = ([args.suite] if args.suite else list(SUITES.keys())) results = [] for name in targets: fn = SUITES[name] if not os.path.exists(os.path.join(TESTS_DIR, fn)): results.append({"suite": name, "file": "tests/%s" % fn, "ok": False, "tests_run": 0, "failures": 0, "errors": 1, "skipped": 0, "passed": 0, "elapsed_sec": 0, "fatal": "test file missing", "cases": []}) continue res, log = run_suite(name, fn, verbosity=args.verbosity) log_path = os.path.join(LOG_DIR, "%s.log" % name) with open(log_path, "w", encoding="utf-8") as fh: fh.write("=== M1b test suite: %s (%s) ===\n" % (name, fn)) fh.write("=== executed_at: %s ===\n" % time.strftime("%Y-%m-%d %H:%M:%S")) fh.write("=== python: %s ===\n" % sys.version.replace("\n", " ")) fh.write("=== result: %s | run=%d pass=%d fail=%d error=%d skip=%d " "| %.3fs ===\n\n" % ( "PASS" if res["ok"] else "FAIL", res["tests_run"], res["passed"], res["failures"], res["errors"], res["skipped"], res["elapsed_sec"])) fh.write(log) fh.write("\n=== END OF LOG ===\n") res["log"] = os.path.relpath(log_path, REPO_ROOT) res["log_bytes"] = os.path.getsize(log_path) results.append(res) print("[%s] %s run=%d pass=%d fail=%d error=%d skip=%d (%.3fs) -> %s" % ( "PASS" if res["ok"] else "FAIL", name, res["tests_run"], res["passed"], res["failures"], res["errors"], res["skipped"], res["elapsed_sec"], res["log"])) total_run = sum(r["tests_run"] for r in results) total_fail = sum(r["failures"] for r in results) total_err = sum(r["errors"] for r in results) total_skip = sum(r["skipped"] for r in results) summary = { "tool": "tools/m1b_run_tests.py", "milestone": "M1b", "executed_at": time.strftime("%Y-%m-%d %H:%M:%S"), "python": sys.version.split()[0], "real_execution": True, "suites": results, "totals": {"run": total_run, "passed": total_run - total_fail - total_err - total_skip, "failures": total_fail, "errors": total_err, "skipped": total_skip}, "all_green": total_fail == 0 and total_err == 0 and total_run > 0, "log_dir": os.path.relpath(LOG_DIR, REPO_ROOT), } os.makedirs(os.path.dirname(REPORT), exist_ok=True) with open(REPORT, "w", encoding="utf-8") as fh: fh.write(json.dumps(summary, ensure_ascii=False, indent=2, default=str) + "\n") print(json.dumps({"all_green": summary["all_green"], "totals": summary["totals"], "report": os.path.relpath(REPORT, REPO_ROOT)}, ensure_ascii=False, indent=2)) return 0 if summary["all_green"] else (total_fail + total_err) if __name__ == "__main__": sys.exit(main())