mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 02:36:27 +00:00
8ceeef3716
Three fixes from Opus advisor review of stage-261:
1. CRITICAL: dropdown-survives-resize bug. The composerToolsetsDropdown is a
DOM sibling of composerToolsetsWrap, not a child, so CSS hiding the wrap
does not cascade-hide an open dropdown. If a user opens the dropdown at
composer-footer >= 1100px and then opens the workspace panel (or resizes
the window), the dropdown would stay open without a visible anchor.
Fixed in three places (defense-in-depth):
- resize listener: closes dropdown when chip.offsetParent === null
- _positionToolsetsDropdown: closes if chip hidden (defense-in-depth)
- toggleToolsetsDropdown: early-returns if chip hidden (defense against
future #1431 redesign code that might invoke from elsewhere)
2. MEDIUM: display:flex changed to display:block to match sibling wraps
(.composer-profile-wrap, .composer-model-wrap, .composer-reasoning-wrap
all use the natural block display).
3. Added 3 new regression tests to pin all three guards.
Refs #1431, #1433.
247 lines
11 KiB
Python
247 lines
11 KiB
Python
"""Tests for #1431 / PR #1433 — composer-footer toolsets chip is responsive.
|
|
|
|
The chip must:
|
|
* Be hidden by default (CSS base rule).
|
|
* Be shown only at wide composer-footer widths (>= 1100px container query).
|
|
* Stay hidden on mobile (@media max-width:640px and @container 520px).
|
|
* Have its visibility controlled by CSS, NOT by JS (single source of truth).
|
|
* Continue to track state through _applyToolsetsChip() so /api/session/toolsets
|
|
keeps working for scripted callers regardless of UI visibility.
|
|
"""
|
|
import re
|
|
|
|
|
|
def _src(name: str) -> str:
|
|
with open(f"static/{name}") as f:
|
|
return f.read()
|
|
|
|
|
|
class TestToolsetsChipResponsiveCSS:
|
|
"""Visibility is controlled by CSS — base hides, container query reveals."""
|
|
|
|
def test_base_rule_defaults_chip_to_hidden(self):
|
|
"""The base .composer-toolsets-wrap rule must include display:none."""
|
|
css = _src("style.css")
|
|
# The base rule (outside any @container or @media block) must default-hide
|
|
m = re.search(
|
|
r'^\s*\.composer-toolsets-wrap\{[^}]*\}',
|
|
css, re.MULTILINE,
|
|
)
|
|
assert m, "Base .composer-toolsets-wrap CSS rule must exist"
|
|
rule = m.group(0)
|
|
assert "display:none" in rule, (
|
|
f"Base rule must default-hide the chip: got {rule!r}"
|
|
)
|
|
|
|
def test_wide_container_query_shows_chip(self):
|
|
"""An @container composer-footer (min-width: 1100px) rule must reveal the chip."""
|
|
css = _src("style.css")
|
|
# Find the min-width container query — accept either display:block or display:flex
|
|
# (we use block to match sibling wraps but either is a valid reveal)
|
|
m = re.search(
|
|
r'@container\s+composer-footer\s*\(\s*min-width:\s*1100px\s*\)\s*\{[^}]*\.composer-toolsets-wrap\s*\{[^}]*display:\s*(block|flex)[^}]*\}',
|
|
css, re.DOTALL,
|
|
)
|
|
assert m, (
|
|
"Must have @container composer-footer (min-width: 1100px) rule "
|
|
"that shows .composer-toolsets-wrap with display:block or display:flex"
|
|
)
|
|
|
|
def test_narrow_container_query_keeps_hiding(self):
|
|
"""The existing @container (max-width: 520px) rule must still hide the chip."""
|
|
css = _src("style.css")
|
|
# Look for the existing 520px rule that already hid composer-toolsets-wrap
|
|
m = re.search(
|
|
r'@container\s+composer-footer\s*\(\s*max-width:\s*520px\s*\).*?\.composer-toolsets-wrap\s*\{\s*display:\s*none\s*!important',
|
|
css, re.DOTALL,
|
|
)
|
|
assert m, (
|
|
"@container composer-footer (max-width: 520px) must continue to "
|
|
"hide .composer-toolsets-wrap with !important"
|
|
)
|
|
|
|
def test_mobile_viewport_keeps_hiding(self):
|
|
"""The existing @media max-width:640px rule must still hide the chip on mobile."""
|
|
css = _src("style.css")
|
|
m = re.search(
|
|
r'@media\s*\(\s*max-width:\s*640px\s*\).*?\.composer-toolsets-wrap\s*\{\s*display:\s*none\s*!important',
|
|
css, re.DOTALL,
|
|
)
|
|
assert m, (
|
|
"@media (max-width:640px) must continue to hide "
|
|
".composer-toolsets-wrap on mobile viewports"
|
|
)
|
|
|
|
|
|
class TestToolsetsChipJSDoesNotForceHide:
|
|
"""JS must NOT set display:none directly — CSS owns visibility."""
|
|
|
|
def test_applyToolsetsChip_does_not_set_display_none(self):
|
|
"""_applyToolsetsChip must not contain wrap.style.display = 'none'."""
|
|
js = _src("ui.js")
|
|
m = re.search(r'function _applyToolsetsChip\([^)]*\)\s*\{.*?\n\}', js, re.DOTALL)
|
|
assert m, "_applyToolsetsChip function must exist"
|
|
body = m.group(0)
|
|
# The PR initially had wrap.style.display = 'none'; we replaced with CSS.
|
|
assert "wrap.style.display = 'none'" not in body, (
|
|
"_applyToolsetsChip must not hardcode display:none — visibility "
|
|
"is controlled by responsive CSS (#1431)"
|
|
)
|
|
assert 'wrap.style.display = "none"' not in body, (
|
|
"_applyToolsetsChip must not hardcode display:none — visibility "
|
|
"is controlled by responsive CSS (#1431)"
|
|
)
|
|
|
|
def test_applyToolsetsChip_clears_inline_style(self):
|
|
"""_applyToolsetsChip must clear inline display so CSS rules can apply."""
|
|
js = _src("ui.js")
|
|
m = re.search(r'function _applyToolsetsChip\([^)]*\)\s*\{.*?\n\}', js, re.DOTALL)
|
|
assert m, "_applyToolsetsChip function must exist"
|
|
body = m.group(0)
|
|
# Either ='' or ="" (clearing inline style)
|
|
assert (
|
|
"wrap.style.display = ''" in body
|
|
or 'wrap.style.display = ""' in body
|
|
), (
|
|
"_applyToolsetsChip must clear wrap.style.display so the CSS "
|
|
"@container query is the single source of truth"
|
|
)
|
|
|
|
def test_applyToolsetsChip_still_tracks_state(self):
|
|
"""State tracking must be unchanged — /api/session/toolsets keeps working."""
|
|
js = _src("ui.js")
|
|
assert "_currentSessionToolsets = toolsets" in js, (
|
|
"_applyToolsetsChip must continue to update _currentSessionToolsets "
|
|
"so /api/session/toolsets reflects the active state"
|
|
)
|
|
|
|
|
|
class TestToolsetsChipHTMLNoInlineDisplay:
|
|
"""index.html must not have inline style='display:none' — CSS owns it."""
|
|
|
|
def test_html_does_not_force_inline_hide(self):
|
|
"""The composerToolsetsWrap div must not have inline style='display:none'."""
|
|
html = _src("index.html")
|
|
# Find the composerToolsetsWrap element
|
|
m = re.search(r'<div[^>]*id="composerToolsetsWrap"[^>]*>', html)
|
|
assert m, "composerToolsetsWrap div must exist in index.html"
|
|
tag = m.group(0)
|
|
assert 'style="display:none"' not in tag, (
|
|
"composerToolsetsWrap must not have inline style='display:none' — "
|
|
"the CSS base rule handles default-hidden state (#1431)"
|
|
)
|
|
# Also catch variants with whitespace/quotes
|
|
assert "display:none" not in tag, (
|
|
f"composerToolsetsWrap must not have any inline display:none: {tag!r}"
|
|
)
|
|
|
|
|
|
class TestToolsetsAPIStillWorks:
|
|
"""The /api/session/toolsets endpoint and dropdown must remain wired."""
|
|
|
|
def test_session_toolsets_endpoint_exists(self):
|
|
"""The api/session/toolsets endpoint must still be registered."""
|
|
# Check api/routes.py for the endpoint
|
|
try:
|
|
with open("api/routes.py") as f:
|
|
src = f.read()
|
|
except FileNotFoundError:
|
|
# If routes.py is named differently, search
|
|
import os
|
|
found = False
|
|
for root, _, files in os.walk("api"):
|
|
for f in files:
|
|
if f.endswith(".py"):
|
|
with open(os.path.join(root, f)) as fp:
|
|
if "session/toolsets" in fp.read():
|
|
found = True
|
|
break
|
|
if found:
|
|
break
|
|
assert found, "api/session/toolsets endpoint must exist somewhere in api/"
|
|
return
|
|
assert "session/toolsets" in src, (
|
|
"/api/session/toolsets endpoint must still be registered "
|
|
"(only the visual chip is hidden, not the underlying state)"
|
|
)
|
|
|
|
def test_toolsets_dropdown_renderer_exists(self):
|
|
"""_renderToolsetsDropdown must still exist for when chip becomes visible."""
|
|
js = _src("ui.js")
|
|
# Some form of toolsets dropdown machinery must remain so when the
|
|
# chip is visible at wide widths, clicking it still opens the picker.
|
|
assert "toggleToolsetsDropdown" in js, (
|
|
"toggleToolsetsDropdown must still exist — when the chip is "
|
|
"visible at wide widths, clicking it must still open the picker"
|
|
)
|
|
assert "_populateToolsetsDropdown" in js, (
|
|
"_populateToolsetsDropdown must still exist for picker population"
|
|
)
|
|
|
|
|
|
class TestToolsetsDropdownResizeGuard:
|
|
"""Opus-found defense: dropdown must close when chip becomes hidden by CSS.
|
|
|
|
The dropdown is a DOM sibling of the wrap, not a child. CSS hiding the
|
|
wrap (e.g. by crossing the 1100px container threshold mid-session via the
|
|
workspace-panel toggle) does NOT cascade-hide the open dropdown. Without
|
|
a guard, the dropdown would either snap to the footer's left edge with no
|
|
anchor, or stay open with no visible chip to dismiss it from.
|
|
"""
|
|
|
|
def test_resize_handler_closes_dropdown_when_chip_hidden(self):
|
|
"""Resize listener must close dropdown when the chip is no longer visible."""
|
|
js = _src("ui.js")
|
|
# Find the resize handler block for the toolsets dropdown
|
|
# It must check chip.offsetParent === null and close, not reposition
|
|
m = re.search(
|
|
r"window\.addEventListener\('resize',\s*\([^)]*\)\s*=>\s*\{[^}]*composerToolsetsDropdown[^}]*\}",
|
|
js, re.DOTALL,
|
|
)
|
|
assert m, "Toolsets resize handler must exist"
|
|
body = m.group(0)
|
|
assert "offsetParent" in body, (
|
|
"Resize handler must check chip.offsetParent === null — without it "
|
|
"the open dropdown stays open after CSS hides the chip mid-session "
|
|
"(e.g. workspace-panel toggle crossing 1100px threshold)"
|
|
)
|
|
assert "closeToolsetsDropdown" in body, (
|
|
"Resize handler must call closeToolsetsDropdown() when chip is "
|
|
"hidden — repositioning a hidden chip leaves the dropdown anchored "
|
|
"to a zero-rect element"
|
|
)
|
|
|
|
def test_position_dropdown_guards_against_hidden_chip(self):
|
|
"""_positionToolsetsDropdown must close-not-reposition if chip hidden."""
|
|
js = _src("ui.js")
|
|
m = re.search(
|
|
r"function _positionToolsetsDropdown\(\)\s*\{.*?\n\}",
|
|
js, re.DOTALL,
|
|
)
|
|
assert m, "_positionToolsetsDropdown function must exist"
|
|
body = m.group(0)
|
|
# Defense-in-depth: even direct callers of _positionToolsetsDropdown
|
|
# must not anchor to a hidden chip.
|
|
assert "offsetParent" in body, (
|
|
"_positionToolsetsDropdown must check chip.offsetParent === null "
|
|
"before reading getBoundingClientRect — defense-in-depth"
|
|
)
|
|
|
|
def test_toggle_dropdown_guards_against_hidden_chip(self):
|
|
"""toggleToolsetsDropdown must early-return if chip is hidden by CSS."""
|
|
js = _src("ui.js")
|
|
m = re.search(
|
|
r"function toggleToolsetsDropdown\(\)\s*\{.*?\n\}",
|
|
js, re.DOTALL,
|
|
)
|
|
assert m, "toggleToolsetsDropdown function must exist"
|
|
body = m.group(0)
|
|
# Currently the only invoker is the chip's own onclick (so this is
|
|
# latent), but defensive guard is needed because the function is in
|
|
# global scope and could be called by future #1431 redesign code.
|
|
assert "offsetParent" in body, (
|
|
"toggleToolsetsDropdown must check chip.offsetParent === null "
|
|
"before opening — function is global and could be invoked when "
|
|
"the chip is hidden by responsive CSS"
|
|
)
|