zapretgui/tests/test_post_mortem_diagnosis.py
loop-uh b554a6fd29 Диагностировать молчаливое завершение winws по фактам
winws2 всегда печатает баннер версии, поэтому проверка «вывод пустой»
не срабатывала никогда: пользователю показывалась строка версии вместо
причины, а ветка диагностики молчаливой смерти была мертва.

- winws_output: единый разбор вывода (баннер отделён от диагностики)
  вместо трёх копий выборки строки;
- silent_exit_probe: причина устанавливается проверками (файл исчез,
  запись в журнале Windows, срабатывание Defender, наличие антивируса),
  а не угадывается; «не смогли проверить» не выдаётся за «в норме»;
- SpawnFailureKind.SILENT_EXIT вместо догадки про внешнее завершение;
- полный стартовый вывод winws2 пишется в общий лог при отказе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:02:12 +03:00

293 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Post-mortem diagnosis acceptance tests (.agent/tasks/postmortem-diagnosis/spec.md).
AC1: diagnose_unexpected_winws_exit builds cause-specific messages.
AC2: LaunchRuntimeService uses the injected diagnoser on the 3rd missed probe,
falls back to the generic message, stays backward compatible without it.
AC3: runner post-mortem accessors.
AC4: resolve_unexpected_exit_message logs exactly one ERROR with data,
stays silent without a runner/snapshot.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import sys
import tempfile
import unittest
from unittest.mock import patch
PROJECT_SRC = Path(__file__).resolve().parents[1] / "src"
if str(PROJECT_SRC) not in sys.path:
sys.path.insert(0, str(PROJECT_SRC))
class DiagnoseUnexpectedExitTests(unittest.TestCase):
def test_silent_exit_for_code_one_without_output(self) -> None:
"""Причина такого отказа не угадывается, а устанавливается пробами."""
from winws_runtime.health.post_mortem import diagnose_unexpected_winws_exit
from winws_runtime.health.silent_exit_probe import SilentExitReport
with patch(
"winws_runtime.health.post_mortem.probe_silent_exit",
return_value=SilentExitReport(verified=("файлы программы на месте",)),
):
result = diagnose_unexpected_winws_exit(1, "", exe_name="winws2.exe")
self.assertEqual(result.kind, "silent_exit")
self.assertIn("winws2.exe", result.message)
self.assertIn("не выдав ни одного сообщения", result.message)
self.assertIn("Причина не установлена", result.message)
def test_crash_codes_are_reported_as_crash(self) -> None:
from winws_runtime.health.post_mortem import diagnose_unexpected_winws_exit
for code in (0xC0000005, 0xC0000409):
result = diagnose_unexpected_winws_exit(code, "", exe_name="winws2.exe")
self.assertEqual(result.kind, "crash", hex(code))
self.assertIn("аварийное завершение", result.message)
self.assertIn(f"0x{code:08X}", result.message)
def test_windivert_output_uses_start_time_diagnosis(self) -> None:
from winws_runtime.health.post_mortem import diagnose_unexpected_winws_exit
with patch(
"winws_runtime.health.process_health_check.diagnose_winws_exit",
return_value=SimpleNamespace(
cause="Драйвер WinDivert выгружен",
solution="Перезапустите Zapret",
),
):
result = diagnose_unexpected_winws_exit(
34,
"windivert: error opening filter: The service cannot be started",
exe_name="winws2.exe",
)
self.assertEqual(result.kind, "diagnosed")
self.assertIn("winws2.exe неожиданно завершился", result.message)
self.assertIn("Драйвер WinDivert выгружен", result.message)
self.assertIn("Перезапустите Zapret", result.message)
def test_transient_dll_init_kind_is_classified(self) -> None:
from winws_runtime.health.post_mortem import diagnose_unexpected_winws_exit
result = diagnose_unexpected_winws_exit(0xC0000142, "", exe_name="winws2.exe")
self.assertEqual(result.kind, "transient_dll_init")
self.assertIn("инициализировать DLL", result.message)
def test_generic_fallback_contains_code_and_relevant_line(self) -> None:
from winws_runtime.health.post_mortem import diagnose_unexpected_winws_exit
result = diagnose_unexpected_winws_exit(
7,
"line one\nsome error happened here\n",
exe_name="winws.exe",
)
self.assertEqual(result.kind, "unknown")
self.assertIn("(код 7)", result.message)
self.assertIn("some error happened here", result.message)
self.assertIn("winws.exe", result.message)
class LaunchRuntimeServiceDiagnoserTests(unittest.TestCase):
def _make_service(self, diagnoser):
from app.state_store import AppUiState, MainWindowStateStore
from winws_runtime.state import LaunchRuntimeService
store = MainWindowStateStore(AppUiState())
if diagnoser is None:
service = LaunchRuntimeService(store)
else:
service = LaunchRuntimeService(store, unexpected_exit_diagnoser=diagnoser)
service.begin_start(launch_method="zapret2_mode", expected_process="winws2.exe")
service.mark_running(pid=4242, expected_process="winws2.exe")
return service
def _drive_process_loss(self, service) -> None:
with patch(
"winws_runtime.state.launch_runtime_service.is_winws_process_pid_alive",
return_value=False,
):
for _probe in range(3):
service.observe_process_details({})
def test_third_missed_probe_uses_diagnoser_message(self) -> None:
service = self._make_service(lambda: "winws2.exe неожиданно завершился: тест")
self._drive_process_loss(service)
snapshot = service.snapshot()
self.assertEqual(snapshot.phase, "failed")
self.assertEqual(snapshot.last_error, "winws2.exe неожиданно завершился: тест")
def test_empty_diagnoser_result_falls_back_to_generic_message(self) -> None:
service = self._make_service(lambda: "")
self._drive_process_loss(service)
snapshot = service.snapshot()
self.assertEqual(snapshot.phase, "failed")
self.assertEqual(snapshot.last_error, "winws2.exe не найден среди активных процессов")
def test_raising_diagnoser_falls_back_to_generic_message(self) -> None:
def _boom() -> str:
raise RuntimeError("diagnosis exploded")
service = self._make_service(_boom)
self._drive_process_loss(service)
snapshot = service.snapshot()
self.assertEqual(snapshot.phase, "failed")
self.assertEqual(snapshot.last_error, "winws2.exe не найден среди активных процессов")
def test_service_without_diagnoser_keeps_previous_behavior(self) -> None:
service = self._make_service(None)
self._drive_process_loss(service)
snapshot = service.snapshot()
self.assertEqual(snapshot.phase, "failed")
self.assertEqual(snapshot.last_error, "winws2.exe не найден среди активных процессов")
def test_diagnoser_not_called_before_third_miss(self) -> None:
calls: list[int] = []
def _diagnoser() -> str:
calls.append(1)
return "причина"
service = self._make_service(_diagnoser)
with patch(
"winws_runtime.state.launch_runtime_service.is_winws_process_pid_alive",
return_value=False,
):
service.observe_process_details({})
service.observe_process_details({})
self.assertEqual(calls, [])
self.assertEqual(service.snapshot().phase, "running")
class RunnerPostMortemAccessorTests(unittest.TestCase):
def test_zapret2_reads_remembered_startup_output_file(self) -> None:
from winws_runtime.runners.zapret2_runner import Winws2StrategyRunner
runner = object.__new__(Winws2StrategyRunner)
with tempfile.TemporaryDirectory() as tmp:
output_path = Path(tmp) / "winws2_startup_x.log"
output_path.write_text("boot ok\nwindivert: driver gone\n", encoding="utf-8")
runner._last_startup_output_path = str(output_path)
self.assertIn("windivert: driver gone", runner.read_post_mortem_output())
runner._last_startup_output_path = ""
self.assertEqual(runner.read_post_mortem_output(), "")
def test_zapret1_has_no_post_mortem_output(self) -> None:
from winws_runtime.runners.zapret1_runner import Winws1StrategyRunner
runner = object.__new__(Winws1StrategyRunner)
self.assertEqual(runner.read_post_mortem_output(), "")
def test_snapshot_none_for_absent_or_alive_process(self) -> None:
from winws_runtime.runners.zapret1_runner import Winws1StrategyRunner
runner = object.__new__(Winws1StrategyRunner)
runner.running_process = None
self.assertIsNone(runner.build_post_mortem_snapshot())
runner.running_process = SimpleNamespace(poll=lambda: None)
runner.current_launch_label = "Preset"
self.assertIsNone(runner.build_post_mortem_snapshot())
def test_snapshot_for_dead_process_contains_exit_facts(self) -> None:
from winws_runtime.runners.zapret2_runner import Winws2StrategyRunner
runner = object.__new__(Winws2StrategyRunner)
runner.running_process = SimpleNamespace(poll=lambda: 1)
runner.current_launch_label = "Default v1"
runner._last_startup_output_path = ""
snapshot = runner.build_post_mortem_snapshot()
self.assertEqual(snapshot["exit_code"], 1)
self.assertEqual(snapshot["strategy_name"], "Default v1")
self.assertEqual(snapshot["output"], "")
class ResolveUnexpectedExitMessageTests(unittest.TestCase):
class _LogRecorder:
def __init__(self) -> None:
self.records: list[tuple[str, str]] = []
def __call__(self, message, level="INFO", *args, **kwargs) -> None:
self.records.append((str(message), str(level)))
def error_messages(self) -> list[str]:
return [message for message, level in self.records if level == "ERROR"]
def test_returns_message_and_logs_single_error_for_dead_process(self) -> None:
from winws_runtime.health.post_mortem import resolve_unexpected_exit_message
runner = SimpleNamespace(
winws_exe=r"G:\zapret\exe\winws2.exe",
build_post_mortem_snapshot=lambda: {
"exit_code": 1,
"output": "",
"strategy_name": "Default v1",
},
)
recorder = self._LogRecorder()
from winws_runtime.health.silent_exit_probe import SilentExitReport
with (
patch("winws_runtime.runners.runner_factory.get_current_runner", return_value=runner),
patch("winws_runtime.health.post_mortem.log", recorder),
patch(
"winws_runtime.health.post_mortem.probe_silent_exit",
return_value=SilentExitReport(verified=("файлы программы на месте",)),
) as probe,
):
message = resolve_unexpected_exit_message()
self.assertIn("winws2.exe", message)
self.assertIn("не выдав ни одного сообщения", message)
self.assertEqual(probe.call_args.kwargs["exe_path"], r"G:\zapret\exe\winws2.exe")
self.assertEqual(recorder.error_messages(), [message])
def test_returns_empty_and_silent_without_runner(self) -> None:
from winws_runtime.health.post_mortem import resolve_unexpected_exit_message
recorder = self._LogRecorder()
with (
patch("winws_runtime.runners.runner_factory.get_current_runner", return_value=None),
patch("winws_runtime.health.post_mortem.log", recorder),
):
message = resolve_unexpected_exit_message()
self.assertEqual(message, "")
self.assertEqual(recorder.error_messages(), [])
def test_returns_empty_and_silent_for_alive_process(self) -> None:
from winws_runtime.health.post_mortem import resolve_unexpected_exit_message
runner = SimpleNamespace(
winws_exe="winws2.exe",
build_post_mortem_snapshot=lambda: None,
)
recorder = self._LogRecorder()
with (
patch("winws_runtime.runners.runner_factory.get_current_runner", return_value=runner),
patch("winws_runtime.health.post_mortem.log", recorder),
):
message = resolve_unexpected_exit_message()
self.assertEqual(message, "")
self.assertEqual(recorder.error_messages(), [])
if __name__ == "__main__":
unittest.main(verbosity=2)