From 285ae7cc071318db26dc696ba0f82a1d398fa719 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:15:54 +0700 Subject: [PATCH] =?UTF-8?q?review(a59):=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5,=20=D0=BA=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=20=D0=B2=D0=B8=D0=B4=D0=BD=D0=BE,=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=8F=D1=82=D0=BE=20=D1=81=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аудит 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 Claude-Session: https://claude.ai/code/session_01EDLuenXmGjWaS2rs8E72En --- .../router/action_handler.py | 8 +- src/antigravity_provider/router/web/server.py | 2 - .../router/web/static/app.js | 65 +++++- .../updater/update_manager.py | 121 ++++++++--- tests/test_a59_visible_update.py | 193 ++++++++++++++++++ 5 files changed, 354 insertions(+), 35 deletions(-) diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 02ab1c1..ffec045 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -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 diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index f5bde22..18e69a5 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -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"): diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 7d17d2a..75c3c20 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -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 = `Загрузка`; - if (status === 'verifying') statusBadge = `Проверка SHA-256`; + if (status === 'checking') statusBadge = `Проверка обновлений`; + else if (status === 'verifying') statusBadge = `Проверка SHA-256`; else if (status === 'installing') statusBadge = `Установка`; else if (status === 'restarting') statusBadge = `Перезапуск`; else if (status === 'completed') statusBadge = `Завершено`; else if (status === 'failed') statusBadge = `Ошибка`; else if (status === 'cancelled') statusBadge = `Отменено`; + // 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 = `
@@ -2755,7 +2796,11 @@ function renderUpdateProgressView(p) {
${escapeHtml(msg)}
-
+ ${isIndeterminate && INDETERMINATE_ACTIVE_STATUSES.has(status) ? ` +
+ ` : ` +
+ `}
@@ -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); diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index 2802821..fd962b4 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -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" diff --git a/tests/test_a59_visible_update.py b/tests/test_a59_visible_update.py index d3f5586..7e80c72 100644 --- a/tests/test_a59_visible_update.py +++ b/tests/test_a59_visible_update.py @@ -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, ( + "после отклонённой отмены опрос хода загрузки должен возобновляться" + )