#!/usr/bin/env python3 """Мутационная проверка: ломаем боевой код и смотрим, падает ли нужный тест. Формат e2e/mutations.tsv: fileoldnewmust_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())