mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
f77a44fce2
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.
344 lines
15 KiB
Python
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
|