98 lines
4.0 KiB
Python
98 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Мутационная проверка: ломаем боевой код и смотрим, падает ли нужный тест.
|
|
|
|
Формат e2e/mutations.tsv: file<TAB>old<TAB>new<TAB>must_fail_test
|
|
Прогон: python3 e2e/mutation_check.py --gradle ./gradlew
|
|
Выход 0 — все мутации убиты (тесты ловят поломку). 1 — есть выжившие (дыра в покрытии).
|
|
"""
|
|
|
|
import argparse
|
|
import glob
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
|
def run_tests(gradle: str) -> dict[str, str]:
|
|
"""Возвращает {test_name: status}, где status = PASS/FAIL."""
|
|
subprocess.run([gradle, "--quiet", "test", "--rerun-tasks"],
|
|
capture_output=True, text=True, timeout=1800)
|
|
status: dict[str, str] = {}
|
|
for f in glob.glob("**/build/test-results/test/*.xml", recursive=True):
|
|
try:
|
|
root = ET.parse(f).getroot()
|
|
except ET.ParseError:
|
|
continue
|
|
for tc in root.iter("testcase"):
|
|
failed = any(c.tag in ("failure", "error") for c in tc)
|
|
status[tc.get("name")] = "FAIL" if failed else "PASS"
|
|
return status
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--gradle", default="./gradlew")
|
|
ap.add_argument("--mutations", default="e2e/mutations.tsv")
|
|
a = ap.parse_args()
|
|
|
|
rows = []
|
|
for line in open(a.mutations, encoding="utf-8"):
|
|
line = line.rstrip("\n")
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
f, old, new, must_fail = line.split("\t")
|
|
old = old.replace("\\n", "\n")
|
|
new = new.replace("\\n", "\n")
|
|
# no-op мутация ничего не проверяет — это ошибка таблицы, а не дыра в покрытии.
|
|
if old == new:
|
|
print(f"ОШИБКА ТАБЛИЦЫ: no-op мутация для {must_fail} (old == new)")
|
|
return 2
|
|
rows.append((f, old, new, must_fail))
|
|
|
|
print("=== базовая линия (без мутаций) ===")
|
|
base = run_tests(a.gradle)
|
|
print(f"тестов найдено: {len(base)}; упавших: {[k for k, v in base.items() if v == 'FAIL']}")
|
|
|
|
survivors = []
|
|
misapplied = 0
|
|
for f, old, new, must_fail in rows:
|
|
backup = f + ".bak"
|
|
shutil.copy2(f, backup)
|
|
try:
|
|
src = open(f, encoding="utf-8").read()
|
|
if old not in src:
|
|
# Мутация не применилась: фрагмента нет в файле. Это ошибка таблицы,
|
|
# а не доказательство покрытия — такой прогон ничего не значит.
|
|
print(f"ОШИБКА ТАБЛИЦЫ | мутация не применилась в {f}: {old[:70]!r}")
|
|
misapplied += 1
|
|
continue
|
|
open(f, "w", encoding="utf-8").write(src.replace(old, new, 1))
|
|
st = run_tests(a.gradle)
|
|
got = st.get(must_fail, "НЕ НАЙДЕН")
|
|
killed = got == "FAIL"
|
|
print(f"{'УБИТА ' if killed else 'ВЫЖИЛА'} | {must_fail} -> {got} | {f}: {old[:50]} -> {new[:30]}")
|
|
if not killed:
|
|
survivors.append((must_fail, f, old, new))
|
|
finally:
|
|
shutil.move(backup, f)
|
|
|
|
print("\n=== восстановление ===")
|
|
st = run_tests(a.gradle)
|
|
print(f"после отката упавших: {[k for k, v in st.items() if v == 'FAIL']}")
|
|
|
|
if survivors:
|
|
print("\nВЫЖИВШИЕ МУТАЦИИ (дыры в покрытии):")
|
|
for t, f, old, new in survivors:
|
|
print(f" - {t}: {f} `{old[:60]}` -> `{new[:40]}`")
|
|
return 1
|
|
if misapplied:
|
|
print(f"\nПРОВАЛ: {misapplied} мутаций не применились — прогон недействителен")
|
|
return 2
|
|
print("\nВСЕ МУТАЦИИ УБИТЫ")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|