Files
hermes-webui/tests/test_project_chip_ui.py
e7ac9f09fd Release: mobile drawer + terminal-backend cache fixes (#6921 #6933 #6671 #6175) (#6990)
* fix: include terminal backend identity in agent cache signature (#5937) (#6175)

* fix(mobile): preserve extension panels in drawer sync

* fix: close mobile sidebar drawer when opening main-view create forms

The add-space/new-profile/new-skill/new-cron forms render in the main
view, which on phones is covered by the full-screen sidebar drawer
(z-index 200). Opening one from inside the drawer only focused the
input (keyboard popped up) while the form stayed hidden underneath.

- openWorkspaceCreate / openProfileCreate / openSkillCreate /
  openCronCreate now close the drawer after rendering the form,
  mirroring their *_Detail siblings.
- move the fromRailClick drawer re-open in switchPanel() into the
  synchronous section: it used to run after await loadXxxPanel(),
  racing (and re-opening over) a drawer close issued from inside the
  drawer during the panel load window.

Verified in Playwright (390x844): drawer closes and stays closed after
plus-click, form visible with focus on the path input; desktop (1280x800)
unchanged; 83 mobile + 26 workspace tests pass.

* fix: close mobile sidebar after project quick-create plus

The per-project '+' button (_attachProjectQuickCreateButton) created the
new project conversation but never closed the mobile sidebar drawer, so on
phones the tap looked like a no-op: the session was created, just hidden
underneath the full-screen drawer (z-index 200).

Close the drawer in the success path after the sidebar repaint, mirroring
$('btnNewChat').onclick in boot.js and the #5409 close in
_openSidebarSession. Failure path keeps the drawer open so the error toast
stays visible for retry.

Adds static regression tests pinning the guarded close call and its
position after the repaint.

* test: pin quick-create drawer close to the success path (try block)

Greptile review note: the string-presence and ordering assertions alone
would still pass if the guarded closeMobileSidebar call were moved into
the later catch branch, silently reintroducing the mobile drawer bug.

Add an explicit assertion that the close sits before the success-path
catch(err), so a regression that moves it into the failure branch is
caught.

* docs(changelog): stamp batch1-p1 (#6921 #6933 #6671 #6175)

---------

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: Rodrigo Gomes da Silva <rodrigo.smscom@gmail.com>
Co-authored-by: silent-reader-cn <silent-reader-cn@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-08-13 16:41:32 +00:00

280 lines
13 KiB
Python

"""Regression tests for the project chip UI fixes (issue #1085).
Two bugs:
1. The right-click context menu opened by `_showProjectContextMenu` was styled
with `background: var(--panel)`, but `--panel` is NOT defined anywhere in
style.css. CSS falls back to `transparent` for undefined variables, so the
menu appeared see-through and the session list bled through. The fix
replaces `var(--panel)` with `var(--surface)` — the same opaque variable
used by `.session-action-menu` and other floating popovers.
2. The `.project-create-input` (used for both rename and new-project creation)
had `width: 100px` hard-coded, so the field was always exactly 100px wide
regardless of the project name being edited. Fix: bound the field with
`min-width: 40px` / `max-width: 180px` and `width: auto`, plus a
`_resizeProjectInput()` JS helper that measures the current value with a
hidden span and sets the pixel width accordingly.
These are static-source tests — CSS/JS behaviour of a popover and an input
sizer can't be exercised faithfully without a browser, but the patterns
worth pinning are the variable names, the absence of the bad ones, and the
presence of the resize helper at both call sites.
"""
import pathlib
REPO = pathlib.Path(__file__).parent.parent
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
# ── Bug 1: context menu background ────────────────────────────────────────────
class TestContextMenuBackground:
def test_panel_variable_not_defined_in_stylesheet(self):
"""`--panel` is not defined as a CSS custom property anywhere — so
any rule using `var(--panel)` falls back to `transparent`, which is
the actual root cause of the menu bleed-through. This test
documents that fact: if `--panel` is ever defined, the test will
need updating but the fix is still safer using `--surface`."""
# Match either ":root --panel:" or "--panel:" assignments; absence
# confirms the fallback-to-transparent failure mode.
assert "--panel:" not in STYLE_CSS, (
"If --panel is now defined, update this test, but the menu "
"should still use --surface for consistency with other popovers."
)
def test_context_menu_uses_surface_not_panel(self):
"""`_showProjectContextMenu` must set the menu background to
`var(--surface)`, not `var(--panel)`."""
# Locate the menu construction
idx = SESSIONS_JS.find("project-ctx-menu")
assert idx >= 0, "project-ctx-menu className not found in sessions.js"
# Look at the surrounding 800 chars where the cssText is set
window = SESSIONS_JS[idx: idx + 1200]
assert "background:var(--surface)" in window, (
"Project context menu must use background:var(--surface) for an "
"opaque surface — var(--panel) is undefined and falls back to "
"transparent."
)
assert "background:var(--panel)" not in window, (
"Project context menu still uses background:var(--panel) — "
"this CSS variable is not defined and renders transparent."
)
def test_session_action_menu_also_uses_surface_for_consistency(self):
"""Sanity check: the existing .session-action-menu (the analogous
right-click menu for session items) uses `var(--surface)` — so the
fix is consistent with the rest of the codebase."""
assert "session-action-menu" in STYLE_CSS
# Find the rule and confirm it uses --surface
idx = STYLE_CSS.find(".session-action-menu")
assert idx >= 0
rule = STYLE_CSS[idx: idx + 400]
assert "var(--surface)" in rule, (
".session-action-menu should use var(--surface) — kept here as "
"the canonical reference for opaque popover surfaces."
)
# ── Bug 2: project-create-input width ─────────────────────────────────────────
class TestProjectCreateInputWidth:
def test_no_hardcoded_100px_width(self):
"""The fixed `width: 100px` on .project-create-input is gone."""
idx = STYLE_CSS.find(".project-create-input{")
assert idx >= 0, ".project-create-input rule not found in style.css"
rule = STYLE_CSS[idx: idx + 400]
assert "width:100px" not in rule and "width: 100px" not in rule, (
"Fixed 100px width must be replaced with min-width/max-width/"
"width:auto so the input grows with its content."
)
def test_min_and_max_width_present(self):
"""Both min-width and max-width must be set on .project-create-input."""
idx = STYLE_CSS.find(".project-create-input{")
rule = STYLE_CSS[idx: idx + 400]
assert "min-width:40px" in rule, (
f"min-width:40px not found in .project-create-input rule: {rule}"
)
assert "max-width:180px" in rule, (
f"max-width:180px not found in .project-create-input rule: {rule}"
)
assert "width:auto" in rule, (
f"width:auto not found in .project-create-input rule: {rule}"
)
class TestResizeProjectInputHelper:
"""The `_resizeProjectInput` helper must exist and be wired into both
rename and create call sites."""
def test_resize_helper_defined(self):
assert "function _resizeProjectInput(" in SESSIONS_JS, (
"_resizeProjectInput helper not found in sessions.js"
)
def test_resize_helper_uses_hidden_span(self):
"""The standard pattern is to measure with a hidden absolute span
sharing the same font/padding as the input. Font and family are read
via getComputedStyle so the sizer stays calibrated if CSS changes."""
idx = SESSIONS_JS.find("function _resizeProjectInput(")
assert idx >= 0
body = SESSIONS_JS[idx: idx + 900]
assert "position:absolute" in body and "visibility:hidden" in body, (
"_resizeProjectInput should use a hidden absolute span to "
"measure the value's rendered width."
)
assert "getComputedStyle(inp)" in body, (
"_resizeProjectInput should use getComputedStyle to read font " "properties so the sizer stays calibrated if CSS changes."
)
assert "Math.min(180" in body, (
"max bound (180) not applied in _resizeProjectInput"
)
assert "Math.max(40" in body, (
"min bound (40) not applied in _resizeProjectInput"
)
def test_rename_calls_resize_helper(self):
"""`_startProjectRename` must call `_resizeProjectInput` once on
creation and again on every input event."""
idx = SESSIONS_JS.find("function _startProjectRename(")
assert idx >= 0
body = SESSIONS_JS[idx: idx + 1200]
assert "_resizeProjectInput(inp)" in body, (
"_startProjectRename must call _resizeProjectInput so the "
"input width matches the existing project name."
)
# Wired into the input event so it grows as the user types
assert "addEventListener('input'" in body and "_resizeProjectInput" in body, (
"_startProjectRename must wire input events to _resizeProjectInput"
)
def test_create_calls_resize_helper(self):
"""Same for `_startProjectCreate` (new-project entry field)."""
idx = SESSIONS_JS.find("function _startProjectCreate(")
assert idx >= 0
body = SESSIONS_JS[idx: idx + 1200]
assert "_resizeProjectInput(inp)" in body, (
"_startProjectCreate must call _resizeProjectInput on focus"
)
assert "addEventListener('input'" in body, (
"_startProjectCreate must wire input events to _resizeProjectInput"
)
class TestProjectChipLongPressTouch:
"""Mobile long-press to open the project context menu (#3760).
Project chips were deletable only via the right-click context menu, which has
no touch equivalent — so mobile users could never remove a project. A 500ms
long-press now opens the same menu.
"""
def _chip_touch_block(self):
# The chip touch handlers live just after the oncontextmenu wiring in the
# project-chip render loop.
idx = SESSIONS_JS.find("Touch long-press")
assert idx != -1, "project-chip long-press touch block not found"
return SESSIONS_JS[idx: idx + 2300]
def test_long_press_opens_project_context_menu(self):
block = self._chip_touch_block()
assert "addEventListener('touchstart'" in block
assert "setTimeout(" in block and "},500);" in block
assert "_showProjectContextMenu(" in block
# visual feedback + scroll-drift cancel, mirroring the session-item pattern
assert "long-pressing" in block
assert "addEventListener('touchmove'" in block
assert ">10" in block # >10px drift cancels the press
def test_long_press_suppresses_synthetic_click_and_filter_tap(self):
block = self._chip_touch_block()
# touchend must be non-passive so it can preventDefault the synthetic click
assert "addEventListener('touchend'" in block
assert "{passive:false}" in block
assert "e.preventDefault();e.stopPropagation();" in block
# the long-press handler cancels the pending single-tap filter timer
assert "clearTimeout(_pClickTimer)" in block
def test_touchstart_clears_inflight_timer_before_scheduling(self):
"""Regression: a second finger / stray touchstart must not orphan the
prior timer (which would then fire the menu after the gesture was
cancelled). touchstart clears any in-flight _lpTimer before scheduling,
and the timer body bails if the gesture was already consumed.
"""
block = self._chip_touch_block()
# clear-before-schedule at the top of touchstart
assert "if(_lpTimer){clearTimeout(_lpTimer);_lpTimer=null;}" in block, (
"touchstart must clear any in-flight long-press timer before scheduling "
"a new one (orphaned-timer fix)"
)
# stale-fire guard inside the timer body
assert "if(_lpHandled) return;" in block, (
"the long-press timer body must no-op if the gesture was already consumed"
)
def test_long_pressing_style_feedback_present(self):
assert ".project-chip.long-pressing" in STYLE_CSS
# Target the base .project-chip rule (the one carrying the layout props),
# not an unrelated theme override of the same selector.
base_idx = STYLE_CSS.find(".project-chip{font-size")
assert base_idx != -1, "base .project-chip rule not found"
chip_rule = STYLE_CSS[base_idx: STYLE_CSS.find("}", base_idx) + 1]
# touch tuning so the native callout/selection doesn't compete with the gesture
assert "touch-action:manipulation" in chip_rule
assert "user-select:none" in chip_rule
class TestQuickCreateMobileDrawer:
"""The project-chip "+" (quick-create) must close the mobile sidebar drawer.
On phones the sidebar is a full-screen drawer (`.sidebar.mobile-open`,
z-index 200) covering the main chat view. The quick-create button creates
the new project conversation but never closed the drawer, so on mobile the
tap *looked* like a no-op — the session was created, just hidden underneath.
Mirrors `$('btnNewChat').onclick` in boot.js and the #5409 close in
`_openSidebarSession`.
"""
def _quick_create_block(self):
idx = SESSIONS_JS.find("function _attachProjectQuickCreateButton(")
assert idx != -1, "project quick-create helper not found in sessions.js"
# Function body incl. the full onclick success path (~3K covers it).
return SESSIONS_JS[idx: idx + 3000]
def test_quick_create_success_path_closes_mobile_drawer(self):
block = self._quick_create_block()
assert "closeMobileSidebar" in block, (
"project quick-create + must close the mobile sidebar after "
"newSession so the new conversation is visible on phones"
)
# Guarded call, matching the _openSidebarSession (#5409) convention.
assert "typeof closeMobileSidebar==='function'" in block
def test_quick_create_close_runs_after_sidebar_repaint(self):
"""The close must sit in the success path after the sidebar repaint —
not before newSession (the drawer would reopen over the pending load)
and not in the catch branch (failure should keep the drawer open so the
user can see the toast and retry)."""
block = self._quick_create_block()
render_idx = block.find("renderSessionList({deferWhileInteracting:false})")
close_idx = block.find("closeMobileSidebar")
catch_idx = block.find("catch(err)")
assert render_idx != -1, "sidebar repaint call not found in quick-create handler"
assert close_idx != -1, "closeMobileSidebar not found in quick-create handler"
assert catch_idx != -1, "success-path try/catch not found in quick-create handler"
assert close_idx > render_idx, (
"closeMobileSidebar must run after the sidebar repaint in the success path"
)
assert close_idx < catch_idx, (
"closeMobileSidebar must stay in the success path (try block) — moving it "
"into the catch branch would keep the drawer open over the error toast"
)