Compare commits

..

No commits in common. "master" and "v2.0.1" have entirely different histories.

8 changed files with 44 additions and 177 deletions

View File

@ -61,10 +61,8 @@
Приложение скачивает ZIP-пакет текущей платформы, проверяет его целостность и Приложение скачивает ZIP-пакет текущей платформы, проверяет его целостность и
безопасность, завершает старый процесс, заменяет бинарник и все каталоги из безопасность, завершает старый процесс, заменяет бинарник и все каталоги из
пакета, а затем запускается заново. Перед заменой создаётся резервная копия; пакета, а затем запускается заново. Перед заменой создаётся резервная копия для
она удаляется только после подтверждения запуска новой версии. Если запуск автоматического восстановления при ошибке. Файл `settings.json` и другие файлы,
не удался, предыдущая версия восстанавливается, а сообщение записывается
в `update-error.log`. Файл `settings.json` и другие файлы,
которых нет в архиве, сохраняются. Для работы проверки релизы репозитория должны которых нет в архиве, сохраняются. Для работы проверки релизы репозитория должны
быть доступны без авторизации. быть доступны без авторизации.
@ -73,11 +71,6 @@
- Windows x86_64: `plate_config_app-windows-x86_64.zip`; - Windows x86_64: `plate_config_app-windows-x86_64.zip`;
- Linux x86_64: `plate_config_app-linux-x86_64.zip`. - Linux x86_64: `plate_config_app-linux-x86_64.zip`.
Для перехода с уже установленной v2.0.1 на v2.0.2 обновите приложение вручную:
закройте его, скачайте ZIP-пакет своей платформы и распакуйте содержимое в
каталог приложения с заменой прежних файлов. `settings.json` удалять не нужно.
Начиная с v2.0.2 последующие обновления можно устанавливать кнопкой в приложении.
--- ---
## Интерфейс приложения ## Интерфейс приложения

View File

@ -1,3 +1,3 @@
"""Plate Config App package.""" """Plate Config App package."""
__version__ = "2.0.2" __version__ = "2.0.1"

View File

@ -2,13 +2,11 @@ from __future__ import annotations
import sys import sys
from PySide6.QtCore import QTimer
from PySide6.QtGui import QFont from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from .main_window import MainWindow from .main_window import MainWindow
from .services import settings_service from .services import settings_service
from .services.update_service import signal_update_ready
from .ui.icons import ICON_COLOR_PRIMARY, icon from .ui.icons import ICON_COLOR_PRIMARY, icon
from .ui.theme import build_stylesheet, get_palette, create_qpalette from .ui.theme import build_stylesheet, get_palette, create_qpalette
@ -29,5 +27,4 @@ def main() -> None:
if not app_icon.isNull(): if not app_icon.isNull():
window.setWindowIcon(app_icon) window.setWindowIcon(app_icon)
window.show() window.show()
QTimer.singleShot(0, signal_update_ready)
sys.exit(app.exec()) sys.exit(app.exec())

View File

@ -31,7 +31,6 @@ DOWNLOAD_CHUNK_SIZE = 1024 * 1024
MAX_DOWNLOAD_BYTES = 300 * 1024 * 1024 MAX_DOWNLOAD_BYTES = 300 * 1024 * 1024
MAX_UNPACKED_BYTES = 600 * 1024 * 1024 MAX_UNPACKED_BYTES = 600 * 1024 * 1024
MAX_ARCHIVE_MEMBERS = 10_000 MAX_ARCHIVE_MEMBERS = 10_000
UPDATE_READY_ENV = "PLATE_CONFIG_UPDATE_READY_FILE"
VERSION_PATTERN = re.compile( VERSION_PATTERN = re.compile(
r"[vV]?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" r"[vV]?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
) )
@ -354,29 +353,18 @@ def current_executable() -> Path:
return Path(sys.executable).resolve() return Path(sys.executable).resolve()
def signal_update_ready() -> None:
ready_file = os.environ.pop(UPDATE_READY_ENV, None)
if ready_file:
try:
Path(ready_file).write_text(str(os.getpid()), encoding="ascii")
except OSError:
pass
def _write_windows_updater(script_path: Path) -> None: def _write_windows_updater(script_path: Path) -> None:
script = r'''param( script = r'''param(
[int]$ApplicationProcessId, [int]$ApplicationProcessId,
[string]$Staging, [string]$Staging,
[string]$Target, [string]$Target,
[string]$Backup, [string]$Backup
[string]$ReadyFile
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$applicationDirectory = Split-Path -Parent $Target $applicationDirectory = Split-Path -Parent $Target
$errorLog = Join-Path $applicationDirectory "update-error.log" $errorLog = Join-Path $applicationDirectory "update-error.log"
$installed = [System.Collections.Generic.List[string]]::new() $installed = [System.Collections.Generic.List[string]]::new()
$backedUp = [System.Collections.Generic.List[string]]::new() $backedUp = [System.Collections.Generic.List[string]]::new()
$restarted = $null
try { try {
Wait-Process -Id $ApplicationProcessId -ErrorAction SilentlyContinue Wait-Process -Id $ApplicationProcessId -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
@ -385,81 +373,35 @@ try {
foreach ($entry in $entries) { foreach ($entry in $entries) {
$destination = Join-Path $applicationDirectory $entry.Name $destination = Join-Path $applicationDirectory $entry.Name
if (Test-Path -LiteralPath $destination) { if (Test-Path -LiteralPath $destination) {
$deadline = (Get-Date).AddSeconds(30) Move-Item -LiteralPath $destination -Destination $Backup -Force
while ($true) {
try {
Move-Item -LiteralPath $destination -Destination $Backup -Force -ErrorAction Stop
break
} catch {
if ((Get-Date) -ge $deadline) { throw }
Start-Sleep -Milliseconds 250
}
}
$backedUp.Add($entry.Name) $backedUp.Add($entry.Name)
} }
Move-Item -LiteralPath $entry.FullName -Destination $destination -Force -ErrorAction Stop Move-Item -LiteralPath $entry.FullName -Destination $destination -Force
$installed.Add($entry.Name) $installed.Add($entry.Name)
} }
if (-not (Test-Path -LiteralPath $Target -PathType Leaf)) { if (-not (Test-Path -LiteralPath $Target -PathType Leaf)) {
throw "В пакете обновления отсутствует исполняемый файл" throw "В пакете обновления отсутствует исполняемый файл"
} }
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue Start-Process -FilePath $Target -WorkingDirectory $applicationDirectory
$previousReadyFile = $env:PLATE_CONFIG_UPDATE_READY_FILE
$previousResetEnvironment = $env:PYINSTALLER_RESET_ENVIRONMENT
try {
$env:PLATE_CONFIG_UPDATE_READY_FILE = $ReadyFile
$env:PYINSTALLER_RESET_ENVIRONMENT = "1"
$restarted = Start-Process -FilePath $Target -WorkingDirectory $applicationDirectory -PassThru
} finally {
$env:PLATE_CONFIG_UPDATE_READY_FILE = $previousReadyFile
$env:PYINSTALLER_RESET_ENVIRONMENT = $previousResetEnvironment
}
$deadline = (Get-Date).AddSeconds(60)
while (-not (Test-Path -LiteralPath $ReadyFile -PathType Leaf)) {
if ($restarted.HasExited) { throw "Новая версия завершилась до запуска интерфейса" }
if ((Get-Date) -ge $deadline) { throw "Новая версия не подтвердила запуск" }
Start-Sleep -Milliseconds 250
}
if ($restarted.HasExited) { throw "Новая версия завершилась сразу после запуска" }
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force
} catch { } catch {
$failure = $_
if ($restarted -and -not $restarted.HasExited) {
& taskkill.exe /PID $restarted.Id /T /F *> $null
Start-Sleep -Milliseconds 500
}
$rollbackErrors = [System.Collections.Generic.List[string]]::new()
foreach ($name in $installed) { foreach ($name in $installed) {
$destination = Join-Path $applicationDirectory $name $destination = Join-Path $applicationDirectory $name
if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction SilentlyContinue
try {
Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction Stop
} catch {
$rollbackErrors.Add($_.ToString())
}
}
} }
foreach ($name in $backedUp) { foreach ($name in $backedUp) {
$backupItem = Join-Path $Backup $name $backupItem = Join-Path $Backup $name
$destination = Join-Path $applicationDirectory $name $destination = Join-Path $applicationDirectory $name
Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $backupItem) { if (Test-Path -LiteralPath $backupItem) {
try { Move-Item -LiteralPath $backupItem -Destination $destination -Force
Move-Item -LiteralPath $backupItem -Destination $destination -Force -ErrorAction Stop
} catch {
$rollbackErrors.Add($_.ToString())
}
} }
} }
Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue
if ($rollbackErrors.Count -eq 0) { Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue $_ | Out-File -LiteralPath $errorLog -Encoding utf8
}
@($failure.ToString()) + @($rollbackErrors) | Out-File -LiteralPath $errorLog -Encoding utf8
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue
exit 1 exit 1
} }
''' '''
@ -473,40 +415,28 @@ application_pid="$1"
staging_path="$2" staging_path="$2"
target_path="$3" target_path="$3"
backup_path="$4" backup_path="$4"
ready_path="$5"
application_dir="$(dirname -- "$target_path")" application_dir="$(dirname -- "$target_path")"
error_log="$application_dir/update-error.log" error_log="$application_dir/update-error.log"
installed_file="$backup_path/.installed" installed_file="$backup_path/.installed"
backed_up_file="$backup_path/.backed_up" backed_up_file="$backup_path/.backed_up"
new_pid=""
rollback() { rollback() {
exit_code="$?" exit_code="$?"
trap - EXIT HUP INT TERM trap - EXIT HUP INT TERM
set +e
if [ -n "$new_pid" ]; then
kill "$new_pid" 2>/dev/null
wait "$new_pid" 2>/dev/null
fi
rollback_failed=0
if [ -f "$installed_file" ]; then if [ -f "$installed_file" ]; then
while IFS= read -r name; do while IFS= read -r name; do
rm -rf -- "$application_dir/$name" || rollback_failed=1 rm -rf -- "$application_dir/$name"
done < "$installed_file" done < "$installed_file"
fi fi
if [ -f "$backed_up_file" ]; then if [ -f "$backed_up_file" ]; then
while IFS= read -r name; do while IFS= read -r name; do
rm -rf -- "$application_dir/$name"
if [ -e "$backup_path/$name" ]; then if [ -e "$backup_path/$name" ]; then
mv -f -- "$backup_path/$name" "$application_dir/$name" || rollback_failed=1 mv -f -- "$backup_path/$name" "$application_dir/$name"
fi fi
done < "$backed_up_file" done < "$backed_up_file"
fi fi
rm -f -- "$ready_path" rm -rf -- "$staging_path" "$backup_path"
rm -rf -- "$staging_path"
if [ "$rollback_failed" -eq 0 ]; then
rm -rf -- "$backup_path"
fi
printf '%s\n' "Не удалось заменить приложение" >"$error_log" printf '%s\n' "Не удалось заменить приложение" >"$error_log"
rm -f -- "$0"
if [ "$exit_code" -eq 0 ]; then if [ "$exit_code" -eq 0 ]; then
exit_code=1 exit_code=1
fi fi
@ -532,25 +462,8 @@ for source_path in "$staging_path"/*; do
done done
test -f "$target_path" test -f "$target_path"
chmod 755 -- "$target_path" chmod 755 -- "$target_path"
rm -f -- "$ready_path" "$target_path" >/dev/null 2>&1 &
PLATE_CONFIG_UPDATE_READY_FILE="$ready_path" PYINSTALLER_RESET_ENVIRONMENT=1 "$target_path" >/dev/null 2>&1 &
new_pid=$!
attempts=0
while [ ! -f "$ready_path" ]; do
if ! kill -0 "$new_pid" 2>/dev/null; then
exit 1
fi
attempts=$((attempts + 1))
if [ "$attempts" -ge 240 ]; then
exit 1
fi
sleep 0.25
done
if ! kill -0 "$new_pid" 2>/dev/null; then
exit 1
fi
trap - EXIT HUP INT TERM trap - EXIT HUP INT TERM
rm -f -- "$ready_path"
rm -rf -- "$backup_path" "$staging_path" rm -rf -- "$backup_path" "$staging_path"
rm -f -- "$0" rm -f -- "$0"
''' '''
@ -581,7 +494,6 @@ def launch_update(
staging_path = target.parent / f".plate_config_update-{updater_id}" staging_path = target.parent / f".plate_config_update-{updater_id}"
backup_path = target.parent / f".plate_config_backup-{updater_id}" backup_path = target.parent / f".plate_config_backup-{updater_id}"
ready_path = target.parent / f".plate_config_ready-{updater_id}"
script_path: Path | None = None script_path: Path | None = None
try: try:
_extract_update_archive(archive_path, staging_path, target.name) _extract_update_archive(archive_path, staging_path, target.name)
@ -610,7 +522,6 @@ def launch_update(
str(staging_path), str(staging_path),
str(target), str(target),
str(backup_path), str(backup_path),
str(ready_path),
], ],
close_fds=True, close_fds=True,
creationflags=creation_flags, creationflags=creation_flags,
@ -632,7 +543,6 @@ def launch_update(
str(staging_path), str(staging_path),
str(target), str(target),
str(backup_path), str(backup_path),
str(ready_path),
], ],
cwd=str(target.parent), cwd=str(target.parent),
close_fds=True, close_fds=True,

View File

@ -5,6 +5,7 @@ from pathlib import Path
from PySide6.QtCore import QThread, QTimer, Qt, Signal from PySide6.QtCore import QThread, QTimer, Qt, Signal
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QFrame, QFrame,
QGraphicsDropShadowEffect, QGraphicsDropShadowEffect,
QHBoxLayout, QHBoxLayout,
@ -229,7 +230,9 @@ class StartTab(QWidget):
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
self._on_update_failed(str(exc)) self._on_update_failed(str(exc))
return return
self.window().close() app = QApplication.instance()
if app is not None:
app.quit()
def _on_update_failed(self, detail: str) -> None: def _on_update_failed(self, detail: str) -> None:
self._update_btn.setText("Скачать обновление") self._update_btn.setText("Скачать обновление")

View File

@ -54,28 +54,10 @@ if ($InstallBuildDeps) {
# ── Сборка ───────────────────────────────────────────────────────── # ── Сборка ─────────────────────────────────────────────────────────
Write-Host "" Write-Host ""
Write-Host "=== Сборка EXE ===" Write-Host "=== Сборка EXE ==="
$pythonHome = (& $venvPython -c "import sys; print(sys.base_prefix)").Trim() & $venvPython -m PyInstaller --noconfirm --clean plate_config_app.spec
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $pythonHome -PathType Container)) {
throw "Не удалось определить каталог Python для сборки."
}
$buildPath = @(
(Split-Path -Parent $venvPython),
$pythonHome,
(Join-Path $pythonHome "DLLs"),
(Join-Path $env:SystemRoot "System32"),
$env:SystemRoot
) -join ";"
$originalPath = $env:PATH
try {
$env:PATH = $buildPath
& $venvPython -m PyInstaller --noconfirm --clean plate_config_app.spec
$buildExitCode = $LASTEXITCODE
} finally {
$env:PATH = $originalPath
}
if ($buildExitCode -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "PyInstaller завершился с ошибкой (код $buildExitCode)." throw "PyInstaller завершился с ошибкой (код $LASTEXITCODE)."
} }
$builtExe = Join-Path $projectRoot "dist\plate_config_app.exe" $builtExe = Join-Path $projectRoot "dist\plate_config_app.exe"

View File

@ -4,7 +4,7 @@ import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import Mock, patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@ -98,18 +98,20 @@ class StartTabUpdateTests(unittest.TestCase):
warning.assert_called_once() warning.assert_called_once()
tab.shutdown() tab.shutdown()
def test_successful_download_launches_update_and_closes_window(self) -> None: def test_successful_download_launches_update_and_quits(self) -> None:
tab = StartTab(lambda: None) tab = StartTab(lambda: None)
downloaded = Path("downloaded-update") downloaded = Path("downloaded-update")
application = Mock()
with ( with (
patch("app.widgets.start_tab.launch_update") as launcher, patch("app.widgets.start_tab.launch_update") as launcher,
patch.object(tab, "close") as close_window, patch("app.widgets.start_tab.QApplication") as application_class,
): ):
application_class.instance.return_value = application
tab._on_update_downloaded(downloaded) tab._on_update_downloaded(downloaded)
launcher.assert_called_once_with(downloaded) launcher.assert_called_once_with(downloaded)
close_window.assert_called_once_with() application.quit.assert_called_once_with()
self.assertEqual("Перезапуск…", tab._update_btn.text()) self.assertEqual("Перезапуск…", tab._update_btn.text())
tab.shutdown() tab.shutdown()

View File

@ -29,7 +29,6 @@ from app.services.update_service import (
parse_version, parse_version,
platform_asset_name, platform_asset_name,
platform_executable_name, platform_executable_name,
signal_update_ready,
) )
@ -200,17 +199,6 @@ class UpdateServiceTests(unittest.TestCase):
with self.assertRaisesRegex(UpdateError, "Неизвестное"): with self.assertRaisesRegex(UpdateError, "Неизвестное"):
archive_executable_name("update.zip") archive_executable_name("update.zip")
def test_signals_restart_only_when_ready_file_is_requested(self) -> None:
with tempfile.TemporaryDirectory() as directory:
marker = Path(directory) / "ready"
with patch.dict(os.environ, {update_service.UPDATE_READY_ENV: str(marker)}):
signal_update_ready()
self.assertEqual(str(os.getpid()), marker.read_text(encoding="ascii"))
self.assertNotIn(update_service.UPDATE_READY_ENV, os.environ)
marker.unlink()
signal_update_ready()
self.assertFalse(marker.exists())
def test_downloads_and_validates_windows_zip(self) -> None: def test_downloads_and_validates_windows_zip(self) -> None:
info = self._update_info() info = self._update_info()
data = make_update_archive( data = make_update_archive(
@ -424,10 +412,6 @@ class UpdateServiceTests(unittest.TestCase):
script = scripts[0].read_text(encoding="utf-8-sig") script = scripts[0].read_text(encoding="utf-8-sig")
self.assertIn("$Staging", script) self.assertIn("$Staging", script)
self.assertIn("$backedUp", script) self.assertIn("$backedUp", script)
self.assertIn("AddSeconds(30)", script)
self.assertIn("$restarted.HasExited", script)
self.assertIn("$ReadyFile", script)
self.assertIn('$env:PYINSTALLER_RESET_ENVIRONMENT = "1"', script)
popen.assert_called_once() popen.assert_called_once()
@unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux") @unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux")
@ -461,8 +445,6 @@ class UpdateServiceTests(unittest.TestCase):
script = scripts[0].read_text(encoding="utf-8") script = scripts[0].read_text(encoding="utf-8")
self.assertIn("staging_path", script) self.assertIn("staging_path", script)
self.assertIn("rollback", script) self.assertIn("rollback", script)
self.assertIn("PLATE_CONFIG_UPDATE_READY_FILE", script)
self.assertIn("PYINSTALLER_RESET_ENVIRONMENT=1", script)
popen.assert_called_once() popen.assert_called_once()
def test_extracts_external_archive_next_to_executable(self) -> None: def test_extracts_external_archive_next_to_executable(self) -> None:
@ -579,7 +561,7 @@ class ReplacementIntegrationTests(unittest.TestCase):
return hashlib.sha256(path.read_bytes()).hexdigest() return hashlib.sha256(path.read_bytes()).hexdigest()
@staticmethod @staticmethod
def _wait_for_rollback( def _wait_for_replacement(
target: Path, target: Path,
expected_digest: str, expected_digest: str,
replaced_resource: Path, replaced_resource: Path,
@ -589,19 +571,17 @@ class ReplacementIntegrationTests(unittest.TestCase):
if ( if (
target.is_file() target.is_file()
and ReplacementIntegrationTests._digest(target) == expected_digest and ReplacementIntegrationTests._digest(target) == expected_digest
and replaced_resource.is_file() and replaced_resource.read_text(encoding="utf-8") == "new"
and replaced_resource.read_text(encoding="utf-8") == "old"
and not list(target.parent.glob(".plate_config_updater-*")) and not list(target.parent.glob(".plate_config_updater-*"))
and not list(target.parent.glob(".plate_config_update-*")) and not list(target.parent.glob(".plate_config_update-*"))
and not list(target.parent.glob(".plate_config_backup-*")) and not list(target.parent.glob(".plate_config_backup-*"))
and (target.parent / "update-error.log").is_file()
): ):
return return
time.sleep(0.1) time.sleep(0.1)
raise AssertionError("Внешний helper не откатил пакет за 20 секунд") raise AssertionError("Внешний helper не заменил пакет за 20 секунд")
@unittest.skipUnless(sys.platform == "win32", "Тест предназначен для Windows") @unittest.skipUnless(sys.platform == "win32", "Тест предназначен для Windows")
def test_windows_helper_rolls_back_when_new_app_exits_early(self) -> None: def test_windows_helper_replaces_full_package(self) -> None:
system_root = Path(os.environ.get("SystemRoot", r"C:\Windows")) system_root = Path(os.environ.get("SystemRoot", r"C:\Windows"))
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
@ -617,20 +597,20 @@ class ReplacementIntegrationTests(unittest.TestCase):
extra_files={"commands/version.txt": b"new"}, extra_files={"commands/version.txt": b"new"},
) )
) )
expected_digest = self._digest(target) expected_digest = hashlib.sha256(new_executable).hexdigest()
processes = self._launch_and_capture(archive, target, "Windows") processes = self._launch_and_capture(archive, target, "Windows")
self._wait_for_rollback( self._wait_for_replacement(
target, target,
expected_digest, expected_digest,
root / "commands/version.txt", root / "commands/version.txt",
) )
processes[0].wait(timeout=5) processes[0].wait(timeout=5)
self._assert_package_preserved(root) self._assert_package_replaced(root)
self._wait_for_windows_unlock(target) self._wait_for_windows_unlock(target)
@unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux") @unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux")
def test_linux_helper_rolls_back_when_new_app_exits_early(self) -> None: def test_linux_helper_replaces_full_package(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
target = root / "plate_config_app-linux-x86_64" target = root / "plate_config_app-linux-x86_64"
@ -645,16 +625,16 @@ class ReplacementIntegrationTests(unittest.TestCase):
extra_files={"commands/version.txt": b"new"}, extra_files={"commands/version.txt": b"new"},
) )
) )
expected_digest = self._digest(target) expected_digest = hashlib.sha256(new_executable).hexdigest()
processes = self._launch_and_capture(archive, target, "Linux") processes = self._launch_and_capture(archive, target, "Linux")
self._wait_for_rollback( self._wait_for_replacement(
target, target,
expected_digest, expected_digest,
root / "commands/version.txt", root / "commands/version.txt",
) )
processes[0].wait(timeout=5) processes[0].wait(timeout=5)
self._assert_package_preserved(root) self._assert_package_replaced(root)
self.assertTrue(target.stat().st_mode & stat.S_IXUSR) self.assertTrue(target.stat().st_mode & stat.S_IXUSR)
@staticmethod @staticmethod
@ -666,12 +646,12 @@ class ReplacementIntegrationTests(unittest.TestCase):
(root / "settings.json").write_text("preserve", encoding="utf-8") (root / "settings.json").write_text("preserve", encoding="utf-8")
@staticmethod @staticmethod
def _assert_package_preserved(root: Path) -> None: def _assert_package_replaced(root: Path) -> None:
self_settings = (root / "settings.json").read_text(encoding="utf-8") self_settings = (root / "settings.json").read_text(encoding="utf-8")
if self_settings != "preserve": if self_settings != "preserve":
raise AssertionError("settings.json не сохранён") raise AssertionError("settings.json не сохранён")
if not (root / "commands/obsolete.txt").exists(): if (root / "commands/obsolete.txt").exists():
raise AssertionError("Старое содержимое каталога не восстановлено") raise AssertionError("Старое содержимое каталога не удалено")
@staticmethod @staticmethod
def _launch_and_capture( def _launch_and_capture(