mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
Merge pull request #2816 from nesquena/release/stage-batch5
Release CU: stage-batch5 — 2-PR low-risk batch (v0.51.123) — gzip+ETag static caching / Open in VS Code
This commit is contained in:
@@ -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
|
||||
|
||||
+156
-4
@@ -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
|
||||
|
||||
+60
-40
@@ -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: '대화 이름 변경',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user