mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 11:40:26 +00:00
7999d1c75a
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.
343 lines
14 KiB
Python
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
|