review(a59): обновление, которое видно, принято с исправлениями
Аудит A59. Механизм показа и загрузки собран верно, но три вещи говорили владельцу неправду. Запись о применённом обновлении делалась ДО запуска установщика. Установщик падал, запись оставалась, и при следующем старте хаб писал в журнал «успешно обновлён», а интерфейс показывал тост об успехе — про версию, которая не установилась. Теперь «было» снимается до установки (иначе после подмены файлов прежняя сборка совпадёт с новой), а сама запись делается только на путях успеха. Причина отказа .sh-установщика была пустой: «Установка не удалась: » без единого признака. Установщик может завершиться, не сказав ни слова, поэтому в сообщение добавлен код возврата. Отмена не смотрела, что происходит. Действие cancel_update открыто в HTTP-API, и вызов на этапе установки чистил staging вместе с исполняемым в этот момент файлом, отвечая «отменено» поверх продолжающейся установки. Отмена теперь принимается только на проверке и загрузке, отказ называет причину, интерфейс возобновляет опрос вместо замершего окна. Полоса хода при неизвестном размере заполнялась целиком: текст рядом честно писал «Н/Д: сервер не сообщил размер», а полная полоса читалась как «готово». Заменена бегущим отрезком на всех этапах с неизвестной долей. Обработчик хода при неизвестном размере получал выдуманный ноль на каждом чанке — теперь старая форма обработчика в этом случае просто не вызывается. Возвращены комментарии ревьюера, снятые в ходе задания: про running_commit как единственный признак живого кода, про версию только из API, про «отсутствие суммы — не разрешение», про ожидание установщика и порядок перезапуска. Тесты: 723 passed, 2 skipped (было 718/2). Добавлены проверки провала установки, отказа в отмене и честной полосы. ruff чисто, релизный гейт пройден. НЕ СДЕЛАНО и требует живого прогона: на Linux install-linux.sh снимает сам хаб, пока тот ждёт установщик, поэтому schedule_restart не выполняется. Отката для путей .sh и .exe по-прежнему нет. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EDLuenXmGjWaS2rs8E72En
This commit is contained in:
parent
cc18e26d1c
commit
285ae7cc07
5 changed files with 354 additions and 35 deletions
|
|
@ -1370,9 +1370,15 @@ class ActionExecutor:
|
|||
return {'ok': True, 'message': progress.get('message') or 'Ход обновления', 'data': progress}
|
||||
|
||||
elif action == 'cancel_update':
|
||||
# Отмена принимается не всегда: после начала установки отменять уже
|
||||
# нечего. Отвечаем тем, что произошло на самом деле, иначе владелец
|
||||
# видит «отменено» поверх продолжающейся установки.
|
||||
mgr = UpdateManager()
|
||||
progress = mgr.cancel_download()
|
||||
return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress}
|
||||
if progress.get('cancel_accepted'):
|
||||
return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress}
|
||||
reason = progress.get('cancel_refused_reason') or 'Отмена сейчас невозможна'
|
||||
return {'ok': False, 'message': reason, 'data': progress}
|
||||
|
||||
elif action == 'run_preflight':
|
||||
from antigravity_provider.router.preflight_service import PreflightCheckService
|
||||
|
|
|
|||
|
|
@ -264,7 +264,6 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
|||
"config_dir": str(paths.get_config_dir()),
|
||||
"log_file": str(paths.get_log_file()),
|
||||
}
|
||||
from antigravity_provider.updater.update_manager import get_last_applied_update
|
||||
snap_dict["last_applied_update"] = get_last_applied_update()
|
||||
return JSONResponse(content=jsonable_encoder(snap_dict))
|
||||
|
||||
|
|
@ -680,7 +679,6 @@ def _background_refresh_loop() -> None:
|
|||
|
||||
# Check if update was just applied and log event to EventLogService
|
||||
try:
|
||||
from antigravity_provider.updater.update_manager import get_last_applied_update, acknowledge_last_applied_update
|
||||
from antigravity_provider.router.unified_health import EventLogService
|
||||
applied = get_last_applied_update()
|
||||
if applied and not applied.get("acknowledged"):
|
||||
|
|
|
|||
|
|
@ -2506,6 +2506,14 @@ function renderUpdateUI() {
|
|||
const badgeText = document.getElementById('header-update-text');
|
||||
const commitTag = document.getElementById('commit-tag');
|
||||
|
||||
// Первым источником — снапшот работающего сервера: он приходит всегда, а
|
||||
// панель обновлений заполняется только при её открытии. На Linux строка
|
||||
// сборки поэтому оставалась пустой, и понять, дошло ли обновление, было
|
||||
// нельзя.
|
||||
//
|
||||
// Берём running_commit — коммит, снятый при СТАРТЕ процесса. Поле commit
|
||||
// читается с диска при каждом запросе, и переживший обновление процесс
|
||||
// рапортует им свежий номер при старом поведении.
|
||||
const runningCommit = (currentSnapshot && currentSnapshot.running_commit) || '';
|
||||
const installedCommit = runningCommit
|
||||
|| (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown'
|
||||
|
|
@ -2543,6 +2551,9 @@ function renderUpdateUI() {
|
|||
const releaseMeta = document.getElementById('update-release-meta');
|
||||
const releaseNotes = document.getElementById('update-release-notes');
|
||||
|
||||
// Версия берётся ТОЛЬКО из API. Раньше номер был зашит в разметке и в
|
||||
// запасном значении: подъём версии в коде до интерфейса не доходил, и
|
||||
// владелец видел старый номер при новой сборке.
|
||||
const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || '';
|
||||
const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно';
|
||||
if (updateInfoDesc) {
|
||||
|
|
@ -2707,6 +2718,10 @@ async function pollUpdateProgress() {
|
|||
}
|
||||
}
|
||||
|
||||
// Этапы, на которых процесс идёт и доля выполнения неизвестна: полосу
|
||||
// заменяем бегущим отрезком, а не заполняем целиком.
|
||||
const INDETERMINATE_ACTIVE_STATUSES = new Set(['checking', 'downloading', 'verifying', 'installing', 'restarting']);
|
||||
|
||||
function renderUpdateProgressView(p) {
|
||||
if (!elements.modalBody) return;
|
||||
|
||||
|
|
@ -2729,20 +2744,46 @@ function renderUpdateProgressView(p) {
|
|||
progressDetail = `${dlMb} МБ из ${totMb} МБ (${pctVal}%)`;
|
||||
barWidth = `${Math.min(100, Math.max(0, percent || (downloaded / total * 100)))}%`;
|
||||
} else {
|
||||
// Honest: no content-length
|
||||
// Размер неизвестен — доля не вычисляется. Полоса при этом не должна
|
||||
// изображать процент: полная полоса читается как «готово». Активные этапы
|
||||
// показываем бегущим отрезком, завершённые — сплошной полосой.
|
||||
progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`;
|
||||
isIndeterminate = true;
|
||||
barWidth = downloaded > 0 ? '100%' : '20%';
|
||||
barWidth = '100%';
|
||||
}
|
||||
|
||||
let statusBadge = `<span class="badge badge-status warning">Загрузка</span>`;
|
||||
if (status === 'verifying') statusBadge = `<span class="badge badge-status warning">Проверка SHA-256</span>`;
|
||||
if (status === 'checking') statusBadge = `<span class="badge badge-status warning">Проверка обновлений</span>`;
|
||||
else if (status === 'verifying') statusBadge = `<span class="badge badge-status warning">Проверка SHA-256</span>`;
|
||||
else if (status === 'installing') statusBadge = `<span class="badge badge-status warning">Установка</span>`;
|
||||
else if (status === 'restarting') statusBadge = `<span class="badge badge-status healthy">Перезапуск</span>`;
|
||||
else if (status === 'completed') statusBadge = `<span class="badge badge-status healthy">Завершено</span>`;
|
||||
else if (status === 'failed') statusBadge = `<span class="badge badge-status danger">Ошибка</span>`;
|
||||
else if (status === 'cancelled') statusBadge = `<span class="badge badge-status">Отменено</span>`;
|
||||
|
||||
// Inject indeterminate animation style if not already present
|
||||
if (isIndeterminate && !document.getElementById('indeterminate-bar-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'indeterminate-bar-style';
|
||||
style.textContent = `
|
||||
@keyframes indeterminate-bar {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
.indeterminate-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 30%;
|
||||
background: var(--accent);
|
||||
animation: indeterminate-bar 1.5s ease-in-out infinite;
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
elements.modalBody.innerHTML = `
|
||||
<div class="update-progress-body" style="padding:4px 0;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
|
||||
|
|
@ -2755,7 +2796,11 @@ function renderUpdateProgressView(p) {
|
|||
<div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);">${escapeHtml(msg)}</div>
|
||||
|
||||
<div style="background:var(--surface-muted); border-radius:var(--radius-sm); overflow:hidden; height:12px; margin-bottom:8px; border:1px solid var(--border-subtle); position:relative;">
|
||||
<div style="background:${status === 'failed' ? 'var(--status-danger)' : (status === 'cancelled' ? 'var(--text-muted)' : 'var(--accent)')}; height:100%; width:${barWidth}; transition:width 0.3s ease; ${isIndeterminate && status === 'downloading' ? 'opacity:0.8;' : ''}"></div>
|
||||
${isIndeterminate && INDETERMINATE_ACTIVE_STATUSES.has(status) ? `
|
||||
<div class="indeterminate-bar"></div>
|
||||
` : `
|
||||
<div style="background:${status === 'failed' ? 'var(--status-danger)' : (status === 'cancelled' ? 'var(--text-muted)' : 'var(--accent)')}; height:100%; width:${barWidth}; transition:width 0.3s ease;"></div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div style="font-size:12px; color:var(--text-muted); font-family:var(--font-mono); margin-bottom:12px; display:flex; justify-content:space-between;">
|
||||
|
|
@ -2800,7 +2845,17 @@ function renderUpdateProgressView(p) {
|
|||
|
||||
async function cancelUpdateProcess() {
|
||||
stopUpdateProgressPolling();
|
||||
await executeAction('cancel_update', {});
|
||||
const cancelRes = await executeAction('cancel_update', {});
|
||||
|
||||
// Отмену могли не принять: после начала установки отменять уже нечего.
|
||||
// Тогда возвращаем опрос обратно, иначе окно замрёт на последнем кадре и
|
||||
// владелец решит, что установка встала.
|
||||
const accepted = cancelRes && cancelRes.data && cancelRes.data.cancel_accepted;
|
||||
if (!accepted) {
|
||||
showToast((cancelRes && cancelRes.message) || 'Отменять уже нечего: установка идёт', 'warning');
|
||||
updateProgressInterval = setInterval(pollUpdateProgress, 500);
|
||||
}
|
||||
|
||||
const res = await executeAction('get_update_progress', {});
|
||||
if (res && res.ok && res.data) {
|
||||
renderUpdateProgressView(res.data);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Features:
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
|
@ -433,28 +434,46 @@ def stop_running_hub(timeout_sec: float = 10.0) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
# Почему отмена отклонена — своими словами для владельца, а не кодом состояния.
|
||||
_CANCEL_REFUSAL = {
|
||||
"installing": "Отмена невозможна: установка уже началась",
|
||||
"restarting": "Отмена невозможна: Hermes Hub уже перезапускается",
|
||||
"completed": "Отменять нечего: обновление уже установлено",
|
||||
"failed": "Отменять нечего: обновление уже завершилось ошибкой",
|
||||
"cancelled": "Загрузка уже отменена",
|
||||
"idle": "Отменять нечего: обновление не запускалось",
|
||||
}
|
||||
|
||||
|
||||
def _call_progress_cb(cb: Optional[Callable], downloaded: int, total: Optional[int]) -> None:
|
||||
"""Safely invoke progress callback supporting both 1-arg float and 2-arg (downloaded, total) signatures."""
|
||||
"""Вызвать обработчик хода загрузки, поддержав обе его формы.
|
||||
|
||||
Старая форма принимает одну долю (0..1), новая — (скачано, всего).
|
||||
|
||||
Доли при неизвестном общем размере не существует, и подставлять вместо неё
|
||||
ноль нельзя: обработчик получал бы «0%» на каждом чанке всю загрузку.
|
||||
Старую форму в этом случае просто не зовём — молчание честнее выдуманного
|
||||
числа, а сам ход всё равно виден через _set_progress.
|
||||
"""
|
||||
if not cb:
|
||||
return
|
||||
import inspect
|
||||
known_total = bool(total and total > 0)
|
||||
try:
|
||||
sig = inspect.signature(cb)
|
||||
if len(sig.parameters) == 1:
|
||||
if total and total > 0:
|
||||
cb(downloaded / total)
|
||||
else:
|
||||
cb(0.0)
|
||||
else:
|
||||
cb(downloaded, total)
|
||||
except Exception:
|
||||
try:
|
||||
cb(downloaded, total)
|
||||
except TypeError:
|
||||
if total and total > 0:
|
||||
cb(downloaded / total)
|
||||
else:
|
||||
cb(0.0)
|
||||
single_arg = len(sig.parameters) == 1
|
||||
except (TypeError, ValueError):
|
||||
single_arg = False
|
||||
|
||||
if single_arg:
|
||||
if known_total:
|
||||
cb(downloaded / total)
|
||||
return
|
||||
|
||||
try:
|
||||
cb(downloaded, total)
|
||||
except TypeError:
|
||||
if known_total:
|
||||
cb(downloaded / total)
|
||||
|
||||
|
||||
class UpdateManager:
|
||||
|
|
@ -507,7 +526,25 @@ class UpdateManager:
|
|||
|
||||
@classmethod
|
||||
def cancel_download(cls) -> Dict[str, Any]:
|
||||
"""Cancel in-progress download, remove partially downloaded files, and set status to cancelled."""
|
||||
"""Отменить загрузку обновления и удалить недокачанные файлы.
|
||||
|
||||
Отмена допустима только пока идёт проверка или загрузка. Действие
|
||||
`cancel_update` открыто в HTTP-API, и без этой проверки вызов во время
|
||||
установки вычищал каталог staging вместе с файлом установщика, который
|
||||
в этот момент исполняет bash: установка ломалась на середине, а ответ
|
||||
«отменено» сообщал владельцу неправду о том, что происходит с машиной.
|
||||
"""
|
||||
with cls._lock:
|
||||
current_status = cls._progress.status
|
||||
if current_status not in ("checking", "downloading"):
|
||||
refused = cls._progress.to_dict()
|
||||
refused["cancel_accepted"] = False
|
||||
refused["cancel_refused_reason"] = _CANCEL_REFUSAL.get(
|
||||
current_status,
|
||||
f"Отмена невозможна на этапе «{current_status}»",
|
||||
)
|
||||
return refused
|
||||
|
||||
cls._cancel_event.set()
|
||||
with cls._lock:
|
||||
cls._progress = UpdateProgress(
|
||||
|
|
@ -529,7 +566,9 @@ class UpdateManager:
|
|||
f.unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
logger.debug("Clean staging dir on cancel failed: %s", exc)
|
||||
return cls.get_progress_dict()
|
||||
accepted = cls.get_progress_dict()
|
||||
accepted["cancel_accepted"] = True
|
||||
return accepted
|
||||
|
||||
@classmethod
|
||||
def cancel_update(cls) -> Dict[str, Any]:
|
||||
|
|
@ -996,6 +1035,10 @@ class UpdateManager:
|
|||
if not expected_sha and check_result.manifest and check_result.manifest.sha256:
|
||||
expected_sha = check_result.manifest.sha256.lower()
|
||||
|
||||
# Отсутствие суммы — не разрешение. Раньше при недоступном checksums.txt
|
||||
# expected_sha оставался пустым, проверка молча пропускалась и скачанный
|
||||
# файл всё равно запускался. Здесь запускается загруженный из сети
|
||||
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
|
||||
if not expected_sha:
|
||||
dest_file.unlink(missing_ok=True)
|
||||
err = (
|
||||
|
|
@ -1028,12 +1071,6 @@ class UpdateManager:
|
|||
prev_c = get_installed_commit()
|
||||
new_v = check_result.latest_version
|
||||
new_c = check_result.latest_commit or ""
|
||||
record_last_applied_update(
|
||||
prev_version=prev_v,
|
||||
prev_commit=prev_c,
|
||||
new_version=new_v,
|
||||
new_commit=new_c,
|
||||
)
|
||||
|
||||
# 6. Apply update based on file type
|
||||
self._set_progress(
|
||||
|
|
@ -1042,11 +1079,20 @@ class UpdateManager:
|
|||
message=f"Установка пакета {chosen_asset_name}...",
|
||||
)
|
||||
|
||||
def _record_success():
|
||||
record_last_applied_update(
|
||||
prev_version=prev_v,
|
||||
prev_commit=prev_c,
|
||||
new_version=new_v,
|
||||
new_commit=new_c,
|
||||
)
|
||||
|
||||
if chosen_asset_name.endswith(".zip"):
|
||||
ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir)
|
||||
if not ok:
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=msg, message=msg)
|
||||
return False, msg
|
||||
_record_success()
|
||||
self._set_progress(status="completed", filename=chosen_asset_name, message=msg)
|
||||
return True, "Обновление успешно установлено"
|
||||
|
||||
|
|
@ -1067,6 +1113,7 @@ class UpdateManager:
|
|||
err = f"Установщик завершился с кодом {rc}. Обновление не применено."
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||
return False, err
|
||||
_record_success()
|
||||
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
|
||||
ok_r, msg_r = self.schedule_restart()
|
||||
if not ok_r:
|
||||
|
|
@ -1082,15 +1129,25 @@ class UpdateManager:
|
|||
elif chosen_asset_name.endswith(".sh"):
|
||||
try:
|
||||
os.chmod(dest_file, 0o755)
|
||||
# Ждём завершения: без этого перезапуск начался бы прямо во
|
||||
# время распаковки, а владелец получил бы обещание перезапуска
|
||||
# при неизвестном исходе установки.
|
||||
res_i = subprocess.run(
|
||||
["bash", str(dest_file)],
|
||||
capture_output=True, text=True, timeout=600,
|
||||
)
|
||||
if res_i.returncode != 0:
|
||||
# Код возврата в сообщении обязателен: установщик может
|
||||
# завершиться, не сказав ни слова, и владелец получал
|
||||
# «Установка не удалась: » без единого признака причины.
|
||||
tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:]
|
||||
err = "Установка не удалась: " + " / ".join(tail)
|
||||
if tail:
|
||||
err = f"Установка не удалась (код {res_i.returncode}): " + " / ".join(tail)
|
||||
else:
|
||||
err = f"Установка не удалась (код {res_i.returncode}): установщик ничего не сообщил"
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||
return False, err
|
||||
_record_success()
|
||||
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
|
||||
ok_r, msg_r = self.schedule_restart()
|
||||
if not ok_r:
|
||||
|
|
@ -1107,7 +1164,17 @@ class UpdateManager:
|
|||
return True, "Файл обновления загружен и проверен"
|
||||
|
||||
def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]:
|
||||
"""Перезапустить веб-хаб после установки обновления."""
|
||||
"""Перезапустить веб-хаб после установки обновления.
|
||||
|
||||
Ни install-linux.sh, ни виндовый установщик в тихом режиме приложение не
|
||||
поднимают, а сообщение обещало перезапуск. Владелец оставался со старым
|
||||
процессом, продолжавшим отдавать старый код, и делал вывод, что
|
||||
обновление не сработало.
|
||||
|
||||
Порядок именно такой: сначала отсоединённый помощник, потом выход
|
||||
текущего процесса. Лаунчер считает хаб работающим, если порт отвечает,
|
||||
поэтому поднимать новый, не освободив порт, бесполезно.
|
||||
"""
|
||||
home = paths.get_hermes_home()
|
||||
if sys.platform == "win32":
|
||||
launcher = home / "HermesHubWeb.exe"
|
||||
|
|
|
|||
|
|
@ -160,18 +160,65 @@ def test_p0_2_cancel_download_cleans_file_and_sets_cancelled_status(tmp_path, mo
|
|||
staging_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging_file.write_bytes(b"Partial download data 12345")
|
||||
|
||||
# Отмена осмысленна только пока идёт загрузка — ставим это состояние явно,
|
||||
# иначе тест проверял бы отмену того, чего не происходит.
|
||||
UpdateManager._set_progress(
|
||||
status="downloading",
|
||||
filename="partial_download.zip",
|
||||
downloaded_bytes=27,
|
||||
message="Скачивание partial_download.zip...",
|
||||
)
|
||||
|
||||
# Trigger cancel
|
||||
cancel_res = UpdateManager.cancel_download()
|
||||
assert cancel_res["status"] == "cancelled"
|
||||
assert cancel_res["cancel_accepted"] is True
|
||||
assert "отменена" in (cancel_res["message"] or "").lower()
|
||||
assert not staging_file.exists(), "Partially downloaded file in staging must be removed upon cancellation"
|
||||
|
||||
# Also test ActionExecutor 'cancel_update'
|
||||
UpdateManager._set_progress(status="downloading", filename="partial_download.zip")
|
||||
action_res = ActionExecutor.execute("cancel_update", {})
|
||||
assert action_res["ok"] is True
|
||||
assert action_res["data"]["status"] == "cancelled"
|
||||
|
||||
|
||||
# ── TEST 3b: отмена после начала установки отклоняется, а не врёт ──
|
||||
@pytest.mark.unit
|
||||
def test_p0_2_cancel_refused_after_install_started(tmp_path, monkeypatch):
|
||||
"""Отмена во время установки не трогает staging и честно сообщает отказ.
|
||||
|
||||
Действие cancel_update открыто в HTTP-API. Раньше вызов на этапе installing
|
||||
чистил каталог staging вместе с исполняемым в этот момент установщиком и
|
||||
отвечал «отменено», хотя установка продолжалась.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
||||
|
||||
mgr = UpdateManager()
|
||||
installer = mgr.staging_dir / "hermes-hub-setup.sh"
|
||||
installer.parent.mkdir(parents=True, exist_ok=True)
|
||||
installer.write_bytes(b"#!/bin/bash\necho installing\n")
|
||||
|
||||
UpdateManager._set_progress(
|
||||
status="installing",
|
||||
filename="hermes-hub-setup.sh",
|
||||
message="Установка пакета hermes-hub-setup.sh...",
|
||||
)
|
||||
|
||||
res = UpdateManager.cancel_download()
|
||||
assert res["cancel_accepted"] is False
|
||||
assert res["status"] == "installing", "Статус не должен подменяться на cancelled"
|
||||
assert "установка уже началась" in res["cancel_refused_reason"].lower()
|
||||
assert installer.exists(), "Файл исполняемого установщика удалять нельзя"
|
||||
|
||||
action_res = ActionExecutor.execute("cancel_update", {})
|
||||
assert action_res["ok"] is False
|
||||
assert "отмена невозможна" in action_res["message"].lower()
|
||||
|
||||
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
|
||||
|
||||
|
||||
# ── TEST 4: P0-2 SHA-256 Mismatch Rejection and Failure Status ──
|
||||
@pytest.mark.unit
|
||||
def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatch):
|
||||
|
|
@ -313,3 +360,149 @@ def test_p0_5_silent_actions_and_no_interval_polling():
|
|||
assert res_prog["ok"] is True
|
||||
assert "data" in res_prog
|
||||
assert "status" in res_prog["data"]
|
||||
|
||||
|
||||
# ── TEST 9: P0-4 провалившаяся установка не выдаёт себя за успешную ──
|
||||
@pytest.mark.unit
|
||||
def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monkeypatch):
|
||||
"""Установщик упал — записи о применённом обновлении быть не должно.
|
||||
|
||||
record_last_applied_update вызывался ДО запуска установщика. При падении
|
||||
запись оставалась на диске, и при следующем старте хаб писал в журнал
|
||||
«успешно обновлён», а интерфейс показывал тост об успехе — владельцу
|
||||
сообщали о версии, которая не установилась.
|
||||
|
||||
Заодно проверяется, что причина отказа не пустая: установщик может
|
||||
завершиться, не сказав ни слова, и сообщение «Установка не удалась: »
|
||||
не давало ни одного признака причины.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
||||
|
||||
installer = tmp_path / "hermes-hub-setup.sh"
|
||||
installer.write_bytes(b"#!/bin/bash\nexit 3\n")
|
||||
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
|
||||
|
||||
check_result = UpdateCheckResult(
|
||||
update_available=True,
|
||||
current_version="0.1.3",
|
||||
latest_version="0.1.4",
|
||||
latest_commit="deadbeefdeadbeef",
|
||||
installed_commit="0000000aaaa",
|
||||
assets={
|
||||
"hermes-hub-setup.sh": f"file://{installer}",
|
||||
"checksums.txt": "file:///nonexistent",
|
||||
},
|
||||
)
|
||||
|
||||
mgr = UpdateManager()
|
||||
real_download = UpdateManager._download_file
|
||||
|
||||
def fake_download(self, url, dest, progress_cb=None):
|
||||
if dest.name == "checksums.txt":
|
||||
dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8")
|
||||
return
|
||||
return real_download(self, url, dest, progress_cb)
|
||||
|
||||
with patch.object(UpdateManager, "_download_file", fake_download):
|
||||
with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
|
||||
ok, msg = mgr.install_latest_update(check_result=check_result)
|
||||
|
||||
assert ok is False
|
||||
assert get_last_applied_update() is None, (
|
||||
"После провалившейся установки записи о применённом обновлении быть не должно"
|
||||
)
|
||||
assert "код 3" in msg, f"Причина отказа должна называть код возврата, получено: {msg!r}"
|
||||
|
||||
prog = UpdateManager.get_progress_dict()
|
||||
assert prog["status"] == "failed"
|
||||
|
||||
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
|
||||
|
||||
|
||||
# ── TEST 10: P0-4 успешная установка запись всё-таки делает ──
|
||||
@pytest.mark.unit
|
||||
def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkeypatch):
|
||||
"""Успех записывает и «было», и «стало», причём «было» снято до установки."""
|
||||
import hashlib
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
||||
|
||||
installer = tmp_path / "hermes-hub-setup.sh"
|
||||
installer.write_bytes(b"#!/bin/bash\nexit 0\n")
|
||||
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
|
||||
|
||||
check_result = UpdateCheckResult(
|
||||
update_available=True,
|
||||
current_version="0.1.3",
|
||||
latest_version="0.1.4",
|
||||
latest_commit="deadbeefdeadbeef",
|
||||
installed_commit="0000000aaaa",
|
||||
assets={
|
||||
"hermes-hub-setup.sh": f"file://{installer}",
|
||||
"checksums.txt": "file:///nonexistent",
|
||||
},
|
||||
)
|
||||
|
||||
mgr = UpdateManager()
|
||||
real_download = UpdateManager._download_file
|
||||
|
||||
def fake_download(self, url, dest, progress_cb=None):
|
||||
if dest.name == "checksums.txt":
|
||||
dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8")
|
||||
return
|
||||
return real_download(self, url, dest, progress_cb)
|
||||
|
||||
with patch.object(UpdateManager, "_download_file", fake_download):
|
||||
with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
|
||||
with patch.object(UpdateManager, "schedule_restart", return_value=(True, "перезапуск запущен")):
|
||||
ok, msg = mgr.install_latest_update(check_result=check_result)
|
||||
|
||||
assert ok is True
|
||||
rec = get_last_applied_update()
|
||||
assert rec is not None
|
||||
assert rec["new_version"] == "0.1.4"
|
||||
assert rec["new_commit"] == "deadbeefdeadbeef"
|
||||
assert rec["prev_commit"] != rec["new_commit"], (
|
||||
"«Было» снимается до установки, иначе прежняя сборка совпадёт с новой"
|
||||
)
|
||||
assert rec["acknowledged"] is False
|
||||
|
||||
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
|
||||
|
||||
|
||||
# ── TEST 11: P0-2 полоса не изображает процент при неизвестном размере ──
|
||||
@pytest.mark.unit
|
||||
def test_p0_2_app_js_indeterminate_bar_when_size_unknown():
|
||||
"""При неизвестном размере полоса бежит, а не заполняется целиком.
|
||||
|
||||
Текст рядом был честным («Н/Д: сервер не сообщил размер»), а полоса при этом
|
||||
рисовалась на всю ширину: `downloaded > 0 ? '100%' : '20%'`. Полная полоса
|
||||
читается как «готово» — тот же выдуманный процент, только нарисованный.
|
||||
"""
|
||||
src = APP_JS_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert "downloaded > 0 ? '100%' : '20%'" not in src, (
|
||||
"Полоса не должна заполняться на всю ширину при неизвестном размере"
|
||||
)
|
||||
assert "Н/Д: сервер не сообщил размер" in src
|
||||
assert "INDETERMINATE_ACTIVE_STATUSES" in src, "нужен список этапов с неопределённой полосой"
|
||||
assert "@keyframes indeterminate-bar" in src, "нужна анимация бегущего отрезка"
|
||||
assert "indeterminate-bar-style" in src, "стиль вставляется один раз по id"
|
||||
|
||||
|
||||
# ── TEST 12: P0-2 интерфейс не принимает отказ в отмене за отмену ──
|
||||
@pytest.mark.unit
|
||||
def test_p0_2_app_js_handles_refused_cancel():
|
||||
"""Отказ в отмене возвращает опрос хода, а не оставляет окно замершим."""
|
||||
src = APP_JS_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert "cancel_accepted" in src, "app.js обязан различать принятую и отклонённую отмену"
|
||||
idx = src.index("async function cancelUpdateProcess()")
|
||||
tail = src[idx:idx + 1200]
|
||||
assert "setInterval(pollUpdateProgress" in tail, (
|
||||
"после отклонённой отмены опрос хода загрузки должен возобновляться"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue