1714 lines
86 KiB
Python
1714 lines
86 KiB
Python
"""
|
||
Mobile layout regression tests — run on every QA pass.
|
||
|
||
These tests check that the CSS and HTML structure required for correct
|
||
mobile rendering (375px–640px 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:
|
||
./scripts/test.sh tests/test_mobile_layout.py -v
|
||
"""
|
||
|
||
import pathlib
|
||
import re
|
||
from html.parser import HTMLParser
|
||
|
||
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")
|
||
|
||
|
||
def _max_width_media_blocks(width_px):
|
||
"""Return all @media(max-width:Npx) bodies using balanced braces."""
|
||
pattern = re.compile(rf'@media\s*\(\s*max-width\s*:\s*{width_px}px\s*\)\s*\{{')
|
||
blocks = []
|
||
for match in pattern.finditer(CSS):
|
||
open_brace = match.end() - 1
|
||
depth = 0
|
||
for idx in range(open_brace, len(CSS)):
|
||
if CSS[idx] == "{":
|
||
depth += 1
|
||
elif CSS[idx] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
blocks.append(CSS[open_brace + 1:idx])
|
||
break
|
||
return blocks
|
||
|
||
|
||
def _composer_phone_media_block():
|
||
for block in _max_width_media_blocks(640):
|
||
if ".composer-footer" in block:
|
||
return block
|
||
raise AssertionError("Missing composer rules in @media(max-width:640px)")
|
||
|
||
|
||
def _strip_css_comments(css):
|
||
return re.sub(r'/\*.*?\*/', '', css, flags=re.DOTALL)
|
||
|
||
|
||
def _rule_body(css, selector):
|
||
for match in re.finditer(r'([^{}]+)\{([^{}]*)\}', _strip_css_comments(css)):
|
||
selectors = {part.strip() for part in match.group(1).split(",")}
|
||
if selector in selectors:
|
||
return match.group(2)
|
||
raise AssertionError(f"Missing CSS rule for {selector}")
|
||
|
||
|
||
def _declarations(rule_body):
|
||
declarations = {}
|
||
for item in rule_body.split(";"):
|
||
if ":" not in item:
|
||
continue
|
||
prop, value = item.split(":", 1)
|
||
declarations[prop.strip()] = re.sub(r'\s+', ' ', value.strip())
|
||
return declarations
|
||
|
||
|
||
def _js_function_body(source, function_name):
|
||
"""Return a JavaScript function body using balanced braces."""
|
||
match = re.search(rf'function\s+{re.escape(function_name)}\s*\([^)]*\)\s*\{{', source)
|
||
if not match:
|
||
raise AssertionError(f"Missing JavaScript function {function_name}()")
|
||
open_brace = match.end() - 1
|
||
depth = 0
|
||
for idx in range(open_brace, len(source)):
|
||
if source[idx] == "{":
|
||
depth += 1
|
||
elif source[idx] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return source[open_brace + 1:idx]
|
||
raise AssertionError(f"Could not parse JavaScript function {function_name}()")
|
||
|
||
|
||
def _optional_declarations(css, selector):
|
||
try:
|
||
return _declarations(_rule_body(css, selector))
|
||
except AssertionError:
|
||
return {}
|
||
|
||
|
||
def _js_function_body(src, name):
|
||
signature = f"function {name}("
|
||
start = src.find(signature)
|
||
if start == -1:
|
||
raise AssertionError(f"Missing JS function {name}()")
|
||
brace = src.find("{", start)
|
||
if brace == -1:
|
||
raise AssertionError(f"Missing function body for {name}()")
|
||
depth = 0
|
||
for idx in range(brace, len(src)):
|
||
char = src[idx]
|
||
if char == "{":
|
||
depth += 1
|
||
elif char == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
return src[brace + 1:idx]
|
||
raise AssertionError(f"Unterminated JS function {name}()")
|
||
|
||
|
||
def _display_hidden(declarations):
|
||
return declarations.get("display", "").replace(" ", "") in {"none", "none!important"}
|
||
|
||
|
||
def _display_inline_flex(declarations):
|
||
return declarations.get("display", "").replace(" ", "") in {"inline-flex", "inline-flex!important"}
|
||
|
||
|
||
class _ComposerLeftDropdownParser(HTMLParser):
|
||
_VOID_TAGS = {
|
||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||
"link", "meta", "param", "source", "track", "wbr",
|
||
}
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.stack = []
|
||
self.violations = []
|
||
|
||
def handle_starttag(self, tag, attrs):
|
||
self._handle_element(tag, attrs, push=True)
|
||
|
||
def handle_startendtag(self, tag, attrs):
|
||
self._handle_element(tag, attrs, push=False)
|
||
|
||
def handle_endtag(self, tag):
|
||
tag = tag.lower()
|
||
for idx in range(len(self.stack) - 1, -1, -1):
|
||
if self.stack[idx]["tag"] == tag:
|
||
del self.stack[idx:]
|
||
break
|
||
|
||
def _handle_element(self, tag, attrs, push):
|
||
tag = tag.lower()
|
||
attrs = dict(attrs)
|
||
classes = set((attrs.get("class") or "").split())
|
||
element_id = attrs.get("id") or ""
|
||
inside_composer_left = any(
|
||
"composer-left" in item["classes"] for item in self.stack
|
||
)
|
||
is_dropdown = (
|
||
element_id.endswith("Dropdown") or
|
||
any("dropdown" in class_name for class_name in classes)
|
||
)
|
||
if inside_composer_left and is_dropdown:
|
||
label = f"#{element_id}" if element_id else "." + ".".join(sorted(classes))
|
||
self.violations.append(label)
|
||
if push and tag not in self._VOID_TAGS:
|
||
self.stack.append({"tag": tag, "classes": classes})
|
||
|
||
|
||
# ── 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_settings_system_version_controls_wrap_on_phone_widths():
|
||
"""Settings -> System version badges must wrap instead of overflowing phones."""
|
||
mobile_css = "\n".join(_max_width_media_blocks(768))
|
||
assert ".settings-section-head" in mobile_css, (
|
||
"Settings section header needs a mobile rule so title and update controls stack."
|
||
)
|
||
assert "flex-direction:column" in mobile_css.replace(" ", ""), (
|
||
"Settings section header should stack vertically on mobile."
|
||
)
|
||
assert "#checkUpdatesBlock" in mobile_css, (
|
||
"Settings update/version controls need a mobile rule."
|
||
)
|
||
assert "flex-wrap:wrap" in mobile_css.replace(" ", ""), (
|
||
"Version badges and Check now button must wrap instead of overflowing."
|
||
)
|
||
assert "width:100%" in mobile_css.replace(" ", ""), (
|
||
"The update controls row should take the available mobile width."
|
||
)
|
||
assert ".settings-version-badge" in mobile_css and "white-space:nowrap" in mobile_css.replace(" ", ""), (
|
||
"Individual version badges should stay intact while the group wraps."
|
||
)
|
||
|
||
|
||
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\{[^}]*padding-top:\s*var\(--app-titlebar-safe-top\)',
|
||
rightpanel_block, re.DOTALL), \
|
||
"mobile rightpanel should reserve the same PWA top inset as the titlebar"
|
||
assert re.search(r'\.rightpanel\{[^}]*box-sizing:\s*border-box',
|
||
rightpanel_block, re.DOTALL), \
|
||
"mobile rightpanel safe-area padding must stay inside its fixed height"
|
||
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"
|
||
assert re.search(r'\.rightpanel\s+\.panel-header\{[^}]*row-gap:\s*8px', rightpanel_block), \
|
||
"mobile workspace header should keep comfortable row spacing"
|
||
|
||
|
||
def test_mobile_sidebar_drawer_uses_transform_instead_of_left():
|
||
"""Mobile sidebar drawer open/close must animate with transform not left offsets."""
|
||
mobile_640 = "\n".join(_max_width_media_blocks(640))
|
||
assert mobile_640, "Missing @media(max-width:640px) block in style.css"
|
||
|
||
sidebar_rule = _declarations(_rule_body(mobile_640, ".sidebar"))
|
||
sidebar_open_rule = _declarations(_rule_body(mobile_640, ".sidebar.mobile-open"))
|
||
|
||
assert sidebar_rule.get("left") == "0", \
|
||
"Mobile .sidebar should keep left:0 in the drawer rules"
|
||
assert sidebar_rule.get("transform") == "translateX(-100%)", \
|
||
"Closed mobile .sidebar should use transform:translateX(-100%)"
|
||
assert sidebar_rule.get("transition") == "transform .25s ease", \
|
||
"Mobile .sidebar should transition transform for drawer animation"
|
||
assert sidebar_rule.get("will-change") == "transform", \
|
||
"Mobile .sidebar should promote the transform layer before drawer animation"
|
||
assert sidebar_open_rule.get("transform") == "translateX(0)", \
|
||
"Open mobile .sidebar should use transform:translateX(0)"
|
||
|
||
|
||
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 test_workspace_panel_boot_restore_is_desktop_only():
|
||
"""Persisted workspace panels should not auto-cover compact/mobile launch."""
|
||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||
assert "if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';" in boot_js
|
||
assert "if(S.session&&S.session.workspace&&panelPref&&!_isCompactWorkspaceViewport()){" in boot_js
|
||
assert "if(_freshPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';" in boot_js
|
||
|
||
|
||
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_fit_stage_classes():
|
||
"""Composer controls should progressively compact based on measured overflow."""
|
||
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"
|
||
assert "@container composer-footer (max-width: 700px)" not in CSS
|
||
assert "@container composer-footer (max-width: 520px)" not in CSS
|
||
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||
fit_body = _js_function_body(ui_js, "_fitComposerFooter")
|
||
assert "scrollWidth" in fit_body and "clientWidth" in fit_body, \
|
||
"_fitComposerFooter() should measure actual left-cluster overflow"
|
||
assert "cf-icons" in fit_body and "cf-burger" in fit_body, \
|
||
"_fitComposerFooter() should toggle the compact stage classes"
|
||
|
||
for selector in (
|
||
".composer-footer.cf-icons .composer-profile-label",
|
||
".composer-footer.cf-icons .composer-profile-chevron",
|
||
".composer-footer.cf-icons #composerProfileLabel",
|
||
".composer-footer.cf-icons .composer-workspace-label",
|
||
".composer-footer.cf-icons #composerWorkspaceLabel",
|
||
".composer-footer.cf-icons .composer-model-label",
|
||
".composer-footer.cf-icons #composerModelLabel",
|
||
".composer-footer.cf-icons .composer-profile-chip",
|
||
".composer-footer.cf-icons .composer-model-chip",
|
||
".composer-footer.cf-icons .composer-divider",
|
||
):
|
||
assert selector in CSS, f"{selector} should be present in the .cf-icons rules"
|
||
assert ".composer-footer.cf-icons .composer-profile-chip{box-sizing:border-box;width:44px" in CSS, \
|
||
".cf-icons should collapse the profile chip to an icon-sized control"
|
||
assert ".composer-footer.cf-icons .composer-workspace-chip{display:none!important" not in CSS.replace(" ", ""), \
|
||
".cf-icons should keep the workspace switch visible rather than blanking it"
|
||
|
||
for selector in (
|
||
".composer-footer.cf-burger .composer-workspace-group",
|
||
".composer-footer.cf-burger .composer-workspace-files-btn",
|
||
".composer-footer.cf-burger .composer-workspace-chip",
|
||
".composer-footer.cf-burger .composer-left > .composer-model-wrap",
|
||
".composer-footer.cf-burger .provider-quota-chip",
|
||
".composer-footer.cf-burger .composer-left > .composer-reasoning-wrap",
|
||
".composer-footer.cf-burger .composer-left > .composer-toolsets-wrap",
|
||
".composer-footer.cf-burger .composer-mobile-config-btn",
|
||
".composer-footer.cf-burger .composer-mobile-config-panel.open",
|
||
".composer-footer.cf-burger .composer-mobile-context-action",
|
||
".composer-footer.cf-burger .ctx-indicator-wrap",
|
||
):
|
||
assert selector in CSS, f"{selector} should be present in the .cf-burger rules"
|
||
assert ".composer-footer.cf-burger .composer-workspace-chip{display:none!important".replace(" ", "") in CSS.replace(" ", ""), \
|
||
".cf-burger must remove the blank workspace switch slot"
|
||
assert ".composer-footer.cf-burger .provider-quota-chip" in CSS and ".composer-footer.cf-burger .composer-left > .composer-toolsets-wrap{display:none!important" in CSS, \
|
||
".cf-burger must fold the inline quota chip into the shared config menu"
|
||
assert ".composer-footer.cf-burger .composer-mobile-config-btn{box-sizing:border-box;position:relative;display:inline-flex!important" in CSS, \
|
||
".cf-burger must expose the config button even on wider viewports"
|
||
|
||
# Regression intent:
|
||
# - this measured 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 CSS, \
|
||
"composer-footer compact stages should be state-agnostic"
|
||
|
||
|
||
def test_composer_icon_stage_workspace_switch_does_not_become_blank_chip():
|
||
"""The icon stage may hide workspace text, but keeps a visible switch affordance."""
|
||
css_ns = CSS.replace(" ", "")
|
||
assert ".composer-footer.cf-icons .composer-workspace-label" in CSS
|
||
assert ".composer-footer.cf-icons #composerWorkspaceLabel" in CSS
|
||
assert ".composer-footer.cf-icons .composer-workspace-chip{display:none!important" not in css_ns, \
|
||
".cf-icons must not hide the visible workspace switch chip"
|
||
assert ".composer-footer.cf-icons .composer-workspace-chevron{display:none" not in css_ns, \
|
||
".cf-icons must not leave the visible workspace switch chip without its chevron"
|
||
|
||
|
||
def test_composer_compact_switch_is_not_viewport_only():
|
||
"""Compact controls should be content-triggered, not bound to viewport width alone."""
|
||
assert ".composer-footer.cf-icons" in CSS, \
|
||
"Icon-chip stage should be expressed as the .cf-icons class"
|
||
assert ".composer-footer.cf-burger" in CSS, \
|
||
"Hamburger stage should be expressed as the .cf-burger class"
|
||
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||
assert "_fitComposerFooter" in ui_js and "scrollWidth" in ui_js, \
|
||
"Composer compacting should be driven by measured overflow"
|
||
assert "ResizeObserver" in ui_js and "MutationObserver" in ui_js, \
|
||
"Composer fitting should rerun when footer size or chip content changes"
|
||
assert "composer-footer (max-width: 700px)" not in CSS
|
||
assert "composer-footer (max-width: 520px)" not in CSS
|
||
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():
|
||
"""Legacy mobile overlay stays hidden because the phone sidebar is full-screen."""
|
||
assert 'id="mobileOverlay"' in HTML, \
|
||
"#mobileOverlay element missing from index.html"
|
||
assert "mobile-overlay" in CSS, \
|
||
".mobile-overlay CSS rule missing from style.css"
|
||
mobile_css = "\n".join(_max_width_media_blocks(640))
|
||
assert re.search(r'\.mobile-overlay\.visible\{[^}]*display:\s*none', mobile_css), (
|
||
"Full-screen mobile sidebar must not dim the PWA status/safe-area with a backdrop"
|
||
)
|
||
|
||
|
||
def test_mobile_sidebar_edge_guard_claims_body_edge_only():
|
||
"""A narrow body-only edge guard helps iOS hand left swipes to WebUI."""
|
||
assert 'id="pwaSidebarEdgeGuard"' in HTML, (
|
||
"mobile sidebar edge guard missing from index.html"
|
||
)
|
||
assert ".pwa-sidebar-edge-guard{display:none;}" in CSS.replace(" ", ""), (
|
||
"edge guard should be hidden outside the phone layout"
|
||
)
|
||
mobile_css = "\n".join(_max_width_media_blocks(640))
|
||
guard = _declarations(_rule_body(mobile_css, ".pwa-sidebar-edge-guard"))
|
||
assert guard.get("display") == "block"
|
||
assert guard.get("position") == "fixed"
|
||
assert guard.get("left") == "0"
|
||
assert guard.get("top") == "calc(52px + var(--app-titlebar-safe-top))", (
|
||
"edge guard should start below the PWA titlebar so it does not block hamburger"
|
||
)
|
||
assert guard.get("width") == "24px"
|
||
assert guard.get("pointer-events") == "none", (
|
||
"edge guard must be pointer-events:none so taps/vertical scrolls starting in the "
|
||
"strip fall through to the .messages scroller; the edge-swipe gesture is handled by "
|
||
"window-level capture listeners, not by the guard intercepting hit-testing (#4660 review)"
|
||
)
|
||
assert guard.get("z-index") == "198", (
|
||
"edge guard should sit below the full-screen sidebar but above the page body"
|
||
)
|
||
|
||
|
||
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_keeps_panel_navigation_available():
|
||
"""Phone breakpoint must keep panel navigation available inside the drawer."""
|
||
mobile_css = "\n".join(_max_width_media_blocks(640))
|
||
assert mobile_css, "Missing @media(max-width:640px) block in style.css"
|
||
assert ".sidebar-nav" in mobile_css, \
|
||
"Phone panel navigation must remain available in the hamburger drawer"
|
||
|
||
|
||
def test_mobile_sidebar_opens_as_full_screen_surface_with_panel_rail():
|
||
"""Phone sidebar should open full-screen while keeping the panel rail visible."""
|
||
mobile_css = "\n".join(_max_width_media_blocks(640))
|
||
assert re.search(r'\.app-titlebar-hamburger,\s*\.app-titlebar-spacer\{[^}]*display:\s*flex', mobile_css), (
|
||
"Phone titlebar hamburger must stay visible"
|
||
)
|
||
assert not re.search(r'\.rail\{[^}]*display:\s*flex[^}]*position:\s*fixed', mobile_css), (
|
||
"Phone must not use a persistent left rail that consumes chat width"
|
||
)
|
||
sidebar_rule = _declarations(_rule_body(mobile_css, ".sidebar"))
|
||
sidebar_open_rule = _declarations(_rule_body(mobile_css, ".sidebar.mobile-open"))
|
||
assert sidebar_rule.get("left") == "0", (
|
||
"Mobile sidebar should stay at left:0 and move with transform"
|
||
)
|
||
assert sidebar_rule.get("width") == "100vw", (
|
||
"Mobile sidebar should fill the viewport like a session page"
|
||
)
|
||
assert sidebar_rule.get("max-width") == "none", (
|
||
"Mobile sidebar must not retain desktop/drawer max width"
|
||
)
|
||
assert sidebar_rule.get("transform") == "translateX(-100%)", (
|
||
"Closed mobile sidebar should sit fully offscreen"
|
||
)
|
||
assert sidebar_rule.get("transition") == "transform .25s ease", (
|
||
"Mobile sidebar should animate with transform"
|
||
)
|
||
assert sidebar_rule.get("will-change") == "transform", (
|
||
"Mobile sidebar should promote the transform layer before opening"
|
||
)
|
||
assert sidebar_open_rule.get("transform") == "translateX(0)", (
|
||
"Open mobile sidebar should slide the full session page into view"
|
||
)
|
||
assert not re.search(r'\.sidebar\s+\.sidebar-nav\{[^}]*display:\s*none', mobile_css), (
|
||
"Full-screen mobile sidebar should keep the panel rail visible"
|
||
)
|
||
assert re.search(r'\.sidebar-nav\{[^}]*position:\s*absolute', mobile_css), (
|
||
"Phone drawer panel tabs should be laid out as an internal side strip"
|
||
)
|
||
assert re.search(r'\.sidebar-nav\{[^}]*width:\s*52px', mobile_css), (
|
||
"Phone drawer panel strip should reserve stable space for 44px targets"
|
||
)
|
||
assert re.search(r'\.sidebar-nav\{[^}]*flex-direction:\s*column', mobile_css), (
|
||
"Phone drawer panel tabs must be vertical, not horizontal"
|
||
)
|
||
assert re.search(r'\.sidebar-nav\s+\.nav-tab\{[^}]*min-width:\s*44px', mobile_css), (
|
||
"Phone drawer panel tabs must be at least 44px wide"
|
||
)
|
||
assert re.search(r'\.sidebar-nav\s+\.nav-tab\{[^}]*min-height:\s*44px', mobile_css), (
|
||
"Phone drawer panel tabs must be at least 44px tall"
|
||
)
|
||
assert re.search(r'\.sidebar\s+\.panel-view\{[^}]*height:\s*100%[^}]*margin-left:\s*52px', mobile_css), (
|
||
"Full-screen mobile sidebar content should start beside the vertical icon strip"
|
||
)
|
||
assert re.search(r'\.mobile-sidebar-close\{[^}]*display:\s*inline-flex\s*!important', mobile_css), (
|
||
"Full-screen mobile session page needs a visible close affordance"
|
||
)
|
||
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*min-width:\s*44px', mobile_css), (
|
||
"Sidebar panel buttons must min-width:44px on phone"
|
||
)
|
||
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*min-height:\s*44px', mobile_css), (
|
||
"Sidebar panel buttons must min-height:44px on phone"
|
||
)
|
||
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*width:\s*auto', mobile_css), (
|
||
"Sidebar panel buttons must override their base 24px width on phone"
|
||
)
|
||
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*height:\s*auto', mobile_css), (
|
||
"Sidebar panel buttons must override their base 24px height on phone"
|
||
)
|
||
assert not re.search(r'(?<!sidebar\s)\.panel-icon-btn\{[^}]*min-width:\s*44px', mobile_css), (
|
||
"Workspace-panel header buttons must not inherit sidebar-only 44px sizing"
|
||
)
|
||
|
||
|
||
def test_compact_titlebar_keeps_hamburger_available():
|
||
"""Compact app chrome must keep the titlebar menu reachable."""
|
||
compact_css = "\n".join(_max_width_media_blocks(900))
|
||
assert re.search(r'\.app-titlebar-hamburger,\s*\.app-titlebar-spacer\{[^}]*display:\s*flex', compact_css), (
|
||
"Compact titlebar should expose the hamburger before true phone width"
|
||
)
|
||
assert ".rightpanel{display:none}" in compact_css.replace(" ", ""), (
|
||
"The compact titlebar breakpoint should match the hidden workspace-panel breakpoint"
|
||
)
|
||
|
||
|
||
def test_mobile_rail_click_opens_full_screen_panel_drawer():
|
||
"""Rail clicks on phone should keep the full-screen drawer open for panel switching."""
|
||
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||
assert "opts.fromRailClick" in panels_js, (
|
||
"switchPanel() should distinguish rail clicks from programmatic switches"
|
||
)
|
||
assert "!_isDesktopWidth()" in panels_js, (
|
||
"Mobile rail-click sidebar handling must be limited to mobile widths"
|
||
)
|
||
switch_panel = panels_js.split("async function switchPanel", 1)[1].split("\n}\n\n// ── Cron panel", 1)[0]
|
||
assert "if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth())" in switch_panel, (
|
||
"Missing mobile rail-click sidebar handler"
|
||
)
|
||
assert "sidebar.classList.remove('mobile-session-page')" in switch_panel, (
|
||
"Phone rail clicks should leave the full-screen session page mode"
|
||
)
|
||
assert "sidebar.classList.add('mobile-panel-drawer', 'mobile-open')" in switch_panel, (
|
||
"Phone rail clicks should open the panel drawer mode"
|
||
)
|
||
rail_handler_idx = switch_panel.index("if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth())")
|
||
close_sidebar_idx = switch_panel.find("closeMobileSidebar();", rail_handler_idx)
|
||
assert close_sidebar_idx == -1, "Phone rail clicks should keep the full-screen drawer open for panel switching"
|
||
assert "overlay.classList.add('visible')" not in switch_panel[rail_handler_idx:], (
|
||
"Full-screen phone rail clicks should not show a backdrop that dims the PWA status bar"
|
||
)
|
||
|
||
|
||
def test_mobile_switch_panel_non_chat_opens_sidebar():
|
||
"""mobileSwitchPanel() non-chat path should open the full-screen panel drawer."""
|
||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||
fn_body = _js_function_body(boot_js, "mobileSwitchPanel")
|
||
assert "if(name==='chat')" in fn_body, (
|
||
"mobileSwitchPanel must close sidebar only on chat target"
|
||
)
|
||
assert "closeMobileSidebar();" in fn_body, (
|
||
"mobileSwitchPanel should still close sidebar on chat"
|
||
)
|
||
assert "sidebar.classList.add('mobile-panel-drawer','mobile-open')" in fn_body, (
|
||
"mobileSwitchPanel non-chat branch should keep adding mobile-open"
|
||
)
|
||
assert "overlay.classList.add('visible')" not in fn_body, (
|
||
"mobileSwitchPanel non-chat branch should not show a backdrop over the PWA status bar"
|
||
)
|
||
|
||
|
||
def test_pwa_edge_swipe_opens_current_mobile_panel():
|
||
"""Left-edge swipe should open the current sidebar panel, matching hamburger."""
|
||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||
body = _js_function_body(boot_js, "_openMobileSidebarFromGesture")
|
||
assert "switchPanel('chat',{bypassSettingsGuard:true})" not in body, (
|
||
"Left-edge gesture should not force Chat; it should preserve the active panel"
|
||
)
|
||
assert "sidebar.classList.remove('mobile-session-page')" in body, (
|
||
"Left-edge gesture should leave the rail-hiding session page mode"
|
||
)
|
||
assert "sidebar.classList.add('mobile-panel-drawer')" in body, (
|
||
"Left-edge gesture should open the full-screen sidebar with panel rail"
|
||
)
|
||
assert "sidebar.classList.add('mobile-open')" in body
|
||
assert "overlay.classList.add('visible')" not in body
|
||
assert "_syncMobileSidebarPanelFromMainView()" in body, (
|
||
"Left-edge gesture should sync sidebar panel from the visible detail view before opening"
|
||
)
|
||
|
||
|
||
def test_mobile_sidebar_open_syncs_panel_from_visible_detail_view():
|
||
"""The mobile sidebar should not fall back to Chat when a module detail is visible."""
|
||
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||
assert "const MAIN_VIEW_PANELS =" in panels_js
|
||
main_view_panels = panels_js.split("const MAIN_VIEW_PANELS =", 1)[1].split("];", 1)[0]
|
||
assert "'todos'" not in main_view_panels, (
|
||
"Todos is a sidebar-only panel; adding showing-todos makes main-view sync ambiguous"
|
||
)
|
||
assert "const MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS = { plugin: 'settings' }" in panels_js
|
||
for panel_id in [
|
||
"panelSettings",
|
||
"panelSkills",
|
||
"panelMemory",
|
||
"panelTasks",
|
||
"panelKanban",
|
||
"panelWorkspaces",
|
||
"panelProfiles",
|
||
"panelTodos",
|
||
"panelInsights",
|
||
"panelLogs",
|
||
]:
|
||
assert f'id="{panel_id}"' in HTML, f"{panel_id} should exist for mobile sidebar sync"
|
||
assert 'id="panelPlugin"' not in HTML, (
|
||
"Plugin pages are main-view only and should sync back to the Settings sidebar list"
|
||
)
|
||
assert "MAIN_VIEW_PANELS.forEach" in panels_js
|
||
panel_from_view = _js_function_body(panels_js, "_panelFromCurrentMainView")
|
||
assert "mainEl.classList.contains('showing-'+panel)" in panel_from_view
|
||
assert "MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS[panel]||panel" in panel_from_view
|
||
assert "$('panel'+_currentPanel.charAt(0).toUpperCase()+_currentPanel.slice(1))" in panel_from_view
|
||
assert "return 'chat'" in panel_from_view
|
||
sync_body = _js_function_body(panels_js, "_syncMobileSidebarPanelFromMainView")
|
||
assert "if(!panelEl)return _currentPanel||'chat'" in sync_body
|
||
assert "_currentPanel=panel" in sync_body
|
||
assert "document.querySelectorAll('[data-panel]')" in sync_body
|
||
assert "document.querySelectorAll('.panel-view')" in sync_body
|
||
assert "showing-x-" in sync_body, (
|
||
"Mobile sidebar sync must recognize an active extension panel instead of treating it as Chat"
|
||
)
|
||
assert "data-panel-token" in sync_body, (
|
||
"Mobile sidebar sync must restore the extension's matching sidebar view"
|
||
)
|
||
assert "const extensionPanel=`x-${extensionToken}`" in sync_body, (
|
||
"Extension nav buttons use x- tokens and must regain their active state on mobile"
|
||
)
|
||
assert "_currentPanel=extensionPanel" not in sync_body, (
|
||
"Extension tokens are not host panels and must not corrupt switchPanel's native state"
|
||
)
|
||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||
toggle_body = _js_function_body(boot_js, "toggleMobileSidebar")
|
||
assert "_syncMobileSidebarPanelFromMainView()" in toggle_body, (
|
||
"Hamburger-opened mobile sidebar should also sync from the visible detail view"
|
||
)
|
||
|
||
|
||
def test_mobile_skill_selection_closes_sidebar_after_detail_load():
|
||
"""Selecting a skill on phone should reveal the newly-loaded detail view."""
|
||
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||
helper = _js_function_body(panels_js, "_closeMobileSidebarAfterPanelSelection")
|
||
assert "if(typeof closeMobileSidebar!=='function')return" in helper
|
||
assert "if(typeof _isDesktopWidth==='function'&&_isDesktopWidth())return" in helper
|
||
assert "closeMobileSidebar()" in helper
|
||
body = _js_function_body(panels_js, "openSkill")
|
||
assert "_renderSkillDetail(name, data.content || '', data.linked_files || {})" in body
|
||
assert "_closeMobileSidebarAfterPanelSelection()" in body
|
||
|
||
|
||
def test_mobile_sidebar_detail_selections_share_close_helper():
|
||
"""Sidebar list commits should consistently reveal their main detail on phone."""
|
||
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||
for name in [
|
||
"openCronDetail",
|
||
"loadKanbanTask",
|
||
"openMemorySection",
|
||
"openWorkspaceDetail",
|
||
"openProfileDetail",
|
||
]:
|
||
body = _js_function_body(panels_js, name)
|
||
assert "_closeMobileSidebarAfterPanelSelection()" in body, (
|
||
f"{name} should close the full-screen mobile sidebar after opening detail"
|
||
)
|
||
settings_body = _js_function_body(panels_js, "switchSettingsSection")
|
||
assert "if(opts&&opts.fromSidebarItem)_closeMobileSidebarAfterPanelSelection()" in settings_body
|
||
assert "switchSettingsSection(_currentSettingsSection);" in panels_js
|
||
assert "mobile-panel-drawer', 'mobile-open'" in panels_js, (
|
||
"Opening Settings from the rail should keep the mobile drawer available"
|
||
)
|
||
for section in ["conversation", "appearance", "preferences", "providers", "plugins", "extensions", "system", "help"]:
|
||
assert f"switchSettingsSection('{section}',{{fromSidebarItem:true}})" in HTML, (
|
||
f"Settings sidebar item {section} should close after selecting its detail"
|
||
)
|
||
|
||
|
||
def test_mobile_session_page_close_button_is_mobile_scoped():
|
||
"""The full-screen session page close button should not appear on desktop."""
|
||
assert 'class="panel-head-btn mobile-sidebar-close' in HTML, (
|
||
"Sidebar needs a close button for the full-screen mobile session page"
|
||
)
|
||
assert 'onclick="closeMobileSidebar()"' in HTML, (
|
||
"Mobile session page close button should close the sidebar"
|
||
)
|
||
assert "mobile-sidebar-close{display:none" in CSS.replace(" ", ""), (
|
||
"Mobile sidebar close button should be hidden by default"
|
||
)
|
||
mobile_css = "\n".join(_max_width_media_blocks(640))
|
||
assert ".mobile-sidebar-close{display:inline-flex!important" in mobile_css.replace(" ", ""), (
|
||
"Mobile sidebar close button should be visible in phone layout"
|
||
)
|
||
|
||
|
||
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_composer_dropdowns_are_not_nested_inside_left_control_row():
|
||
"""Composer dropdown surfaces should remain outside .composer-left.
|
||
|
||
The left row can wrap/scroll on phones; dropdowns need to be siblings so
|
||
that overflow rules on the control row cannot clip them.
|
||
"""
|
||
parser = _ComposerLeftDropdownParser()
|
||
parser.feed(HTML)
|
||
assert not parser.violations, (
|
||
"Composer dropdowns must not be nested inside .composer-left: "
|
||
+ ", ".join(parser.violations)
|
||
)
|
||
|
||
|
||
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"
|
||
assert "function closeMobileWorkspacePanelFromChat(e)" in boot_js
|
||
assert "$('mainChat')?.addEventListener('pointerdown', closeMobileWorkspacePanelFromChat);" in boot_js
|
||
|
||
|
||
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.
|
||
# The handler grew comments after #1432 (in-flight guard refactor), so use a
|
||
# generous window to cover the full handler body.
|
||
idx = boot_js.find("$('btnNewChat').onclick")
|
||
handler_block = boot_js[idx:idx+1500]
|
||
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)+24])
|
||
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"
|
||
)
|
||
|
||
|
||
def test_mobile_titlebar_has_new_conversation_button():
|
||
"""Mobile titlebar shows the New Conversation action and keeps it next to reload."""
|
||
header_match = re.search(
|
||
r'<header class="app-titlebar"[^>]*>(?P<body>.*?)</header>',
|
||
HTML,
|
||
re.S,
|
||
)
|
||
assert header_match, "app-titlebar header block missing"
|
||
header_html = header_match.group("body")
|
||
|
||
idx_btn = header_html.find('id="btnTitlebarNewChat"')
|
||
idx_reload = header_html.find('id="btnReload"')
|
||
idx_spacer = header_html.find('class="app-titlebar-spacer"')
|
||
|
||
assert idx_btn != -1, "titlebar mobile new chat button should exist"
|
||
assert idx_reload != -1, "titlebar reload button should remain present"
|
||
assert idx_spacer != -1, "titlebar spacer should remain present"
|
||
assert idx_spacer < idx_btn < idx_reload, (
|
||
"titlebar new chat button must sit left of the reload button on mobile"
|
||
)
|
||
assert "btnTitlebarNewChat" in header_html
|
||
assert "data-i18n-title=\"new_conversation\"" in header_html
|
||
assert "data-i18n-aria-label=\"new_conversation\"" in header_html
|
||
assert "aria-label=\"New conversation\"" in header_html
|
||
assert "title=\"New conversation\"" in header_html
|
||
assert "$('btnNewChat').click()" in header_html
|
||
|
||
|
||
def test_titlebar_new_chat_button_mobile_visibility_css():
|
||
"""Keep the titlebar new-chat control mobile-only and reuse reload button styling."""
|
||
base_rule = _declarations(_rule_body(CSS, ".app-titlebar-new-chat"))
|
||
assert base_rule.get("display") == "none", "app-titlebar new chat button must be hidden by default"
|
||
mobile_blocks = "".join(_max_width_media_blocks(640))
|
||
mobile_rule = _declarations(_rule_body(mobile_blocks, ".app-titlebar-new-chat"))
|
||
assert mobile_rule.get("display") == "inline-flex", (
|
||
"app-titlebar new chat button must be visible in mobile layout rules"
|
||
)
|
||
desktop_css = re.sub(
|
||
r"@media\(max-width:640px\).*",
|
||
"",
|
||
CSS,
|
||
flags=re.S,
|
||
)
|
||
assert ".app-titlebar-new-chat{display:inline-flex;}" not in desktop_css, (
|
||
"titlebar new chat button must not be exposed by desktop PWA/fullscreen rules"
|
||
)
|
||
|
||
|
||
def test_titlebar_reload_button_visibility_css_contract():
|
||
"""Keep reload hidden by default, keep standalone visibility, and expose it on mobile width."""
|
||
base_rule = _declarations(_rule_body(CSS, ".app-titlebar-reload"))
|
||
assert _display_hidden(base_rule), "app-titlebar reload button should stay hidden by default"
|
||
|
||
standalone_mode_pattern = re.compile(
|
||
r"@media\s*\(\s*display-mode:\s*standalone\s*\)\s*,\s*"
|
||
r"\(\s*display-mode:\s*fullscreen\s*\)\s*\{"
|
||
)
|
||
standalone_rule_body = None
|
||
for match in standalone_mode_pattern.finditer(CSS):
|
||
open_brace = match.end() - 1
|
||
depth = 0
|
||
for idx in range(open_brace, len(CSS)):
|
||
if CSS[idx] == "{":
|
||
depth += 1
|
||
elif CSS[idx] == "}":
|
||
depth -= 1
|
||
if depth == 0:
|
||
block = CSS[open_brace + 1 : idx]
|
||
if ".app-titlebar-reload" in block:
|
||
standalone_rule_body = block
|
||
break
|
||
if standalone_rule_body is not None:
|
||
break
|
||
assert standalone_rule_body is not None, (
|
||
"standalone/fullscreen media block for titlebar reload could not be parsed"
|
||
)
|
||
standalone_rule = _declarations(_rule_body(standalone_rule_body, ".app-titlebar-reload"))
|
||
assert standalone_rule.get("display") == "inline-flex", (
|
||
"titlebar reload should remain inline-flex in standalone/fullscreen"
|
||
)
|
||
|
||
mobile_blocks = "".join(_max_width_media_blocks(640))
|
||
mobile_rule = _declarations(_rule_body(mobile_blocks, ".app-titlebar-reload"))
|
||
assert _display_inline_flex(mobile_rule), (
|
||
"app-titlebar reload button should be visible in phone-width titlebar rules"
|
||
)
|
||
|
||
|
||
# ── 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_viewport_disables_page_zoom_for_native_pwa_shell():
|
||
"""Installed PWA launches should not rubber-band into browser-style page zoom."""
|
||
assert 'name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"' in HTML
|
||
|
||
|
||
def test_pwa_safe_area_top_stays_scoped_to_installed_modes():
|
||
"""The PWA shell should not opt into cover-mode geometry for every browser surface."""
|
||
assert 'viewport-fit=cover' not in HTML
|
||
assert 'apple-mobile-web-app-status-bar-style" content="black-translucent"' in HTML
|
||
assert "@media (display-mode: standalone), (display-mode: fullscreen)" in CSS
|
||
assert "--app-titlebar-safe-top:env(safe-area-inset-top" in CSS
|
||
assert "--app-safe-bottom:" not in CSS
|
||
|
||
|
||
def test_titlebar_safe_area_top_uses_scoped_variable():
|
||
"""The titlebar must use the safe-area variable instead of direct env()."""
|
||
# Match the GLOBAL `.app-titlebar{...}` rule, not skin-scoped variants like
|
||
# `:root.dark[data-skin="neon"] .app-titlebar{...}` (#3164) which can appear
|
||
# earlier in the file. Require the selector to start the line (optionally
|
||
# indented) with no `[data-skin=` scope prefix.
|
||
m = re.search(r'(?m)^\s*\.app-titlebar\{(?P<body>[^}]*)\}', CSS)
|
||
assert m, ".app-titlebar rule missing from style.css"
|
||
rule = m.group("body")
|
||
assert "padding-top:var(--app-titlebar-safe-top)" in rule, (
|
||
".app-titlebar must use the scoped safe-area variable for top padding"
|
||
)
|
||
assert "padding-top:env(safe-area-inset-top" not in rule, (
|
||
".app-titlebar must not apply env(safe-area-inset-top) directly in "
|
||
"the base browser/webview layout"
|
||
)
|
||
|
||
|
||
def test_safe_area_variables_available_for_pwa_shell():
|
||
"""Top safe-area variable should be available to installed PWA shell CSS."""
|
||
assert "--app-titlebar-safe-top:0px" in CSS, (
|
||
"titlebar top safe-area variable must default to 0px"
|
||
)
|
||
assert "--app-titlebar-safe-top:env(safe-area-inset-top" in CSS, (
|
||
"CSS must expose env(safe-area-inset-top) through --app-titlebar-safe-top"
|
||
)
|
||
assert "padding:8px 10px calc(12px + var(--keyboard-bottom-inset, 0px))!important" in CSS, (
|
||
"Phone composer should keep the proven pre-cover-mode padding contract"
|
||
)
|
||
|
||
|
||
def test_pwa_startup_classes_have_native_shell_affordances():
|
||
"""The JS-startup fallback classes should mirror browser display-mode CSS.
|
||
|
||
iOS and embedded webviews do not always evaluate display-mode media queries
|
||
the same way as Chromium. pwa-startup.js adds classes early, so CSS should
|
||
provide the same native-feel affordances through those classes.
|
||
"""
|
||
assert ".pwa-standalone" in CSS
|
||
assert ".pwa-standalone .app-titlebar-reload" in CSS
|
||
assert "overscroll-behavior:none" in CSS
|
||
assert ".pwa-offline .app-titlebar::after" in CSS
|
||
assert "pwa-title-resume" in CSS
|
||
|
||
|
||
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)"
|
||
|
||
|
||
def test_mobile_composer_footer_stays_single_row():
|
||
"""Phone composer controls should stay in one footer row."""
|
||
mobile_css = _composer_phone_media_block()
|
||
|
||
footer = _declarations(_rule_body(mobile_css, ".composer-footer"))
|
||
assert footer.get("flex-wrap") == "nowrap", \
|
||
"mobile composer footer must stay visually single-row"
|
||
|
||
left = _declarations(_rule_body(mobile_css, ".composer-left"))
|
||
assert left.get("flex") != "1 1 100%", \
|
||
"mobile composer-left controls must not take their own full-width row"
|
||
assert left.get("width") != "100%", \
|
||
"mobile composer-left controls must not span a separate row"
|
||
assert left.get("flex-wrap") == "nowrap", \
|
||
"mobile composer-left controls must remain in one row"
|
||
|
||
right = _declarations(_rule_body(mobile_css, ".composer-right"))
|
||
assert right.get("flex") != "1 1 100%", \
|
||
"mobile composer-right actions must not take their own full-width row"
|
||
assert right.get("width") != "100%", \
|
||
"mobile composer-right actions must not span a separate row"
|
||
assert right.get("justify-content") == "flex-end", \
|
||
"mobile composer-right actions must stay end-aligned"
|
||
|
||
|
||
def test_mobile_composer_left_scrolls_horizontally_without_wrapping():
|
||
"""If many primary controls are visible, the single control row should scroll."""
|
||
left = _declarations(_rule_body(_composer_phone_media_block(), ".composer-left"))
|
||
assert left.get("overflow-x") == "auto", \
|
||
"mobile composer-left must allow horizontal overflow in the single row"
|
||
assert left.get("overflow-y") == "hidden", \
|
||
"mobile composer-left must not create a second vertical control row"
|
||
assert left.get("max-height") == "none", \
|
||
"mobile composer-left must not preserve the old bounded two-row height"
|
||
|
||
|
||
def test_mobile_composer_left_children_do_not_shrink_into_each_other():
|
||
"""Phone composer controls must scroll or compact, never shrink/overlap siblings."""
|
||
mobile_css = _composer_phone_media_block()
|
||
left = _declarations(_rule_body(mobile_css, ".composer-left"))
|
||
assert left.get("gap") == "10px", \
|
||
"mobile composer-left needs explicit spacing between 44px touch targets"
|
||
|
||
children = _declarations(_rule_body(mobile_css, ".composer-left > *"))
|
||
assert children.get("flex-shrink") == "0", \
|
||
"mobile composer-left children must not shrink and visually overlap"
|
||
|
||
for selector in (
|
||
".composer-profile-wrap",
|
||
".composer-ws-wrap",
|
||
):
|
||
declarations = _declarations(_rule_body(mobile_css, selector))
|
||
assert declarations.get("flex") == "0 0 auto", \
|
||
f"{selector} must opt out of flex shrinking on phones"
|
||
|
||
workspace_group = _declarations(_rule_body(mobile_css, ".composer-workspace-group"))
|
||
assert workspace_group.get("flex") == "0 0 44px", \
|
||
".composer-workspace-group must reserve exactly one 44px slot on phones"
|
||
|
||
|
||
def test_legacy_320px_composer_tightens_spacing_without_shrinking_targets():
|
||
"""At 320px, keep 44px controls but use smaller gutters so config stays visible."""
|
||
narrow_blocks = [block for block in _max_width_media_blocks(340) if ".composer-left" in block]
|
||
assert narrow_blocks, "Missing 320px/legacy-phone composer spacing override"
|
||
narrow_css = narrow_blocks[0]
|
||
|
||
footer = _declarations(_rule_body(narrow_css, ".composer-footer"))
|
||
left = _declarations(_rule_body(narrow_css, ".composer-left"))
|
||
wrap = _declarations(_rule_body(narrow_css, ".composer-wrap"))
|
||
|
||
assert footer.get("gap") == "4px", \
|
||
"320px footer should tighten only the gutter between left controls and send"
|
||
assert left.get("gap") == "2px", \
|
||
"320px left controls need compact gutters to fit config before the fixed send button"
|
||
assert wrap.get("padding-left") == "8px!important", \
|
||
"320px composer should reclaim a little side padding without shrinking touch targets"
|
||
assert ".send-btn{width:44px;height:44px;" in _composer_phone_media_block(), \
|
||
"narrow spacing override must not shrink the 44px send button"
|
||
assert ".composer-mobile-config-btn{box-sizing:border-box;position:relative;display:inline-flex!important;width:44px;height:44px" in _composer_phone_media_block(), \
|
||
"narrow spacing override must not shrink the 44px mobile config button"
|
||
|
||
|
||
def test_mobile_composer_workspace_switch_does_not_leave_empty_icon_slot():
|
||
"""The phone footer should keep only the useful workspace files button inline."""
|
||
mobile_css = _composer_phone_media_block()
|
||
workspace_group = _declarations(_rule_body(mobile_css, ".composer-workspace-group"))
|
||
workspace_files = _declarations(_rule_body(mobile_css, ".composer-workspace-files-btn"))
|
||
workspace_chip = _declarations(_rule_body(mobile_css, ".composer-workspace-chip"))
|
||
|
||
assert workspace_group.get("max-width") == "44px", \
|
||
"workspace group should collapse to one 44px files button on phones"
|
||
assert workspace_group.get("width") == "44px", \
|
||
"workspace group should have an exact border-box phone width"
|
||
assert workspace_group.get("box-sizing") == "border-box", \
|
||
"workspace group must use border-box for its 44px phone slot"
|
||
assert workspace_group.get("border") == "none", \
|
||
"workspace files shortcut should not keep the desktop pill/circle border on phones"
|
||
assert workspace_group.get("background") == "transparent", \
|
||
"workspace files shortcut should visually match other transparent mobile icon buttons"
|
||
assert workspace_files.get("max-width") == "44px", \
|
||
"workspace files button should be the only visible workspace footer target on phones"
|
||
assert workspace_files.get("width") == "44px", \
|
||
"workspace files button should have an exact border-box phone width"
|
||
assert workspace_files.get("box-sizing") == "border-box", \
|
||
"workspace files button must not grow beyond its 44px phone slot due to padding"
|
||
assert workspace_chip.get("display") == "none!important", \
|
||
"workspace switch chip has no visible mobile label/icon and must not consume a blank slot"
|
||
|
||
|
||
def test_mobile_composer_overflow_control_present():
|
||
"""Phone composer must expose a compact overflow/settings control."""
|
||
assert 'id="composerMobileConfigBtn"' in HTML, \
|
||
"#composerMobileConfigBtn missing from index.html"
|
||
assert 'id="composerMobileConfigPanel"' in HTML, \
|
||
"#composerMobileConfigPanel missing from index.html"
|
||
assert 'aria-controls="composerMobileConfigPanel"' in HTML, \
|
||
"mobile config button must be associated with its panel"
|
||
left_start = HTML.index('<div class="composer-left">')
|
||
left_end = HTML.index('<div class="composer-right">', left_start)
|
||
assert 'id="composerMobileConfigPanel"' not in HTML[left_start:left_end], \
|
||
"mobile overflow panel must not be nested inside .composer-left where overflow can clip it"
|
||
assert "function toggleMobileComposerConfig()" in (REPO / "static" / "ui.js").read_text(encoding="utf-8"), \
|
||
"toggleMobileComposerConfig() must be defined in static/ui.js"
|
||
|
||
mobile_css = _composer_phone_media_block()
|
||
btn = _declarations(_rule_body(mobile_css, ".composer-mobile-config-btn"))
|
||
panel = _declarations(_rule_body(CSS, ".composer-mobile-config-panel"))
|
||
panel_open = _declarations(_rule_body(mobile_css, ".composer-mobile-config-panel.open"))
|
||
assert btn.get("display") == "inline-flex!important", \
|
||
"mobile overflow button must be visible at phone width"
|
||
assert panel.get("display") == "none", \
|
||
"mobile overflow panel should be closed by default"
|
||
assert panel.get("position") == "absolute", \
|
||
"mobile overflow panel should open above the composer footer"
|
||
assert panel.get("flex-wrap") == "wrap", \
|
||
"mobile overflow panel must allow the context details row to span below primary actions"
|
||
assert panel_open.get("display") == "flex", \
|
||
"mobile overflow panel must become visible when opened"
|
||
|
||
|
||
def test_model_and_reasoning_controls_live_in_mobile_overflow_panel():
|
||
"""Model and reasoning controls must remain reachable through the phone overflow."""
|
||
panel_start = HTML.index('id="composerMobileConfigPanel"')
|
||
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
|
||
panel_html = HTML[panel_start:panel_end]
|
||
assert 'id="composerMobileModelAction"' in panel_html, \
|
||
"mobile model action must be inside the overflow panel"
|
||
assert 'id="composerMobileQuotaAction"' in panel_html, \
|
||
"mobile quota action must be inside the overflow panel"
|
||
assert 'id="composerMobileReasoningAction"' in panel_html, \
|
||
"mobile reasoning action must be inside the overflow panel"
|
||
assert 'onclick="toggleModelDropdown()"' in panel_html, \
|
||
"mobile model action must reuse the existing model dropdown"
|
||
assert 'onclick="toggleReasoningDropdown()"' in panel_html, \
|
||
"mobile reasoning action must reuse the existing reasoning dropdown"
|
||
assert 'id="composerMobileModelLabel"' in panel_html, \
|
||
"mobile model action must expose the selected model label"
|
||
assert 'id="composerMobileQuotaLabel"' in panel_html, \
|
||
"mobile quota action must expose the selected quota label"
|
||
assert 'id="composerMobileReasoningLabel"' in panel_html, \
|
||
"mobile reasoning action must expose the selected reasoning label"
|
||
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||
assert "composerMobileModelAction" in ui_js, \
|
||
"model dropdown positioning/click handling must know the mobile model action"
|
||
assert "composerMobileQuotaAction" in ui_js, \
|
||
"quota sync must know the mobile quota action"
|
||
assert "composerMobileReasoningAction" in ui_js, \
|
||
"reasoning dropdown positioning/click handling must know the mobile reasoning action"
|
||
|
||
mobile_css = _composer_phone_media_block()
|
||
assert ".composer-left > .composer-model-wrap" in mobile_css, \
|
||
"phone width must hide the footer model chip behind overflow"
|
||
assert ".composer-left > .composer-reasoning-wrap" in mobile_css, \
|
||
"phone width must hide the footer reasoning chip behind overflow"
|
||
assert ".composer-mobile-config-action" in mobile_css, \
|
||
"mobile overflow panel must size the model/reasoning actions"
|
||
|
||
|
||
def test_mobile_overflow_panel_quota_order_matches_desktop_sequence():
|
||
"""The mobile overflow panel should keep the same shared control order as desktop."""
|
||
panel_start = HTML.index('id="composerMobileConfigPanel"')
|
||
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
|
||
panel_html = HTML[panel_start:panel_end]
|
||
workspace_idx = panel_html.index('id="composerMobileWorkspaceAction"')
|
||
model_idx = panel_html.index('id="composerMobileModelAction"')
|
||
quota_idx = panel_html.index('id="composerMobileQuotaAction"')
|
||
reasoning_idx = panel_html.index('id="composerMobileReasoningAction"')
|
||
context_idx = panel_html.index('id="composerMobileContextAction"')
|
||
assert workspace_idx < model_idx < quota_idx < reasoning_idx < context_idx, \
|
||
"mobile control order should mirror the desktop/shared control sequence"
|
||
|
||
|
||
def test_model_and_reasoning_dropdowns_use_mobile_panel_anchors():
|
||
"""Model/reasoning dropdowns must anchor to mobile actions while the overflow is open."""
|
||
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||
model_start = ui_js.index("function _positionModelDropdown()")
|
||
model_end = ui_js.index("function renderModelDropdown()", model_start)
|
||
model_body = ui_js[model_start:model_end]
|
||
for expected in (
|
||
"composerMobileConfigPanel",
|
||
"composerMobileModelAction",
|
||
"classList.contains('open')",
|
||
):
|
||
assert expected in model_body, \
|
||
f"_positionModelDropdown must keep mobile-panel anchor logic ({expected})"
|
||
|
||
reasoning_start = ui_js.index("function _positionReasoningDropdown()")
|
||
reasoning_end = ui_js.index("function closeReasoningDropdown()", reasoning_start)
|
||
reasoning_body = ui_js[reasoning_start:reasoning_end]
|
||
for expected in (
|
||
"composerMobileConfigPanel",
|
||
"composerMobileReasoningAction",
|
||
"classList.contains('open')",
|
||
):
|
||
assert expected in reasoning_body, \
|
||
f"_positionReasoningDropdown must keep mobile-panel anchor logic ({expected})"
|
||
|
||
|
||
def test_context_details_live_in_mobile_overflow_panel():
|
||
"""Context details should be reachable in overflow without adding a composer slot."""
|
||
panel_start = HTML.index('id="composerMobileConfigPanel"')
|
||
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
|
||
panel_html = HTML[panel_start:panel_end]
|
||
for element_id in (
|
||
"composerMobileContextAction",
|
||
"composerMobileContextUsage",
|
||
"composerMobileContextTokens",
|
||
"composerMobileContextThreshold",
|
||
"composerMobileContextCost",
|
||
"composerMobileCtxCompressBtn",
|
||
):
|
||
assert f'id="{element_id}"' in panel_html, \
|
||
f"#{element_id} must be inside the mobile overflow panel"
|
||
|
||
right_start = HTML.index('<div class="composer-right">', HTML.index('<div class="composer-footer">'))
|
||
right_end = HTML.index('<div class="composer-mobile-config-panel"', right_start)
|
||
right_html = HTML[right_start:right_end]
|
||
assert 'id="composerMobileContextAction"' not in right_html, \
|
||
"mobile context details must not live in composer-right as another phone slot"
|
||
assert 'id="composerMobileCtxBadge"' not in right_html, \
|
||
"mobile context badge must stay on the config button, not composer-right"
|
||
|
||
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||
sync_start = ui_js.index("function _syncMobileCtxDisplay(state)")
|
||
sync_end = ui_js.index("// ── Touch support", sync_start)
|
||
sync_body = ui_js[sync_start:sync_end]
|
||
for expected in (
|
||
"DEFAULT_CTX=128*1024",
|
||
"hasExplicitCtx",
|
||
"hasPromptTok",
|
||
"rawPct",
|
||
"overflowed",
|
||
"composerMobileContextUsage",
|
||
"composerMobileContextTokens",
|
||
"composerMobileCtxCompressBtn",
|
||
):
|
||
assert expected in sync_body, \
|
||
f"_syncCtxIndicator must preserve upstream context logic while updating mobile context UI ({expected})"
|
||
|
||
mobile_css = _composer_phone_media_block()
|
||
ctx_wrap = _declarations(_rule_body(mobile_css, ".ctx-indicator-wrap"))
|
||
assert ctx_wrap.get("display") == "none!important", \
|
||
"standalone context indicator must remain hidden from the phone composer row"
|
||
|
||
context_row = _declarations(_rule_body(CSS, ".composer-mobile-context-action"))
|
||
assert context_row.get("flex") == "1 0 100%", \
|
||
"mobile context details should span the overflow panel instead of crowding the action row"
|
||
context_button = _declarations(_rule_body(CSS, ".composer-mobile-context-compress"))
|
||
assert context_button.get("width") == "auto", \
|
||
"mobile compress affordance should be compact inside the context row"
|
||
|
||
|
||
def test_context_indicator_click_opens_shared_mobile_config_menu():
|
||
"""The desktop context ring should open the same menu used by phone mode."""
|
||
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||
assert "function openMobileComposerConfig()" in ui_js, \
|
||
"mobile config open path should be reusable outside the phone button"
|
||
assert "function openComposerContextMenu(e)" in ui_js, \
|
||
"context indicator needs a named click path into the shared config menu"
|
||
|
||
context_menu_body = _js_function_body(ui_js, "openComposerContextMenu")
|
||
for expected in (
|
||
"e.preventDefault()",
|
||
"e.stopPropagation()",
|
||
"ctxTooltip",
|
||
"openMobileComposerConfig()",
|
||
):
|
||
assert expected in context_menu_body, \
|
||
f"context click should open the shared menu without leaving tooltip state behind ({expected})"
|
||
|
||
assert "btn.addEventListener('click',openComposerContextMenu)" in ui_js, \
|
||
"context indicator click must open the shared composer config menu"
|
||
|
||
panel_open = _declarations(_rule_body(CSS, ".composer-mobile-config-panel.open"))
|
||
assert panel_open.get("display") == "flex", \
|
||
"the shared composer config panel must be displayable when opened outside phone CSS"
|
||
|
||
|
||
def test_workspace_control_lives_in_mobile_overflow_panel():
|
||
"""Workspace switching must stay reachable even when the inline switch chip is hidden."""
|
||
panel_start = HTML.index('id="composerMobileConfigPanel"')
|
||
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
|
||
panel_html = HTML[panel_start:panel_end]
|
||
assert 'id="composerMobileWorkspaceAction"' in panel_html, \
|
||
"mobile workspace action must be inside the overflow panel"
|
||
assert 'onclick="toggleComposerWsDropdown()"' in panel_html, \
|
||
"mobile workspace action must reuse the existing workspace dropdown"
|
||
assert 'id="composerMobileWorkspaceLabel"' in panel_html, \
|
||
"mobile workspace action must expose the current workspace label"
|
||
|
||
mobile_css = _composer_phone_media_block()
|
||
workspace_chip = _declarations(_rule_body(mobile_css, ".composer-workspace-chip"))
|
||
assert workspace_chip.get("display") == "none!important", \
|
||
"inline workspace switch chip must remain hidden on phones"
|
||
|
||
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||
pos_start = panels_js.index("function _positionComposerWsDropdown()")
|
||
pos_end = panels_js.index("function _positionProfileDropdown()", pos_start)
|
||
position_body = panels_js[pos_start:pos_end]
|
||
assert "composerMobileWorkspaceAction" in position_body, \
|
||
"workspace dropdown positioning must know the mobile workspace action"
|
||
assert "composerMobileConfigPanel" in position_body, \
|
||
"workspace dropdown positioning must anchor to the mobile panel action while open"
|
||
assert "anchor to #composerMobileWorkspaceAction" in position_body, \
|
||
"workspace dropdown positioning should document the mobile-panel anchor choice"
|
||
|
||
toggle_start = panels_js.index("function toggleComposerWsDropdown()")
|
||
toggle_end = panels_js.index("function closeWsDropdown()", toggle_start)
|
||
toggle_body = panels_js[toggle_start:toggle_end]
|
||
assert "usingMobileAction" in toggle_body and "chip.disabled" in toggle_body, \
|
||
"mobile workspace action must bypass only the hidden/disabled desktop chip guard"
|
||
assert "!e.target.closest('#composerMobileWorkspaceAction')" in panels_js, \
|
||
"workspace dropdown click-away handling must include the mobile workspace action"
|
||
|
||
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||
assert "e.target.closest('#composerWsDropdown')" in ui_js, \
|
||
"mobile overflow click-away handling must allow interaction with the workspace dropdown"
|
||
|
||
|
||
def test_mobile_config_panel_escape_closes_panel_and_dropdowns():
|
||
"""Escape should close mobile overflow state without touching desktop-only dropdowns."""
|
||
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||
keydown_start = ui_js.index("document.addEventListener('keydown',function(e){", ui_js.index("function toggleMobileComposerConfig()"))
|
||
keydown_end = ui_js.index("\n});", keydown_start)
|
||
keydown_body = ui_js[keydown_start:keydown_end]
|
||
assert "e.key!=='Escape'" in keydown_body, \
|
||
"mobile config Escape handler must only handle Escape"
|
||
assert "composerMobileConfigPanel" in keydown_body, \
|
||
"mobile config Escape handler must look up the mobile config panel"
|
||
assert "classList.contains('open')" in keydown_body, \
|
||
"mobile config Escape handler must be gated on the open mobile panel"
|
||
for expected in (
|
||
"closeMobileComposerConfig()",
|
||
"closeWsDropdown",
|
||
"closeModelDropdown()",
|
||
"closeReasoningDropdown()",
|
||
):
|
||
assert expected in keydown_body, \
|
||
f"mobile config Escape handler must close related state ({expected})"
|
||
|
||
|
||
def test_reasoning_chip_updates_desktop_and_mobile_controls():
|
||
"""Reasoning chip sync should keep both footer and mobile overflow labels current."""
|
||
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||
chip_start = ui_js.index("function _applyReasoningChip(eff)")
|
||
chip_end = ui_js.index("function fetchReasoningChip(", chip_start)
|
||
chip_body = ui_js[chip_start:chip_end]
|
||
for expected in (
|
||
"composerReasoningWrap",
|
||
"composerMobileReasoningAction",
|
||
"composerReasoningLabel",
|
||
"composerMobileReasoningLabel",
|
||
"label.textContent=text",
|
||
"mobileLabel.textContent=text",
|
||
):
|
||
assert expected in chip_body, \
|
||
f"_applyReasoningChip must update desktop and mobile reasoning UI ({expected})"
|
||
|
||
|
||
def test_mobile_config_kickers_have_i18n_fallbacks():
|
||
"""Mobile overflow kicker labels should be localizable without losing HTML fallback text."""
|
||
panel_start = HTML.index('id="composerMobileConfigPanel"')
|
||
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
|
||
panel_html = HTML[panel_start:panel_end]
|
||
i18n_js = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||
en_start = i18n_js.index(" en: {")
|
||
en_end = i18n_js.index("\n ru: {", en_start)
|
||
english = i18n_js[en_start:en_end]
|
||
for key, label in (
|
||
("composer_mobile_workspace", "Workspace"),
|
||
("composer_mobile_model", "Model"),
|
||
("composer_mobile_quota", "Quota"),
|
||
("composer_mobile_reasoning", "Reasoning"),
|
||
("composer_mobile_context", "Context"),
|
||
):
|
||
assert f'data-i18n="{key}">{label}</span>' in panel_html, \
|
||
f"mobile panel kicker {label} must keep data-i18n and fallback text"
|
||
assert f"{key}: '{label}'" in english, \
|
||
f"English locale must define {key}"
|
||
|
||
|
||
def test_mobile_composer_primary_controls_keep_touch_friendly_sizing():
|
||
"""Visible phone composer controls and overflow controls must keep 44px targets."""
|
||
mobile_css = _composer_phone_media_block()
|
||
for selector in (
|
||
".composer-mobile-config-btn",
|
||
".composer-profile-chip",
|
||
".composer-mobile-config-action",
|
||
):
|
||
declarations = _declarations(_rule_body(mobile_css, selector))
|
||
assert declarations.get("box-sizing") == "border-box", \
|
||
f"{selector} must use border-box so padding/border cannot exceed 44px"
|
||
assert declarations.get("min-height") == "44px", \
|
||
f"{selector} must keep a 44px minimum height on phones"
|
||
if selector != ".composer-mobile-config-action":
|
||
assert declarations.get("min-width") == "44px", \
|
||
f"{selector} must keep a 44px minimum width on phones"
|
||
|
||
send = _declarations(_rule_body(mobile_css, ".send-btn"))
|
||
assert send.get("width") == "44px", ".send-btn must keep 44px width on phones"
|
||
assert send.get("height") == "44px", ".send-btn must keep 44px height on phones"
|
||
|
||
ctx_wrap = _declarations(_rule_body(mobile_css, ".ctx-indicator-wrap"))
|
||
assert ctx_wrap.get("display") == "none!important", \
|
||
"context indicator must not add a late-appearing composer-right slot on phones"
|
||
|
||
# #3062 replaced the old text badge (composerMobileCtxBadge / .composer-mobile-ctx-badge)
|
||
# with an SVG context-usage ring overlaid on the config button. The invariant is the
|
||
# same: the ring is a visual indicator hosted ON the 44px config button (whose sizing is
|
||
# asserted above), and it must not steal/shrink that touch target. The ring SVG is
|
||
# aria-hidden and uses currentColor; it carries no pointer events of its own.
|
||
assert 'id="composerMobileCtxRing"' in HTML, \
|
||
"mobile context-usage ring element must exist in the composer config button"
|
||
assert 'id="composerMobileCtxBadge"' not in HTML, \
|
||
"old text badge should be fully replaced by the ring, not left dangling"
|
||
# Locate the ring's markup and confirm it does not become an interactive/sized control
|
||
# that would compete with the config button's 44px target.
|
||
_ring_idx = HTML.find('id="composerMobileCtxRing"')
|
||
_ring_tag = HTML[HTML.rfind("<", 0, _ring_idx):HTML.find(">", _ring_idx) + 1]
|
||
assert "aria-hidden" in _ring_tag, \
|
||
"the context ring is decorative overlay — it must be aria-hidden so it doesn't steal the config button's role/touch target"
|
||
|
||
icon_btn = _declarations(_rule_body(mobile_css, ".icon-btn"))
|
||
assert icon_btn.get("min-width") == "44px", \
|
||
".icon-btn controls such as attach/mic must keep 44px minimum width on phones"
|
||
assert icon_btn.get("min-height") == "44px", \
|
||
".icon-btn controls such as attach/mic must keep 44px minimum height on phones"
|
||
|
||
if ".composer-workspace-files-btn" in mobile_css:
|
||
files_btn = _declarations(_rule_body(mobile_css, ".composer-workspace-files-btn"))
|
||
workspace_group = _declarations(_rule_body(mobile_css, ".composer-workspace-group"))
|
||
assert files_btn.get("min-width") == "44px", \
|
||
".composer-workspace-files-btn must keep a 44px minimum width on phones"
|
||
assert workspace_group.get("min-height") == "44px", \
|
||
".composer-workspace-group must preserve 44px touch height on phones"
|
||
|
||
|
||
# ── 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)"
|
||
)
|
||
|
||
|
||
def test_touch_keyboard_inset_uses_touch_primary_media_query():
|
||
"""The keyboard inset path must key off touch-primary media queries."""
|
||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||
assert "function _isTouchKeyboardViewport()" in boot_js, \
|
||
"boot.js must define a touch-keyboard viewport predicate"
|
||
assert "matchMedia('(hover:none) and (pointer:coarse)')" in boot_js or \
|
||
'matchMedia("(hover:none) and (pointer:coarse)")' in boot_js, \
|
||
"touch keyboard inset eligibility must use the hover:none + pointer:coarse media query"
|
||
assert "any-pointer:fine" in boot_js, \
|
||
"touch keyboard inset eligibility must exclude co-existing fine-pointer setups"
|
||
assert "!_hasFinePointerCoexisting()" in boot_js, \
|
||
"touch keyboard inset eligibility must skip hardware-keyboard/trackpad devices"
|
||
|
||
|
||
def test_touch_keyboard_inset_writes_and_clears_css_variable():
|
||
"""visualViewport geometry must write and clear the keyboard inset variable."""
|
||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||
assert "setProperty('--keyboard-bottom-inset'" in boot_js, \
|
||
"boot.js must write --keyboard-bottom-inset on touch keyboard viewport changes"
|
||
assert "removeProperty('--keyboard-bottom-inset')" in boot_js, \
|
||
"boot.js must clear --keyboard-bottom-inset when the inset is zero or ineligible"
|
||
assert "Math.max(0,Math.ceil(window.innerHeight-(vv.height+vv.offsetTop)))" in boot_js, \
|
||
"boot.js must compute the bottom inset from innerHeight and visualViewport geometry"
|
||
|
||
|
||
def test_touch_keyboard_inset_ignores_pinch_zoom_scale():
|
||
"""A pinch-zoomed viewport (vv.scale != 1) must not be read as keyboard
|
||
occlusion — otherwise Chromium 'force enable zoom' produces a large spurious
|
||
inset that jitters on pan (#5738 UX-gate hardening)."""
|
||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||
assert "vv.scale" in boot_js, \
|
||
"boot.js must consult visualViewport.scale before treating shrinkage as keyboard occlusion"
|
||
assert "Math.abs((vv.scale||1)-1)>0.05" in boot_js, \
|
||
"boot.js must bail out of the inset when the viewport is pinch-zoomed"
|
||
|
||
|
||
def test_touch_keyboard_inset_primes_during_visual_viewport_setup():
|
||
"""The existing visualViewport setup path must prime the inset immediately."""
|
||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||
setup_start = boot_js.index("if(window.visualViewport){")
|
||
setup_end = boot_js.index("window.visualViewport.addEventListener('resize'", setup_start)
|
||
setup_block = boot_js[setup_start:setup_end]
|
||
assert "_syncKeyboardBottomInset();" in setup_block, \
|
||
"boot.js must sync the keyboard inset once during visualViewport setup"
|
||
|
||
|
||
def test_touch_keyboard_inset_primes_on_pageshow_restore():
|
||
"""BFCache restore must resync the inset before restore work continues."""
|
||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||
pageshow_start = boot_js.index("window.addEventListener('pageshow'")
|
||
pageshow_end = boot_js.index("const _srch = document.getElementById('sessionSearch');", pageshow_start)
|
||
pageshow_block = boot_js[pageshow_start:pageshow_end]
|
||
assert "_syncKeyboardBottomInset();" in pageshow_block, \
|
||
"boot.js must sync the keyboard inset during pageshow restore"
|
||
|
||
|
||
def test_touch_keyboard_inset_applies_to_composer_padding():
|
||
"""Touch/coarse composer padding must consume the keyboard inset variable."""
|
||
css_ns = re.sub(r'\s+', '', CSS)
|
||
assert "@media(hover:none)and(pointer:coarse)" in css_ns, \
|
||
"style.css must scope the keyboard inset padding to touch-primary viewports"
|
||
assert ".composer-wrap{padding-bottom:calc(14px+var(--keyboard-bottom-inset,0px));}" in css_ns, \
|
||
"style.css must add the keyboard inset to composer bottom padding"
|
||
|
||
|
||
|
||
# ── Sidebar tabs on mobile ───────────────────────────────────────────────────
|
||
|
||
def test_profiles_sidebar_tab_present():
|
||
"""Sidebar tab strip must include Profiles."""
|
||
# Tolerate additional utility classes (e.g. `has-tooltip` from #1775).
|
||
# We just need a nav-tab classed button targeting the profiles panel.
|
||
import re
|
||
pattern = r'class="[^"]*\bnav-tab\b[^"]*"[^>]*data-panel="profiles"'
|
||
assert re.search(pattern, HTML), \
|
||
"Sidebar nav must have a nav-tab button with data-panel=\"profiles\""
|
||
|
||
|
||
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_does_not_depend_on_viewport_heuristic():
|
||
"""The viewport-shrink heuristic was unreliable on iOS/Android and must be gone."""
|
||
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||
assert "function _isVirtualKeyboardLikelyOpen()" not in boot_js, \
|
||
"the unreliable visualViewport keyboard heuristic function must be removed"
|
||
assert "&&_isVirtualKeyboardLikelyOpen()" not in boot_js, \
|
||
"the mobile Enter override must no longer call the viewport heuristic"
|
||
assert "window.innerHeight-vv.height>120" not in boot_js, \
|
||
"the viewport height-delta probe must no longer gate the mobile Enter override"
|
||
|
||
|
||
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"
|