Files
hermes-webui/tests/test_1764_context_menu_essentials.py
nesquena-hermes f77a44fce2 feat(ux): three high-leverage context-menu essentials from #1764
Issue #1764 asked for a much larger surface (Reveal + Copy-path on
every UI surface that references a file path, plus Rename in session
menus). Per Nathan's curation we ship only the three highest-leverage
pieces in this PR — they cover the three concrete user-visible
frictions Cygnus reported, and leave the broader sweep for follow-up.

## 1. Copy file path in workspace tree right-click menu

The tree's right-click already had Rename and Reveal in File Manager.
Reveal is slow when the user just wants the path string for a
terminal/editor — and there was no Copy-path action anywhere.

Added "Copy file path" between Reveal and Delete. It POSTs to a new
`/api/file/path` endpoint that resolves the relative tree-rooted path
into the absolute on-disk path (the frontend can't compute it because
only the server knows the workspace root) and writes the result to
the OS clipboard via `navigator.clipboard.writeText()`. Falls back to
the legacy execCommand pattern on browsers where the modern Clipboard
API is gated.

The new endpoint deliberately does NOT require the target to exist:
copy-path on a recently-deleted file is still useful (paste into a
terminal to investigate). `safe_resolve` continues to gate path
traversal — the test suite pins this with a `../../../../../etc/passwd`
attempt that 400s.

## 2. Rename in session three-dot menu

Cygnus's specific ask: double-click rename in the sidebar is timing-
sensitive — the first click frequently registers as "open the chat"
before the second click arrives, so users open the conversation when
they meant to rename it. Putting Rename in the menu eliminates the
timing entirely.

Added Rename as the FIRST item in `_openSessionActionMenu` (above
Pin). It reuses the existing `startRename` closure attached to each
session row — no duplicated state, no second API call out of band
with the double-click path. Mechanism: the row builder now stores
`el._startRename = startRename` and `el.dataset.sid = s.session_id`,
so the menu can find the row by data-sid and call its closure
directly. This keeps all the `_renamingSid`/`oldTitle`/`applyTitle`
bookkeeping single-sourced.

Read-only imported sessions skip the menu item via the same
`_isReadOnlySession` gate the closure already uses.

## 3. Reveal-failed toast includes the resolved server-side path

Cygnus posted a screenshot of a "Failed to reveal: not found" toast
that dropped the path entirely. Without it the user can't tell which
file the system expected — useful when a stale session row still
references a deleted file.

Server-side fix in `_handle_file_reveal`: instead of returning
`bad(handler, "File not found", 404)`, return
`bad(handler, f"File not found: {target}", 404)` where target is the
resolved absolute path. Frontend toast also defends against err with
no .message: `(err.message||err)` instead of `err.message` alone.

Verified live: a missing-file reveal now produces:

    Failed to reveal: File not found: /home/hermes/workspace/missing-xyz.txt

Cygnus's exact diagnostic-friction is gone.

## Tests

* tests/test_1764_context_menu_essentials.py (new)
  - 13 source-level pinning tests
  - 6 live HTTP behaviour tests against the conftest test server

* tests/test_1466_sidebar_cancel_clarify.py
  - Two assertion-window bumps (3200→4400, 3600→4800) to accommodate
    the new Rename action prepended to _openSessionActionMenu. The
    test relied on a fixed-byte-window function-body slice — comments
    added explaining why the bumps were needed.

* All 9 locales got translations for the 5 new keys
  (copy_file_path, path_copied, path_copy_failed, session_rename,
  session_rename_desc) — locale parity tests pass.

## Verification

Full pytest suite: 4671 passed, 2 skipped, 3 xpassed (matches
pre-change baseline).

Live browser verification on port 8789:
- Right-click .git folder in workspace tree → menu shows
  Rename / Reveal in File Manager / Copy file path / Delete (red).
- Click Copy file path → clipboard gets "/home/hermes/workspace/.git",
  toast confirms "File path copied to clipboard".
- Open session three-dot menu → Rename conversation appears first
  with pencil icon, followed by Pin / Move / Archive / Duplicate /
  Delete in the same order as before.
- Trigger reveal on a non-existent file → toast reads
  "Failed to reveal: File not found: /home/hermes/workspace/<filename>".
  The resolved server-side path is now visible in the failure.

Refs nesquena/hermes-webui#1764.
2026-05-07 01:39:52 +00:00

344 lines
15 KiB
Python

"""Regression tests for issue #1764 — three context-menu essentials.
The issue asked for a much larger surface, but per Nathan's curation we
ship only three high-leverage pieces in this PR:
1. **Copy file path** in the workspace tree right-click menu — resolves
the absolute on-disk path on the server (so the user gets the full
path, not the relative tree-rooted one) and writes it to the
clipboard.
2. **Rename** in the session three-dot menu — Cygnus reported double-click
rename being timing-sensitive (first click opens the chat before the
second click arrives). Putting Rename in the menu eliminates the
timing entirely.
3. **Reveal-failed toast includes the resolved path** — the existing
handler returned bare "File not found" (404) and the frontend toast
surfaced only `err.message`, dropping the path entirely. This makes
it impossible for users to tell *which* file the system expected
(e.g. a stale session row pointing at a deleted file). Now the
server includes the resolved server-side path in the message.
These tests pin the source-level wiring — they do not exercise the live
HTTP endpoints (those are covered by integration tests where they exist
in the wider suite).
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parent.parent
ROUTES = ROOT / "api" / "routes.py"
UI = ROOT / "static" / "ui.js"
SESSIONS = ROOT / "static" / "sessions.js"
I18N = ROOT / "static" / "i18n.js"
# ════════════════════════════════════════════════════════════════════
# Item A — Copy file path in workspace tree right-click menu
# ════════════════════════════════════════════════════════════════════
class TestCopyFilePathMenuItem:
def test_menu_item_present(self):
"""The workspace file context menu must include a Copy file path
action that calls the new /api/file/path endpoint and writes the
result to the clipboard.
"""
src = UI.read_text(encoding="utf-8")
# Item label is sourced via t('copy_file_path') — pin the call.
assert "t('copy_file_path')" in src
# Endpoint POSTed to.
assert "/api/file/path" in src
# Clipboard write.
assert "navigator.clipboard.writeText(abs)" in src
def test_menu_item_has_clipboard_fallback(self):
"""Some browsers gate the modern Clipboard API (older Safari, any
non-secure context). The action must fall back to the legacy
execCommand pattern so users on those browsers still get a copy.
"""
src = UI.read_text(encoding="utf-8")
assert "document.execCommand('copy')" in src
# Hidden textarea pattern — uses a fixed-position offscreen element
# so the page doesn't visibly scroll when select() runs.
assert "position:fixed;left:-9999px" in src
def test_menu_item_uses_path_copied_translation(self):
"""The success toast keys must be wired to translatable strings,
not hardcoded English.
"""
src = UI.read_text(encoding="utf-8")
assert "t('path_copied')" in src
assert "t('path_copy_failed')" in src
def test_endpoint_handler_present(self):
"""Server-side endpoint must exist and route through the dispatcher."""
src = ROUTES.read_text(encoding="utf-8")
assert 'parsed.path == "/api/file/path"' in src
assert "def _handle_file_path(handler, body):" in src
# Must use safe_resolve to prevent path traversal.
# Find the handler body and check.
m = re.search(
r"def _handle_file_path\(handler, body\):\s*(?:\"\"\".*?\"\"\")?\s*(.*?)(?=\ndef )",
src,
re.DOTALL,
)
assert m, "_handle_file_path body not found"
body = m.group(1)
assert "safe_resolve(Path(s.workspace)" in body
assert "session_id" in body # require() check
# Returns the absolute path as a string.
assert 'j(handler, {"ok": True, "path": str(target)})' in body
def test_endpoint_handler_does_not_require_existence(self):
"""Copy-path on a recently-deleted file is still useful (paste into
terminal to investigate). The handler must not 404 on missing files.
"""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_path\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
# No exists() check — that's specifically what we want NOT to be
# there. Distinguishing from _handle_file_reveal which does check.
assert "exists()" not in body, (
"Copy-path must not gate on exists() — copying a stale path is "
"still useful for debugging deleted files."
)
# ════════════════════════════════════════════════════════════════════
# Item B — Rename in session three-dot menu
# ════════════════════════════════════════════════════════════════════
class TestSessionRenameMenuItem:
def test_rename_action_in_menu(self):
"""The session three-dot menu (`_openSessionActionMenu`) must
include Rename as the first item, gated on _isReadOnlySession.
"""
src = SESSIONS.read_text(encoding="utf-8")
# Rename block must be inside _openSessionActionMenu.
# Pin the structural anchor.
assert "if(!_isReadOnlySession(session)){" in src
assert "t('session_rename')" in src
assert "t('session_rename_desc')" in src
def test_rename_dispatches_to_row_closure(self):
"""The menu's rename action must trigger the existing startRename
closure attached to the row element — no duplicated state, no
separate API call out of band with the double-click path.
"""
src = SESSIONS.read_text(encoding="utf-8")
# Row-attached closure invocation.
assert "row._startRename" in src
# Row lookup by data-sid.
assert ".session-item[data-sid=" in src
def test_row_exposes_start_rename(self):
"""The session row builder must attach `_startRename` to the row
element so the menu (defined in a different function) can find it
without duplicating the closure's state (oldTitle, applyTitle, the
_renamingSid bookkeeping, etc.).
"""
src = SESSIONS.read_text(encoding="utf-8")
assert "el._startRename = startRename" in src
assert "el.dataset.sid = s.session_id" in src
def test_rename_appears_before_pin(self):
"""Cygnus's specific ask: Rename should be at the top of the menu,
not buried under Pin / Move / Archive / etc. Pin that ordering.
"""
src = SESSIONS.read_text(encoding="utf-8")
rename_idx = src.find("t('session_rename')")
pin_idx = src.find("t('session_pin')")
assert rename_idx > 0 and pin_idx > 0
assert rename_idx < pin_idx, (
"Rename must appear before Pin in _openSessionActionMenu."
)
def test_rename_translation_keys_present(self):
"""English translation keys must exist for the new menu item."""
src = I18N.read_text(encoding="utf-8")
assert "session_rename: 'Rename conversation'" in src
assert "session_rename_desc: 'Edit the title of this conversation'" in src
# ════════════════════════════════════════════════════════════════════
# Item C — reveal-failed toast includes the resolved path
# ════════════════════════════════════════════════════════════════════
class TestRevealFailedTostIncludesPath:
def test_handler_includes_target_in_404_message(self):
"""When `target.exists()` returns false, the 404 response body must
include the resolved server-side path so the frontend toast can
show users *which* file the system expected. Previously it was
just "File not found" with no path — useless for diagnosing stale
session rows.
"""
src = ROUTES.read_text(encoding="utf-8")
# Find _handle_file_reveal body.
m = re.search(
r"def _handle_file_reveal\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m, "_handle_file_reveal not found"
body = m.group(0)
# The bad() call for not-exists must include the path.
assert 'f"File not found: {target}"' in body, (
"Reveal handler must include the resolved path in the 404 message."
)
# And NOT the bare unhelpful message.
# (We allow the substring 'File not found' because the new f-string
# contains it as a prefix; pin via the f-string presence above.)
assert 'bad(handler, "File not found", 404)' not in body, (
"Old bare 'File not found' message must be removed."
)
def test_existing_translation_key_unchanged(self):
"""The frontend toast prefix `reveal_failed: 'Failed to reveal: '`
is unchanged — the additional path comes from the server-side
message, so the prefix + message concat still reads well.
"""
src = I18N.read_text(encoding="utf-8")
assert "reveal_failed: 'Failed to reveal: '" in src
def test_reveal_call_site_uses_message_or_err(self):
"""The frontend reveal handler call site must guard against err
being a non-Error object (e.g. a network-layer reject without a
.message). Previously `err.message` alone could produce
"Failed to reveal: undefined" — we use `(err.message||err)`.
"""
src = UI.read_text(encoding="utf-8")
# Match both possible forms (with or without parens).
assert (
"(err.message||err)" in src or "(err.message || err)" in src
), "Reveal-failed toast must guard against err with no .message"
# ════════════════════════════════════════════════════════════════════
# Behaviour tests — exercise the live HTTP endpoints against the
# module-scoped test server (started by conftest.py at port 8788).
# ════════════════════════════════════════════════════════════════════
import json
import pathlib
import sys
import urllib.error
import urllib.request
sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_BASE # noqa: E402
def _post(path, body=None, headers=None):
data = json.dumps(body or {}).encode()
h = {"Content-Type": "application/json"}
if headers:
h.update(headers)
req = urllib.request.Request(TEST_BASE + path, data=data, headers=h)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
class TestFilePathEndpointBehaviour:
"""End-to-end exercise of the new /api/file/path endpoint against the
live test server."""
def _new_session(self):
body, status = _post("/api/session/new", {})
assert status == 200, body
return body["session"]["session_id"]
def test_returns_absolute_path_for_relative_input(self):
"""The endpoint must resolve a relative workspace-rooted path into
the absolute on-disk path. This is the whole point — the frontend
can't compute it because only the server knows the workspace root.
"""
sid = self._new_session()
body, status = _post("/api/file/path", {"session_id": sid, "path": "."})
assert status == 200, body
assert body.get("ok") is True
# Path should be absolute (starts with /).
assert body.get("path", "").startswith("/"), body
def test_does_not_404_on_missing_file(self):
"""Copy-path on a stale-but-recently-deleted file must still
succeed — that's specifically what makes the action useful for
debugging."""
sid = self._new_session()
body, status = _post(
"/api/file/path",
{"session_id": sid, "path": "definitely-does-not-exist-xyz123.tmp"},
)
assert status == 200, body
assert body.get("ok") is True
# Even though the file doesn't exist, we get back a resolved path.
assert "definitely-does-not-exist-xyz123.tmp" in body.get("path", "")
def test_rejects_path_traversal(self):
"""The endpoint must use safe_resolve, which rejects paths that
escape the workspace root."""
sid = self._new_session()
body, status = _post(
"/api/file/path",
{"session_id": sid, "path": "../../../../../../etc/passwd"},
)
assert status == 400, body # safe_resolve raises ValueError → bad()
# Error message must NOT include the attempted traversal target's
# contents, just a generic safe-resolve message.
assert "passwd" not in body.get("error", "").lower() or "outside" in body.get("error", "").lower()
def test_missing_session_id_returns_400(self):
body, status = _post("/api/file/path", {"path": "foo.txt"})
assert status == 400, body
assert "session_id" in body.get("error", "")
def test_unknown_session_returns_404(self):
body, status = _post(
"/api/file/path", {"session_id": "fake-session-xyz", "path": "."}
)
assert status == 404, body
assert "session" in body.get("error", "").lower()
class TestRevealHandlerErrorIncludesPath:
"""End-to-end check that the reveal endpoint's 404 includes the path."""
def _new_session(self):
body, status = _post("/api/session/new", {})
assert status == 200, body
return body["session"]["session_id"]
def test_404_message_contains_resolved_path(self):
"""Reveal of a missing file must surface the resolved server-side
path in the error, so the frontend toast can show users *which*
file was missing — useful when a stale row points at a deleted
file (#1764)."""
sid = self._new_session()
body, status = _post(
"/api/file/reveal",
{"session_id": sid, "path": "missing-xyz-1764.txt"},
)
assert status == 404, body
err = body.get("error", "")
# Must include the filename in the resolved path.
assert "missing-xyz-1764.txt" in err, (
f"Reveal 404 message must include the resolved path, got: {err!r}"
)
# Must keep the human-readable prefix.
assert "File not found" in err