zapretgui/tests/test_installer_runtime_swap.py
loop-uh f100d1f7ef Проверять целостность поставки и чинить её самостоятельно
Программа не знала, из чего состоит её собственная установка, поэтому
пропажу winws2.exe обнаруживал только конструктор runner-а — в самом конце
цепочки запуска, и наружу шёл голый FileNotFoundError с советом
«Переустановите программу».

Что добавлено:

- install_integrity: манифест поставки (_internal/install_manifest.json),
  быстрая проверка по размеру и полная по SHA-256, классификация причины
  (файл удалили после установки / установка неполная / файлы повреждены).
- Генерация манифеста в installer stage и отказ сборки, если в поставке
  нет движка, WinDivert.dll или драйвера: непустого каталога exe
  недостаточно, чтобы релиз оказался рабочим.
- Preflight перед запуском DPI с учётом нужного движка, фоновая проверка
  после старта окна и полная проверка на первом запуске после обновления.
- Самовосстановление тем же конвейером, которым идёт обновление: сначала
  сохранённый установщик из кэша (работает без сети), затем загрузка.
  Лимит — одна попытка на запуск и три в сутки, иначе антивирус и
  программа уходят в петлю переустановок.
- Многострочный диагноз больше не печатается построчно уровнем ERROR:
  одна ошибка запуска даёт одно уведомление вместо четырёх.

В коммит также попала параллельная работа по другим подсистемам
(watchdog обновления, blockcheck-verdict, ui-guard), потому что модули
целостности опираются на её update_paths.

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

136 lines
6.1 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.

from __future__ import annotations
"""Установщик не имеет права разрушать рабочую версию до готовности новой.
Прежняя схема удаляла ``{app}\\_internal`` до распаковки, а ``[InstallDelete]``
не участвует в откате Inno: любой сбой распаковки оставлял систему вообще без
приложения. Здесь закреплена схема «распаковать рядом — переключить одним
переименованием — проверить — только потом удалить прежнее».
"""
from pathlib import Path
import re
import unittest
PUBLIC_ROOT = Path(__file__).resolve().parents[1]
PRIVATE_ROOT = PUBLIC_ROOT.parent / "private_zapretgui"
INNO_SCRIPT = PRIVATE_ROOT / "build_zapret" / "zapret_universal.iss"
def _read_script() -> str:
return INNO_SCRIPT.read_text(encoding="utf-8")
def _section(iss: str, name: str) -> str:
match = re.search(rf"^\[{name}\]$(.*?)(?=^\[|\Z)", iss, re.MULTILINE | re.DOTALL)
return match.group(1) if match else ""
def _routine(iss: str, name: str) -> str:
code = iss[iss.index("[Code]"):]
headers = list(re.finditer(r"^(?:function|procedure)\s+([A-Za-z_]\w*)", code, re.MULTILINE))
for index, header in enumerate(headers):
if header.group(1) != name:
continue
end = headers[index + 1].start() if index + 1 < len(headers) else len(code)
return code[header.start():end]
return ""
@unittest.skipUnless(INNO_SCRIPT.is_file(), "нужен закрытый репозиторий со сборкой установщика")
class InstallerRuntimeLayoutTests(unittest.TestCase):
def setUp(self) -> None:
self.iss = _read_script()
def test_runtime_is_unpacked_next_to_the_working_copy(self) -> None:
files = _section(self.iss, "Files")
runtime_lines = [
line
for line in files.splitlines()
if line.strip().startswith("Source:") and "_internal" in line
]
self.assertEqual(len(runtime_lines), 1)
self.assertIn(r'DestDir: "{app}\_internal.new"', runtime_lines[0])
def test_runtime_is_not_scheduled_for_replacement_after_reboot(self) -> None:
"""restartreplace оставлял пользователя без приложения до перезагрузки."""
files = _section(self.iss, "Files")
self.assertNotIn("restartreplace", files)
def test_working_runtime_is_never_deleted_before_installation(self) -> None:
install_delete = _section(self.iss, "InstallDelete")
deleted_names = re.findall(r'Name:\s*"([^"]+)"', install_delete)
self.assertNotIn(r"{app}\_internal", deleted_names)
self.assertIn(r"{app}\_internal.new", deleted_names)
self.assertNotIn(r"{app}\_internal.old", deleted_names)
def test_uninstall_removes_the_working_runtime_and_both_swap_leftovers(self) -> None:
uninstall_delete = _section(self.iss, "UninstallDelete")
deleted_names = re.findall(r'Name:\s*"([^"]+)"', uninstall_delete)
for expected in (r"{app}\_internal", r"{app}\_internal.new", r"{app}\_internal.old"):
self.assertIn(expected, deleted_names)
@unittest.skipUnless(INNO_SCRIPT.is_file(), "нужен закрытый репозиторий со сборкой установщика")
class InstallerRuntimeSwapTests(unittest.TestCase):
def setUp(self) -> None:
self.iss = _read_script()
self.swap = _routine(self.iss, "CommitRuntimeSwap")
self.assertTrue(self.swap, "процедура свопа runtime отсутствует")
def test_swap_refuses_to_start_without_a_complete_new_version(self) -> None:
checkpoint = self.swap.index("RuntimeDirectoryContainsExe(StagingDir)")
rename = self.swap.index("TryRenameRuntimeDirectory(LiveDir, BackupDir)")
self.assertLess(checkpoint, rename)
def test_swap_moves_working_copy_aside_before_switching(self) -> None:
move_aside = self.swap.index("TryRenameRuntimeDirectory(LiveDir, BackupDir)")
switch = self.swap.index("TryRenameRuntimeDirectory(StagingDir, LiveDir)")
self.assertLess(move_aside, switch)
def test_backup_is_removed_only_after_the_new_version_is_verified(self) -> None:
verify = self.swap.index("RuntimeDirectoryContainsExe(LiveDir)")
cleanup = self.swap.index("TryRemoveRuntimeDirectory(BackupDir)", verify)
self.assertLess(verify, cleanup)
def test_every_failure_restores_the_previous_version(self) -> None:
rollbacks = self.swap.count("TryRenameRuntimeDirectory(BackupDir, LiveDir)")
# Откат нужен и при неудачном переключении, и при непрошедшей проверке.
self.assertGreaterEqual(rollbacks, 2)
def test_directory_operations_survive_a_temporary_lock(self) -> None:
rename = _routine(self.iss, "TryRenameRuntimeDirectory")
remove = _routine(self.iss, "TryRemoveRuntimeDirectory")
self.assertIn("for Attempt := 1 to", rename)
self.assertIn("Sleep(", rename)
self.assertIn("for Attempt := 1 to", remove)
def test_swap_runs_before_anything_else_after_installation(self) -> None:
step_changed = _routine(self.iss, "CurStepChanged")
swap_call = step_changed.index("CommitRuntimeSwap(FailureReason)")
for later in ("WriteInstallOwnerMarker", "RelocatePersistentBindingsIfNeeded",
"CleanupPreviousInstallIfRequested"):
self.assertLess(swap_call, step_changed.index(later))
def test_failed_swap_stops_finalization_and_tells_the_user(self) -> None:
step_changed = _routine(self.iss, "CurStepChanged")
failure_branch = step_changed[step_changed.index("if not RuntimeSwapSucceeded then"):]
self.assertIn("WarnRuntimeSwapFailed(FailureReason)", failure_branch)
self.assertIn("Exit;", failure_branch)
self.assertIn("MsgBox", _routine(self.iss, "WarnRuntimeSwapFailed"))
if __name__ == "__main__":
unittest.main()