"""TIASshot のヘッドレスセルフテストのランナー(/verify・verifier エージェント用).
手順(すべてローカルのみ.実機カメラ・ネットワーク上のサービスには触れない):
1. ランナーがビルドする構成(Debug|x64)の出力 bin/x64/Debug・obj/x64/Debug を削除する
(汚れた bin による依存漏れの見逃しを防ぐ.ENV_04「クリーン環境での検証」).
Release 等ほかの構成の出力(人間のビルド成果物)には触れない
--full のときは packages も退避して nuget restore からやり直す(依存更新時)
2. Debug|x64 をビルドする(SELFTEST フラグ入り)
3. 既存テスト(NUnit・x64)を実行する(--skip-tests で省略)
4. TIASshot.exe --selftest を実行し,終了コードと結果ファイルで判定する
出力: .verify/selftest/run-<日時>/ に build.log・test.log・selftest-result.txt・summary.json
終了コード: 0=全て合格,1=不合格あり,2=前提条件を満たさない(ツール未導入等)
使い方(リポジトリルートで実行):
python scripts/verify/run_selftest.py # 通常(Debug 出力削除 → ビルド → テスト → セルフテスト)
python scripts/verify/run_selftest.py --full # 依存更新時(packages 退避 → nuget restore から)
python scripts/verify/run_selftest.py --no-clean # Debug 出力を消さない(開発中の素早い再確認用.判定には使わない)
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
SLN = REPO / "TIASshot.sln"
EXE = REPO / "TIASshot" / "bin" / "x64" / "Debug" / "TIASshot.exe"
TEST_DLL = REPO / "TIASshot.Tests" / "bin" / "x64" / "Debug" / "TIASshot.Tests.dll"
# クリーン対象はランナーがビルドする構成(Debug|x64)の出力だけ.bin 全体を消すと人間の Release 出力まで消える
CLEAN_DIRS = [REPO / p / d / "x64" / "Debug" for p in ("TIASshot", "TIASshot.Tests") for d in ("bin", "obj")]
SELFTEST_TIMEOUT_SEC = 420 # セルフテスト内部のウォッチドッグ(5 分)より長くする
BUILD_TIMEOUT_SEC = 1200
EXIT_OK = 0
EXIT_FAIL = 1
EXIT_PRECONDITION = 2
class PreconditionError(Exception):
"""前提条件を満たさない(verifier は「検証不能」と報告する)."""
def find_vs() -> Path:
vswhere = Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / \
"Microsoft Visual Studio" / "Installer" / "vswhere.exe"
if not vswhere.exists():
raise PreconditionError(f"vswhere.exe が見つかりません: {vswhere}(Visual Studio / Build Tools を導入してください)")
out = subprocess.run([str(vswhere), "-latest", "-products", "*", "-property", "installationPath"],
capture_output=True, text=True, check=False)
path = out.stdout.strip().splitlines()[0] if out.stdout.strip() else ""
if not path:
raise PreconditionError("vswhere が Visual Studio / Build Tools を検出できません")
return Path(path)
def find_nuget() -> str:
found = shutil.which("nuget")
if found:
return found
local = REPO / "nuget.exe"
if local.exists():
return str(local)
raise PreconditionError("nuget.exe が見つかりません(setup.ps1 を実行するか PATH に追加してください)")
def run_logged(cmd: list[str], log: Path, timeout: int) -> int:
"""コマンドを実行し,標準出力・標準エラーをログへ書く.戻り値は終了コード."""
with log.open("w", encoding="utf-8", errors="replace") as f:
f.write("> " + " ".join(cmd) + "\n\n")
f.flush()
proc = subprocess.run(cmd, cwd=REPO, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout, check=False)
f.write(decode_output(proc.stdout))
return proc.returncode
def decode_output(data: bytes) -> str:
"""ツールの出力を復号する(MSBuild / vstest はリダイレクト時 UTF-8.古い環境の cp932 にも備える)."""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp932", errors="replace")
def clean_build_outputs() -> None:
for d in CLEAN_DIRS:
if d.exists():
try:
shutil.rmtree(d)
except OSError as ex:
raise PreconditionError(
f"{d} を削除できません(TIASshot.exe や Visual Studio が使用中の可能性): {ex}") from ex
def restore_packages_clean(run_dir: Path, nuget: str) -> tuple[bool, str]:
"""packages を退避して nuget restore からやり直す(clone 直後と同じ状態を再現する)."""
packages = REPO / "packages"
backup = REPO / f"packages.bak-{run_dir.name}"
if packages.exists():
try:
# 改名は原子的.失敗時は packages が元のまま残る(中途半端な状態にならない)
packages.rename(backup)
except OSError as ex:
raise PreconditionError(
f"packages を退避できません(Visual Studio 等が使用中の可能性.packages は変更していません): {ex}") from ex
code = run_logged([nuget, "restore", str(SLN)], run_dir / "restore.log", BUILD_TIMEOUT_SEC)
if code != 0:
# 復元に失敗したら退避した packages を戻す(人間の作業環境を壊さない)
if packages.exists():
shutil.rmtree(packages, ignore_errors=True)
if backup.exists():
backup.rename(packages)
return False, f"nuget restore 失敗(exit {code}).packages は元に戻しました"
if backup.exists():
shutil.rmtree(backup, ignore_errors=True)
return True, "nuget restore 成功(packages を再取得)"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--full", action="store_true", help="packages を退避して nuget restore からやり直す(依存更新時)")
parser.add_argument("--no-clean", action="store_true", help="Debug 出力(bin/obj の x64/Debug)を削除しない(開発時の短縮用.判定には使わない)")
parser.add_argument("--skip-tests", action="store_true", help="既存テスト(vstest)を実行しない(開発時の短縮用.判定には使わない)")
args = parser.parse_args()
# verifier(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" / "selftest" / f"run-{stamp}"
run_dir.mkdir(parents=True, exist_ok=True)
summary: dict = {"run_dir": str(run_dir), "steps": [], "result": None}
def step(name: str, ok: bool, detail: str) -> None:
summary["steps"].append({"name": name, "ok": ok, "detail": detail})
print(f"[{'OK' if ok else 'NG'}] {name}: {detail}", flush=True)
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 args.no_clean:
clean_build_outputs()
step("clean", True, "Debug 出力(bin/obj の x64/Debug)を削除")
if args.full:
ok, detail = restore_packages_clean(run_dir, find_nuget())
step("restore", ok, detail)
if not ok:
raise PreconditionError(detail)
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)
step("build", code == 0, f"Debug|x64 ビルド exit {code}(build.log)")
if code != 0:
exit_code = EXIT_FAIL
raise RuntimeError("ビルド失敗")
if not args.skip_tests:
if not vstest.exists():
raise PreconditionError(f"vstest.console.exe が見つかりません: {vstest}")
code = run_logged([str(vstest), str(TEST_DLL), "/Platform:x64"], run_dir / "test.log", BUILD_TIMEOUT_SEC)
step("tests", code == 0, f"既存テスト exit {code}(test.log)")
if code != 0:
exit_code = EXIT_FAIL
result_file = run_dir / "selftest-result.txt"
try:
proc = subprocess.run([str(EXE), "--selftest", "--selftest-out", str(result_file)], cwd=REPO,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=SELFTEST_TIMEOUT_SEC, check=False)
code = proc.returncode
except subprocess.TimeoutExpired:
code = None
text = result_file.read_text(encoding="utf-8-sig", errors="replace") if result_file.exists() else ""
heads = [ln for ln in text.splitlines() if ln.startswith("[") or ln.startswith("RESULT:")]
summary["selftest_exit_code"] = code
summary["selftest_lines"] = heads
step("selftest", code == 0, f"exit {code}(selftest-result.txt)")
for ln in heads:
print(f" {ln}", flush=True)
if code != 0:
exit_code = EXIT_FAIL
except PreconditionError as ex:
step("precondition", False, str(ex))
exit_code = EXIT_PRECONDITION
except RuntimeError as ex:
step("abort", False, str(ex))
exit_code = EXIT_FAIL
except subprocess.TimeoutExpired as ex:
step("timeout", False, f"{ex.cmd} が {ex.timeout} 秒で終わりません")
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())