diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcbbb23..ecad5762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ ## [Unreleased] +## [v0.51.123] — 2026-05-24 — Release CU (stage-batch5 — 2-PR low-risk batch — gzip+ETag static caching / Open in VS Code) + +### Performance + +- **PR #2779** by @v2psv — Static asset serving negotiates gzip, emits ETags, and uses `immutable` cache headers for fingerprinted URLs. `_serve_static()` in `api/routes.py` previously sent every `/static/*` response with `Cache-Control: no-store` and no `Content-Encoding`, so a page reload over a slow link re-downloaded the full ~2.4 MB JS+CSS shell on every visit. The fix layers three changes inside the same function: (1) gzip the body when the client opts in via `Accept-Encoding`, gated to compressible MIME types and files >1 KB; (2) emit a weak ETag derived from `(size, mtime_ns)` and short-circuit conditional GETs to `304 Not Modified`; (3) send `Cache-Control: public, max-age=31536000, immutable` when the URL carries a non-empty `?v=…` fingerprint (the `__WEBUI_VERSION__` token already substituted by the index template and referenced from `static/sw.js`'s `SHELL_ASSETS`), falling back to `public, max-age=300` otherwise. Raw bytes, compressed bytes, and ETags are cached in-process keyed by `(size, mtime_ns)` so a redeploy is picked up without a restart, while missing/random paths never enter the cache and image/font types skip gzip to avoid wasted CPU on already-compressed payloads. Measured against an asyncio TCP proxy that injects RTT + bandwidth caps for representative VPN scenarios: cold loads improve 2.7-3.1× (e.g. 80 ms RTT / 10 Mbps WireGuard goes from 4.0 s to 1.3 s), warm reloads improve 3.3-4.0× via 304 responses, and bytes-on-the-wire drop 74% on cold loads. Loopback (already fast) still benefits 2.4×. Scope is strictly `/static/*`: `/api/*`, `/stream`, `/`, `/index.html`, `/session/*`, and login/auth routes are served by independent handlers and continue to send `no-store` exactly as before — no change to CSRF, session payloads, SSE buffering, or login flows. 11 regression tests pin gzip negotiation, ETag/304 round-trip including `Vary: Accept-Encoding`, fingerprint-driven cache policy including empty `?v=`, image/tiny-file skip rules, redeploy invalidation, and the existing path-traversal sandbox. + +### Added + +- **PR #2787** by @munim — "Open in VS Code" action in workspace file browser (resolves #2735). Right-clicking any file, folder, or the workspace root now shows an **Open in VS Code** menu item alongside the existing Reveal in File Manager action. The action calls a new `POST /api/file/open-vscode` endpoint which resolves the workspace-relative path via the existing `safe_resolve` traversal guard, then launches VS Code via `subprocess.Popen` (fire-and-forget, consistent with `_handle_file_reveal`). The endpoint resolves the executable via `shutil.which()` first, then falls back to a hardcoded list of common install locations (macOS: `/usr/local/bin/code` and the app-bundle CLI; Linux: `/usr/bin/code`, `/snap/bin/code`; Windows: `%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd` and the `%PROGRAMFILES%` variants) so the action works even when the server process inherits a minimal PATH. Configurable via a new optional `vscode` block in `config.yaml`: `command` overrides the default `code` executable; `host_path_prefix` + `container_path_prefix` enable Docker/container host-path translation. If the command cannot be found anywhere, a descriptive error is returned instead of a bare OS error. i18n keys `open_in_vscode` and `open_in_vscode_failed` added with full translations in all 10 locales. 26 new tests in `tests/test_2735_open_in_vscode.py` pin source wiring, command-resolution logic, i18n completeness, translated strings, and live endpoint error paths. + ## [v0.51.122] — 2026-05-24 — Release CT (stage-batch4 — 4-PR low-risk batch — stale cache tail / inflight UI / segment flush / reasoning accumulator) ### Fixed diff --git a/api/routes.py b/api/routes.py index 6dc8dfb5..eee4f166 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6,6 +6,7 @@ Extracted from server.py (Sprint 11) so server.py is a thin shell. import html as _html import copy import io +import gzip import json import logging import os @@ -5455,6 +5456,9 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/file/path": return _handle_file_path(handler, body) + if parsed.path == "/api/file/open-vscode": + return _handle_file_open_vscode(handler, body) + # ── Workspace management (POST) ── if parsed.path == "/api/workspaces/add": return _handle_workspace_add(handler, body) @@ -6237,6 +6241,20 @@ _STATIC_MIME = { # MIME types that are text-based and should carry charset=utf-8 _TEXT_MIME_TYPES = {"text/css", "application/javascript", "text/html", "image/svg+xml", "text/plain"} +# MIME types worth gzipping. Image and font formats (png/jpg/webp/woff2) are +# already compressed; gzip would only add CPU and a few bytes of framing. +_COMPRESSIBLE_MIME = { + "text/css", "application/javascript", "text/html", "image/svg+xml", + "application/json", "text/plain", +} + +# In-process cache for raw bytes, compressed bytes, and ETag. The cache is keyed +# by absolute path and invalidated on (size, high-precision mtime) change, so a +# redeploy is picked up without a process restart. Missing/random paths never +# enter the cache; memory cost is bounded by the static/ tree's served files. +_STATIC_CACHE: dict = {} +_STATIC_CACHE_LOCK = threading.Lock() + def _serve_static(handler, parsed): static_root = (Path(__file__).parent.parent / "static").resolve() @@ -6252,13 +6270,63 @@ def _serve_static(handler, parsed): ext = static_file.suffix.lower() ct = _STATIC_MIME.get(ext.lstrip("."), "text/plain") ct_header = f"{ct}; charset=utf-8" if ct in _TEXT_MIME_TYPES else ct + + # Look up or populate the per-file cache (raw, optional gzip, ETag). + # Keyed by absolute path; invalidated by (size, nanosecond mtime). + st = static_file.stat() + sig = (st.st_size, st.st_mtime_ns) + cache_key = str(static_file) + raw = gz = etag = None + with _STATIC_CACHE_LOCK: + cached = _STATIC_CACHE.get(cache_key) + if cached and cached[0] == sig: + _, raw, gz, etag = cached + if raw is None: + raw = static_file.read_bytes() + # Weak ETag: equality semantics, derived from filesystem identity. + etag = f'W/"{sig[0]:x}-{sig[1]:x}"' + gz = (gzip.compress(raw, compresslevel=6) + if ct in _COMPRESSIBLE_MIME and len(raw) > 1024 + else None) + with _STATIC_CACHE_LOCK: + _STATIC_CACHE[cache_key] = (sig, raw, gz, etag) + + # The page template substitutes __WEBUI_VERSION__ at request time (see the + # `/`/`/index.html`/`/session/` branch above), and static/sw.js's + # SHELL_ASSETS list relies on the same convention. So a fingerprinted URL + # is safe to cache aggressively: any redeploy changes the URL. + version_values = parse_qs(parsed.query, keep_blank_values=True).get("v", [""]) + has_fingerprint = bool(version_values[0]) + cache_control = ( + "public, max-age=31536000, immutable" if has_fingerprint + else "public, max-age=300" + ) + + # 304 short-circuit on conditional GET. + if handler.headers.get("If-None-Match") == etag: + handler.send_response(304) + handler.send_header("ETag", etag) + handler.send_header("Cache-Control", cache_control) + if gz is not None: + handler.send_header("Vary", "Accept-Encoding") + handler.end_headers() + return True + + accept_enc = (handler.headers.get("Accept-Encoding") or "").lower() + use_gzip = gz is not None and "gzip" in accept_enc + body = gz if use_gzip else raw + handler.send_response(200) handler.send_header("Content-Type", ct_header) - handler.send_header("Cache-Control", "no-store") - raw = static_file.read_bytes() - handler.send_header("Content-Length", str(len(raw))) + handler.send_header("Content-Length", str(len(body))) + handler.send_header("ETag", etag) + handler.send_header("Cache-Control", cache_control) + if gz is not None: + handler.send_header("Vary", "Accept-Encoding") + if use_gzip: + handler.send_header("Content-Encoding", "gzip") handler.end_headers() - handler.wfile.write(raw) + handler.wfile.write(body) return True @@ -9526,6 +9594,90 @@ def _handle_file_path(handler, body): return bad(handler, _sanitize_error(e)) +def _handle_file_open_vscode(handler, body): + """Open a workspace file or folder in VS Code (#2735). + + Reads optional ``vscode`` config block from config.yaml: + + vscode: + command: code # executable on PATH; defaults to "code" + host_path_prefix: /home/user/projects # Docker host path + container_path_prefix: /app/workspace # matching container path + + If ``host_path_prefix`` and ``container_path_prefix`` are both set, + paths that begin with ``container_path_prefix`` are translated to the + host prefix before being handed to VS Code. This lets users running + Hermes WebUI inside Docker still open files in their local editor. + """ + try: + require(body, "session_id", "path") + except ValueError as e: + return bad(handler, str(e)) + try: + s = get_session(body["session_id"]) + except KeyError: + return bad(handler, "Session not found", 404) + try: + target = safe_resolve(Path(s.workspace), body["path"]) + if not target.exists(): + return bad(handler, f"File not found: {target}", 404) + + target_str = str(target) + + # Optional Docker host/container path translation + from api.config import get_config as _get_cfg # noqa: PLC0415 + vscode_cfg = _get_cfg().get("vscode", {}) + if not isinstance(vscode_cfg, dict): + vscode_cfg = {} + container_prefix = vscode_cfg.get("container_path_prefix", "") + host_prefix = vscode_cfg.get("host_path_prefix", "") + if container_prefix and host_prefix and target_str.startswith(container_prefix): + target_str = host_prefix + target_str[len(container_prefix):] + + cmd = vscode_cfg.get("command", "code") + # Resolve the command to an absolute path so subprocess.Popen finds it + # even when the server process inherits a minimal PATH (e.g. when + # launched via start.sh on macOS where /usr/local/bin may be absent). + resolved_cmd = shutil.which(cmd) + if resolved_cmd is None: + # Try common VS Code installation paths as fallback. + # macOS: /usr/local/bin/code (symlink) or app bundle CLI + # Linux: /usr/bin/code or snap + # Windows: user-install under %LOCALAPPDATA%, system-install under %PROGRAMFILES% + _local_app_data = os.environ.get("LOCALAPPDATA", "") + _prog_files = os.environ.get("PROGRAMFILES", "C:\\Program Files") + _prog_files_x86 = os.environ.get("PROGRAMFILES(X86)", "C:\\Program Files (x86)") + _vscode_fallbacks = [ + # macOS + "/usr/local/bin/code", + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + # Linux + "/usr/bin/code", + "/snap/bin/code", + # Windows (user install) + os.path.join(_local_app_data, "Programs", "Microsoft VS Code", "bin", "code.cmd"), + # Windows (system install) + os.path.join(_prog_files, "Microsoft VS Code", "bin", "code.cmd"), + os.path.join(_prog_files_x86, "Microsoft VS Code", "bin", "code.cmd"), + ] + for fb in _vscode_fallbacks: + if fb and Path(fb).exists(): + resolved_cmd = fb + break + if resolved_cmd is None: + return bad( + handler, + f"VS Code command not found: {cmd!r}. " + "Install VS Code and ensure the 'code' CLI is on PATH, " + "or set vscode.command in config.yaml to the full path.", + ) + subprocess.Popen([resolved_cmd, target_str]) + + return j(handler, {"ok": True, "path": body["path"]}) + except (ValueError, PermissionError, OSError) as e: + return bad(handler, _sanitize_error(e)) + + def _handle_workspace_add(handler, body): # Strip surrounding paired quotes BEFORE any further processing — macOS # Finder's "Copy as Pathname" wraps paths in single quotes, and users diff --git a/static/i18n.js b/static/i18n.js index dcda7f35..0f785a31 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -402,10 +402,12 @@ const LOCALES = { rename_prompt: 'New name:', deleted: 'Deleted ', delete_failed: 'Delete failed: ', - reveal_in_finder: 'Reveal in File Manager', - reveal_failed: 'Failed to reveal: ', - copy_file_path: 'Copy file path', - download_folder: 'Download Folder', + reveal_in_finder: 'Reveal in File Manager', + reveal_failed: 'Failed to reveal: ', + copy_file_path: 'Copy file path', + open_in_vscode: 'Open in VS Code', + open_in_vscode_failed: 'Failed to open in VS Code: ', + download_folder: 'Download Folder', path_copied: 'File path copied to clipboard', path_copy_failed: 'Failed to copy path: ', session_rename: 'Rename conversation', @@ -1636,10 +1638,12 @@ const LOCALES = { rename_prompt: 'Nuovo nome:', deleted: 'Eliminato ', delete_failed: 'Eliminazione fallita: ', - reveal_in_finder: 'Mostra nel File Manager', - reveal_failed: 'Mostra fallito: ', - copy_file_path: 'Copia percorso file', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostra nel File Manager', + reveal_failed: 'Mostra fallito: ', + copy_file_path: 'Copia percorso file', + open_in_vscode: 'Apri in VS Code', + open_in_vscode_failed: 'Apertura in VS Code fallita: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Percorso file copiato negli appunti', path_copy_failed: 'Copia percorso fallita: ', session_rename: 'Rinomina conversazione', @@ -2862,10 +2866,12 @@ const LOCALES = { rename_prompt: '新しい名前:', deleted: '削除しました: ', delete_failed: '削除失敗: ', - reveal_in_finder: 'ファイルマネージャーで表示', - reveal_failed: '表示に失敗しました: ', - copy_file_path: 'ファイルパスをコピー', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'ファイルマネージャーで表示', + reveal_failed: '表示に失敗しました: ', + copy_file_path: 'ファイルパスをコピー', + open_in_vscode: 'VS Codeで開く', + open_in_vscode_failed: 'VS Codeで開けませんでした: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'ファイルパスをクリップボードにコピーしました', path_copy_failed: 'パスのコピーに失敗しました: ', session_rename: '会話の名前を変更', @@ -4014,10 +4020,12 @@ const LOCALES = { rename_prompt: 'Новое имя:', deleted: 'Удалено ', delete_failed: 'Не удалось удалить: ', - reveal_in_finder: 'Показать в файловом менеджере', - reveal_failed: 'Не удалось открыть: ', - copy_file_path: 'Копировать путь к файлу', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Показать в файловом менеджере', + reveal_failed: 'Не удалось открыть: ', + copy_file_path: 'Копировать путь к файлу', + open_in_vscode: 'Открыть в VS Code', + open_in_vscode_failed: 'Не удалось открыть в VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Путь к файлу скопирован в буфер обмена', path_copy_failed: 'Не удалось скопировать путь: ', session_rename: 'Переименовать беседу', @@ -5159,10 +5167,12 @@ const LOCALES = { rename_prompt: 'Nuevo nombre:', deleted: 'Eliminado ', delete_failed: 'Error al eliminar: ', - reveal_in_finder: 'Mostrar en el gestor de archivos', - reveal_failed: 'Error al mostrar: ', - copy_file_path: 'Copiar ruta del archivo', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostrar en el gestor de archivos', + reveal_failed: 'Error al mostrar: ', + copy_file_path: 'Copiar ruta del archivo', + open_in_vscode: 'Abrir en VS Code', + open_in_vscode_failed: 'Error al abrir en VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Ruta del archivo copiada al portapapeles', path_copy_failed: 'Error al copiar la ruta: ', session_rename: 'Renombrar conversación', @@ -6307,10 +6317,12 @@ const LOCALES = { rename_prompt: 'Neuer Name:', deleted: 'Gelöscht ', delete_failed: 'Löschen fehlgeschlagen: ', - reveal_in_finder: 'Im Dateimanager anzeigen', - reveal_failed: 'Anzeige fehlgeschlagen: ', - copy_file_path: 'Dateipfad kopieren', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Im Dateimanager anzeigen', + reveal_failed: 'Anzeige fehlgeschlagen: ', + copy_file_path: 'Dateipfad kopieren', + open_in_vscode: 'In VS Code öffnen', + open_in_vscode_failed: 'In VS Code öffnen fehlgeschlagen: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Dateipfad in die Zwischenablage kopiert', path_copy_failed: 'Pfad konnte nicht kopiert werden: ', session_rename: 'Unterhaltung umbenennen', @@ -7507,10 +7519,12 @@ const LOCALES = { rename_prompt: '新名称:', deleted: '已删除 ', delete_failed: '删除失败:', - reveal_in_finder: '在文件管理器中显示', - reveal_failed: '显示失败:', - copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '在文件管理器中显示', + reveal_failed: '显示失败:', + copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84', + open_in_vscode: '在VS Code中打开', + open_in_vscode_failed: '在VS Code中打开失败:', + download_folder: 'Download Folder', // TODO: translate path_copied: '\u6587\u4ef6\u8def\u5f84\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f', path_copy_failed: '\u590d\u5236\u8def\u5f84\u5931\u8d25\uff1a', session_rename: '\u91cd\u547d\u540d\u5bf9\u8bdd', @@ -8576,10 +8590,12 @@ const LOCALES = { rename_prompt: '新名稱:', deleted: '\u5df2\u522a\u9664 ', delete_failed: '\u522a\u9664\u5931\u6557\uff1a', - reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a', - reveal_failed: '\u986f\u793a\u5931\u6557\uff1a', - copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a', + reveal_failed: '\u986f\u793a\u5931\u6557\uff1a', + copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91', + open_in_vscode: '在VS Code中開啟', + open_in_vscode_failed: '在VS Code中開啟失敗:', + download_folder: 'Download Folder', // TODO: translate path_copied: '\u6a94\u6848\u8def\u5f91\u5df2\u8907\u88fd\u5230\u526a\u8cbc\u7c3f', path_copy_failed: '\u8907\u88fd\u8def\u5f91\u5931\u6557\uff1a', session_rename: '\u91cd\u65b0\u547d\u540d\u5c0d\u8a71', @@ -9888,10 +9904,12 @@ const LOCALES = { delete_confirm: (name) => `Excluir ${name}?`, deleted: 'Excluído ', delete_failed: 'Falha ao excluir: ', - reveal_in_finder: 'Mostrar no gerenciador de arquivos', - reveal_failed: 'Falha ao mostrar: ', - copy_file_path: 'Copiar caminho do arquivo', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostrar no gerenciador de arquivos', + reveal_failed: 'Falha ao mostrar: ', + copy_file_path: 'Copiar caminho do arquivo', + open_in_vscode: 'Abrir no VS Code', + open_in_vscode_failed: 'Falha ao abrir no VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Caminho do arquivo copiado para a área de transferência', path_copy_failed: 'Falha ao copiar caminho: ', session_rename: 'Renomear conversa', @@ -11012,10 +11030,12 @@ const LOCALES = { rename_prompt: '새 이름:', deleted: '삭제됨: ', delete_failed: '삭제 실패: ', - reveal_in_finder: '파일 관리자에서 열기', - reveal_failed: '표시 실패: ', - copy_file_path: '파일 경로 복사', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '파일 관리자에서 열기', + reveal_failed: '표시 실패: ', + copy_file_path: '파일 경로 복사', + open_in_vscode: 'VS Code에서 열기', + open_in_vscode_failed: 'VS Code에서 열기 실패: ', + download_folder: 'Download Folder', // TODO: translate path_copied: '파일 경로가 클립보드에 복사되었습니다', path_copy_failed: '경로 복사 실패: ', session_rename: '대화 이름 변경', diff --git a/static/ui.js b/static/ui.js index 5dc03b7a..c6a5cd2a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7918,6 +7918,12 @@ function _showWorkspaceRootContextMenu(e){ catch(err){showToast(t('reveal_failed')+(err.message||err));} })); + menu.appendChild(_workspaceContextMenuItem(t('open_in_vscode'),async()=>{ + menu.remove(); + try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:'.'})});} + catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));} + })); + menu.appendChild(_workspaceContextMenuItem(t('copy_file_path'),async()=>{ menu.remove(); try{ @@ -8163,6 +8169,15 @@ function _showFileContextMenu(e, item){ revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}}; menu.appendChild(revealItem); + // Open in VS Code (#2735) + const vscodeItem=document.createElement('div'); + vscodeItem.textContent=t('open_in_vscode'); + vscodeItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + vscodeItem.onmouseenter=()=>vscodeItem.style.background='var(--hover-bg)'; + vscodeItem.onmouseleave=()=>vscodeItem.style.background=''; + vscodeItem.onclick=async()=>{menu.remove();try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}}; + menu.appendChild(vscodeItem); + // Copy file path — resolves the absolute on-disk path on the server (so the // user gets the full /home/.../workspace/foo.py rather than the relative // path the file tree shows) and writes it to the OS clipboard. Useful for diff --git a/tests/test_2735_open_in_vscode.py b/tests/test_2735_open_in_vscode.py new file mode 100644 index 00000000..dbf2fb8c --- /dev/null +++ b/tests/test_2735_open_in_vscode.py @@ -0,0 +1,342 @@ +"""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 diff --git a/tests/test_static_asset_compression_and_cache.py b/tests/test_static_asset_compression_and_cache.py new file mode 100644 index 00000000..3cda9597 --- /dev/null +++ b/tests/test_static_asset_compression_and_cache.py @@ -0,0 +1,272 @@ +"""Regression tests for static-asset compression + cache headers in _serve_static. + +Pre-fix shape: + /static/* served raw bytes with `Cache-Control: no-store` and no + `Content-Encoding`. A page reload over a slow link re-downloaded the + full ~2.4 MB shell on every visit, even though every reference in + static/index.html and static/sw.js carries `?v=__WEBUI_VERSION__` + fingerprinting that already guarantees a fresh URL on redeploy. + +Fix: _serve_static now negotiates gzip when the client opts in, emits +weak ETags for conditional GETs, and sends `max-age=31536000, immutable` +when the request URL carries a `?v=…` fingerprint (`max-age=300` +otherwise). Bytes + headers are cached in-process and invalidated on +(size, mtime) change so a redeploy is picked up without a restart. + +These tests pin both halves — header policy AND the cache-invalidation +contract — so future refactors of _serve_static cannot silently +re-introduce no-store or break the gzip/304 path. +""" + +import gzip +from types import SimpleNamespace +from urllib.parse import urlparse + + +class _FakeHandler: + """Minimal request handler stand-in matching tests/test_session_static_assets.py.""" + + def __init__(self, request_headers=None): + self.status = None + self.sent_headers = [] + self.body = bytearray() + self.wfile = self + self.headers = dict(request_headers or {}) + + def send_response(self, status): + self.status = status + + def send_header(self, name, value): + self.sent_headers.append((name, value)) + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data) + + def header(self, name): + for key, value in self.sent_headers: + if key.lower() == name.lower(): + return value + return None + + +def _make_static_file(static_root, name, content): + path = static_root / name + path.write_bytes(content if isinstance(content, bytes) else content.encode("utf-8")) + return path + + +def _serve(routes, path, query="", request_headers=None): + """Invoke _serve_static via the real urllib parse path.""" + parsed = urlparse(f"http://x{path}{('?' + query) if query else ''}") + h = _FakeHandler(request_headers) + routes._serve_static(h, parsed) + return h + + +def _patch_static_root(monkeypatch, static_root): + """Force _serve_static to read from a temp directory and clear its cache.""" + from api import routes + monkeypatch.setattr( + routes, "_serve_static", + lambda handler, parsed, _root=static_root, _orig=routes._serve_static: _orig(handler, parsed), + ) + # Tests redirect by writing files to the real static dir's parent layout + # via a fixture; instead we monkeypatch the module-level Path computation. + # _serve_static derives static_root from `Path(__file__).parent.parent / "static"`, + # so we monkeypatch __file__ via a closure that re-resolves with our temp tree. + # Simpler: patch the cache and call the real function with a parsed path that + # resolves under the real static dir. We use the fixture below instead. + + +# ── Fixture: build a tiny isolated static tree and rebind paths ─────────── + + +import pytest + + +@pytest.fixture +def isolated_static(tmp_path, monkeypatch): + """Stand up an isolated static/ tree and rebind _serve_static to use it. + + Yields the static_root Path so tests can drop files into it. + """ + from api import routes + + static_root = tmp_path / "static" + static_root.mkdir() + + # Patch the cache so cross-test state cannot leak. + monkeypatch.setattr(routes, "_STATIC_CACHE", {}, raising=True) + + # _serve_static derives static_root from Path(__file__).parent.parent. + # Rebind by monkeypatching Path resolution: we wrap the function so the + # caller-visible signature is unchanged. + original = routes._serve_static + + def wrapped(handler, parsed): + # Trick: temporarily monkeypatch Path so the function sees our temp tree. + import api.routes as ar + orig_file = ar.__file__ + # Place a sentinel api/routes.py "next to" tmp_path so the relative + # walk lands in our static_root. + fake_api_dir = tmp_path / "api" + fake_api_dir.mkdir(exist_ok=True) + fake_routes = fake_api_dir / "routes.py" + if not fake_routes.exists(): + fake_routes.write_text("# stub for path resolution\n") + monkeypatch.setattr(ar, "__file__", str(fake_routes)) + try: + return original(handler, parsed) + finally: + monkeypatch.setattr(ar, "__file__", orig_file) + + monkeypatch.setattr(routes, "_serve_static", wrapped) + yield static_root + + +# ── Tests ───────────────────────────────────────────────────────────────── + + +def test_plain_get_returns_raw_bytes_with_etag(isolated_static): + from api import routes + payload = b"console.log('hello');\n" * 200 # > 1 KB so gzip-eligible + _make_static_file(isolated_static, "ui.js", payload) + + h = _serve(routes, "/static/ui.js") + assert h.status == 200 + assert h.header("Content-Type") == "application/javascript; charset=utf-8" + assert h.header("Content-Encoding") is None # no gzip without Accept-Encoding + assert h.header("ETag") is not None and h.header("ETag").startswith('W/"') + assert h.header("Cache-Control") == "public, max-age=300" # no fingerprint + assert bytes(h.body) == payload + + +def test_gzip_negotiated_when_client_accepts(isolated_static): + from api import routes + payload = (b"a" * 50_000) + _make_static_file(isolated_static, "ui.js", payload) + + h = _serve(routes, "/static/ui.js", request_headers={"Accept-Encoding": "gzip, deflate"}) + assert h.status == 200 + assert h.header("Content-Encoding") == "gzip" + assert h.header("Vary") == "Accept-Encoding" + assert gzip.decompress(bytes(h.body)) == payload + assert int(h.header("Content-Length")) == len(h.body) < len(payload) + + +def test_fingerprinted_url_gets_immutable_cache(isolated_static): + from api import routes + _make_static_file(isolated_static, "ui.js", b"x" * 2000) + + h = _serve(routes, "/static/ui.js", query="v=abc1234") + assert h.header("Cache-Control") == "public, max-age=31536000, immutable" + + +def test_empty_fingerprint_value_gets_short_cache(isolated_static): + """Only a non-empty version token is an immutable-cache fingerprint.""" + from api import routes + _make_static_file(isolated_static, "ui.js", b"x" * 2000) + + h = _serve(routes, "/static/ui.js", query="v=") + assert h.header("Cache-Control") == "public, max-age=300" + + +def test_unfingerprinted_url_gets_short_cache(isolated_static): + from api import routes + _make_static_file(isolated_static, "ui.js", b"x" * 2000) + + h = _serve(routes, "/static/ui.js") + assert h.header("Cache-Control") == "public, max-age=300" + + +def test_conditional_get_returns_304(isolated_static): + from api import routes + _make_static_file(isolated_static, "ui.js", b"hello world\n" * 100) + + first = _serve(routes, "/static/ui.js", query="v=abc") + etag = first.header("ETag") + assert etag is not None + + second = _serve(routes, "/static/ui.js", query="v=abc", + request_headers={"If-None-Match": etag}) + assert second.status == 304 + assert second.header("ETag") == etag + assert second.header("Cache-Control") == "public, max-age=31536000, immutable" + assert second.header("Vary") == "Accept-Encoding" + assert bytes(second.body) == b"" + + +def test_etag_changes_when_file_changes(isolated_static): + """Cache must invalidate when (size, mtime) changes — guards redeploy correctness.""" + import time + from api import routes + + f = _make_static_file(isolated_static, "ui.js", b"v1" * 1000) + first = _serve(routes, "/static/ui.js") + etag_v1 = first.header("ETag") + + # Touch with a later mtime (1 s granularity matches the ETag formula). + time.sleep(1.1) + f.write_bytes(b"v2-different-content" * 50) + + second = _serve(routes, "/static/ui.js") + etag_v2 = second.header("ETag") + assert etag_v1 != etag_v2 + # Old ETag now produces a 200, not a stale 304. + third = _serve(routes, "/static/ui.js", request_headers={"If-None-Match": etag_v1}) + assert third.status == 200 + + +def test_etag_changes_for_same_size_edits_within_same_second(isolated_static): + """The cache signature must keep sub-second mtime precision.""" + import os + from api import routes + + f = _make_static_file(isolated_static, "ui.js", b"a" * 2048) + second = 1_900_000_000 + os.utime(f, ns=(second * 1_000_000_000, second * 1_000_000_000)) + + first = _serve(routes, "/static/ui.js") + etag_v1 = first.header("ETag") + + f.write_bytes(b"b" * 2048) + os.utime(f, ns=(second * 1_000_000_000 + 123_000_000, + second * 1_000_000_000 + 123_000_000)) + + second_response = _serve(routes, "/static/ui.js") + assert second_response.header("ETag") != etag_v1 + assert bytes(second_response.body) == b"b" * 2048 + + +def test_image_is_not_gzipped(isolated_static): + """Already-compressed binary types must skip gzip to avoid wasted CPU.""" + from api import routes + # 4 KB of pseudo-PNG (real header doesn't matter, only the MIME does) + _make_static_file(isolated_static, "favicon.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 4000) + + h = _serve(routes, "/static/favicon.png", request_headers={"Accept-Encoding": "gzip"}) + assert h.status == 200 + assert h.header("Content-Encoding") is None + assert h.header("Content-Type") == "image/png" + + +def test_tiny_file_is_not_gzipped(isolated_static): + """Files under 1 KB skip gzip — framing overhead exceeds savings.""" + from api import routes + _make_static_file(isolated_static, "tiny.js", b"export {};\n") + + h = _serve(routes, "/static/tiny.js", request_headers={"Accept-Encoding": "gzip"}) + assert h.status == 200 + assert h.header("Content-Encoding") is None + + +def test_path_traversal_still_rejected(isolated_static): + """Sandbox check from the original implementation must remain intact.""" + from api import routes + _make_static_file(isolated_static, "ui.js", b"ok") + # Try to break out of static/ — must 404, not serve external files. + h = _serve(routes, "/static/../api/routes.py") + assert h.status == 404