"""TIASshot の NUnit テストのランナー(tester / refactorer エージェント・開発中の確認用).
MSBuild・vstest を直接呼べない環境(エージェントの Git Bash 等)からも,ENV_04 の「テスト」と同じ手順を
1 コマンドで実行できるようにする.セルフテスト(TIASshot.exe --selftest)は実行しない(それは run_selftest.py).
手順(すべてローカルのみ):
1. Debug|x64 の出力(bin/x64/Debug・obj/x64/Debug)を削除する(--no-clean で省略)
汚れた bin による依存漏れの見逃しや,更新日時の古いファイルが再コンパイルされない取りこぼしを防ぐ
2. Debug|x64 をビルドする
3. vstest でテストを実行する(--filter でカテゴリ等を絞り込み,--repeat で同じテストを繰り返す)
結果は TRX から集計し,失敗したテストの名前とメッセージを表示する
出力: .verify/tests/run-<日時>/ に build.log・test-<n>.log・result-<n>.trx・summary.json
終了コード: 0=全て合格,1=不合格あり(ビルド失敗・テスト失敗・該当テスト 0 件),2=前提条件を満たさない
使い方(リポジトリルートで実行):
python scripts/verify/run_tests.py # 全件(判定用)
python scripts/verify/run_tests.py --filter "TestCategory=Integration" # 層②だけ
python scripts/verify/run_tests.py --filter "TestCategory=Integration" --repeat 3 # 安定性確認(3 回連続)
python scripts/verify/run_tests.py --filter "FullyQualifiedName~ConfigTests" --no-clean # 開発中の素早い再確認
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from run_selftest import (BUILD_TIMEOUT_SEC, EXIT_FAIL, EXIT_OK, EXIT_PRECONDITION, REPO, SLN, TEST_DLL,
PreconditionError, clean_build_outputs, find_vs, run_logged)
TRX_NS = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
MAX_MESSAGE_LINES = 8
def parse_trx(trx: Path) -> dict:
"""TRX から件数と失敗したテスト(名前・メッセージ)を取り出す(vstest の表示言語に依存しない)."""
root = ET.parse(trx).getroot()
counters = root.find("t:ResultSummary/t:Counters", TRX_NS)
counts = {k: int(counters.get(k, "0")) for k in ("total", "executed", "passed", "failed", "notExecuted")} \
if counters is not None else {}
failures = []
for r in root.iterfind("t:Results/t:UnitTestResult", TRX_NS):
if r.get("outcome") != "Failed":
continue
msg = r.find("t:Output/t:ErrorInfo/t:Message", TRX_NS)
text = (msg.text or "").strip() if msg is not None else ""
failures.append({"name": r.get("testName"), "duration": r.get("duration"),
"message": "\n".join(text.splitlines()[:MAX_MESSAGE_LINES])})
return {"counts": counts, "failures": failures}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--filter", help="vstest の /TestCaseFilter に渡す式(例: TestCategory=Integration)")
parser.add_argument("--repeat", type=int, default=1, help="テストを繰り返す回数(ビルドは 1 回.既定 1)")
parser.add_argument("--no-clean", action="store_true", help="Debug 出力を削除しない(開発時の短縮用.判定には使わない)")
args = parser.parse_args()
if args.repeat < 1:
parser.error("--repeat は 1 以上を指定してください")
# エージェント(Git Bash 経由)が読めるよう,本ランナーの出力は UTF-8 に固定する
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
run_dir = REPO / ".verify" / "tests" / f"run-{stamp}"
run_dir.mkdir(parents=True, exist_ok=True)
summary: dict = {"run_dir": str(run_dir), "filter": args.filter, "repeat": args.repeat, "runs": [], "result": None}
exit_code = EXIT_OK
try:
vs = find_vs()
msbuild = vs / "MSBuild" / "Current" / "Bin" / "MSBuild.exe"
vstest = vs / "Common7" / "IDE" / "CommonExtensions" / "Microsoft" / "TestWindow" / "vstest.console.exe"
if not msbuild.exists():
raise PreconditionError(f"MSBuild.exe が見つかりません: {msbuild}")
if not vstest.exists():
raise PreconditionError(f"vstest.console.exe が見つかりません: {vstest}")
if not args.no_clean:
clean_build_outputs()
print("[OK] clean: Debug 出力(bin/obj の x64/Debug)を削除", flush=True)
code = run_logged([str(msbuild), str(SLN), "/t:Build", "/p:Configuration=Debug", "/p:Platform=x64",
"/nologo", "/v:minimal"], run_dir / "build.log", BUILD_TIMEOUT_SEC)
print(f"[{'OK' if code == 0 else 'NG'}] build: Debug|x64 ビルド exit {code}({run_dir / 'build.log'})", flush=True)
summary["build_exit_code"] = code
if code != 0:
for ln in (run_dir / "build.log").read_text(encoding="utf-8", errors="replace").splitlines():
if " error " in ln:
print(f" {ln.strip()}", flush=True)
raise RuntimeError("ビルド失敗")
for n in range(1, args.repeat + 1):
trx_name = f"result-{n}.trx"
cmd = [str(vstest), str(TEST_DLL), "/Platform:x64",
f"/logger:trx;LogFileName={trx_name}", f"/ResultsDirectory:{run_dir}"]
if args.filter:
cmd.append(f"/TestCaseFilter:{args.filter}")
code = run_logged(cmd, run_dir / f"test-{n}.log", BUILD_TIMEOUT_SEC)
trx = run_dir / trx_name
parsed = parse_trx(trx) if trx.exists() else {"counts": {}, "failures": []}
counts = parsed["counts"]
total = counts.get("total", 0)
failed = counts.get("failed", 0)
# 絞り込み式の誤りで 0 件になったのを合格と取り違えない
ok = code == 0 and total > 0 and failed == 0
summary["runs"].append({"run": n, "exit_code": code, **parsed})
print(f"[{'OK' if ok else 'NG'}] tests ({n}/{args.repeat}): 合計 {total},成功 {counts.get('passed', 0)},"
f"失敗 {failed},未実行 {counts.get('notExecuted', 0)}(exit {code}.{run_dir / f'test-{n}.log'})", flush=True)
if total == 0:
print(" 該当するテストがありません(--filter の式を確認してください)", flush=True)
for f in parsed["failures"]:
print(f" FAILED {f['name']}", flush=True)
for ln in f["message"].splitlines():
print(f" {ln}", flush=True)
if not ok:
exit_code = EXIT_FAIL
except PreconditionError as ex:
print(f"[NG] precondition: {ex}", flush=True)
exit_code = EXIT_PRECONDITION
except RuntimeError as ex:
print(f"[NG] abort: {ex}", flush=True)
exit_code = EXIT_FAIL
summary["result"] = {EXIT_OK: "PASS", EXIT_FAIL: "FAIL", EXIT_PRECONDITION: "PRECONDITION"}[exit_code]
(run_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"RESULT: {summary['result']} (出力: {run_dir})", flush=True)
return exit_code
if __name__ == "__main__":
sys.exit(main())