fix(installer,release): находки agy с живой Windows-машины (A61) #6

Merged
ochenstarik-ui merged 13 commits from installer/a61-port-live-fixes into main 2026-09-03 18:03:14 +00:00
2 changed files with 118 additions and 64 deletions
Showing only changes of commit a3373f9f76 - Show all commits

View file

@ -249,7 +249,14 @@ def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
env["HERMES_HOME"] = str(hermes_home) env["HERMES_HOME"] = str(hermes_home)
script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py" script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py"
proc = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
# Кодировка задаётся явно с обеих сторон трубы. Скрипт печатает по-русски и
# сам переводит вывод на UTF-8; родитель же на Windows по умолчанию читает
# трубу в кодировке системы (cp1252) и разваливается на UnicodeDecodeError,
# оставляя proc.stdout равным None — падал разбор вывода, а не проверки.
run_kwargs = dict(env=env, capture_output=True, text=True, encoding="utf-8", errors="replace")
proc = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}" assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}"
assert "10/10 CHECKS PASSED" in proc.stdout assert "10/10 CHECKS PASSED" in proc.stdout
@ -259,6 +266,6 @@ def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True) AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True)
AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False) AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False)
proc_filled = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True) proc_filled = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}" assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}"
assert "10/10 CHECKS PASSED" in proc_filled.stdout assert "10/10 CHECKS PASSED" in proc_filled.stdout

View file

@ -9,9 +9,11 @@ Verifies:
""" """
from __future__ import annotations from __future__ import annotations
import contextlib
import io import io
import json import json
import os import os
import re
import shutil import shutil
import sys import sys
import tempfile import tempfile
@ -249,23 +251,41 @@ def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatc
# ── TEST 5: P0-3 Process Isolation stop_running_hub ── # ── TEST 5: P0-3 Process Isolation stop_running_hub ──
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
@pytest.mark.unit @pytest.mark.unit
def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(): def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(simulated_os):
"""stop_running_hub filters by current UID on Linux and never targets own PID.""" """Чужие процессы хаба останавливаются, собственный — никогда.
Проверяется на обеих ветках, а не на той, где случился прогон. Ветки
останавливают процессы по-разному: на Linux os.kill по списку от pgrep,
на Windows taskkill по списку от wmic. Тест знал только про первую и на
Windows-раннере падал на пустом списке убитых, хотя проверять надо один и
тот же инвариант «свой PID не трогаем».
"""
current_pid = os.getpid() current_pid = os.getpid()
is_win = simulated_os == "windows"
# Mock subprocess.run for pgrep with patch("antigravity_provider.updater.update_manager.sys") as mock_sys:
with patch("subprocess.run") as mock_run: mock_sys.platform = "win32" if is_win else "linux"
# Simulate pgrep returning other PID and own PID
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999 {current_pid}\n")
with patch("os.kill") as mock_kill: with patch("subprocess.run") as mock_run:
stop_running_hub(timeout_sec=0.1) # wmic и pgrep перечисляют один и тот же набор: чужой PID и свой.
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999\n{current_pid}\n")
# Check that kill was called on 99999 but NEVER on current_pid with patch("os.kill") as mock_kill:
killed_pids = [call.args[0] for call in mock_kill.call_args_list] stop_running_hub(timeout_sec=0.1)
assert 99999 in killed_pids
assert current_pid not in killed_pids, "stop_running_hub must never kill current PID" if is_win:
killed_pids = [
int(call.args[0][-1])
for call in mock_run.call_args_list
if call.args and call.args[0] and call.args[0][0] == "taskkill"
]
else:
killed_pids = [call.args[0] for call in mock_kill.call_args_list]
assert 99999 in killed_pids, f"[{simulated_os}] чужой процесс хаба не остановлен: {killed_pids}"
assert current_pid not in killed_pids, f"[{simulated_os}] остановлен собственный процесс"
# ── TEST 6: P0-3 apply_update_sync Rollback on Corruption ── # ── TEST 6: P0-3 apply_update_sync Rollback on Corruption ──
@ -381,40 +401,43 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
installer = tmp_path / "hermes-hub-setup.sh" with platform_installer(tmp_path, exit_code=3) as (asset_name, installer):
installer.write_bytes(b"#!/bin/bash\nexit 3\n") sha = hashlib.sha256(installer.read_bytes()).hexdigest()
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
check_result = UpdateCheckResult( check_result = UpdateCheckResult(
update_available=True, update_available=True,
current_version="0.1.3", current_version="0.1.3",
latest_version="0.1.4", latest_version="0.1.4",
latest_commit="deadbeefdeadbeef", latest_commit="deadbeefdeadbeef",
installed_commit="0000000aaaa", installed_commit="0000000aaaa",
assets={ assets={
"hermes-hub-setup.sh": f"file://{installer}", asset_name: f"file://{installer}",
"checksums.txt": "file:///nonexistent", "checksums.txt": "file:///nonexistent",
}, },
) )
mgr = UpdateManager() mgr = UpdateManager()
real_download = UpdateManager._download_file real_download = UpdateManager._download_file
def fake_download(self, url, dest, progress_cb=None): def fake_download(self, url, dest, progress_cb=None):
if dest.name == "checksums.txt": if dest.name == "checksums.txt":
dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8") dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8")
return return
return real_download(self, url, dest, progress_cb) return real_download(self, url, dest, progress_cb)
with patch.object(UpdateManager, "_download_file", fake_download): with patch.object(UpdateManager, "_download_file", fake_download):
with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True): with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
ok, msg = mgr.install_latest_update(check_result=check_result) ok, msg = mgr.install_latest_update(check_result=check_result)
assert ok is False assert ok is False
assert get_last_applied_update() is None, ( assert get_last_applied_update() is None, (
"После провалившейся установки записи о применённом обновлении быть не должно" "После провалившейся установки записи о применённом обновлении быть не должно"
) )
assert "код 3" in msg, f"Причина отказа должна называть код возврата, получено: {msg!r}" # Проверяется, что код назван, а не как он склоняется: ветки формулируют
# по-разному («код 3» и «кодом 3»), инвариант же один.
assert re.search(r"код\w*\s+3", msg), (
f"Причина отказа должна называть код возврата, получено: {msg!r}"
)
prog = UpdateManager.get_progress_dict() prog = UpdateManager.get_progress_dict()
assert prog["status"] == "failed" assert prog["status"] == "failed"
@ -422,6 +445,31 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
UpdateManager._set_progress(status="idle", message="Готов к обновлению") UpdateManager._set_progress(status="idle", message="Готов к обновлению")
# ── Установщик под ту систему, на которой идёт прогон ──
#
# Ветки установки различаются: на Windows выбирается HermesHubSetup.exe и
# запускается через Popen, на Linux — hermes-hub-setup.sh через bash. Тесты
# ниже проверяют не установщик, а учёт его результата, поэтому подставляется
# тот файл, который данная система действительно выбирает. Раньше в них был
# зашит bash-скрипт, и на Windows-раннере установка отвечала «в релизе не
# найден подходящий файл обновления» — падало допущение теста, не продукт.
@contextlib.contextmanager
def platform_installer(tmp_path, exit_code: int):
"""Отдать (имя ассета, путь) и заставить установщик вернуть exit_code."""
if sys.platform == "win32":
installer = tmp_path / "HermesHubSetup.exe"
# Содержимое не исполняется: запуск подменён, проверяется учёт кода.
installer.write_bytes(b"MZ\x90\x00 hermes hub test installer\n")
with patch("subprocess.Popen") as mock_popen:
mock_popen.return_value = MagicMock(wait=MagicMock(return_value=exit_code))
yield "HermesHubSetup.exe", installer
else:
installer = tmp_path / "hermes-hub-setup.sh"
installer.write_bytes(f"#!/bin/bash\nexit {exit_code}\n".encode("utf-8"))
yield "hermes-hub-setup.sh", installer
# ── TEST 10: P0-4 успешная установка запись всё-таки делает ── # ── TEST 10: P0-4 успешная установка запись всё-таки делает ──
@pytest.mark.unit @pytest.mark.unit
def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkeypatch): def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkeypatch):
@ -431,35 +479,34 @@ def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkey
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
installer = tmp_path / "hermes-hub-setup.sh" with platform_installer(tmp_path, exit_code=0) as (asset_name, installer):
installer.write_bytes(b"#!/bin/bash\nexit 0\n") sha = hashlib.sha256(installer.read_bytes()).hexdigest()
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
check_result = UpdateCheckResult( check_result = UpdateCheckResult(
update_available=True, update_available=True,
current_version="0.1.3", current_version="0.1.3",
latest_version="0.1.4", latest_version="0.1.4",
latest_commit="deadbeefdeadbeef", latest_commit="deadbeefdeadbeef",
installed_commit="0000000aaaa", installed_commit="0000000aaaa",
assets={ assets={
"hermes-hub-setup.sh": f"file://{installer}", asset_name: f"file://{installer}",
"checksums.txt": "file:///nonexistent", "checksums.txt": "file:///nonexistent",
}, },
) )
mgr = UpdateManager() mgr = UpdateManager()
real_download = UpdateManager._download_file real_download = UpdateManager._download_file
def fake_download(self, url, dest, progress_cb=None): def fake_download(self, url, dest, progress_cb=None):
if dest.name == "checksums.txt": if dest.name == "checksums.txt":
dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8") dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8")
return return
return real_download(self, url, dest, progress_cb) return real_download(self, url, dest, progress_cb)
with patch.object(UpdateManager, "_download_file", fake_download): with patch.object(UpdateManager, "_download_file", fake_download):
with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True): with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
with patch.object(UpdateManager, "schedule_restart", return_value=(True, "перезапуск запущен")): with patch.object(UpdateManager, "schedule_restart", return_value=(True, "перезапуск запущен")):
ok, msg = mgr.install_latest_update(check_result=check_result) ok, msg = mgr.install_latest_update(check_result=check_result)
assert ok is True assert ok is True
rec = get_last_applied_update() rec = get_last_applied_update()