Files
hermes-webui/tests/test_1764_context_menu_essentials.py
T
nesquena-hermes 40591f146f test(#3664): Windows pytest-harness batch #3 (#4274) (#4276)
rodboev test-only #4274 (test_issue609, test_sprint3, + 2 others). Linux
assertions stay at full strictness. 63 tests pass. No version stamp (tests-only).
Note: #4273 was NOT included — it touches api/workspace.py runtime (home-expansion
in the blocked-roots boundary), needs a separate full gate.

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-15 15:57:18 -07:00

345 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 must include nested fork rows too.
assert "function _findSessionRenameRow(" in src
assert ".session-item[data-sid=" in src
assert ".session-child-session[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
assert Path(body.get("path", "")).is_absolute(), 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