HUB-1: зелёный main — граница workspace и UTF-8 в verification #2

Merged
ochenstarik-ui merged 10 commits from hub/audit-p0-green-main into main 2026-09-03 17:59:50 +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.""" """Чужие процессы хаба останавливаются, собственный — никогда.
current_pid = os.getpid()
Проверяется на обеих ветках, а не на той, где случился прогон. Ветки
останавливают процессы по-разному: на Linux os.kill по списку от pgrep,
на Windows taskkill по списку от wmic. Тест знал только про первую и на
Windows-раннере падал на пустом списке убитых, хотя проверять надо один и
тот же инвариант «свой PID не трогаем».
"""
current_pid = os.getpid()
is_win = simulated_os == "windows"
with patch("antigravity_provider.updater.update_manager.sys") as mock_sys:
mock_sys.platform = "win32" if is_win else "linux"
# Mock subprocess.run for pgrep
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
# Simulate pgrep returning other PID and own PID # wmic и pgrep перечисляют один и тот же набор: чужой PID и свой.
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999 {current_pid}\n") mock_run.return_value = MagicMock(returncode=0, stdout=f"99999\n{current_pid}\n")
with patch("os.kill") as mock_kill: with patch("os.kill") as mock_kill:
stop_running_hub(timeout_sec=0.1) stop_running_hub(timeout_sec=0.1)
# Check that kill was called on 99999 but NEVER on 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] killed_pids = [call.args[0] for call in mock_kill.call_args_list]
assert 99999 in killed_pids
assert current_pid not in killed_pids, "stop_running_hub must never kill current PID" 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,8 +401,7 @@ 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(
@ -392,7 +411,7 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
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",
}, },
) )
@ -402,7 +421,7 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
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)
@ -414,7 +433,11 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
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,8 +479,7 @@ 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(
@ -442,7 +489,7 @@ def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkey
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",
}, },
) )
@ -452,7 +499,7 @@ def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkey
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)