mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
24b1e6f3fc
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211) Merges PRs #1208, #1209, #1210 (#1152 rebased): - fix(providers): OAuth provider cards show correct Configured status in Settings. get_providers() was discarding has_key=True from _provider_has_key() for OAuth providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers from the Settings panel. Surfaces auth_error string. (closes #1202) - ux(profiles): profile chip shows spinner and new name immediately on switch. Optimistic name update + .switching CSS class + chip disabled + finally cleanup. populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all. - feat: YOLO mode toggle — skip all approvals per session. /yolo slash command, "Skip all this session" button on approval cards, amber ⚡ pill indicator in composer footer. Session-scoped, in-memory. Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467) Original author: @bergeouss (PR #1152) Tests: 2837 passed (+50 new tests vs previous release) QA harness: 20/20 passed + all browser API checks passed
146 lines
5.6 KiB
Python
146 lines
5.6 KiB
Python
"""
|
|
Tests for profile-switch UX improvements — spinner indicator + parallelized fetches.
|
|
|
|
Two changes:
|
|
1. switchToProfile() shows a spinner on the profile chip during the async switch,
|
|
with an optimistic name update and error revert.
|
|
2. populateModelDropdown() and loadWorkspaceList() are now parallelized via Promise.all
|
|
instead of sequential awaits.
|
|
"""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
|
|
|
|
|
class TestProfileSwitchSpinner:
|
|
"""Static-analysis tests for the spinner loading indicator."""
|
|
|
|
JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
|
|
|
def _get_switch_fn(self):
|
|
idx = self.JS.find("async function switchToProfile(name) {")
|
|
assert idx != -1, "switchToProfile not found in panels.js"
|
|
depth = 0
|
|
for i, ch in enumerate(self.JS[idx:], idx):
|
|
if ch == "{": depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return self.JS[idx: i + 1]
|
|
raise AssertionError("Could not extract switchToProfile")
|
|
|
|
def test_switching_class_added_on_start(self):
|
|
"""The switching CSS class must be added before any awaits."""
|
|
fn = self._get_switch_fn()
|
|
assert "classList.add('switching')" in fn, (
|
|
"switchToProfile() does not add 'switching' CSS class to the chip."
|
|
)
|
|
|
|
def test_switching_class_removed_in_finally(self):
|
|
"""The switching class must be removed in a finally block."""
|
|
fn = self._get_switch_fn()
|
|
finally_idx = fn.find("} finally {")
|
|
assert finally_idx != -1, "switchToProfile() has no finally block."
|
|
assert "classList.remove('switching')" in fn[finally_idx:], (
|
|
"The finally block does not remove 'switching' class."
|
|
)
|
|
|
|
def test_optimistic_name_set_before_api_call(self):
|
|
"""Chip label must be updated to new name before the API call."""
|
|
fn = self._get_switch_fn()
|
|
api_call_idx = fn.find("await api('/api/profile/switch'")
|
|
opt_name_idx = fn.find("_chipLabel.textContent = name")
|
|
assert opt_name_idx != -1, "No optimistic name update found."
|
|
assert opt_name_idx < api_call_idx, (
|
|
"Optimistic name update must happen BEFORE the API call."
|
|
)
|
|
|
|
def test_chip_disabled_during_switch(self):
|
|
"""Chip must be disabled to prevent double-clicks."""
|
|
fn = self._get_switch_fn()
|
|
assert "_chip.disabled = true" in fn, (
|
|
"switchToProfile() does not disable the chip."
|
|
)
|
|
finally_idx = fn.find("} finally {")
|
|
assert finally_idx != -1
|
|
assert "_chip.disabled = false" in fn[finally_idx:], (
|
|
"The finally block does not re-enable the chip."
|
|
)
|
|
|
|
def test_error_reverts_chip_label_to_previous_name(self):
|
|
"""On error, the chip label must revert to the previous name."""
|
|
fn = self._get_switch_fn()
|
|
catch_idx = fn.find("} catch (e) {")
|
|
assert catch_idx != -1
|
|
assert "_prevProfileName" in fn[catch_idx:], (
|
|
"The catch block does not restore _prevProfileName."
|
|
)
|
|
|
|
|
|
class TestParallelizedFetches:
|
|
"""Verify that model and workspace fetches are parallelized."""
|
|
|
|
JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
|
|
|
def _get_switch_fn(self):
|
|
idx = self.JS.find("async function switchToProfile(name) {")
|
|
assert idx != -1
|
|
depth = 0
|
|
for i, ch in enumerate(self.JS[idx:], idx):
|
|
if ch == "{": depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return self.JS[idx: i + 1]
|
|
raise AssertionError("Could not extract switchToProfile")
|
|
|
|
def test_populate_and_workspace_in_promise_all(self):
|
|
"""Both fetches must be inside Promise.all([...])."""
|
|
fn = self._get_switch_fn()
|
|
assert "Promise.all([populateModelDropdown(), loadWorkspaceList()])" in fn, (
|
|
"populateModelDropdown() and loadWorkspaceList() are not parallelized."
|
|
)
|
|
|
|
def test_no_sequential_await_pattern(self):
|
|
"""The old sequential await pattern must be gone."""
|
|
fn = self._get_switch_fn()
|
|
sequential = re.search(
|
|
r"await populateModelDropdown\(\)\s*;\s*\n\s*await loadWorkspaceList",
|
|
fn
|
|
)
|
|
assert not sequential, (
|
|
"Old sequential await pattern still present — both fetches would run twice."
|
|
)
|
|
|
|
def test_apply_steps_after_promise_all(self):
|
|
"""Model apply step must come after Promise.all resolves."""
|
|
fn = self._get_switch_fn()
|
|
promise_all_idx = fn.find("await Promise.all(")
|
|
apply_model_idx = fn.find("S._pendingProfileModel = modelToUse")
|
|
assert apply_model_idx != -1
|
|
assert apply_model_idx > promise_all_idx, (
|
|
"Model apply step must come AFTER Promise.all resolves."
|
|
)
|
|
|
|
|
|
class TestSpinnerCss:
|
|
"""Verify the spinner CSS class is defined correctly."""
|
|
|
|
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
|
|
|
def test_switching_class_defined(self):
|
|
assert ".composer-profile-chip.switching" in self.CSS
|
|
|
|
def test_switching_class_has_cursor_wait(self):
|
|
idx = self.CSS.find(".composer-profile-chip.switching")
|
|
assert idx != -1
|
|
block = self.CSS[idx: idx + 200]
|
|
assert "cursor:wait" in block
|
|
|
|
def test_switching_class_has_pointer_events_none(self):
|
|
idx = self.CSS.find(".composer-profile-chip.switching")
|
|
assert idx != -1
|
|
block = self.CSS[idx: idx + 200]
|
|
assert "pointer-events:none" in block
|