Files
hermes-webui/tests/test_mobile_layout.py
T
2026-04-30 15:24:36 +00:00

502 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Mobile layout regression tests — run on every QA pass.
These tests check that the CSS and HTML structure required for correct
mobile rendering (375px640px viewport widths) is intact after every change.
They are static checks (no server needed) that catch common regressions:
- Mobile breakpoints present for key layout elements
- Right panel slide-over markup and CSS intact
- Profile dropdown not clipped by overflow on mobile
- Composer footer chips scroll correctly on narrow viewports
- Mobile sidebar navigation stays available on phones
- No full-viewport overflow that would break scroll
Run as part of the standard test suite:
pytest tests/test_mobile_layout.py -v
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
# ── Mobile breakpoint rules ───────────────────────────────────────────────────
def test_mobile_breakpoint_900px_present():
"""@media(max-width:900px) must hide the right panel and show mobile-files-btn."""
assert "@media(max-width:900px)" in CSS or "@media (max-width: 900px)" in CSS, \
"Missing @media(max-width:900px) breakpoint in style.css"
# Right panel should be hidden at 900px, replaced by slide-over
assert ".rightpanel{display:none" in CSS or ".rightpanel {display:none" in CSS or \
re.search(r'max-width:900px\).*?\.rightpanel\{display:none', CSS, re.DOTALL), \
".rightpanel must be display:none at max-width:900px (slide-over replaces it)"
def test_mobile_breakpoint_640px_present():
"""@media(max-width:640px) must exist for narrow phone layouts."""
assert "@media(max-width:640px)" in CSS or "@media (max-width: 640px)" in CSS, \
"Missing @media(max-width:640px) breakpoint in style.css"
def test_rightpanel_mobile_slide_over_css():
"""Right panel must have position:fixed slide-over CSS for mobile."""
# At max-width:900px the rightpanel should be position:fixed, off-screen right
assert "position:fixed" in CSS, \
"style.css must have position:fixed for rightpanel mobile slide-over"
assert ".rightpanel.mobile-open{right:0" in CSS or ".rightpanel.mobile-open {right:0" in CSS, \
".rightpanel.mobile-open must set right:0 to slide panel in from right"
assert "min(300px, 100vw)" in CSS or "min(300px,100vw)" in CSS, \
"rightpanel mobile width should be capped defensively with 100vw"
assert "var(--mobile-rightpanel-width)" in CSS, \
"mobile rightpanel width variable should be used in compact mode rules"
assert "calc(-1 * var(--mobile-rightpanel-width))" in CSS, \
"closed mobile rightpanel should be off-canvas using a width-based negative offset"
mobile_640 = re.search(r'@media\(max-width:640px\)\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}', CSS, re.DOTALL)
assert mobile_640, "@media(max-width:640px) block missing from style.css"
rightpanel_block = mobile_640.group(1)
assert re.search(r'\.rightpanel\{[^}]*width:\s*var\(--mobile-rightpanel-width\)\s*!important',
rightpanel_block, re.DOTALL), \
".rightpanel width must use var(--mobile-rightpanel-width) with !important in mobile block"
assert re.search(r'\.rightpanel\.mobile-open\{[^}]*right:\s*0\s*!important',
rightpanel_block, re.DOTALL), \
"mobile-open mobile rightpanel must force right:0 with !important"
assert re.search(r'\.rightpanel\{[^}]*box-shadow:\s*none\s*!important',
rightpanel_block, re.DOTALL), \
"closed mobile rightpanel should have no shadow to avoid right-edge bleed"
assert re.search(r'\.rightpanel\.mobile-open\{[^}]*box-shadow:\s*-4px 0 24px rgba\(0,\s*0,\s*0,\s*\.?4\)',
rightpanel_block, re.DOTALL), \
"open mobile rightpanel should keep the edge shadow"
def test_workspace_panel_inline_width_is_desktop_only():
"""Persisted rightpanel width must only be restored above compact/mobile breakpoints."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "function _syncWorkspacePanelInlineWidth()" in boot_js, \
"_syncWorkspacePanelInlineWidth() must exist to keep panel width mobile-safe"
assert "_syncWorkspacePanelInlineWidth();" in boot_js, \
"_syncWorkspacePanelInlineWidth() must be called when viewport changes"
assert "localStorage.getItem('hermes-panel-w')" in boot_js, \
"Panel width helper must source hermes-panel-w from localStorage"
assert "_workspacePanelEls();" in boot_js and "style.removeProperty('width')" in boot_js, \
"Panel helper must clear inline width while in compact/mobile viewport"
def _container_query_block(css: str, container_query: str):
query_pattern = re.compile(
rf'@container\s+{re.escape(container_query)}\s*\{{',
re.DOTALL,
)
for match in query_pattern.finditer(css):
start = match.end() - 1
end = css.find("@container", start + 1)
if end == -1:
end = css.find("@media", start + 1)
if end == -1:
end = len(css)
block = css[start + 1:end]
return block
return ""
def _container_media_block(css: str, media_query: str):
query_pattern = re.compile(
rf'@media\s*\(\s*max-width:\s*{re.escape(media_query)}\s*\)\s*\{{',
re.DOTALL,
)
def _media_block_end(css_text: str, open_brace_idx: int) -> int:
depth = 0
for idx in range(open_brace_idx, len(css_text)):
if css_text[idx] == "{":
depth += 1
elif css_text[idx] == "}":
depth -= 1
if depth == 0:
return idx
return -1
def _strip_nested_media(block: str) -> str:
parts = []
cursor = 0
while True:
nested = block.find("@media", cursor)
if nested == -1:
parts.append(block[cursor:])
break
parts.append(block[cursor:nested])
nested_open = block.find("{", nested)
if nested_open == -1:
break
nested_close = _media_block_end(block, nested_open)
if nested_close == -1:
break
cursor = nested_close + 1
return "".join(parts)
for match in query_pattern.finditer(css):
start = match.end() - 1
end = _media_block_end(css, start)
if end == -1:
continue
block = css[start + 1:end]
block = _strip_nested_media(block)
if ".composer-profile-label" in block or ".composer-profile-chip" in block:
return block
return ""
def test_composer_controls_switch_to_icon_only_by_container_width():
"""Composer controls should progressively compact based on footer width."""
assert re.search(r'\.composer-footer\s*\{[^}]*container-type:inline-size[^}]*container-name:composer-footer[^}]*\}', CSS), \
".composer-footer should define container-type:inline-size and container-name:composer-footer"
compact_700 = _container_query_block(CSS, "composer-footer (max-width: 700px)")
assert compact_700, "Expected composer mid-width compact rules at @container composer-footer (max-width: 700px)"
for selector in (
".composer-workspace-label",
".composer-model-label",
".composer-workspace-chevron",
".composer-model-chevron",
"#composerWorkspaceLabel",
"#composerModelLabel",
".composer-workspace-chip",
".composer-model-chip",
".composer-divider",
):
assert selector in compact_700, f"{selector} should be present in the 700px composer compact block"
assert "display:none" in compact_700
assert "max-width:52px" in compact_700
# Ensure this first stage does not prematurely remove profile/reasoning labels.
assert ".composer-profile-label" not in compact_700
assert ".composer-reasoning-label" not in compact_700
assert ".composer-profile-chevron" not in compact_700
assert ".composer-reasoning-chevron" not in compact_700
compact_520 = _container_query_block(CSS, "composer-footer (max-width: 520px)")
assert compact_520, "Expected full composer icon-only rules at @container composer-footer (max-width: 520px)"
for selector in (
".composer-profile-label",
".composer-workspace-label",
".composer-model-label",
".composer-reasoning-label",
".composer-profile-chevron",
".composer-workspace-chevron",
".composer-model-chevron",
".composer-reasoning-chevron",
"#composerProfileLabel",
"#composerWorkspaceLabel",
"#composerModelLabel",
"#composerReasoningLabel",
".composer-workspace-chip",
".composer-model-chip",
".composer-profile-chip",
".composer-reasoning-chip",
):
assert selector in compact_520, f"{selector} should be present in the 520px composer compact block"
assert "max-width:44px" in compact_520
assert "display:none" in compact_520
# Regression intent:
# - this container rule should not depend on right-panel open/closed state.
# - left-sidebar-only constriction must still collapse composer controls together.
assert ".layout:not(.workspace-panel-collapsed)" not in compact_700, \
"composer-footer compact rule should be state-agnostic (left sidebar + closed right panel case included)"
assert ".layout:not(.workspace-panel-collapsed)" not in compact_520, \
"composer-footer compact rule should be state-agnostic (left sidebar + closed right panel case included)"
def test_composer_compact_switch_is_not_viewport_only():
"""Compact controls should be container-triggered, not bound to viewport width alone."""
assert "composer-footer (max-width: 700px)" in CSS, \
"Container-query breakpoint should track composer footer width"
assert "composer-footer (max-width: 520px)" in CSS, \
"Container-query second-stage breakpoint should track composer footer width"
assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*860px\)', CSS) is None, \
"Full icon-only should not be tied to a 860px threshold any more"
assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*1000px\)', CSS) is None, \
"Full icon-only/first-stage container gate should not be tied to 1000px"
media_860 = _container_media_block(CSS, "860px")
assert media_860 == "", \
"Composer compact breakpoint should not be a dedicated 860px viewport media query"
media_900 = _container_media_block(CSS, "900px")
assert media_900 == "", \
"Composer compact breakpoint should use container queries, not viewport media at 900px"
def test_mobile_overlay_present():
"""Mobile overlay element must exist for tap-to-close sidebar behavior."""
assert 'id="mobileOverlay"' in HTML, \
"#mobileOverlay element missing from index.html"
assert "mobile-overlay" in CSS, \
".mobile-overlay CSS rule missing from style.css"
def test_sidebar_nav_present():
"""Sidebar top navigation tabs must be present."""
assert 'class="sidebar-nav"' in HTML, \
".sidebar-nav missing from index.html"
assert ".sidebar-nav{" in CSS or ".sidebar-nav {" in CSS, \
".sidebar-nav CSS rule missing from style.css"
def test_mobile_does_not_hide_sidebar_nav():
"""Phone breakpoint must keep the sidebar top navigation visible."""
mobile_block = re.search(r'@media\(max-width:640px\)\{(.*)\n\s*\}', CSS, re.DOTALL)
assert mobile_block, "Missing @media(max-width:640px) block in style.css"
assert ".sidebar-nav{display:none" not in mobile_block.group(1).replace(" ", ""), \
".sidebar-nav must stay visible on mobile"
def test_mobile_files_button_present():
"""Mobile files toggle button (#btnWorkspacePanelToggle.workspace-toggle-btn) must be in HTML and CSS."""
assert 'id="btnWorkspacePanelToggle"' in HTML, \
"#btnWorkspacePanelToggle missing from index.html"
assert "workspace-toggle-btn" in CSS, \
".workspace-toggle-btn CSS missing from style.css"
# ── Profile dropdown overflow ─────────────────────────────────────────────────
def test_profile_dropdown_not_clipped_by_overflow():
"""Profile dropdown must not be inside an overflow:hidden or overflow-x:auto ancestor
without a higher z-index escape hatch.
The topbar-chips container uses overflow-x:auto on mobile, which creates a
stacking context that clips absolutely-positioned children. The profile dropdown
must use position:fixed on mobile OR the topbar-chips must not clip it.
"""
# The profile-chip wrapper must have position:relative so the dropdown can escape
assert 'id="profileChipWrap"' in HTML, \
"#profileChipWrap missing from index.html"
# Profile dropdown must have a z-index high enough to clear the topbar
assert ".profile-dropdown{" in CSS or ".profile-dropdown {" in CSS, \
".profile-dropdown CSS rule missing"
# z-index must be at least 200 (topbar is z-index:10)
m = re.search(r'\.profile-dropdown\{[^}]*z-index:(\d+)', CSS)
if m:
assert int(m.group(1)) >= 100, \
f".profile-dropdown z-index {m.group(1)} is too low — must be >= 100 to clear topbar"
def test_topbar_chips_mobile_overflow():
"""topbar-chips must use overflow-x:auto on mobile for chip scrolling.
Chips (profile, workspace, model, files) must scroll horizontally on narrow
viewports rather than wrapping onto a second line which would break the topbar layout.
"""
# At narrow viewport, topbar-chips should scroll
assert "overflow-x:auto" in CSS or "overflow-x: auto" in CSS, \
"topbar-chips must have overflow-x:auto for mobile chip scrolling"
# ── Workspace panel close ─────────────────────────────────────────────────────
def test_workspace_close_button_present():
"""Workspace panel must have a close/hide button accessible on mobile."""
# Accept handleWorkspaceClose() (two-step close: file→browse→closed), or the
# lower-level functions directly. handleWorkspaceClose is preferred because
# it dismisses a file preview first before closing the panel.
has_close = (
'onclick="handleWorkspaceClose()"' in HTML or
'onclick="closeWorkspacePanel()"' in HTML or
'onclick="toggleWorkspacePanel()"' in HTML
)
assert has_close, \
"handleWorkspaceClose() or closeWorkspacePanel() must be wired to a button to close the workspace panel on mobile"
def test_toggle_mobile_files_js_defined():
"""toggleMobileFiles() must be defined in boot.js."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "function toggleMobileFiles()" in boot_js, \
"toggleMobileFiles() missing from static/boot.js"
assert "mobile-open" in boot_js, \
"toggleMobileFiles() must toggle mobile-open class on the right panel"
def test_new_conversation_closes_mobile_sidebar():
"""New conversation must close the mobile drawer so the chat pane is visible immediately."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
# Handler is now multi-line — search for the full block rather than a single line.
assert "$('btnNewChat').onclick" in boot_js, "btnNewChat onclick handler missing from static/boot.js"
# Find the handler block and verify closeMobileSidebar appears in it.
idx = boot_js.find("$('btnNewChat').onclick")
handler_block = boot_js[idx:idx+500]
assert "closeMobileSidebar" in handler_block, \
"btnNewChat handler must closeMobileSidebar() after creating the new session"
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+12])
assert "closeMobileSidebar" in shortcut_block, \
"Cmd/Ctrl+K new chat shortcut must closeMobileSidebar() after creating the new session"
def test_new_conversation_shortcut_works_while_busy():
"""Cmd/Ctrl+K should still create a new conversation while the current one is busy.
The previous behavior gated the shortcut on !S.busy, which meant users had
to wait for a long generation to finish before they could start something
new — the exact moment they want to switch context.
"""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
# Inspect the next 10 lines after the keybinding match — the gating block
# would live there if it had been kept.
idx = boot_js.splitlines().index(shortcut_line)
shortcut_block = "\n".join(boot_js.splitlines()[idx:idx + 10])
# Strip the existing message-count guard (which is unrelated and stays) so
# we only check for an S.busy gate on the newSession() call itself.
assert "if(!S.busy)" not in shortcut_block, (
"Cmd/Ctrl+K must not be blocked by the current session's busy state"
)
assert "if (!S.busy)" not in shortcut_block, (
"Cmd/Ctrl+K must not be blocked by the current session's busy state"
)
# ── Viewport and scroll safety ────────────────────────────────────────────────
def test_body_overflow_hidden():
"""body must have overflow:hidden to prevent double scrollbars on mobile."""
assert "body{" in CSS or "body {" in CSS, \
"body rule missing from style.css"
assert re.search(r'body\{[^}]*overflow:hidden', CSS), \
"body must have overflow:hidden to prevent double scrollbars"
def test_flex_parents_allow_message_scroller_to_shrink():
"""The top-level flex containers must opt into min-height:0 so .messages can scroll on mobile.
Mobile Safari/Chrome can trap scroll when a flex child with overflow:auto sits inside
parents whose min-height remains auto. Both .layout and .main need min-height:0.
"""
assert re.search(r'\.layout\{[^}]*min-height:0', CSS), \
".layout must set min-height:0 so the chat column can shrink and scroll"
assert re.search(r'\.main\{[^}]*min-height:0', CSS), \
".main must set min-height:0 so .messages remains scrollable while busy"
def test_messages_touch_scrolling_hints_present():
"""The messages scroller must advertise touch-friendly scrolling behavior.
On mobile browsers, momentum scrolling and explicit pan-y/overscroll behavior help
prevent the chat area from feeling locked while the app body itself stays overflow:hidden.
"""
assert re.search(r'\.messages\{[^}]*-webkit-overflow-scrolling:\s*touch', CSS), \
".messages must enable -webkit-overflow-scrolling:touch for mobile momentum scroll"
assert re.search(r'\.messages\{[^}]*touch-action:\s*pan-y', CSS), \
".messages must set touch-action:pan-y so vertical swipe gestures scroll the transcript"
assert re.search(r'\.messages\{[^}]*overscroll-behavior-y:\s*contain', CSS), \
".messages must contain vertical overscroll so the transcript keeps the gesture"
def test_100dvh_viewport_height():
"""Layout must use 100dvh (dynamic viewport height) for correct mobile sizing.
On mobile Safari and Chrome, 100vh includes the browser chrome (address bar),
causing content to be hidden. 100dvh accounts for the actual available height.
"""
assert "100dvh" in CSS, \
"style.css must use 100dvh for correct mobile viewport height (100vh hides content under address bar)"
def test_composer_touch_target_size():
"""Send button and composer inputs must have minimum 44px touch targets on mobile.
Apple HIG and Google Material guidelines both require 44px minimum touch targets.
"""
# Check that mobile CSS doesn't make the send button smaller than 44×44
# We check that there's at least a min-height definition for touch targets
assert re.search(r'(min-height|height).*44px', CSS), \
"style.css must define 44px minimum touch targets for mobile (send button, nav buttons)"
# ── Input zoom prevention ─────────────────────────────────────────────────────
def test_composer_textarea_font_size_mobile():
"""Composer textarea must have font-size >= 16px on mobile.
iOS Safari zooms the viewport when an input with font-size < 16px is focused,
which breaks the layout. The composer textarea must be >= 16px at mobile widths.
"""
# Check for 16px font-size on the textarea in a mobile breakpoint
assert re.search(r'font-size:16px', CSS), \
"Composer textarea must have font-size:16px at mobile widths to prevent iOS zoom-on-focus"
def test_touch_device_inputs_meet_zoom_threshold():
"""All input/textarea/select must clear iOS Safari's 16px zoom threshold
on touch-primary devices, not just the composer textarea (#1167).
This locks the global media-query floor so future per-element font-size
tweaks (sidebar search 13px, settings selects 12px, dialog inputs 14px,
onboarding fields 13px) cannot accidentally re-introduce auto-zoom.
"""
# The hover:none + pointer:coarse pair is the canonical touch-primary
# detection (won't match desktop with mouse, won't match touch laptops
# that report hover:hover).
pattern = re.compile(
r'@media\s*\(hover:none\)\s*and\s*\(pointer:coarse\)\s*\{[^}]*'
r'input\s*,\s*textarea\s*,\s*select\s*\{[^}]*'
r'font-size:\s*max\(\s*16px',
re.DOTALL,
)
assert pattern.search(CSS), (
"style.css must contain a (hover:none) and (pointer:coarse) media "
"query that bumps input/textarea/select to font-size:max(16px,…) "
"so iOS Safari does not auto-zoom on focus (#1167)"
)
# ── Sidebar tabs on mobile ───────────────────────────────────────────────────
def test_profiles_sidebar_tab_present():
"""Sidebar tab strip must include Profiles."""
assert 'class="nav-tab" data-panel="profiles"' in HTML, \
"Sidebar nav must have a Profiles tab"
def test_mobile_bottom_nav_removed():
"""The old fixed mobile bottom nav should not be present anymore."""
assert "mobile-bottom-nav" not in HTML, \
"mobile-bottom-nav markup should be removed from index.html"
assert "mobile-bottom-nav" not in CSS, \
"mobile-bottom-nav CSS should be removed from style.css"
# ── Mobile Enter key inserts newline (PR #315, fixes #269) ───────────────────
def test_mobile_enter_newline_condition_present():
"""boot.js keydown handler must detect touch-primary devices via pointer:coarse."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "pointer:coarse" in boot_js, \
"boot.js must use pointer:coarse media query for mobile Enter detection"
def test_mobile_enter_newline_uses_match_media():
"""boot.js must call matchMedia for pointer detection, not a hardcoded flag."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "matchMedia('(pointer:coarse)')" in boot_js or 'matchMedia("(pointer:coarse)")' in boot_js, \
"boot.js must use matchMedia('(pointer:coarse)') for mobile detection"
def test_mobile_enter_newline_only_overrides_enter_default():
"""Mobile newline override must only apply when _sendKey is the default 'enter'."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
# The _mobileDefault check must gate on _sendKey==='enter' so ctrl+enter users aren't affected
assert "_sendKey===" in boot_js and "'enter'" in boot_js, \
"Mobile newline fallback must check window._sendKey==='enter' to avoid overriding user preference"
def test_mobile_enter_does_not_affect_desktop_logic():
"""The mobile Enter override must not alter the existing else branch for desktop users."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
# The else branch (desktop, sends on Enter without Shift) must still be present
assert "if(!e.shiftKey){e.preventDefault();send();" in boot_js, \
"Desktop Enter-to-send logic (else branch) must still be present in boot.js"