From 90aeceb9fdc9dc94795eec1cc0f3c971bd4919d1 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 22:47:55 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=88=D0=BB=D0=B8=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец: «справа постоянно выходят статусы, прям без остановки. я вообще ничего не вижу за ними». Это был не таймер, а замкнутый круг. Отрисовка настроек запускала опрос состояния сжатия; executeAction на успехе вызывал fetchSnapshot; тот снова перерисовывал настройки — и так без конца. Интерфейс сам себя кормил запросами к серверу, показывая на каждом обороте два тоста: на запрос и на ответ. Тем же путём заливал «poll_native_auth» во время входа. Опросы, которые запускает сам интерфейс, а не владелец, теперь молчат и не дёргают снапшот — второе и разрывает круг. Отказ опроса показывается там, где его запросили: у мастера входа для этого своя область сообщений. Состояние сжатия запрашивается при открытии экрана настроек и по кнопке, а не при каждой отрисовке. 700 passed, 2 skipped; ruff чисто; релизный гейт пройден. Co-Authored-By: Claude Opus 5 --- installer/HermesHubSetup.cs | 2 +- launcher/HermesHub.exe | Bin 6144 -> 6144 bytes launcher/HermesHubWeb.exe | Bin 20992 -> 20992 bytes .../router/web/static/app.js | 37 ++++++-- tests/test_ui_polling_is_silent.py | 82 ++++++++++++++++++ 5 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 tests/test_ui_polling_is_silent.py diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index a117da3..be08342 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -18,7 +18,7 @@ namespace HermesHubSetup // Подставляется сборщиком из фактического git-коммита. Раньше здесь // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // какого кода собран установщик. - public const string BuildCommit = "28f35f8"; + public const string BuildCommit = "fa7bbef"; public const string MIN_HERMES_VERSION = "0.20.0"; public const string MAX_TESTED_HERMES = "0.20.4"; diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe index f950cd75b1f15b4c26a4420ebeb9562615045cee..7cef02a0f0679ff718b83fc73bb4e344f02e0769 100644 GIT binary patch delta 34 qcmZoLXfT-2!8CpT#;(&m{1+WtSXtM2_lKOjRy562Vsi@b94-L+Mh*`E delta 34 qcmZoLXfT-2!NfmxW7laO{{I47zTbH6)bQwY^UUtj;>{_%bGQH)t`MmJ diff --git a/launcher/HermesHubWeb.exe b/launcher/HermesHubWeb.exe index 98beb15bb99ca66f804fd984d9539d38279453f1..38e0dfd84a19cb7232c1edad902676964682ce5c 100644 GIT binary patch delta 79 zcmZoz!q~8caY6^vjQJb8ekrIJS-1g#nUk)ev9XJ;iJ^guuBDl!xvr^^i=mOJv7?cb ii`(WEiWigxvj6|ruodQYoHjk^qN ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` })); if (result.ok) { - showToast(result.message || 'Действие выполнено успешно', 'success'); - fetchSnapshot(); + if (!silent) { + showToast(result.message || 'Действие выполнено успешно', 'success'); + fetchSnapshot(); + } return result; } else { - showToast(result.message || 'Отказ выполнения действия', 'warning'); + // Отказ опроса показываем в том месте, которое его запросило: у мастера + // входа для этого своя область сообщений. Тостом заливать нельзя. + if (!silent) showToast(result.message || 'Отказ выполнения действия', 'warning'); return result; } } catch (err) { console.error(`Action ${actionName} failed:`, err); - showToast(`Ошибка сети: ${err.message}`, 'error'); + if (!silent) showToast(`Ошибка сети: ${err.message}`, 'error'); return { ok: false, message: `Ошибка сети: ${err.message}` }; } } @@ -1801,7 +1824,9 @@ function renderSettingsView() { if (compKeepRecentSel && s.compression_keep_recent_messages !== undefined) { compKeepRecentSel.value = String(s.compression_keep_recent_messages); } - checkCompressionStatus(); + // checkCompressionStatus() отсюда убран: отрисовка настроек происходит на + // каждом обновлении снапшота, и опрос замыкал круг сам на себя. Состояние + // запрашивается при открытии экрана настроек и по кнопке. } function populateCompressorProfiles(s) { diff --git a/tests/test_ui_polling_is_silent.py b/tests/test_ui_polling_is_silent.py new file mode 100644 index 0000000..10040af --- /dev/null +++ b/tests/test_ui_polling_is_silent.py @@ -0,0 +1,82 @@ +"""Опросы интерфейса не должны заливать владельца уведомлениями и кормить себя. + +Владелец: «справа постоянно выходят статусы, прям без остановки. я вообще +ничего не вижу за ними». + +Это был не таймер, а замкнутый круг: отрисовка настроек запускала опрос +состояния сжатия, executeAction на успехе вызывал fetchSnapshot, тот снова +перерисовывал настройки — и так без конца. Заодно каждый оборот показывал два +тоста: на запрос и на ответ. + +Тем же путём заливали «poll_native_auth» во время входа. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +APP_JS = ( + Path(__file__).resolve().parent.parent + / "src" / "antigravity_provider" / "router" / "web" / "static" / "app.js" +) + + +@pytest.fixture(scope="module") +def source() -> str: + return APP_JS.read_text(encoding="utf-8") + + +def test_polling_actions_are_listed_as_silent(source): + assert "SILENT_ACTIONS" in source + for action in ( + "get_compression_status", + "poll_native_auth", + "poll_redirect_auth", + "poll_device_auth", + ): + assert f"'{action}'" in source, f"{action} обязан быть среди молчаливых" + + +def test_silent_actions_show_no_toasts(source): + """Все четыре места, где executeAction показывает тост, под охраной silent.""" + body = source.split("async function executeAction")[1].split("// ── GLOBAL HEADER")[0] + + start, success, failure, network = ( + body.split("try {")[0], + body.split("if (result.ok)")[1].split("} else {")[0], + body.split("} else {")[1].split("} catch")[0], + body.split("} catch")[1], + ) + for part, where in ( + (start, "начало действия"), + (success, "успех"), + (failure, "отказ"), + (network, "сбой сети"), + ): + assert "showToast(" in part, f"место не найдено: {where}" + assert part.index("if (!silent)") < part.index("showToast("), ( + f"тост без проверки молчаливости: {where}" + ) + + +def test_silent_actions_do_not_refetch_the_snapshot(source): + """Круг замыкался именно здесь: успех опроса тянул за собой снапшот.""" + body = source.split("async function executeAction")[1].split("// ── GLOBAL HEADER")[0] + success_block = body.split("if (result.ok)")[1].split("} else {")[0] + assert "if (!silent)" in success_block + assert success_block.index("if (!silent)") < success_block.index("fetchSnapshot(") + + +def test_settings_render_does_not_start_a_poll(source): + """Опрос из отрисовки убран — она идёт на каждом обновлении снапшота.""" + render = source.split("function populateCompressorProfiles")[0] + render_tail = render[-2000:] + calls = re.findall(r"^\s*checkCompressionStatus\(\);", render_tail, re.MULTILINE) + assert not calls, "опрос из отрисовки настроек запускать нельзя" + + +def test_status_is_requested_when_the_screen_opens(source): + opening = source.split("if (viewName === 'settings')")[1][:400] + assert "checkCompressionStatus()" in opening