v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255) When base_url is configured in config.yaml, resolve_model_provider() now trusts the configured provider/base_url entirely and skips the slash-based OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom being silently routed to OpenRouter, resulting in 401 errors. Fixes #230 * test: mobile layout regression suite — 14 tests for every QA run (#254) Adds tests/test_mobile_layout.py with 14 static regression tests that run on every QA pass to catch mobile layout breakage before it reaches prod. Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, 100dvh, 44px touch targets, 16px font-size on textarea. * feat: /skills slash command lists and filters available Hermes skills (#257) Adds /skills [query] command to commands.js. Fetches from /api/skills, groups by category (alphabetically sorted), displays as a formatted assistant message. Optional query filters by name, description, or category. i18n keys added for en, de, zh, zh-Hant. 1 regression test added. Fixes #248 * feat: shared app dialogs replace native confirm()/prompt() calls (#251) Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt call sites across panels.js, sessions.js, ui.js, workspace.js. Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore, ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant. 5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of native dialog calls. Extracted from PR #242. * fix: Android Chrome mobile — workspace panel close + profile dropdown (#256) Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when toggling the right workspace panel. New closeMobileFiles() helper closes the panel with correct overlay state tracking. Overlay onclick calls both closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x) added to workspace panel header. Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at max-width:900px, escaping the overflow-x:auto stacking context that was clipping it on Android Chrome. Fix applied during review: closeMobileSidebar() now checks if the right panel is still open before hiding the overlay, preventing the overlay from disappearing when only the sidebar is closed. Fixes #247 Fixes #246 * feat: session ⋯ action dropdown replaces per-row buttons (#252) Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash) with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full keyboard (Escape), click-outside, scroll, and resize-reposition handling. Position:fixed prevents sidebar clipping. 5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete (danger style). Each with icon and descriptive subtitle. Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons asserts the new trigger and menu functions exist, old per-row classes are gone. Extracted from PR #242. * docs: v0.47.0 release notes, bump version, update test counts (645) --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
177
tests/test_mobile_layout.py
Normal file
177
tests/test_mobile_layout.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
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 bottom nav and overlay markup present
|
||||
- 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 "right:-320px" in CSS or "right: -320px" in CSS, \
|
||||
"rightpanel must start off-screen (right:-320px) on mobile"
|
||||
|
||||
|
||||
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_mobile_bottom_nav_present():
|
||||
"""Mobile bottom navigation bar must be present."""
|
||||
assert "mobile-bottom-nav" in HTML or "mobile-nav-btn" in HTML, \
|
||||
"Mobile bottom nav (.mobile-bottom-nav or .mobile-nav-btn) missing from index.html"
|
||||
assert "mobile-bottom-nav" in CSS, \
|
||||
".mobile-bottom-nav CSS rule missing from style.css"
|
||||
|
||||
|
||||
def test_mobile_files_button_present():
|
||||
"""Mobile files toggle button (#btnMobileFiles) must be in HTML and CSS."""
|
||||
assert 'id="btnMobileFiles"' in HTML, \
|
||||
"#btnMobileFiles missing from index.html"
|
||||
assert "mobile-files-btn" in CSS, \
|
||||
".mobile-files-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."""
|
||||
# Either a dedicated mobile close button or the X button that closes the panel
|
||||
has_close = (
|
||||
'onclick="toggleMobileFiles()"' in HTML or
|
||||
'toggleMobileFiles' in HTML
|
||||
)
|
||||
assert has_close, \
|
||||
"toggleMobileFiles() 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"
|
||||
|
||||
|
||||
# ── 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_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"
|
||||
@@ -378,3 +378,46 @@ def test_custom_endpoint_uses_model_config_api_key_for_model_discovery(monkeypat
|
||||
groups = {g['provider']: [m['id'] for m in g['models']] for g in result['groups']}
|
||||
assert 'Custom' in groups
|
||||
assert 'gpt-5.2' in groups['Custom']
|
||||
|
||||
|
||||
# -- Issue #230: custom provider with slash model name -----------------------
|
||||
|
||||
def test_custom_endpoint_slash_model_routes_to_custom_not_openrouter():
|
||||
"""Regression test for #230.
|
||||
|
||||
When provider=custom (or any non-openrouter provider) and base_url is set,
|
||||
a model name containing a slash (e.g. google/gemma-4-26b-a4b) must NOT be
|
||||
rerouted to OpenRouter -- it should stay on the configured custom endpoint.
|
||||
"""
|
||||
# --- custom provider with slash model name should NOT go to openrouter ---
|
||||
model, provider, base_url = _resolve_with_config(
|
||||
'google/gemma-4-26b-a4b',
|
||||
provider='custom',
|
||||
base_url='http://127.0.0.1:1234/v1',
|
||||
default='google/gemma-4-26b-a4b',
|
||||
)
|
||||
assert provider.startswith('custom'), (
|
||||
"Expected provider starting with 'custom', got '{}'. "
|
||||
"Slash in model name should NOT trigger OpenRouter rerouting when base_url is set.".format(provider)
|
||||
)
|
||||
assert base_url == 'http://127.0.0.1:1234/v1', (
|
||||
"Expected base_url 'http://127.0.0.1:1234/v1', got '{}'.".format(base_url)
|
||||
)
|
||||
assert model == 'google/gemma-4-26b-a4b', (
|
||||
"Model name should be preserved as-is, got '{}'.".format(model)
|
||||
)
|
||||
|
||||
# --- openrouter with slash model name MUST still route to openrouter -----
|
||||
model_or, provider_or, _ = _resolve_with_config(
|
||||
'google/gemma-4-26b-a4b',
|
||||
provider='openrouter',
|
||||
base_url='https://openrouter.ai/api/v1',
|
||||
default='google/gemma-4-26b-a4b',
|
||||
)
|
||||
assert provider_or == 'openrouter', (
|
||||
"Expected provider 'openrouter', got '{}'. "
|
||||
"Slash model via openrouter provider must still resolve to openrouter.".format(provider_or)
|
||||
)
|
||||
assert model_or == 'google/gemma-4-26b-a4b', (
|
||||
"Model name should be preserved for openrouter, got '{}'.".format(model_or)
|
||||
)
|
||||
|
||||
@@ -472,3 +472,24 @@ def test_upload_error_has_no_trace_field():
|
||||
assert "trace" not in body, \
|
||||
"Upload errors must not leak stack traces to clients"
|
||||
assert "error" in body, "Error responses must include an 'error' key"
|
||||
|
||||
|
||||
# ── #248: /skills slash command ───────────────────────────────────────────────
|
||||
|
||||
def test_skills_slash_command_defined():
|
||||
"""#248: /skills command must be registered in COMMANDS and implemented.
|
||||
Verifies the command entry, function definition, and i18n key are all present.
|
||||
"""
|
||||
src = (REPO_ROOT / "static/commands.js").read_text()
|
||||
|
||||
# 1. 'skills' must appear in the COMMANDS array definition
|
||||
assert "name:'skills'" in src or 'name:"skills"' in src, \
|
||||
"COMMANDS array must include an entry with name:'skills'"
|
||||
|
||||
# 2. cmdSkills function must be defined
|
||||
assert "function cmdSkills" in src, \
|
||||
"cmdSkills function must be defined in commands.js"
|
||||
|
||||
# 3. i18n key cmd_skills must be referenced (wired to COMMANDS entry)
|
||||
assert "cmd_skills" in src, \
|
||||
"cmd_skills i18n key must be referenced in commands.js"
|
||||
|
||||
@@ -700,10 +700,20 @@ def test_style_css_active_session_uses_gold(cleanup_test_sessions):
|
||||
"Active session gold color (#e8a030) not found in style.css"
|
||||
|
||||
|
||||
def test_sessions_js_active_skips_project_border(cleanup_test_sessions):
|
||||
"""sessions.js must not override active session border-left with project color."""
|
||||
def test_sessions_js_uses_action_menu_not_per_row_buttons(cleanup_test_sessions):
|
||||
"""sessions.js must use the single ⋯ action menu instead of per-row buttons.
|
||||
|
||||
The per-row button overlay was replaced with a single ⋯ trigger that opens a
|
||||
positioned dropdown (session-action-menu). This removes the borderLeftColor
|
||||
project colour override that the old code applied, which was the original
|
||||
concern this test guarded. The new design uses a dot indicator for project
|
||||
membership instead.
|
||||
"""
|
||||
src = REPO_ROOT / "static" / "sessions.js"
|
||||
code = src.read_text()
|
||||
# The fix: only set borderLeftColor if NOT the active session
|
||||
assert "isActive" in code, "isActive check not found in sessions.js"
|
||||
assert "borderLeftColor" in code, "borderLeftColor not found in sessions.js"
|
||||
assert "session-actions-trigger" in code, "session-actions-trigger not found in sessions.js"
|
||||
assert "_openSessionActionMenu" in code, "_openSessionActionMenu not found in sessions.js"
|
||||
assert "closeSessionActionMenu" in code, "closeSessionActionMenu not found in sessions.js"
|
||||
# The old per-row buttons must not be present (they were replaced by the menu)
|
||||
assert "act-pin" not in code, "old act-pin per-row button still in sessions.js"
|
||||
assert "act-archive" not in code, "old act-archive per-row button still in sessions.js"
|
||||
|
||||
59
tests/test_sprint33.py
Normal file
59
tests/test_sprint33.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Sprint 33 Tests: Shared app dialogs replace native confirm/prompt usage.
|
||||
|
||||
These tests verify the static assets expose the reusable confirm/input modal
|
||||
and that browser-native confirm/prompt calls are no longer used in the Web UI.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
|
||||
|
||||
def read(path):
|
||||
return (REPO / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_index_has_shared_app_dialog_markup():
|
||||
html = read("static/index.html")
|
||||
assert 'id="appDialogOverlay"' in html
|
||||
assert 'id="appDialog"' in html
|
||||
assert 'id="appDialogTitle"' in html
|
||||
assert 'id="appDialogDesc"' in html
|
||||
assert 'id="appDialogInput"' in html
|
||||
assert 'id="appDialogCancel"' in html
|
||||
assert 'id="appDialogConfirm"' in html
|
||||
|
||||
|
||||
def test_app_dialog_css_rules_exist():
|
||||
css = read("static/style.css")
|
||||
for selector in (
|
||||
".app-dialog-overlay",
|
||||
".app-dialog",
|
||||
".app-dialog-input",
|
||||
".app-dialog-actions",
|
||||
".app-dialog-btn.confirm",
|
||||
".app-dialog-btn.confirm.danger",
|
||||
):
|
||||
assert selector in css, f"missing CSS selector: {selector}"
|
||||
|
||||
|
||||
def test_ui_js_exposes_shared_dialog_helpers():
|
||||
src = read("static/ui.js")
|
||||
assert "function showConfirmDialog(opts={})" in src
|
||||
assert "function showPromptDialog(opts={})" in src
|
||||
assert "document.addEventListener('keydown'" in src
|
||||
|
||||
|
||||
def test_no_native_confirm_calls_remain_in_static_js():
|
||||
for path in (REPO / "static").glob("*.js"):
|
||||
src = path.read_text(encoding="utf-8")
|
||||
assert not re.search(r"\bconfirm\s*\(", src), f"native confirm() remains in {path.name}"
|
||||
|
||||
|
||||
def test_no_native_prompt_calls_remain_in_static_js():
|
||||
for path in (REPO / "static").glob("*.js"):
|
||||
src = path.read_text(encoding="utf-8")
|
||||
assert not re.search(r"\bprompt\s*\(", src), f"native prompt() remains in {path.name}"
|
||||
Reference in New Issue
Block a user