zapretgui/tests/test_build_resource_layout.py
loop-uh 8d4a04f27c
Some checks failed
Zapret source guards / validate (push) Successful in 4s
Windows-сборка / Stable Stable 21.1.1.6 опубликован
Windows-сборка / Dev Ошибка: Сборка Nuitka (21.1.5.67)
Хранить запасной кэш обновления в user
2026-08-16 18:40:19 +03:00

1981 lines
95 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
import base64
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
PUBLIC_ROOT = Path(__file__).resolve().parents[1]
PRIVATE_ROOT = PUBLIC_ROOT.parent / "private_zapretgui"
class BuildResourceLayoutTests(unittest.TestCase):
def _read_inno_script(self) -> str:
return (PRIVATE_ROOT / "build_zapret" / "zapret_universal.iss").read_text(encoding="utf-8")
@staticmethod
def _inno_code_routines(iss: str) -> dict[str, str]:
"""Тела всех подпрограмм секции [Code], склеенные по имени."""
code = iss[iss.index("[Code]"):]
headers = list(
re.finditer(r"^(?:function|procedure)\s+([A-Za-z_]\w*)", code, re.MULTILINE)
)
routines: dict[str, str] = {}
for index, header in enumerate(headers):
end = headers[index + 1].start() if index + 1 < len(headers) else len(code)
name = header.group(1)
routines[name] = routines.get(name, "") + code[header.start():end]
return routines
@classmethod
def _inno_routines_reachable_from(cls, iss: str, entry: str) -> dict[str, str]:
"""Подпрограммы, достижимые по вызовам из entry (включая сам entry)."""
routines = cls._inno_code_routines(iss)
reached: dict[str, str] = {}
pending = [entry]
while pending:
name = pending.pop()
if name in reached or name not in routines:
continue
body = routines[name]
reached[name] = body
for called in re.findall(r"\b([A-Za-z_]\w*)\s*[(;,]", body):
if called in routines and called not in reached:
pending.append(called)
return reached
@staticmethod
def _release_request(release_model, **overrides):
values = {
"channel": "dev",
"version": "1.2.3.4",
"changes": "test",
"build_method": "nuitka",
"fast_exe": False,
"fast_exe_dest": None,
"publish_telegram": False,
"telegram_use_socks": False,
"skip_forgejo": True,
"forgejo_nonfatal": False,
"skip_ssh": True,
"run_installer": False,
"version_is_explicit": True,
}
values.update(overrides)
return release_model.ReleaseRequest(**values)
def test_profile_templates_are_private_resources(self) -> None:
private_template = PRIVATE_ROOT / "resources" / "profile" / "templates" / "all_profiles.txt"
public_template = PUBLIC_ROOT / "src" / "profile" / "templates" / "all_profiles.txt"
self.assertTrue(private_template.exists(), private_template)
self.assertFalse(public_template.exists(), public_template)
def test_inno_installs_profile_templates_from_prepared_stage(self) -> None:
iss = self._read_inno_script()
self.assertIn(r'{#SOURCEPATH}\system\templates\*.txt', iss)
self.assertNotIn("PUBLICSRC", iss)
self.assertNotIn("PRIVATERESOURCES", iss)
self.assertNotIn("PROJECTPATH", iss)
def test_inno_copies_lists_to_base_without_nested_base_or_user_dirs(self) -> None:
iss = self._read_inno_script()
list_lines = [
line
for line in iss.splitlines()
if r'{#SOURCEPATH}\lists' in line and r'DestDir: "{app}\lists\base"' in line
]
self.assertTrue(list_lines)
for line in list_lines:
self.assertNotIn("recursesubdirs", line)
self.assertNotIn("createallsubdirs", line)
def test_inno_does_not_package_legacy_help_folder(self) -> None:
iss = self._read_inno_script()
self.assertNotIn(r'{#SOURCEPATH}\help', iss)
self.assertNotIn(r'{app}\system\help', iss)
self.assertNotIn(r'{app}\help', iss)
def test_inno_shortcuts_are_recreated_without_touching_the_other_channel(self) -> None:
iss = self._read_inno_script()
self.assertIn("#define ShortcutName AppName", iss)
self.assertNotIn(r'Type: files; Name: "{commondesktop}\{#AppName} v*.lnk"', iss)
self.assertNotIn(r'Type: files; Name: "{group}\{#AppName} v*.lnk"', iss)
self.assertIn("function IsOtherChannelShortcutName(const FileName: string): Boolean;", iss)
self.assertIn("Result := Pos('zapret 2 dev ', Lowercase(FileName)) > 0;", iss)
self.assertIn("function LegacyChannelShortcutExistsInDirectory(", iss)
self.assertIn("procedure RemoveLegacyChannelShortcuts;", iss)
self.assertIn("RemoveLegacyChannelShortcuts;", iss)
self.assertIn("HadDesktopShortcut: Boolean;", iss)
self.assertIn("function ChannelDesktopShortcutExists: Boolean;", iss)
self.assertIn("function ShouldCreateDesktopIcon: Boolean;", iss)
self.assertIn("HadDesktopShortcut := ChannelDesktopShortcutExists;", iss)
self.assertIn(
r'Name: "{commondesktop}\{#ShortcutName}"; Filename: "{app}\_internal\Zapret.exe"; WorkingDir: "{app}"; Check: ShouldCreateDesktopIcon',
iss,
)
self.assertIn('Name: desktopicon; Description: "Создать ярлык на рабочем столе"; Flags: unchecked', iss)
self.assertNotIn(
r'Name: "{commondesktop}\{#ShortcutName}"; Filename: "{app}\_internal\Zapret.exe"; Tasks: desktopicon',
iss,
)
self.assertIn("function RepairAllUserShortcuts(", iss)
self.assertIn(
"RepairAllUserShortcuts(PreviousInstallRoot, NewInstallRoot)",
iss,
)
self.assertNotIn("ExecAsOriginalUser(", iss)
self.assertIn("CurrentVersion\\ProfileList", iss)
self.assertIn("$link.TargetPath=$newExe", iss)
def test_pyinstaller_icon_source_is_private_dist_ico_only(self) -> None:
builder = (PRIVATE_ROOT / "build_zapret" / "pyinstaller_builder.py").read_text(encoding="utf-8")
self.assertIn("PRIVATE_ROOT / 'dist' / 'ico' / icon_file", builder)
self.assertNotIn("root_path / icon_file", builder)
self.assertNotIn("root_path / 'ico' / icon_file", builder)
self.assertNotIn("сборка без иконки", builder)
def test_nuitka_uses_current_icons_and_source_driven_dynamic_modules(self) -> None:
builder = (PRIVATE_ROOT / "build_zapret" / "nuitka_builder.py").read_text(encoding="utf-8")
self.assertIn('"ZapretDevLogo4.ico" if channel == CHANNEL_DEV else "Zapret2.ico"', builder)
self.assertNotIn("ZapretDevLogo3.ico", builder)
self.assertNotIn("Zapret1.ico", builder)
self.assertIn("iter_lazy_feature_facade_modules", builder)
self.assertIn("iter_lazy_page_modules", builder)
self.assertIn('nuitka_args.append(f"--include-module={module}")', builder)
self.assertNotIn("packages_to_include = [", builder)
def test_nuitka_dynamic_modules_cover_facades_and_lazy_pages(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.nuitka_builder", None)
from build_zapret import nuitka_builder
modules = nuitka_builder._lazy_project_modules()
nuitka_builder._validate_lazy_project_modules(modules)
finally:
sys.modules.pop("build_zapret.nuitka_builder", None)
sys.path[:] = old_path
self.assertIn("app.feature_facades.appearance", modules)
self.assertIn("presets.ui.control.zapret2.page", modules)
self.assertIn("presets.ui.control.zapret1.page", modules)
def test_both_builders_cover_the_same_dynamic_application_modules(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.nuitka_builder", None)
sys.modules.pop("build_zapret.pyinstaller_builder", None)
from build_zapret import nuitka_builder, pyinstaller_builder
expected = set(nuitka_builder._lazy_project_modules())
pyinstaller_hidden = set(pyinstaller_builder._hiddenimports_for_spec())
self.assertTrue(expected)
self.assertTrue(expected.issubset(pyinstaller_hidden))
self.assertIn("app.feature_facades.appearance", expected)
self.assertIn("presets.ui.control.zapret2.page", expected)
finally:
sys.modules.pop("build_zapret.nuitka_builder", None)
sys.modules.pop("build_zapret.pyinstaller_builder", None)
sys.path[:] = old_path
def test_nuitka_does_not_bundle_builder_or_full_qt_dependencies(self) -> None:
builder = (PRIVATE_ROOT / "build_zapret" / "nuitka_builder.py").read_text(encoding="utf-8")
self.assertNotIn('"--include-qt-plugins=all"', builder)
self.assertNotIn(' "paramiko",', builder)
self.assertNotIn(' "pkg_resources",', builder)
self.assertIn('"--nofollow-import-to=numpy"', builder)
self.assertIn('"--nofollow-import-to=PIL"', builder)
self.assertIn('"--nofollow-import-to=cryptography"', builder)
self.assertIn('"--nofollow-import-to=PyQt6.QtOpenGL"', builder)
self.assertIn('"--noinclude-dlls=opengl32sw.dll"', builder)
self.assertIn('"--noinclude-dlls=Qt6Pdf.dll"', builder)
self.assertIn('"--python-flag=no_docstrings"', builder)
self.assertIn('"--report-diffable"', builder)
def test_nuitka_uses_local_work_dir_for_windows_network_share(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.nuitka_builder", None)
from build_zapret import nuitka_builder
with tempfile.TemporaryDirectory() as temp_dir:
local_app_data = Path(temp_dir) / "LocalAppData"
network_build_dir = Path(r"\\10.20.0.1\zapretgui\private_zapretgui\build_zapret")
with (
patch.object(nuitka_builder.sys, "platform", "win32"),
patch.object(nuitka_builder, "BUILD_DIR", network_build_dir),
patch.dict(os.environ, {"LOCALAPPDATA": str(local_app_data)}),
):
work_dir = nuitka_builder._nuitka_work_dir()
self.assertEqual(
work_dir,
local_app_data / "ZapretGUI" / "build_zapret" / "nuitka",
)
finally:
sys.modules.pop("build_zapret.nuitka_builder", None)
sys.path[:] = old_path
def test_nuitka_clears_generated_outputs_before_rebuild(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.nuitka_builder", None)
from build_zapret import nuitka_builder
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
build_cache = temp_path / "main.build"
stale_dist = temp_path / "main.dist"
build_cache.mkdir()
stale_dist.mkdir()
(build_cache / "cache.bin").write_bytes(b"cache")
(stale_dist / "unused.dll").write_bytes(b"old")
nuitka_builder._clear_previous_nuitka_outputs(temp_path)
self.assertFalse(build_cache.exists())
self.assertFalse(stale_dist.exists())
finally:
sys.modules.pop("build_zapret.nuitka_builder", None)
sys.path[:] = old_path
def test_builders_do_not_delete_unrelated_runtime_or_nuitka_caches(self) -> None:
pyinstaller = (PRIVATE_ROOT / "build_zapret" / "pyinstaller_builder.py").read_text(encoding="utf-8")
self.assertNotIn("def cleanup_pyinstaller_temp", pyinstaller)
self.assertIn('local_root / "ZapretGUI" / "build_zapret" / "pyinstaller"', pyinstaller)
self.assertIn("for generated in (work, out):", pyinstaller)
self.assertIn("main.build Nuitka сохранён", pyinstaller)
def test_release_builder_defaults_to_nuitka(self) -> None:
gui = (PRIVATE_ROOT / "build_zapret" / "build_release_gui.py").read_text(encoding="utf-8")
cli = (PRIVATE_ROOT / "build_zapret" / "build_release_cli.py").read_text(encoding="utf-8")
model = (PRIVATE_ROOT / "build_zapret" / "release_model.py").read_text(encoding="utf-8")
self.assertIn('DEFAULT_BUILD_METHOD = "nuitka"', model)
self.assertIn('self.build_method_var = tk.StringVar(value=DEFAULT_BUILD_METHOD)', gui)
self.assertIn('value="nuitka"', gui)
self.assertIn('value="pyinstaller"', gui)
self.assertIn('choices=["nuitka", "pyinstaller"]', cli)
self.assertIn("default=DEFAULT_BUILD_METHOD", cli)
def test_gui_and_cli_are_thin_adapters_for_one_release_pipeline(self) -> None:
build_dir = PRIVATE_ROOT / "build_zapret"
gui = (build_dir / "build_release_gui.py").read_text(encoding="utf-8")
cli = (build_dir / "build_release_cli.py").read_text(encoding="utf-8")
pipeline = (build_dir / "release_pipeline.py").read_text(encoding="utf-8")
readme = (build_dir / "README.md").read_text(encoding="utf-8")
self.assertIn("ReleaseRequest(", gui)
self.assertIn("ReleaseRequest(", cli)
self.assertIn("ReleasePipeline(", gui)
self.assertIn("ReleasePipeline(request", cli)
self.assertNotIn("build_release_gui import", cli)
self.assertNotIn("ConsoleReleaseBuilder", cli)
self.assertNotIn("class _Value", cli)
for obsolete_owner in (
"def build_process",
"def run_inno_setup",
"def deploy_to_ssh",
"def create_forgejo_release",
"def fast_deploy_exe",
"def prepare_installer_stage",
):
self.assertNotIn(obsolete_owner, gui)
self.assertNotIn(obsolete_owner, cli)
self.assertIn("class ReleasePipeline:", pipeline)
self.assertIn("steps: list[tuple[int, str, Callable[[], None]]]", pipeline)
self.assertIn("def prepare_installer_stage", pipeline)
self.assertIn("def build_installer", pipeline)
self.assertIn("def publish_forgejo", pipeline)
self.assertIn("def deploy", pipeline)
self.assertIn("GUI ─┐", readme)
self.assertIn("ReleaseRequest → ReleasePipeline", readme)
def test_release_pipeline_owns_the_complete_step_order(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import release_model, release_pipeline
calls: list[str] = []
request = self._release_request(
release_model,
run_installer=True,
skip_forgejo=False,
skip_ssh=False,
)
builder = release_pipeline.ReleasePipeline(request, log=Mock())
capabilities = release_pipeline.ReleaseCapabilities(
forgejo=True,
ssh=True,
telegram=True,
nuitka=True,
pyinstaller=True,
)
with (
patch.object(
release_pipeline,
"detect_release_capabilities",
return_value=capabilities,
),
patch.object(
release_pipeline,
"write_build_secrets",
side_effect=lambda *_args: calls.append("secrets"),
),
patch.object(
release_pipeline,
"write_build_info",
side_effect=lambda *_args: calls.append("build_info"),
),
patch.object(
release_pipeline,
"run_nuitka",
side_effect=lambda *_args, **_kwargs: calls.append("nuitka"),
),
patch.object(
builder,
"build_installer",
side_effect=lambda: calls.append("installer"),
),
patch.object(
builder,
"run_installer",
side_effect=lambda: calls.append("run_installer"),
),
patch.object(
builder,
"publish_forgejo",
side_effect=lambda: calls.append("forgejo"),
),
patch.object(
builder,
"deploy",
side_effect=lambda: calls.append("ssh"),
),
patch.object(
release_pipeline,
"update_versions_file",
side_effect=lambda *_args: calls.append("versions"),
),
):
builder._run_locked()
self.assertEqual(
calls,
[
"secrets",
"build_info",
"nuitka",
"installer",
"run_installer",
"forgejo",
"ssh",
"versions",
],
)
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_cli_plan_does_not_load_gui_or_release_pipeline(self) -> None:
environment = os.environ.copy()
environment.pop("PYTHONPATH", None)
command = (
"import sys; "
"from build_zapret.build_release_cli import main; "
"result=main(['--show-plan','--no-run-installer','--changes','test']); "
"assert result == 0; "
"assert 'build_zapret.build_release_gui' not in sys.modules; "
"assert 'build_zapret.release_pipeline' not in sys.modules; "
"assert 'PyQt6' not in sys.modules"
)
result = subprocess.run(
[sys.executable, "-X", "utf8", "-c", command],
cwd=PRIVATE_ROOT,
env=environment,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Канал: dev", result.stdout)
self.assertIn("следующая автоматически", result.stdout)
def test_ci_and_builder_document_only_internal_exe_launch(self) -> None:
ci = (PRIVATE_ROOT / "build_zapret" / "ci_build.py").read_text(encoding="utf-8")
readme = (PRIVATE_ROOT / "build_zapret" / "README.md").read_text(encoding="utf-8")
self.assertIn("out_dir = artifact_root / RUNTIME_DIR_NAME", ci)
self.assertIn('choices=["nuitka", "pyinstaller"]', ci)
self.assertIn('default="nuitka"', ci)
self.assertIn("target_dir=out_dir", ci)
self.assertIn("<корень установки>\\_internal\\Zapret.exe", readme)
self.assertIn("Запуск самого приложения из Python-исходников запрещён", readme)
def test_documented_ci_module_command_has_one_package_import_graph(self) -> None:
environment = os.environ.copy()
environment.pop("PYTHONPATH", None)
help_result = subprocess.run(
[sys.executable, "-m", "build_zapret.ci_build", "--help"],
cwd=PRIVATE_ROOT,
env=environment,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
)
self.assertEqual(help_result.returncode, 0, help_result.stdout + help_result.stderr)
self.assertIn("--method {nuitka,pyinstaller}", help_result.stdout)
identity_result = subprocess.run(
[
sys.executable,
"-c",
(
"import sys; import build_zapret.ci_build; "
"assert 'paths' not in sys.modules; "
"assert 'channel_constants' not in sys.modules; "
"assert 'runtime_output' not in sys.modules; "
"assert 'build_zapret.paths' in sys.modules; "
"assert 'build_zapret.nuitka_builder' in sys.modules; "
"assert 'build_zapret.pyinstaller_builder' in sys.modules"
),
],
cwd=PRIVATE_ROOT,
env=environment,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
)
self.assertEqual(identity_result.returncode, 0, identity_result.stdout + identity_result.stderr)
def test_builder_core_uses_package_imports_without_build_dir_path_injection(self) -> None:
build_dir = PRIVATE_ROOT / "build_zapret"
core_files = (
"ci_build.py",
"nuitka_builder.py",
"pyinstaller_builder.py",
"runtime_output.py",
"write_build_info.py",
"forgejo_release.py",
"ssh_deploy.py",
"release_model.py",
"release_pipeline.py",
"build_release_gui.py",
"build_release_cli.py",
)
forbidden_imports = (
"from channel_constants import",
"from paths import",
"from runtime_output import",
"from build_local_config import",
)
for file_name in core_files:
source = (build_dir / file_name).read_text(encoding="utf-8")
for forbidden in forbidden_imports:
self.assertNotIn(forbidden, source, f"{file_name}: {forbidden}")
paths_source = (build_dir / "paths.py").read_text(encoding="utf-8")
package_source = (build_dir / "__init__.py").read_text(encoding="utf-8")
gui_source = (build_dir / "build_release_gui.py").read_text(encoding="utf-8")
self.assertNotIn("for p in (PUBLIC_SRC, BUILD_DIR)", paths_source)
self.assertNotIn("forgejo_release import", package_source)
self.assertNotIn("build_local_config import", package_source)
self.assertNotIn("setup_forgejo_imports", gui_source)
self.assertNotIn("setup_ssh_imports", gui_source)
def test_ci_dispatches_both_builders_without_touching_real_outputs(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.ci_build", None)
from build_zapret import ci_build
calls: list[str] = []
def produce(name: str, *, target_dir: Path) -> Path:
calls.append(name)
target_dir.mkdir(parents=True, exist_ok=True)
exe = target_dir / "Zapret.exe"
exe.write_bytes(name.encode("ascii"))
return exe
def fake_nuitka(*_args, target_dir: Path, **_kwargs) -> Path:
return produce("nuitka", target_dir=target_dir)
def fake_pyinstaller(*_args, target_dir: Path, **_kwargs) -> Path:
return produce("pyinstaller", target_dir=target_dir)
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
common = [
"--channel",
"dev",
"--version",
"1.2.3.4",
]
with (
patch.object(ci_build, "apply_zapret_proxy_env", None),
patch.object(ci_build, "write_build_info"),
patch.object(ci_build, "run_nuitka", side_effect=fake_nuitka),
patch.object(ci_build, "run_pyinstaller", side_effect=fake_pyinstaller),
patch.object(
ci_build,
"create_spec_file",
side_effect=lambda *_args, **_kwargs: calls.append("spec"),
),
):
with patch.object(
sys,
"argv",
["ci_build", *common, "--out", str(root / "default")],
):
self.assertEqual(ci_build.main(), 0)
with patch.object(
sys,
"argv",
[
"ci_build",
*common,
"--method",
"pyinstaller",
"--out",
str(root / "pyinstaller"),
],
):
self.assertEqual(ci_build.main(), 0)
self.assertEqual(calls, ["nuitka", "spec", "pyinstaller"])
self.assertEqual(
(root / "default" / "_internal" / "Zapret.exe").read_bytes(),
b"nuitka",
)
self.assertEqual(
(root / "pyinstaller" / "_internal" / "Zapret.exe").read_bytes(),
b"pyinstaller",
)
finally:
sys.modules.pop("build_zapret.ci_build", None)
sys.path[:] = old_path
def test_forgejo_runs_source_guards_while_windows_release_stays_local(self) -> None:
workflow = (PUBLIC_ROOT / ".forgejo" / "workflows" / "source-guards.yml").read_text(
encoding="utf-8"
)
readme = (PRIVATE_ROOT / "build_zapret" / "README.md").read_text(encoding="utf-8")
self.assertIn("https://data.forgejo.org/actions/checkout@v6", workflow)
self.assertIn("python3 -m compileall -q src", workflow)
self.assertFalse(
(PUBLIC_ROOT / ".forgejo" / "workflows" / "windows-release.yml").exists()
)
self.assertIn("CodexZapretRelease", readme)
self.assertIn("Forgejo", readme)
def test_cli_uses_nuitka_by_default_and_normalizes_old_flat_target(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.build_release_cli", None)
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import build_release_cli, release_pipeline
with patch.object(
build_release_cli,
"check_telegram_configured",
return_value=(True, "Telegram настроен"),
):
defaults, show_plan = build_release_cli.parse_args(
[
"--version",
"21.1.5.4",
"--changes",
"test",
]
)
self.assertFalse(show_plan)
self.assertEqual(defaults.channel, "dev")
self.assertEqual(defaults.build_method, "nuitka")
self.assertTrue(defaults.publish_telegram)
self.assertTrue(defaults.run_installer)
self.assertFalse(defaults.skip_forgejo)
self.assertFalse(defaults.skip_ssh)
self.assertTrue(defaults.version_is_explicit)
parsed, show_plan = build_release_cli.parse_args(
[
"--channel",
"dev",
"--version",
"21.1.5.4",
"--changes",
"test",
"--skip-forgejo",
"--skip-ssh",
"--no-publish-telegram",
"--no-run-installer",
]
)
self.assertFalse(show_plan)
self.assertEqual(parsed.build_method, "nuitka")
self.assertFalse(parsed.publish_telegram)
self.assertFalse(parsed.run_installer)
with (
patch.object(
build_release_cli,
"check_telegram_configured",
return_value=(True, "Telegram настроен"),
),
patch.object(build_release_cli, "run_release_build") as run_build,
patch("builtins.print"),
):
result = build_release_cli.main(
[
"--version",
"21.1.5.4",
"--changes",
"test",
"--show-plan",
]
)
self.assertEqual(result, 0)
run_build.assert_not_called()
request = build_release_cli.ReleaseRequest(
channel="dev",
version="21.1.5.4",
changes="test",
build_method="nuitka",
fast_exe=True,
fast_exe_dest="/Zapret/Dev/Zapret.exe",
publish_telegram=False,
telegram_use_socks=False,
skip_forgejo=True,
forgejo_nonfatal=False,
skip_ssh=True,
run_installer=False,
version_is_explicit=True,
)
builder = release_pipeline.ReleasePipeline(request, log=Mock())
self.assertEqual(
builder.fast_destination_exe(),
Path("/Zapret/Dev/_internal/Zapret.exe"),
)
finally:
sys.modules.pop("build_zapret.build_release_cli", None)
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_cli_defaults_to_dev_and_increments_fourth_version_part_independently(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.build_release_cli", None)
from build_zapret import build_release_cli
with tempfile.TemporaryDirectory() as temp_dir:
versions_file = Path(temp_dir) / "version_Local.json"
versions_file.write_text(
json.dumps(
{
"stable": {"version": "21.1.1.3"},
"dev": {"version": "21.1.5.13"},
"next_suggested": {
"stable": "99.99.99.99",
"dev": "88.88.88.88",
},
}
),
encoding="utf-8",
)
with (
patch.object(build_release_cli, "VERSIONS_FILE", versions_file),
patch.object(
build_release_cli,
"check_telegram_configured",
return_value=(False, "Telegram не настроен"),
),
):
dev, _ = build_release_cli.parse_args(["--changes", "test"])
stable, _ = build_release_cli.parse_args(
["--channel", "stable", "--changes", "test"]
)
plan = build_release_cli.format_release_plan(
dev,
ssh_enabled=False,
versions_file=versions_file,
)
self.assertEqual(dev.channel, "dev")
self.assertEqual(dev.version, "21.1.5.14")
self.assertFalse(dev.version_is_explicit)
self.assertEqual(stable.version, "21.1.1.4")
self.assertIn("Stable: 21.1.1.3 → следующая 21.1.1.4", plan)
self.assertIn("Dev: 21.1.5.13 → следующая 21.1.5.14", plan)
self.assertIn(
"Будет выпущена версия: 21.1.5.14 (следующая автоматически)",
plan,
)
finally:
sys.modules.pop("build_zapret.build_release_cli", None)
sys.path[:] = old_path
def test_both_builders_share_one_internal_runtime_layout(self) -> None:
pipeline = (PRIVATE_ROOT / "build_zapret" / "release_pipeline.py").read_text(encoding="utf-8")
nuitka = (PRIVATE_ROOT / "build_zapret" / "nuitka_builder.py").read_text(encoding="utf-8")
pyinstaller = (PRIVATE_ROOT / "build_zapret" / "pyinstaller_builder.py").read_text(encoding="utf-8")
paths = (PRIVATE_ROOT / "build_zapret" / "paths.py").read_text(encoding="utf-8")
installer = self._read_inno_script()
self.assertIn('RUNTIME_DIR_NAME = "_internal"', paths)
self.assertIn("target_dir = DIST_RUNTIME_DIR", nuitka)
self.assertIn("contents_directory='.'", pyinstaller)
self.assertIn("replace_runtime_output(dist_dir, target_dir)", nuitka)
self.assertIn("target_dir = Path(target_dir or DIST_RUNTIME_DIR).resolve()", pyinstaller)
self.assertIn("replace_runtime_output(source_runtime_dir, target_dir)", pyinstaller)
self.assertIn("runtime_stage = stage_root / RUNTIME_DIR_NAME", pipeline)
self.assertNotIn("_nuitka_runtime", pipeline)
self.assertNotIn("_nuitka_runtime", installer)
self.assertNotIn(r'Source: "{#SOURCEPATH}\Zapret.exe"', installer)
# Распаковка идёт рядом с рабочей папкой: {app}\_internal заменяется
# переименованием уже после того, как новая версия разложена целиком
# (см. tests/test_installer_runtime_swap.py).
self.assertIn(
r'Source: "{#SOURCEPATH}\_internal\*"; DestDir: "{app}\_internal.new"',
installer,
)
self.assertIn('Type: filesandordirs; Name: "{app}\\_internal.new"', installer)
self.assertIn('Type: files; Name: "{app}\\Zapret.exe"', installer)
self.assertIn('Type: files; Name: "{app}\\*.dll"', installer)
self.assertIn('Type: files; Name: "{app}\\*.pyd"', installer)
self.assertIn('Type: filesandordirs; Name: "{app}\\src"', installer)
def test_scheduled_release_task_requires_confirmation_and_writes_result(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.scheduled_release_task", None)
from build_zapret import scheduled_release_task
with tempfile.TemporaryDirectory() as temp_dir:
task_root = Path(temp_dir)
request_path = task_root / "request.json"
request_path.write_text(
json.dumps(
{
"request_id": "preview-1",
"args": ["--show-plan"],
}
),
encoding="utf-8",
)
with patch.object(scheduled_release_task, "release_main", return_value=0):
result = scheduled_release_task.run_task(task_root)
self.assertEqual(result, 0)
result_data = json.loads(
(task_root / "result.json").read_text(encoding="utf-8")
)
self.assertEqual(result_data["request_id"], "preview-1")
self.assertEqual(result_data["status"], "success")
self.assertEqual(result_data["exit_code"], 0)
with patch.object(scheduled_release_task, "release_main") as release_main:
result = scheduled_release_task.run_task(task_root)
self.assertEqual(result, 2)
release_main.assert_not_called()
result_data = json.loads(
(task_root / "result.json").read_text(encoding="utf-8")
)
self.assertEqual(result_data["status"], "success")
request_path.write_text(
json.dumps(
{
"request_id": "release-without-confirmation",
"args": ["--version", "21.1.5.4"],
}
),
encoding="utf-8",
)
with patch.object(scheduled_release_task, "release_main") as release_main:
result = scheduled_release_task.run_task(task_root)
self.assertEqual(result, 1)
release_main.assert_not_called()
result_data = json.loads(
(task_root / "result.json").read_text(encoding="utf-8")
)
self.assertEqual(result_data["status"], "failed")
self.assertIn("confirm_release", result_data["error"])
finally:
sys.modules.pop("build_zapret.scheduled_release_task", None)
sys.path[:] = old_path
def test_scheduled_release_task_decodes_utf8_changes_without_windows_console(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.scheduled_release_task", None)
from build_zapret import scheduled_release_task
changes = (
"Интерфейс больше не зависает.\n"
"Русский текст передаётся без OEM-кодировки Windows."
)
encoded_changes = base64.b64encode(
changes.encode("utf-8")
).decode("ascii")
with tempfile.TemporaryDirectory() as temp_dir:
task_root = Path(temp_dir)
(task_root / "request.json").write_text(
json.dumps(
{
"request_id": "preview-utf8-base64",
"changes_utf8_base64": encoded_changes,
"args": ["--show-plan"],
},
ensure_ascii=True,
),
encoding="utf-8",
)
captured_args: list[str] = []
with patch.object(
scheduled_release_task,
"release_main",
side_effect=lambda args: captured_args.extend(args) or 0,
):
result = scheduled_release_task.run_task(task_root)
self.assertEqual(result, 0)
self.assertEqual(captured_args[-2:], ["--changes", changes])
self.assertNotIn("", captured_args[-1])
finally:
sys.modules.pop("build_zapret.scheduled_release_task", None)
sys.path[:] = old_path
def test_scheduled_release_task_is_registered_without_console_window(self) -> None:
registration = (
PRIVATE_ROOT / "register_release_task.ps1"
).read_text(encoding="utf-8")
readme = (
PRIVATE_ROOT / "build_zapret" / "README.md"
).read_text(encoding="utf-8")
self.assertIn("'pythonw.exe'", registration)
self.assertIn("-m build_zapret.scheduled_release_task", registration)
self.assertIn("-WorkingDirectory $PrivateRoot", registration)
self.assertNotIn("build_release_task.cmd", registration)
self.assertIn("не показывает чёрное окно `cmd.exe`", readme)
def test_release_publication_subprocesses_are_hidden_on_windows(self) -> None:
pipeline = (
PRIVATE_ROOT / "build_zapret" / "release_pipeline.py"
).read_text(encoding="utf-8")
forgejo = (
PRIVATE_ROOT / "build_zapret" / "forgejo_release.py"
).read_text(encoding="utf-8")
ssh = (
PRIVATE_ROOT / "build_zapret" / "ssh_deploy.py"
).read_text(encoding="utf-8")
installer = self._read_inno_script()
self.assertIn('"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)', pipeline)
self.assertIn('FORGEJO_TOKEN_FILE_ENV = "ZAPRET_FORGEJO_TOKEN_PATH"', forgejo)
self.assertIn("def _trusted_asset_url", forgejo)
self.assertNotIn("subprocess", forgejo)
self.assertGreater(ssh.count("subprocess.Popen("), 0)
self.assertEqual(
ssh.count("creationflags=_hidden_subprocess_flags()"),
ssh.count("subprocess.Popen("),
)
self.assertNotIn("capture_output=True,\n text=True,\n creationflags", ssh)
self.assertIn("'', SW_HIDE, ewWaitUntilTerminated", installer)
def test_gui_and_cli_share_one_release_build_lock(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret.release_pipeline import acquire_release_build_lock
with tempfile.TemporaryDirectory() as temp_dir:
lock_path = Path(temp_dir) / "release.lock"
first = acquire_release_build_lock(lock_path)
try:
with self.assertRaisesRegex(RuntimeError, "уже запущена"):
acquire_release_build_lock(lock_path)
finally:
first.release()
second = acquire_release_build_lock(lock_path)
second.release()
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_both_builders_use_strict_atomic_runtime_normalization(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.runtime_output", None)
from build_zapret import runtime_output
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source = root / "source"
target = root / "_internal"
source.mkdir()
target.mkdir()
(source / "Zapret.exe").write_bytes(b"new-exe")
(source / "runtime.dll").write_bytes(b"new-dll")
(target / "Zapret.exe").write_bytes(b"old-exe")
(target / "old.dll").write_bytes(b"old-dll")
produced = runtime_output.replace_runtime_output(source, target)
self.assertEqual(produced, target / "Zapret.exe")
self.assertEqual(produced.read_bytes(), b"new-exe")
self.assertEqual((target / "runtime.dll").read_bytes(), b"new-dll")
self.assertFalse((target / "old.dll").exists())
self.assertFalse((root / "_internal.new").exists())
self.assertFalse((root / "_internal.old").exists())
invalid_source = root / "invalid"
invalid_source.mkdir()
with self.assertRaisesRegex(FileNotFoundError, "Zapret.exe"):
runtime_output.replace_runtime_output(invalid_source, target)
self.assertEqual(produced.read_bytes(), b"new-exe")
finally:
sys.modules.pop("build_zapret.runtime_output", None)
sys.path[:] = old_path
def test_fast_deploy_replaces_complete_internal_runtime(self) -> None:
gui = (PRIVATE_ROOT / "build_zapret" / "build_release_gui.py").read_text(encoding="utf-8")
cli = (PRIVATE_ROOT / "build_zapret" / "build_release_cli.py").read_text(encoding="utf-8")
model = (PRIVATE_ROOT / "build_zapret" / "release_model.py").read_text(encoding="utf-8")
pipeline = (PRIVATE_ROOT / "build_zapret" / "release_pipeline.py").read_text(encoding="utf-8")
self.assertIn("produced = replace_runtime_output(source_runtime, target_runtime)", pipeline)
self.assertNotIn("Синхронизация библиотек Nuitka рядом с Zapret.exe", gui)
self.assertNotIn("def publish_exe_to_telegram", gui)
self.assertIn("if request.fast_exe and request.publish_telegram:", model)
self.assertNotIn("replace_runtime_output(", gui)
self.assertNotIn("replace_runtime_output(", cli)
def test_cli_rejects_publishing_internal_runtime_as_one_exe(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_model", None)
from build_zapret import release_model
request = release_model.ReleaseRequest(
channel="dev",
version="1.2.3.4",
changes="test",
build_method="nuitka",
fast_exe=True,
fast_exe_dest=None,
publish_telegram=True,
telegram_use_socks=False,
skip_forgejo=True,
forgejo_nonfatal=False,
skip_ssh=True,
run_installer=False,
version_is_explicit=True,
)
with self.assertRaisesRegex(ValueError, "всю папку _internal"):
release_model.validate_release_request(request)
finally:
sys.modules.pop("build_zapret.release_model", None)
sys.path[:] = old_path
def test_fast_deploy_cleanup_removes_only_old_flat_runtime(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import release_model, release_pipeline
request = release_model.ReleaseRequest(
channel="dev",
version="1.2.3.4",
changes="test",
build_method="nuitka",
fast_exe=True,
fast_exe_dest=None,
publish_telegram=False,
telegram_use_socks=False,
skip_forgejo=True,
forgejo_nonfatal=False,
skip_ssh=True,
run_installer=False,
version_is_explicit=True,
)
builder = release_pipeline.ReleasePipeline(request, log=Mock())
with tempfile.TemporaryDirectory() as temp_dir:
app_root = Path(temp_dir) / "Zapret" / "Dev"
internal = app_root / "_internal"
settings = app_root / "settings"
internal.mkdir(parents=True)
settings.mkdir()
(internal / "Zapret.exe").write_bytes(b"new")
(settings / "settings.sqlite3").write_bytes(b"sqlite")
(app_root / "Zapret.exe").write_bytes(b"old")
(app_root / "python314.dll").write_bytes(b"old")
(app_root / "_socket.pyd").write_bytes(b"old")
(app_root / "base_library.zip").write_bytes(b"old")
(app_root / "PyQt6").mkdir()
(app_root / "src").mkdir()
removed = builder._cleanup_legacy_flat_runtime(app_root)
self.assertEqual(removed, 6)
self.assertTrue((internal / "Zapret.exe").is_file())
self.assertTrue((settings / "settings.sqlite3").is_file())
self.assertFalse((app_root / "Zapret.exe").exists())
self.assertFalse((app_root / "python314.dll").exists())
self.assertFalse((app_root / "_socket.pyd").exists())
self.assertFalse((app_root / "base_library.zip").exists())
self.assertFalse((app_root / "PyQt6").exists())
self.assertFalse((app_root / "src").exists())
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_installer_stage_keeps_runtime_only_inside_internal(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import release_model, release_pipeline
builder = release_pipeline.ReleasePipeline(
self._release_request(release_model),
log=Mock(),
)
with tempfile.TemporaryDirectory() as temp_dir:
temp_root = Path(temp_dir)
source_root = temp_root / "source"
runtime_root = temp_root / "runtime"
stage_parent = temp_root / "stage"
stage_root = stage_parent / "installer_root"
source_root.mkdir()
runtime_root.mkdir()
(runtime_root / "Zapret.exe").write_bytes(b"exe")
(runtime_root / "python314.dll").write_bytes(b"dll")
for dir_name in (
"bin",
"exe",
"lists",
"lua",
"windivert.filter",
"themes",
):
directory = source_root / dir_name
directory.mkdir()
(directory / "required.dat").write_bytes(b"resource")
for service_list in (
"apple.txt",
"gemini.txt",
"git-zapret-moe.txt",
"linkedin.txt",
"notion.txt",
):
(source_root / "lists" / service_list).write_text("example.com\n", encoding="utf-8")
for exe_name, payload in (
("winws.exe", b"winws1-binary"),
("winws2.exe", b"winws2-binary"),
("WinDivert.dll", b"windivert-dll"),
("Monkey64.sys", b"windivert-driver"),
):
(source_root / "exe" / exe_name).write_bytes(payload)
icon_directory = source_root / "ico"
icon_directory.mkdir()
(icon_directory / "Zapret2.ico").write_bytes(b"stable-icon")
(icon_directory / "ZapretDevLogo4.ico").write_bytes(b"dev-icon")
generated_source_list = source_root / "lists" / "other.txt"
generated_source_list.write_text("source must stay untouched", encoding="utf-8")
source_before = {
path.relative_to(source_root): path.read_bytes()
for path in source_root.rglob("*")
if path.is_file()
}
with (
patch.object(release_pipeline, "DIST_DIR", source_root),
patch.object(release_pipeline, "DIST_RUNTIME_DIR", runtime_root),
patch.object(release_pipeline, "STAGE_DIR", stage_parent),
):
prepared = builder.prepare_installer_stage()
self.assertEqual(prepared, stage_root)
self.assertTrue((stage_root / "_internal" / "Zapret.exe").is_file())
self.assertTrue((stage_root / "_internal" / "python314.dll").is_file())
self.assertFalse((stage_root / "Zapret.exe").exists())
self.assertFalse((stage_root / "python314.dll").exists())
self.assertEqual(
generated_source_list.read_text(encoding="utf-8"),
"source must stay untouched",
)
source_after = {
path.relative_to(source_root): path.read_bytes()
for path in source_root.rglob("*")
if path.is_file()
}
self.assertEqual(source_after, source_before)
self.assertTrue((stage_root / "presets" / "winws2_builtin").is_dir())
self.assertTrue(any((stage_root / "presets" / "winws2_builtin").glob("*.txt")))
self.assertTrue((stage_root / "presets" / "winws2_builtin" / "Universal V5.txt").is_file())
self.assertTrue((stage_root / "presets" / "winws1_builtin").is_dir())
self.assertTrue(any((stage_root / "presets" / "winws1_builtin").glob("*.txt")))
self.assertTrue((stage_root / "system" / "strategy_catalogs" / "winws2").is_dir())
self.assertTrue((stage_root / "system" / "templates").is_dir())
for service_list in (
"apple.txt",
"gemini.txt",
"git-zapret-moe.txt",
"linkedin.txt",
"notion.txt",
):
self.assertTrue((stage_root / "lists" / service_list).is_file(), service_list)
self.assertTrue((stage_root / "system" / "hosts_catalog.sqlite3").is_file())
# json и sos выведены из поставки целиком
self.assertFalse((stage_root / "json").exists())
self.assertFalse((stage_root / "sos").exists())
self.assertTrue((stage_root / "ico" / "windows11_fluent" / "sidebar").is_dir())
# Манифест целостности едет внутри _internal: установщик
# заменяет этот каталог целиком, поэтому у установленной
# программы манифест всегда от её собственной версии.
from install_integrity import MANIFEST_FILE_NAME, load_manifest
manifest = load_manifest(stage_root / "_internal")
self.assertIsNotNone(manifest)
self.assertTrue((stage_root / "_internal" / MANIFEST_FILE_NAME).is_file())
manifest_paths = {entry.path for entry in manifest.entries}
self.assertIn("exe/winws2.exe", manifest_paths)
self.assertIn("exe/winws.exe", manifest_paths)
self.assertTrue(
{"exe/winws.exe", "exe/winws2.exe", "exe/WinDivert.dll", "exe/Monkey64.sys"}
<= {entry.path for entry in manifest.critical_entries()}
)
# Пользовательские данные в манифест не попадают: программа их
# правит сама, и расхождение с поставкой там нормально.
self.assertFalse(any(path.startswith("presets/") for path in manifest_paths))
self.assertFalse(any(path.startswith("lists/") for path in manifest_paths))
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_installer_stage_rejects_damaged_hosts_catalog_before_copy(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import release_model, release_pipeline
builder = release_pipeline.ReleasePipeline(
self._release_request(release_model),
log=Mock(),
)
with tempfile.TemporaryDirectory() as temp_dir:
temp_root = Path(temp_dir)
database = temp_root / "private" / "resources" / "data" / "hosts_catalog.sqlite3"
database.parent.mkdir(parents=True)
database.write_bytes(b"not a sqlite database")
with (
patch.object(release_pipeline, "PRIVATE_ROOT", temp_root / "private"),
patch.object(release_pipeline, "STAGE_DIR", temp_root / "stage"),
self.assertRaisesRegex(RuntimeError, "база каталога Hosts повреждена"),
):
builder.prepare_installer_stage()
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_managed_lists_materialization_replaces_previous_ipset_ru(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import release_model, release_pipeline
builder = release_pipeline.ReleasePipeline(
self._release_request(release_model),
log=Mock(),
)
with tempfile.TemporaryDirectory() as temp_dir:
lists_dir = Path(temp_dir) / "lists"
(lists_dir / "base").mkdir(parents=True)
(lists_dir / "user").mkdir()
(lists_dir / "base" / "ipset-ru.txt").write_text(
"1.1.1.0/24\n",
encoding="utf-8",
)
(lists_dir / "ipset-ru.txt").write_text(
"1.1.1.0/24\n",
encoding="utf-8",
)
user_path = lists_dir / "user" / "ipset-ru.txt"
user_path.write_text("9.9.9.9\n", encoding="utf-8")
with patch.object(
release_pipeline,
"get_ipset_ru_base_text",
side_effect=("2.2.2.0/24\n", "3.3.3.0/24\n"),
):
builder._materialize_managed_lists(lists_dir)
self.assertEqual(
(lists_dir / "base" / "ipset-ru.txt").read_text(encoding="utf-8"),
"2.2.2.0/24\n",
)
self.assertEqual(
(lists_dir / "ipset-ru.txt").read_text(encoding="utf-8"),
"2.2.2.0/24\n",
)
builder._materialize_managed_lists(lists_dir)
self.assertEqual(
(lists_dir / "base" / "ipset-ru.txt").read_text(encoding="utf-8"),
"3.3.3.0/24\n",
)
self.assertEqual(
(lists_dir / "ipset-ru.txt").read_text(encoding="utf-8"),
"3.3.3.0/24\n",
)
self.assertEqual(user_path.read_text(encoding="utf-8"), "9.9.9.9\n")
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_installer_stage_refuses_delivery_without_engine(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import release_model, release_pipeline
builder = release_pipeline.ReleasePipeline(
self._release_request(release_model),
log=Mock(),
)
with tempfile.TemporaryDirectory() as temp_dir:
temp_root = Path(temp_dir)
source_root = temp_root / "source"
runtime_root = temp_root / "runtime"
stage_parent = temp_root / "stage"
source_root.mkdir()
runtime_root.mkdir()
(runtime_root / "Zapret.exe").write_bytes(b"exe")
for dir_name in (
"bin",
"exe",
"lists",
"lua",
"windivert.filter",
"themes",
):
directory = source_root / dir_name
directory.mkdir()
(directory / "required.dat").write_bytes(b"resource")
# Каталог exe непустой, но движка в нём нет: именно так
# выглядит сборка, у которой антивирус забрал winws2.exe.
(source_root / "exe" / "winws.exe").write_bytes(b"winws1-binary")
(source_root / "exe" / "WinDivert.dll").write_bytes(b"windivert-dll")
(source_root / "exe" / "Monkey64.sys").write_bytes(b"windivert-driver")
icon_directory = source_root / "ico"
icon_directory.mkdir()
(icon_directory / "Zapret2.ico").write_bytes(b"stable-icon")
(icon_directory / "ZapretDevLogo4.ico").write_bytes(b"dev-icon")
with (
patch.object(release_pipeline, "DIST_DIR", source_root),
patch.object(release_pipeline, "DIST_RUNTIME_DIR", runtime_root),
patch.object(release_pipeline, "STAGE_DIR", stage_parent),
self.assertRaisesRegex(FileNotFoundError, "winws2.exe"),
):
builder.prepare_installer_stage()
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_installer_stage_rejects_any_overlap_with_read_roots(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import release_pipeline
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
source_root = root / "source"
source_root.mkdir()
sentinel = source_root / "keep.txt"
sentinel.write_text("unchanged", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "отдельной папкой"):
release_pipeline._require_isolated_installer_stage(
source_root / "stage",
(source_root,),
)
with self.assertRaisesRegex(RuntimeError, "отдельной папкой"):
release_pipeline._require_isolated_installer_stage(
root,
(source_root,),
)
self.assertEqual(sentinel.read_text(encoding="utf-8"), "unchanged")
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_successful_installer_stage_cleanup_removes_only_expected_directory(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import release_model, release_pipeline
log = Mock()
builder = release_pipeline.ReleasePipeline(
self._release_request(release_model),
log=log,
)
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
expected_stage = root / "stage" / "installer_root"
expected_stage.mkdir(parents=True)
(expected_stage / "prepared.txt").write_text("ready", encoding="utf-8")
with patch.object(release_pipeline, "STAGE_DIR", root / "stage"):
builder._cleanup_installer_stage(expected_stage)
self.assertFalse(expected_stage.exists())
log.put.assert_called_once()
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_successful_installer_stage_cleanup_rejects_unexpected_directory(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import release_model, release_pipeline
log = Mock()
builder = release_pipeline.ReleasePipeline(
self._release_request(release_model),
log=log,
)
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
expected_stage = root / "stage" / "installer_root"
unexpected_stage = root / "keep"
expected_stage.mkdir(parents=True)
unexpected_stage.mkdir()
sentinel = unexpected_stage / "keep.txt"
sentinel.write_text("unchanged", encoding="utf-8")
with (
patch.object(release_pipeline, "STAGE_DIR", root / "stage"),
self.assertRaisesRegex(
RuntimeError,
"неожиданного installer stage",
),
):
builder._cleanup_installer_stage(unexpected_stage)
self.assertEqual(sentinel.read_text(encoding="utf-8"), "unchanged")
log.put.assert_not_called()
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_installer_stage_rejects_missing_required_resource_directory(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.release_pipeline", None)
from build_zapret import release_model, release_pipeline
builder = release_pipeline.ReleasePipeline(
self._release_request(release_model),
log=Mock(),
)
with tempfile.TemporaryDirectory() as temp_dir:
temp_root = Path(temp_dir)
source_root = temp_root / "source"
runtime_root = temp_root / "runtime"
stage_root = temp_root / "stage"
source_root.mkdir()
runtime_root.mkdir()
(runtime_root / "Zapret.exe").write_bytes(b"exe")
with (
patch.object(release_pipeline, "DIST_DIR", source_root),
patch.object(release_pipeline, "DIST_RUNTIME_DIR", runtime_root),
patch.object(release_pipeline, "STAGE_DIR", stage_root),
self.assertRaisesRegex(
FileNotFoundError,
"обязательный каталог",
),
):
builder.prepare_installer_stage()
finally:
sys.modules.pop("build_zapret.release_pipeline", None)
sys.path[:] = old_path
def test_inno_installs_only_required_ico_resources(self) -> None:
iss = self._read_inno_script()
self.assertNotIn(r'Source: "{#SOURCEPATH}\ico\*"', iss)
self.assertIn(r'Source: "{#SOURCEPATH}\ico\Zapret2.ico"; DestDir: "{app}\system\ico"', iss)
self.assertIn(r'Source: "{#SOURCEPATH}\ico\ZapretDevLogo4.ico"; DestDir: "{app}\system\ico"', iss)
self.assertIn(
r'Source: "{#SOURCEPATH}\ico\windows11_fluent\sidebar\*.svg"; DestDir: "{app}\system\ico\windows11_fluent\sidebar"',
iss,
)
def test_inno_supports_a_changed_install_root_without_losing_user_data(self) -> None:
iss = self._read_inno_script()
self.assertIn("DisableDirPage=no", iss)
self.assertIn("UsePreviousAppDir=yes", iss)
self.assertIn("AppendDefaultDirName=no", iss)
self.assertIn("function NormalizeInstallRoot(const Value: string): string;", iss)
self.assertIn("function ValidateDestinationInstallRoot(", iss)
self.assertIn("function IsDirectoryEmpty(const Value: string): Boolean;", iss)
self.assertIn("function InstallOwnerMarkerAllowsReuse(const Root: string): Boolean;", iss)
self.assertIn("function InstallOwnerMarkerIsInstalled(const Root: string): Boolean;", iss)
self.assertIn("function WriteInstallOwnerMarker(", iss)
self.assertIn("PrepareDestinationOwnership", iss)
self.assertIn("function ReadPreviousInstallRoot: string;", iss)
self.assertIn("function IsRegisteredInstallRoot(const Value: string): Boolean;", iss)
self.assertIn("function IsSafeInstallRoot(const Value: string): Boolean;", iss)
self.assertIn("not PathHasReparsePointAncestor(Root)", iss)
self.assertIn("function ResolveSelectedInstallRoot(const Value: string): string;", iss)
self.assertIn("procedure ApplySelectedInstallRoot;", iss)
self.assertIn("WizardForm.DirBrowseButton.OnClick := @SelectInstallParent;", iss)
self.assertIn("function MigrateUserDataIfInstallRootChanged: Boolean;", iss)
self.assertIn("PreviousInstallRoot + '\\user'", iss)
self.assertIn("PreviousInstallRoot + '\\lists\\user'", iss)
self.assertIn("function CopyExternalListFiles(const SourceListsDir, DestListsDir: string): Boolean;", iss)
self.assertIn("External list migrated as user data", iss)
self.assertIn("CopyExternalListFiles(PreviousInstallRoot + '\\lists', NewInstallRoot + '\\lists')", iss)
self.assertIn("PreviousInstallRoot + '\\presets\\winws1'", iss)
self.assertIn("PreviousInstallRoot + '\\presets\\winws2'", iss)
# logs/tmp живут в user\ и переезжают вместе с ним;
# themes/ico — поставка system\, копировать их не нужно.
self.assertNotIn("PreviousInstallRoot + '\\logs'", iss)
self.assertNotIn("PreviousInstallRoot + '\\themes'", iss)
self.assertNotIn(
"CopyDirectoryTree(PreviousInstallRoot + '\\profile'",
iss,
)
# lua\ теперь чисто поставочный: при смене корня копировать нечего.
self.assertNotIn(
"CopyDirectoryTree(PreviousInstallRoot + '\\lua'",
iss,
)
# Старый settings\ намеренно не переносится: раскладка user\ начинается с чистого состояния.
self.assertNotIn(
"CopyDirectoryTree(PreviousInstallRoot + '\\settings'",
iss,
)
self.assertNotIn(
"CopyDirectoryTree(PreviousInstallRoot + '\\presets',",
iss,
)
self.assertNotIn(
"CopyDirectoryTree(PreviousInstallRoot + '\\strategy_scan_resume.json'",
iss,
)
self.assertIn("(FindRec.Attributes and $400) <> 0", iss)
self.assertIn("UserDataMigrationHasSkippedEntries := True;", iss)
self.assertIn("UserDataMigrationPerformed := True;", iss)
self.assertIn("MigratedFromRoot := PreviousInstallRoot;", iss)
self.assertIn("MigratedToRoot := NewInstallRoot;", iss)
self.assertNotIn("RemoveDir(PreviousInstallRoot)", iss)
self.assertNotIn('Type: filesandordirs; Name: "{app}\\*"', iss)
self.assertNotIn('Type: filesandordirs; Name: "{localappdata}\\ZapretUpdate"', iss)
self.assertIn('Type: files; Name: "{app}\\.zapret-install-owner"', iss)
source_lines = [line.strip() for line in iss.splitlines() if line.strip().startswith("Source:")]
self.assertTrue(source_lines)
for line in source_lines:
with self.subTest(line=line):
self.assertIn('DestDir: "{app}', line)
self.assertIn('Filename: "{app}\\_internal\\Zapret.exe"', iss)
self.assertIn('WorkingDir: "{app}"', iss)
def test_inno_previous_install_cleanup_is_explicit_and_runs_only_after_success(self) -> None:
iss = self._read_inno_script()
self.assertIn("CleanupPreviousInstallPage: TInputOptionWizardPage;", iss)
self.assertIn("CleanupPreviousInstallPage := CreateInputOptionPage(", iss)
self.assertIn("CleanupPreviousInstallPage.Values[0] := False;", iss)
self.assertIn("function ShouldSkipPage(PageID: Integer): Boolean;", iss)
self.assertIn("function ShouldOfferPreviousInstallCleanup: Boolean;", iss)
self.assertIn("function IsPreviousInstallCleanupSelected: Boolean;", iss)
self.assertIn("if IsAutoUpdate() or WizardSilent() or", iss)
self.assertIn("(not IsPreviousInstallCleanupSelected())", iss)
self.assertIn("UserDataMigrationHasSkippedEntries or", iss)
self.assertIn("function IsSafeInstallRoot(const Value: string): Boolean;", iss)
self.assertIn("MatchingFileExists(Root + '\\unins*.exe')", iss)
self.assertNotIn("function IsDeletablePreviousInstallRoot", iss)
self.assertIn("function IsProtectedInstallRoot(const Value: string): Boolean;", iss)
self.assertIn("PathsEqual(Root, ExpandConstant('{sd}\\Zapret'))", iss)
self.assertIn("не подтверждён успешный перенос данных", iss)
self.assertIn("not FileExists(NewInstallRoot + '\\_internal\\Zapret.exe')", iss)
self.assertIn("function TryGetDirectoryIdentity(const Directory: string; var Identity: string): Boolean;", iss)
self.assertIn("ExecAndCaptureOutput(", iss)
self.assertIn("'file queryFileID \"' + Directory + '\"'", iss)
self.assertIn("if not DifferentDirectoryIdentitiesVerified(PreviousInstallRoot, NewInstallRoot) then", iss)
self.assertIn("procedure DisablePreviousUninstallerIfKept;", iss)
self.assertIn("OldUninstaller + '.disabled'", iss)
self.assertIn("function RetargetGuiAutostartTask(const OldRoot, NewRoot: string): Boolean;", iss)
# Задача автозапуска проверяется и при обновлении на месте,
# а не только при переносе установки в другую папку.
self.assertIn("RetargetGuiAutostartTask(AutostartOldRoot, NewInstallRoot)", iss)
self.assertIn("AutostartOldRoot := NewInstallRoot", iss)
self.assertIn("'ZapretGUI Autostart'", iss)
self.assertIn("Action.WorkingDirectory := NewRoot;", iss)
self.assertIn("TaskUserId := Definition.Principal.UserId;", iss)
self.assertIn("function PersistentServiceReferencesOldRoot(const OldRoot: string): Boolean;", iss)
self.assertIn("SYSTEM\\CurrentControlSet\\Services\\ZapretTelegramProxy", iss)
self.assertIn("if PersistentServiceReferencesOldRoot(OldRoot) then", iss)
self.assertIn("function RetargetTelegramProxyService(", iss)
self.assertIn("function RepairAllUserShortcuts(", iss)
self.assertNotIn("ExecAsOriginalUser(", iss)
self.assertNotIn("{userstartup}", iss)
self.assertNotIn("{userappdata}", iss)
self.assertNotIn("{localappdata}", iss)
self.assertNotIn("PinnedShortcutRelocationSafe", iss)
post_install = iss.index("if (CurStep = ssPostInstall) then")
bindings_call = iss.index("RelocatePersistentBindingsIfNeeded(FailureReason)", post_install)
cleanup_call = iss.index("CleanupPreviousInstallIfRequested;", post_install)
disable_old_uninstaller_call = iss.index("DisablePreviousUninstallerIfKept;", post_install)
delete_call = iss.index("DelTree(PreviousInstallRoot, True, True, True)")
self.assertLess(delete_call, post_install)
self.assertLess(bindings_call, cleanup_call)
self.assertLess(cleanup_call, disable_old_uninstaller_call)
self.assertGreater(cleanup_call, post_install)
def test_inno_rejects_unsafe_or_nested_relocation_paths(self) -> None:
iss = self._read_inno_script()
self.assertIn("NewInstallRoot := NormalizeInstallRoot(WizardDirValue);", iss)
self.assertIn("if IsProtectedInstallRoot(NewInstallRoot) and", iss)
self.assertIn("IsNestedPath(NewInstallRoot, PreviousInstallRoot)", iss)
self.assertIn("IsNestedPath(PreviousInstallRoot, NewInstallRoot)", iss)
self.assertIn("function PathHasReparsePointAncestor(const Value: string): Boolean;", iss)
self.assertIn("if PathHasReparsePointAncestor(NewInstallRoot) then", iss)
self.assertIn("if Copy(NewInstallRoot, 1, 2) = '\\\\' then", iss)
self.assertIn("IsNestedPath(Root, ExpandConstant('{win}'))", iss)
self.assertIn("IsNestedPath(Root, GetEnv('ProgramW6432'))", iss)
self.assertIn("Previous InstallLocation ignored because it is not a registered Zapret install root", iss)
self.assertIn("IsDirectoryEmpty(NewInstallRoot)", iss)
self.assertIn("InstallOwnerMarkerAllowsReuse(NewInstallRoot)", iss)
self.assertIn("DestinationConfirmationRequired := True;", iss)
self.assertIn("папка уже содержит файлы", iss)
def test_inno_uses_registered_install_root_without_runtime_layout_branches(self) -> None:
iss = self._read_inno_script()
registered_root_start = iss.index("function IsRegisteredInstallRoot")
registered_root_end = iss.index("function IsSafeInstallRoot", registered_root_start)
registered_root = iss[registered_root_start:registered_root_end]
safe_root_start = iss.index("function IsSafeInstallRoot")
safe_root_end = iss.index("function IsAutoUpdate", safe_root_start)
safe_root = iss[safe_root_start:safe_root_end]
registry_start = iss.index("function ReadPreviousInstallRoot")
registry_end = iss.index("function ShouldOfferPreviousInstallCleanup", registry_start)
registry = iss[registry_start:registry_end]
validation_start = iss.index("function ValidateDestinationInstallRoot")
validation_end = iss.index("function PrepareDestinationOwnership", validation_start)
validation = iss[validation_start:validation_end]
self.assertNotIn("HasKnownZapretExecutable", iss)
self.assertNotIn("IsRecognizedPreviousInstallRoot", iss)
self.assertIn("MatchingFileExists(Root + '\\unins*.exe')", registered_root)
self.assertNotIn("IsProtectedInstallRoot", registered_root)
self.assertIn("IsRegisteredInstallRoot(Value)", safe_root)
self.assertIn("(not IsSharedInstallRoot(Value))", safe_root)
self.assertIn("Uninstall\\{#UninstallKeyName}", registry)
self.assertIn("'InstallLocation'", registry)
self.assertIn("if IsRegisteredInstallRoot(Value) then", registry)
self.assertNotIn("IsSafeInstallRoot", validation)
self.assertIn("PathsEqual(PreviousInstallRoot, NewInstallRoot)", validation)
self.assertIn(
"if IsProtectedInstallRoot(NewInstallRoot) and\n"
" (not PathsEqual(PreviousInstallRoot, NewInstallRoot)) then",
validation,
)
def test_inno_keeps_legacy_programdata_install_but_nests_new_parent_selection(self) -> None:
iss = self._read_inno_script()
protected_start = iss.index("function IsProtectedInstallRoot")
protected_end = iss.index("function IsSharedInstallRoot", protected_start)
protected = iss[protected_start:protected_end]
resolver_start = iss.index("function ResolveSelectedInstallRoot")
resolver_end = iss.index("procedure ApplySelectedInstallRoot", resolver_start)
resolver = iss[resolver_start:resolver_end]
install_delete_start = iss.index("[InstallDelete]")
install_delete_end = iss.index("[UninstallDelete]", install_delete_start)
install_delete = iss[install_delete_start:install_delete_end]
self.assertNotIn("ExpandConstant('{commonappdata}')", protected)
self.assertIn(
"Result := PathsEqual(Value, ExpandConstant('{commonappdata}'));",
iss,
)
self.assertIn("PathsEqual(Root, PreviousInstallRoot)", resolver)
self.assertIn("HasChannelInstallSuffix(Root)", resolver)
self.assertIn("(CompareText(Leaf, 'Dev') = 0)", resolver)
self.assertIn("(CompareText(Leaf, 'Stable') = 0)", resolver)
self.assertIn("AddBackslash(ParentRoot) + '{#InstallLeaf}'", resolver)
self.assertIn("AddBackslash(Root) + '{#InstallLeaf}'", resolver)
self.assertIn("AddBackslash(Root) + 'Zapret\\{#InstallLeaf}'", resolver)
self.assertIn("BrowseForFolder(", iss)
self.assertIn("ApplySelectedInstallRoot;", iss)
self.assertIn(
'Type: files; Name: "{app}\\*.dll"; Check: ShouldDeleteLegacyRootRuntime',
install_delete,
)
self.assertIn(
'Type: filesandordirs; Name: "{app}\\src"; Check: ShouldDeleteLegacyRootRuntime',
install_delete,
)
self.assertIn("if not IsSharedInstallRoot(AppRoot) then", iss)
def test_inno_retires_legacy_layout_but_preserves_user_state(self) -> None:
iss = self._read_inno_script()
install_delete_start = iss.index("[InstallDelete]")
install_delete_end = iss.index("[UninstallDelete]", install_delete_start)
install_delete = iss[install_delete_start:install_delete_end]
deleted_names = re.findall(r'Name:\s*"([^"]+)"', install_delete)
# user\ — пользовательское состояние: установщик его не трогает.
self.assertNotIn(r"{app}\user", deleted_names)
self.assertNotIn(r"{app}\user\settings.sqlite3", deleted_names)
self.assertNotIn(r"{app}\user\premium.sqlite3", deleted_names)
self.assertNotIn(r"{app}\user\update_cache", deleted_names)
# Старый settings\ тоже не удаляется: там может остаться привязка
# Premium dev-установки прежней раскладки.
self.assertNotIn(r"{app}\settings", deleted_names)
# Мёртвые каталоги старой раскладки вычищаются при обновлении.
self.assertIn(r'Type: filesandordirs; Name: "{app}\json"', install_delete)
self.assertIn(r'Type: filesandordirs; Name: "{app}\sos"', install_delete)
self.assertIn(r'Type: filesandordirs; Name: "{app}\data"', install_delete)
self.assertIn(r'Type: filesandordirs; Name: "{app}\profile"', install_delete)
self.assertIn(r'Type: filesandordirs; Name: "{app}\_update_cache"', install_delete)
# Рантайм-файлы оркестратора старой раскладки внутри lua\
self.assertIn(r'Type: files; Name: "{app}\lua\whitelist.txt"', install_delete)
self.assertIn(r'Type: files; Name: "{app}\lua\learned-strategies.lua"', install_delete)
def test_inno_replaces_system_directory_content_from_delivery(self) -> None:
iss = self._read_inno_script()
install_delete = iss[iss.index("[InstallDelete]"):iss.index("[UninstallDelete]")]
self.assertIn(
r'Source: "{#SOURCEPATH}\system\hosts_catalog.sqlite3"; DestDir: "{app}\system"',
iss,
)
self.assertNotIn(r'Source: "{#SOURCEPATH}\json\hosts_catalog\*"', iss)
self.assertIn(
r'Type: filesandordirs; Name: "{app}\system\strategy_catalogs"',
install_delete,
)
self.assertIn(
r'Type: filesandordirs; Name: "{app}\system\templates"',
install_delete,
)
self.assertIn(
r'Type: files; Name: "{app}\system\hosts_catalog.sqlite3"',
install_delete,
)
# system\ целиком в [InstallDelete] нельзя: там уже лежит
# system\install-owner, записанный до начала копирования.
self.assertNotIn(r'Type: filesandordirs; Name: "{app}\system"', install_delete)
def test_inno_persistent_bindings_follow_install_roots_not_previous_exe_layout(self) -> None:
iss = self._read_inno_script()
retarget_start = iss.index("function RetargetGuiAutostartTask")
retarget_end = iss.index("function QuotePowerShellLiteral", retarget_start)
retarget = iss[retarget_start:retarget_end]
repair_start = iss.index("function RepairAllUserShortcuts")
repair_end = iss.index("function RemoveGuiAutostartTaskIfOwned", repair_start)
repair = iss[repair_start:repair_end]
remove_task_start = iss.index("function RemoveGuiAutostartTaskIfOwned")
remove_task_end = iss.index("function RemoveAllUserShortcutsForUninstall", remove_task_start)
remove_task = iss[remove_task_start:remove_task_end]
remove_links_start = iss.index("function RemoveAllUserShortcutsForUninstall")
remove_links_end = iss.index("procedure AppendRelocationReason", remove_links_start)
remove_links = iss[remove_links_start:remove_links_end]
self.assertIn("if PathsEqual(ActionPath, NewExe) then", retarget)
self.assertIn("else if IsNestedPath(ActionPath, OldRoot) or", retarget)
self.assertIn("IsNestedPath(ActionPath, NewRoot) then", retarget)
self.assertIn("GUI autostart task points to another install, left untouched.", retarget)
self.assertNotIn("OldExe", retarget)
self.assertIn("OldRootPrefix := AddBackslash(NormalizeInstallRoot(OldRoot));", repair)
self.assertIn("$target.StartsWith($oldRoot,[StringComparison]::OrdinalIgnoreCase)", repair)
self.assertNotIn("$oldExe", repair)
self.assertIn("if IsNestedPath(Action.Path, AppRoot) then", remove_task)
self.assertIn("RootPrefix := AddBackslash(NormalizeInstallRoot(AppRoot));", remove_links)
self.assertIn("$target.StartsWith($root,[StringComparison]::OrdinalIgnoreCase)", remove_links)
def test_inno_auto_update_has_an_explicit_success_only_flow(self) -> None:
iss = self._read_inno_script()
installer_launcher = (PUBLIC_ROOT / "src" / "updater" / "update.py").read_text(encoding="utf-8")
update_pipeline = (PUBLIC_ROOT / "src" / "updater" / "update_pipeline.py").read_text(encoding="utf-8")
auto_update_start = iss.index("function IsAutoUpdate: Boolean;")
auto_update_end = iss.index("function ReadPreviousInstallRoot", auto_update_start)
auto_update = iss[auto_update_start:auto_update_end]
self.assertIn("'/AUTOUPDATE'", auto_update)
self.assertNotIn("'/SILENT'", auto_update)
self.assertNotIn("'/VERYSILENT'", auto_update)
self.assertNotIn("'/NORESTART'", auto_update)
self.assertIn('"/AUTOUPDATE",', update_pipeline)
# /SUPPRESSMSGBOXES означает ответ Abort в ситуациях Abort/Retry:
# с ним сбой распаковки проходил молча, без единого сообщения.
self.assertIn('"/SILENT",', update_pipeline)
self.assertNotIn('"/VERYSILENT",', update_pipeline)
self.assertNotIn('"/SUPPRESSMSGBOXES",', update_pipeline)
self.assertNotIn('"/RESTARTAPPLICATIONS",', update_pipeline)
self.assertIn('f"/DIR={APPLICATION_PATHS.root}"', update_pipeline)
self.assertIn('f"/LOG={setup_log}"', update_pipeline)
self.assertNotIn("arguments.split()", installer_launcher)
self.assertIn("subprocess.list2cmdline(argument_list)", installer_launcher)
self.assertIn('"-EncodedCommand",', installer_launcher)
self.assertIn("process.communicate(timeout=120)", installer_launcher)
self.assertNotIn("procedure DeinitializeSetup;", iss)
self.assertIn("if (CurStep = ssDone) and IsAutoUpdate() then", iss)
self.assertIn(
"Log('Post-install finalization incomplete; launching application anyway.');",
iss,
)
self.assertIn("PostInstallFinalizationSucceeded", iss)
self.assertIn("function ShouldLaunchAfterInteractiveInstall: Boolean;", iss)
self.assertIn("Check: ShouldLaunchAfterInteractiveInstall", iss)
initialize_start = iss.index("function InitializeSetup: Boolean;")
initialize_end = iss.index("procedure InitializeWizard;", initialize_start)
initialize = iss[initialize_start:initialize_end]
self.assertNotIn("KillProcessWithRetry", initialize)
self.assertNotIn("NormalizePinnedTaskbarShortcut", initialize)
def test_inno_stops_only_processes_from_the_selected_installation(self) -> None:
iss = self._read_inno_script()
self.assertIn(
"function StopInstallRootProcesses(const Root, ExcludeExe: string): Boolean;",
iss,
)
self.assertIn("function StopRelocationInstallProcesses: Boolean;", iss)
process_start = iss.index("function StopInstallRootProcesses")
process_end = iss.index("function StopRelocationInstallProcesses", process_start)
process_stop = iss[process_start:process_end]
self.assertIn("RootPrefix := AddBackslash(NormalizedRoot);", process_stop)
self.assertIn("SourceInstaller := NormalizeInstallRoot(ExcludeExe);", process_stop)
self.assertIn("if IsSharedInstallRoot(NormalizedRoot) then", process_stop)
self.assertIn("NormalizedRoot + '\\Zapret.exe'", process_stop)
self.assertIn("NormalizedRoot + '\\_internal\\Zapret.exe'", process_stop)
self.assertIn("NormalizedRoot + '\\exe\\winws.exe'", process_stop)
self.assertIn("NormalizedRoot + '\\exe\\winws2.exe'", process_stop)
self.assertIn("PathMatchExpression", process_stop)
self.assertIn("$path.StartsWith($root,[StringComparison]::OrdinalIgnoreCase)", process_stop)
self.assertIn("$path -ine $setup", process_stop)
self.assertIn("Stop-Process -Force", process_stop)
self.assertNotIn("KillProcessAtPathWithRetry", iss)
self.assertIn("function NativePowerShellExe: string;", iss)
self.assertIn(
"ExpandConstant('{sysnative}\\WindowsPowerShell\\v1.0\\powershell.exe')",
iss,
)
self.assertNotIn(
"ExpandConstant('{sys}\\WindowsPowerShell\\v1.0\\powershell.exe')",
iss,
)
self.assertIn("if Exec(NativePowerShellExe,", iss)
self.assertNotIn("taskkill.exe", iss)
self.assertNotIn("ProcessName -eq", iss)
self.assertNotIn("KillProcessWithRetry", iss)
prepare_start = iss.index("function PrepareToInstall(var NeedsRestart: Boolean): string;")
prepare_end = iss.index("function GetInstallDir(Param: string): string;", prepare_start)
prepare = iss[prepare_start:prepare_end]
ownership_call = prepare.index("if not PrepareDestinationOwnership then")
process_stop_call = prepare.index("if not StopRelocationInstallProcesses then")
self.assertLess(ownership_call, process_stop_call)
self.assertIn("StopApplicationServices(PreviousInstallRoot, WizardDirValue);", prepare)
self.assertIn("function ServiceReferencesInstallRoot(", iss)
self.assertIn("function RegistryValueReferencesInstallOwnedPath(", iss)
self.assertIn("Service does not belong to selected install roots, skipped", iss)
uninstall_start = iss.index("procedure CurUninstallStepChanged(")
uninstall_end = iss.index("procedure CurStepChanged(", uninstall_start)
uninstall = iss[uninstall_start:uninstall_end]
self.assertIn("PrepareApplicationUninstall(AppRoot);", uninstall)
self.assertIn("procedure PrepareApplicationUninstall(const AppRoot: string);", iss)
self.assertIn("StopInstallRootProcesses(AppRoot, '');", iss)
self.assertIn("StopApplicationServices(AppRoot, '');", iss)
self.assertIn("StopAndDeleteServiceForRoots('ZapretTelegramProxy', AppRoot, '');", iss)
self.assertIn("function RemoveGuiAutostartTaskIfOwned(const AppRoot: string): Boolean;", iss)
self.assertIn("Folder.DeleteTask('ZapretGUI Autostart', 0);", iss)
self.assertIn("function RemoveAllUserShortcutsForUninstall(", iss)
def test_inno_uninstall_path_avoids_setup_only_api(self) -> None:
"""Setup-only константы и API мастера роняют деинсталлятор.
ExpandConstant('{srcexe}') в StopInstallRootProcesses срывал удаление
с "Cannot evaluate "srcexe" constant during Uninstall", поэтому
проверяется вся цепочка вызовов из CurUninstallStepChanged.
"""
iss = self._read_inno_script()
reachable = self._inno_routines_reachable_from(iss, "CurUninstallStepChanged")
for expected in (
"PrepareApplicationUninstall",
"StopInstallRootProcesses",
"StopApplicationServices",
"RemoveGuiAutostartTaskIfOwned",
"RemoveAllUserShortcutsForUninstall",
):
self.assertIn(expected, reachable)
setup_only_constant = re.compile(r"ExpandConstant\('\{src(exe)?[}\\]")
setup_only_api = re.compile(
r"\bWizard(DirValue|Silent|Form|IsTaskSelected|IsComponentSelected)\b"
)
for name, body in sorted(reachable.items()):
self.assertIsNone(
setup_only_constant.search(body),
f"{name} раскрывает setup-only константу в деинсталляторе",
)
self.assertIsNone(
setup_only_api.search(body),
f"{name} использует setup-only API мастера в деинсталляторе",
)
def test_inno_uninstall_cleanup_failure_never_blocks_removal(self) -> None:
iss = self._read_inno_script()
prepare_start = iss.index("procedure PrepareApplicationUninstall(")
prepare_end = iss.index("function InitializeSetup: Boolean;", prepare_start)
prepare = iss[prepare_start:prepare_end]
self.assertIn("Uninstall preparation failed, continuing removal: ", prepare)
self.assertLess(prepare.index("try"), prepare.index("StopInstallRootProcesses"))
self.assertLess(prepare.index("StopAndDeleteServiceForRoots"), prepare.index("except"))
def test_inno_reads_all_required_resources_only_from_prepared_stage(self) -> None:
iss = self._read_inno_script()
expected_sources = (
r'{#SOURCEPATH}\system\strategy_catalogs\winws2\*.txt',
r'{#SOURCEPATH}\system\strategy_catalogs\winws1\*.txt',
r'{#SOURCEPATH}\system\templates\*.txt',
r'{#SOURCEPATH}\presets\winws2_builtin\*.txt',
r'{#SOURCEPATH}\presets\winws1_builtin\*.txt',
r'{#SOURCEPATH}\system\hosts_catalog.sqlite3',
r'{#SOURCEPATH}\ico\windows11_fluent\sidebar\*.svg',
)
for source in expected_sources:
self.assertIn(source, iss)
builder = (PRIVATE_ROOT / "build_zapret" / "release_pipeline.py").read_text(encoding="utf-8")
self.assertIn("resource_sets = (", builder)
self.assertIn("PUBLIC_SRC / \"presets\" / \"builtin\" / \"winws2\"", builder)
self.assertIn("PRIVATE_ROOT / \"resources\" / \"profile\" / \"templates\"", builder)
self.assertIn("PRIVATE_ROOT / \"resources\" / \"system\"", builder)
self.assertNotIn("f'/DPUBLICSRC=", builder)
self.assertNotIn("f'/DPRIVATERESOURCES=", builder)
def test_installer_stage_copies_only_required_ico_resources(self) -> None:
builder = (PRIVATE_ROOT / "build_zapret" / "release_pipeline.py").read_text(encoding="utf-8")
self.assertIn('REQUIRED_INSTALLER_ICO_FILES = ("Zapret2.ico", "ZapretDevLogo4.ico")', builder)
self.assertIn("for file_name in REQUIRED_INSTALLER_ICO_FILES:", builder)
self.assertIn("shutil.copy2(source, icon_directory / file_name)", builder)
self.assertNotIn('"ico",\n "lists",', builder)
def test_installer_stage_does_not_copy_legacy_help_folder(self) -> None:
builder = (PRIVATE_ROOT / "build_zapret" / "release_pipeline.py").read_text(encoding="utf-8")
self.assertNotIn('"help",', builder)
def test_pyinstaller_hiddenimports_include_lazy_feature_facades(self) -> None:
old_path = list(sys.path)
sys.path.insert(0, str(PRIVATE_ROOT))
try:
sys.modules.pop("build_zapret.pyinstaller_builder", None)
from build_zapret import pyinstaller_builder
hiddenimports = pyinstaller_builder._hiddenimports_for_spec()
finally:
sys.modules.pop("build_zapret.pyinstaller_builder", None)
sys.path[:] = old_path
self.assertIn("app.feature_facades.blockcheck", hiddenimports)
if __name__ == "__main__":
unittest.main()