mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
33a145a669
## Release v0.50.240 Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures). --- ### Added - **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282 - **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482 - **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479 - **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485 - **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481 - **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568 - **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281 - **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268 - **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269 ### Fixed - **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266 - **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278 - **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267 - **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273 --- ### Test results ``` 3199 passed, 2 skipped, 3 xpassed in 72.79s ``` ### PRs on hold (not included) #1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
228 lines
9.9 KiB
Python
228 lines
9.9 KiB
Python
"""Test: Excalidraw inline embed (#479)"""
|
|
import re
|
|
|
|
|
|
def test_excalidraw_extension_regex():
|
|
"""Verify _EXCALIDRAW_EXTS regex is defined."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
assert '_EXCALIDRAW_EXTS' in src, "Missing _EXCALIDRAW_EXTS regex"
|
|
assert '.excalidraw' in src, "Excalidraw regex should match .excalidraw"
|
|
|
|
|
|
def test_excalidraw_media_handler():
|
|
"""Verify MEDIA: .excalidraw files trigger inline loading."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
assert 'excalidraw-inline-load' in src, "Missing excalidraw-inline-load class"
|
|
assert 'excalidraw_loading' in src, "Missing excalidraw_loading i18n key usage"
|
|
|
|
|
|
def test_loadExcalidrawInline_function():
|
|
"""Verify loadExcalidrawInline lazy-load function exists."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
assert 'function loadExcalidrawInline()' in src, "Missing loadExcalidrawInline function"
|
|
|
|
|
|
def test_excalidraw_json_validation():
|
|
"""Verify Excalidraw handler validates JSON format."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
|
|
assert 'JSON.parse' in func, "Should parse JSON"
|
|
assert 'excalidraw_invalid' in func, "Should handle invalid format"
|
|
assert "data.type!=='excalidraw'" in func, "Should validate type field is 'excalidraw'"
|
|
|
|
|
|
def test_excalidraw_size_cap():
|
|
"""Verify Excalidraw inline rendering has a size cap."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
|
|
assert 'EXCALIDRAW_MAX_SIZE' in func, "Should have EXCALIDRAW_MAX_SIZE constant"
|
|
assert 'excalidraw_too_large' in func, "Should use excalidraw_too_large i18n for oversized files"
|
|
|
|
|
|
def test_excalidraw_error_handling():
|
|
"""Verify Excalidraw error handling."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 3500]
|
|
assert 'excalidraw_error' in func, "Should use excalidraw_error i18n on fetch failure"
|
|
|
|
|
|
def test_excalidraw_svg_renderer_exists():
|
|
"""Verify SVG renderer for Excalidraw elements exists."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
assert 'function _renderExcalidrawCanvases()' in src, "Missing _renderExcalidrawCanvases function"
|
|
start = src.find('function _renderExcalidrawCanvases()')
|
|
end = src.find('// ── PDF inline preview', start)
|
|
render = src[start:end if end != -1 else start + 8000]
|
|
assert '<svg' in render, "Should generate SVG"
|
|
assert 'excalidraw-svg' in render, "Should use excalidraw-svg CSS class"
|
|
|
|
|
|
def test_excalidraw_renders_element_types():
|
|
"""Verify SVG renderer handles common Excalidraw element types."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
start = src.find('function _renderExcalidrawCanvases()')
|
|
end = src.find('// ── PDF inline preview', start)
|
|
render = src[start:end if end != -1 else start + 8000]
|
|
element_types = ['rectangle', 'ellipse', 'text', 'line', 'arrow', 'diamond', 'draw']
|
|
for etype in element_types:
|
|
assert f"el.type==='{etype}'" in render, f"Should handle element type: {etype}"
|
|
|
|
|
|
def test_excalidraw_arrow_marker():
|
|
"""Verify SVG renderer includes arrow marker definition."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
start = src.find('function _renderExcalidrawCanvases()')
|
|
end = src.find('// ── PDF inline preview', start)
|
|
render = src[start:end if end != -1 else start + 8000]
|
|
assert 'arrowhead' in render, "Should define arrowhead marker for arrows"
|
|
assert '<marker' in render, "Should use SVG <marker> element"
|
|
|
|
|
|
def test_excalidraw_bounds_calculation():
|
|
"""Verify SVG renderer calculates viewBox from element bounds."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
start = src.find('function _renderExcalidrawCanvases()')
|
|
end = src.find('// ── PDF inline preview', start)
|
|
render = src[start:end if end != -1 else start + 8000]
|
|
assert 'viewBox' in render, "Should calculate SVG viewBox"
|
|
assert 'minX' in render, "Should track minimum X bound"
|
|
assert 'maxX' in render, "Should track maximum X bound"
|
|
|
|
|
|
def test_excalidraw_empty_elements():
|
|
"""Verify empty diagrams show a message."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
start = src.find('function _renderExcalidrawCanvases()')
|
|
end = src.find('// ── PDF inline preview', start)
|
|
render = src[start:end if end != -1 else start + 8000]
|
|
assert 'excalidraw_empty' in render, "Should handle empty diagrams"
|
|
assert 'excalidraw_render_error' in render, "Should handle render errors"
|
|
|
|
|
|
def test_excalidraw_download_link():
|
|
"""Verify Excalidraw embed includes download link."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
func = src[src.find('function loadExcalidrawInline()'):src.find('function loadExcalidrawInline()') + 2000]
|
|
assert 'excalidraw-open-link' in func, "Should include open/download link"
|
|
assert 'excalidraw_download' in func, "Should use excalidraw_download i18n"
|
|
|
|
|
|
def test_excalidraw_called_after_render():
|
|
"""Verify loadExcalidrawInline is called after message rendering."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
assert src.count('loadExcalidrawInline()') >= 2, \
|
|
"loadExcalidrawInline should be called at least twice"
|
|
|
|
|
|
def test_excalidraw_embed_wrap_structure():
|
|
"""Verify Excalidraw embed uses proper container structure."""
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
assert 'excalidraw-embed-wrap' in src, "Missing excalidraw-embed-wrap container"
|
|
assert 'excalidraw-canvas' in src, "Missing excalidraw-canvas div"
|
|
assert 'data-excalidraw' in src, "Missing data-excalidraw attribute"
|
|
|
|
|
|
def test_excalidraw_i18n_keys():
|
|
"""Verify Excalidraw i18n keys exist in all 7 locales."""
|
|
with open('static/i18n.js') as f:
|
|
src = f.read()
|
|
required_keys = [
|
|
'excalidraw_loading', 'excalidraw_too_large', 'excalidraw_invalid',
|
|
'excalidraw_error', 'excalidraw_label', 'excalidraw_download',
|
|
'excalidraw_empty', 'excalidraw_render_error',
|
|
]
|
|
for key in required_keys:
|
|
count = src.count(f"{key}:")
|
|
assert count == 7, f"Key '{key}' found {count} times, expected 7"
|
|
|
|
|
|
def test_excalidraw_css_classes():
|
|
"""Verify Excalidraw CSS classes are defined."""
|
|
with open('static/style.css') as f:
|
|
src = f.read()
|
|
required_classes = [
|
|
'excalidraw-embed-wrap', 'excalidraw-canvas', 'excalidraw-svg',
|
|
'excalidraw-empty', 'excalidraw-open-link',
|
|
]
|
|
for cls in required_classes:
|
|
assert cls in src, f"Missing CSS class: .{cls}"
|
|
|
|
|
|
# ── XSS regression: SVG attribute injection from JSON values ────────────────
|
|
#
|
|
# The Excalidraw renderer parses JSON from a remote/attacker-controllable file
|
|
# and interpolates field values (strokeColor, backgroundColor, strokeWidth,
|
|
# fontSize, points coordinates) into raw SVG attribute templates. The whole
|
|
# SVG string is then assigned to el.innerHTML — so any value that can
|
|
# contain `"`, `<`, `>` could break out of the attribute and inject DOM.
|
|
#
|
|
# Example attack payload in a malicious .excalidraw file:
|
|
# {"elements":[{"type":"rectangle","x":0,"y":0,"width":10,"height":10,
|
|
# "strokeColor":"red\"/></svg><img src=x onerror=alert(1)>"}]}
|
|
#
|
|
# Defense: string colors/fonts must flow through an HTML attribute escaper;
|
|
# numeric fields (strokeWidth, fontSize, x/y/width/height, point coords) must
|
|
# be coerced via Number()/isFinite gates so they cannot carry strings.
|
|
|
|
def _excalidraw_render_block():
|
|
with open('static/ui.js') as f:
|
|
src = f.read()
|
|
start = src.find('function _renderExcalidrawCanvases')
|
|
assert start != -1, '_renderExcalidrawCanvases not found'
|
|
# End at next sibling section
|
|
end = src.find('// ── PDF inline preview', start)
|
|
assert end != -1, 'end marker not found'
|
|
return src[start:end]
|
|
|
|
|
|
def test_excalidraw_string_color_fields_are_attribute_escaped():
|
|
"""strokeColor / backgroundColor flow into stroke="..." / fill="..." in
|
|
SVG attributes. They must run through an HTML attribute escaper before
|
|
interpolation, otherwise a value like 'red"/><script>...' breaks out."""
|
|
block = _excalidraw_render_block()
|
|
# The escaper helper used in this block (named _sa for SVG-attr escape).
|
|
# If renamed, update both the helper and this assertion together.
|
|
assert '_sa(el.strokeColor' in block, (
|
|
'el.strokeColor must be escaped via _sa() before SVG attribute interpolation'
|
|
)
|
|
assert '_sa(el.backgroundColor' in block, (
|
|
'el.backgroundColor must be escaped via _sa() before SVG attribute interpolation'
|
|
)
|
|
# Helper definition must exist and escape the four HTML-significant chars.
|
|
assert "const _sa=" in block, 'attribute-escape helper _sa must be defined'
|
|
for ch in ('&', '"', '<', '>'):
|
|
assert repr(ch) in repr(block) or ch in block.split("const _sa=", 1)[1].split('\n', 1)[0], (
|
|
f'attribute escaper must replace {ch!r}'
|
|
)
|
|
|
|
|
|
def test_excalidraw_numeric_fields_are_coerced_via_Number():
|
|
"""strokeWidth / fontSize / x / y / width / height / point coords must be
|
|
coerced to finite numbers, so a string like '2"/><script>...' cannot leak
|
|
into the SVG attribute."""
|
|
block = _excalidraw_render_block()
|
|
assert 'const _num=' in block, 'numeric coerce helper _num must be defined'
|
|
assert '_num(el.strokeWidth' in block, 'strokeWidth must be coerced via _num()'
|
|
assert '_num(el.fontSize' in block or '_num(el.x' in block, (
|
|
'numeric el.* fields must flow through _num() for coercion'
|
|
)
|
|
# The bare `el.strokeWidth||2` and `el.x||0` pattern is the bug; ensure
|
|
# neither pattern remains after the fix.
|
|
assert 'el.strokeWidth||2' not in block, (
|
|
'strokeWidth must use _num() coerce, not || fallback (string passes through ||)'
|
|
)
|