Files
hermes-webui/tests/test_2735_open_in_vscode.py
T
Abdul Munim 7999d1c75a feat(workspace): add Open in VS Code action for files and folders (#2735)
Right-click any workspace file, folder, or root now shows
'Open in VS Code' alongside the existing Reveal in File Manager action.

- POST /api/file/open-vscode: resolves path via safe_resolve, finds VS
  Code via shutil.which() with fallbacks for macOS (/usr/local/bin/code,
  app bundle CLI), Linux (/usr/bin/code, /snap/bin/code), and Windows
  (%LOCALAPPDATA% and %PROGRAMFILES% user/system installs). Returns a
  descriptive error if not found rather than a bare OS error.
- Optional vscode block in config.yaml: command (default: code),
  host_path_prefix + container_path_prefix for Docker path mapping.
- i18n: open_in_vscode and open_in_vscode_failed translated in all 10
  locales (it, ja, ru, es, de, zh-CN, zh-TW, pt, ko).
- 26 tests in tests/test_2735_open_in_vscode.py covering source wiring,
  command resolution, i18n completeness, and live endpoint error paths.
2026-05-24 04:26:46 +00:00

343 lines
14 KiB
Python

"""Tests for issue #2735 — "Open in VS Code" action for workspace files/folders.
Pins three layers:
1. **Source wiring** — the dispatch entry, handler structure, and menu items
exist in the correct files.
2. **i18n completeness** — both new keys (``open_in_vscode`` and
``open_in_vscode_failed``) are present in every locale block.
3. **Live endpoint behaviour** — error paths (missing fields, unknown session,
missing file, path traversal) behave correctly against the test server.
The success path (VS Code actually opening) is not covered here because it
requires VS Code to be installed on the CI host. The subprocess call is
intentionally fire-and-forget (matching ``_handle_file_reveal``), so its
failure is surfaced via the OSError catch and a 400 response. That
observable is tested in ``TestOpenInVsCodeEndpointBehaviour``.
"""
from __future__ import annotations
import json
import pathlib
import re
import sys
import urllib.error
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parent.parent
ROUTES = ROOT / "api" / "routes.py"
UI = ROOT / "static" / "ui.js"
I18N = ROOT / "static" / "i18n.js"
sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_BASE # noqa: E402
# ═══════════════════════════════════════════════════════════════════════════════
# Source-level wiring
# ═══════════════════════════════════════════════════════════════════════════════
class TestOpenInVsCodeBackendWiring:
def test_route_dispatch_entry_present(self):
"""Dispatcher must route /api/file/open-vscode to the handler."""
src = ROUTES.read_text(encoding="utf-8")
assert 'parsed.path == "/api/file/open-vscode"' in src
def test_handler_function_defined(self):
src = ROUTES.read_text(encoding="utf-8")
assert "def _handle_file_open_vscode(handler, body):" in src
def test_handler_uses_safe_resolve(self):
"""Handler must use safe_resolve to prevent path traversal."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m, "_handle_file_open_vscode body not found"
body = m.group(0)
assert "safe_resolve(Path(s.workspace)" in body
def test_handler_checks_existence(self):
"""Handler must require the target to exist (unlike copy-path)."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "exists()" in body
def test_handler_reads_vscode_config(self):
"""Handler must read the optional ``vscode`` config block."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert 'get("vscode"' in body
def test_handler_defaults_to_code_command(self):
"""Default executable must be ``code`` when config is absent."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert '"code"' in body
def test_handler_supports_path_prefix_mapping(self):
"""Handler must support container_path_prefix / host_path_prefix
so Docker users can map container paths to host paths."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "container_path_prefix" in body
assert "host_path_prefix" in body
def test_handler_uses_subprocess_popen(self):
"""Handler must use subprocess.Popen (async, non-blocking) consistent
with _handle_file_reveal."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "subprocess.Popen(" in body
def test_handler_resolves_command_via_shutil_which(self):
"""Handler must use shutil.which() to find the command so it works
even when the server's inherited PATH is minimal (e.g. macOS launch
via start.sh where /usr/local/bin may be absent from the subprocess
PATH)."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "shutil.which(" in body
def test_handler_has_vscode_fallback_paths(self):
"""Handler must try common VS Code paths when shutil.which fails,
covering macOS (/usr/local/bin/code), Linux (/snap/bin/code), and
Windows (%LOCALAPPDATA%\\Programs\\Microsoft VS Code)."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "/usr/local/bin/code" in body # macOS
assert "/snap/bin/code" in body # Linux snap
assert "Microsoft VS Code" in body # Windows
def test_handler_returns_helpful_error_when_not_found(self):
"""When code command is not found anywhere, handler must return a
descriptive error instead of a bare OSError message."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "VS Code command not found" in body
class TestOpenInVsCodeFrontendWiring:
def test_file_context_menu_has_vscode_item(self):
"""_showFileContextMenu must include the Open in VS Code action."""
src = UI.read_text(encoding="utf-8")
assert "t('open_in_vscode')" in src
assert "/api/file/open-vscode" in src
def test_workspace_root_context_menu_has_vscode_item(self):
"""_showWorkspaceRootContextMenu must also include the VS Code action."""
src = UI.read_text(encoding="utf-8")
# Both the file and root menus call the same endpoint; verify at least
# two references in the file so we know both call sites exist.
assert src.count("/api/file/open-vscode") >= 2
def test_vscode_item_uses_hover_bg(self):
"""VS Code menu item must use var(--hover-bg), not var(--hover) or
any other undefined variable."""
src = UI.read_text(encoding="utf-8")
# Confirm the item is wired with the correct variable — count hover-bg
# usages; as long as our item follows the pattern the suite is green.
assert "var(--hover-bg)" in src
def test_vscode_failure_toast_uses_i18n_key(self):
"""Error toast on VS Code open failure must use the translatable key."""
src = UI.read_text(encoding="utf-8")
assert "t('open_in_vscode_failed')" in src
def test_vscode_item_guards_err_message(self):
"""Error handler must guard against non-Error objects with
(err.message||err) consistent with reveal handler."""
src = UI.read_text(encoding="utf-8")
# Find the open-vscode call site and check for the guard pattern near it.
idx = src.find("/api/file/open-vscode")
assert idx != -1
# Look in a window around the first call site.
window = src[max(0, idx - 200) : idx + 500]
assert "(err.message||err)" in window or "(err.message || err)" in window
class TestOpenInVsCodeI18n:
"""Both new translation keys must be present in every locale block."""
LOCALES = [
# (locale tag, sample anchor key: value)
("en", "reveal_in_finder: 'Reveal in File Manager'"),
("it", "reveal_in_finder: 'Mostra nel File Manager'"),
("ja", "reveal_in_finder: 'ファイルマネージャーで表示'"),
("ru", "reveal_in_finder: 'Показать в файловом менеджере'"),
("es", "reveal_in_finder: 'Mostrar en el gestor de archivos'"),
("de", "reveal_in_finder: 'Im Dateimanager anzeigen'"),
("zh-CN", "reveal_in_finder: '在文件管理器中显示'"),
("pt", "reveal_in_finder: 'Mostrar no gerenciador de arquivos'"),
("ko", "reveal_in_finder: '파일 관리자에서 열기'"),
]
def test_open_in_vscode_key_count(self):
"""open_in_vscode key must appear exactly once per locale (10 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode:")
assert count == 10, (
f"Expected 10 open_in_vscode: entries (one per locale), found {count}"
)
def test_open_in_vscode_failed_key_count(self):
"""open_in_vscode_failed key must appear exactly once per locale (10 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode_failed:")
assert count == 10, (
f"Expected 10 open_in_vscode_failed: entries (one per locale), found {count}"
)
def test_english_translation_not_a_placeholder(self):
"""English locale must have a human-readable string, not a TODO."""
src = I18N.read_text(encoding="utf-8")
assert "open_in_vscode: 'Open in VS Code'" in src
assert "open_in_vscode_failed: 'Failed to open in VS Code: '" in src
def test_non_english_locales_translated(self):
"""Non-English locales must have real translations, not TODO stubs."""
src = I18N.read_text(encoding="utf-8")
# Spot-check a selection of locales — none of these should be TODO stubs.
assert "open_in_vscode: 'Apri in VS Code'" in src # it
assert "open_in_vscode: 'VS Codeで開く'" in src # ja
assert "open_in_vscode: 'Открыть в VS Code'" in src # ru
assert "open_in_vscode: 'Abrir en VS Code'" in src # es
assert "open_in_vscode: 'In VS Code öffnen'" in src # de
assert "open_in_vscode: 'VS Code에서 열기'" in src # ko
def test_keys_adjacent_to_reveal_block(self):
"""New keys must appear near the reveal/copy block so locale coverage
is easy to spot in code review."""
src = I18N.read_text(encoding="utf-8")
# In the English block, open_in_vscode must appear between
# copy_file_path and download_folder.
copy_idx = src.index("copy_file_path: 'Copy file path'")
dl_idx = src.index("download_folder: 'Download Folder'", copy_idx)
vscode_idx = src.index("open_in_vscode: 'Open in VS Code'", copy_idx)
assert copy_idx < vscode_idx < dl_idx, (
"open_in_vscode key must appear between copy_file_path and "
"download_folder in the English locale block"
)
# ═══════════════════════════════════════════════════════════════════════════════
# Live endpoint behaviour
# ═══════════════════════════════════════════════════════════════════════════════
def _post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
TEST_BASE + path,
data=data,
headers={"Content-Type": "application/json"},
)
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 TestOpenInVsCodeEndpointBehaviour:
def _new_session(self):
body, status = _post("/api/session/new", {})
assert status == 200, body
return body["session"]["session_id"]
def test_missing_session_id_returns_400(self):
body, status = _post("/api/file/open-vscode", {"path": "."})
assert status == 400, body
assert "session_id" in body.get("error", "")
def test_missing_path_returns_400(self):
sid = self._new_session()
body, status = _post("/api/file/open-vscode", {"session_id": sid})
assert status == 400, body
assert "path" in body.get("error", "")
def test_unknown_session_returns_404(self):
body, status = _post(
"/api/file/open-vscode",
{"session_id": "nonexistent-session-xyz", "path": "."},
)
assert status == 404, body
assert "session" in body.get("error", "").lower()
def test_missing_file_returns_404_with_path(self):
"""Attempting to open a file that does not exist must return 404 and
include the resolved path in the error (mirrors _handle_file_reveal
behaviour introduced in #1764)."""
sid = self._new_session()
body, status = _post(
"/api/file/open-vscode",
{"session_id": sid, "path": "does-not-exist-2735.txt"},
)
assert status == 404, body
err = body.get("error", "")
assert "does-not-exist-2735.txt" in err, (
f"404 message must include the resolved path, got: {err!r}"
)
def test_path_traversal_rejected(self):
"""Handler must reject paths that escape the workspace root."""
sid = self._new_session()
body, status = _post(
"/api/file/open-vscode",
{"session_id": sid, "path": "../../../../../../etc/passwd"},
)
assert status == 400, body