fix(ui): уведомления шли без остановки и закрывали интерфейс
Владелец: «справа постоянно выходят статусы, прям без остановки. я вообще ничего не вижу за ними». Это был не таймер, а замкнутый круг. Отрисовка настроек запускала опрос состояния сжатия; executeAction на успехе вызывал fetchSnapshot; тот снова перерисовывал настройки — и так без конца. Интерфейс сам себя кормил запросами к серверу, показывая на каждом обороте два тоста: на запрос и на ответ. Тем же путём заливал «poll_native_auth» во время входа. Опросы, которые запускает сам интерфейс, а не владелец, теперь молчат и не дёргают снапшот — второе и разрывает круг. Отказ опроса показывается там, где его запросили: у мастера входа для этого своя область сообщений. Состояние сжатия запрашивается при открытии экрана настроек и по кнопке, а не при каждой отрисовке. 700 passed, 2 skipped; ruff чисто; релизный гейт пройден. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fa7bbef8af
commit
90aeceb9fd
5 changed files with 114 additions and 7 deletions
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -117,6 +117,10 @@ function switchView(viewName) {
|
|||
|
||||
if (viewName === 'settings') {
|
||||
fetchSettings();
|
||||
// Состояние сжатия спрашиваем при открытии экрана, а не при каждой
|
||||
// отрисовке настроек: раньше опрос запускался на каждом обновлении
|
||||
// снапшота и замыкал круг сам на себя.
|
||||
checkCompressionStatus();
|
||||
}
|
||||
|
||||
if (currentSnapshot) {
|
||||
|
|
@ -535,8 +539,23 @@ async function saveAuthTokenFromPrompt() {
|
|||
}
|
||||
|
||||
// ── ACTIONS EXECUTION (POST /api/action) ──
|
||||
|
||||
// Действия-опросы: их запускает сам интерфейс, а не владелец.
|
||||
//
|
||||
// Показывать их тостами нельзя: опрос состояния сжатия шёл при каждом
|
||||
// обновлении снапшота, и владелец не видел за уведомлениями самой программы.
|
||||
// Хуже того, успешное действие вызывало fetchSnapshot, тот перерисовывал
|
||||
// настройки, а перерисовка снова запускала опрос — круг замыкался и кормил
|
||||
// сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот.
|
||||
const SILENT_ACTIONS = new Set([
|
||||
'get_compression_status',
|
||||
'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth',
|
||||
'poll_redirect_auth', 'poll_device_auth',
|
||||
]);
|
||||
|
||||
async function executeAction(actionName, actionData = {}) {
|
||||
showToast(`Выполняется «${actionName}»...`, 'info');
|
||||
const silent = SILENT_ACTIONS.has(actionName);
|
||||
if (!silent) showToast(`Выполняется «${actionName}»...`, 'info');
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (authToken) headers['X-Hub-Token'] = authToken;
|
||||
|
|
@ -555,16 +574,20 @@ async function executeAction(actionName, actionData = {}) {
|
|||
const result = await res.json().catch(() => ({ 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) {
|
||||
|
|
|
|||
82
tests/test_ui_polling_is_silent.py
Normal file
82
tests/test_ui_polling_is_silent.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue