mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
Consolidate session post-render processing
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2166** by @franksong2702 — Session switches no longer repeat the transcript post-render pass multiple times. The message renderer now schedules one `postProcessRenderedMessages(inner)` pass for both cached and freshly rebuilt transcripts, scopes inline preview/math/tree/code helpers to the rendered message container, and removes the extra idle-session `highlightCode()` call after `renderMessages()`. Local 8787 measurement on three large real sessions reduced post-render helper calls from `highlightCode=3x` / PDF+HTML+Mermaid+KaTeX `2x` to `1x` each, with static regression coverage pinning the consolidated pass.
|
||||
|
||||
- **PR #2136** by @LumenYoung — Stale stream writebacks no longer poison the active session transcript. `cancel_stream()` intentionally clears `active_stream_id` early so the UI can accept a follow-up turn while an old worker is unwinding — but the old worker could still return later from `run_conversation()` and persist its stale result over the newer transcript, causing visible transcript / turn journal / `state.db` to disagree (especially around cancel+retry on compressed continuations). Adds a single-line ownership check `_stream_writeback_is_current(session, stream_id)` (token equality against `session.active_stream_id`) and short-circuits both finalize paths: the success path in `_run_agent_streaming` and the cancel-handler path in `cancel_stream()`. When the stream no longer owns the writeback, both paths log `Skipping stale stream/cancel writeback` and return cleanly without persisting. 89-line regression suite in `tests/test_stale_stream_writeback.py`; companion updates to `tests/test_issue1361_cancel_data_loss.py` and `tests/test_sprint42.py` for the new return-without-persist behavior.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -654,10 +654,7 @@ async function loadSession(sid){
|
||||
updateQueueBadge(sid);
|
||||
syncTopbar();renderMessages();
|
||||
if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid);
|
||||
// Kick off loadDir first (issues network requests), then highlight code.
|
||||
// The fetch is dispatched before the CPU-bound Prism pass begins.
|
||||
const _dirP=loadDir('.');
|
||||
highlightCode();
|
||||
await _dirP;
|
||||
}
|
||||
}
|
||||
|
||||
+39
-20
@@ -4851,8 +4851,7 @@ function renderMessages(options){
|
||||
_wireMessageWindowLoadEarlierButton();
|
||||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||||
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>postProcessRenderedMessages(inner));
|
||||
if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver();
|
||||
if(typeof loadTodos==='function'&&document.getElementById('panelTodos')&&document.getElementById('panelTodos').classList.contains('active')){loadTodos();}
|
||||
return;
|
||||
@@ -5409,8 +5408,7 @@ function renderMessages(options){
|
||||
// scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up.
|
||||
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
|
||||
// Apply syntax highlighting after DOM is built
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>postProcessRenderedMessages(inner));
|
||||
// Refresh todo panel if it's currently open
|
||||
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
|
||||
loadTodos();
|
||||
@@ -5721,6 +5719,19 @@ async function regenerateResponse(btn) {
|
||||
} catch(e) { setStatus(t('regen_failed') + e.message); }
|
||||
}
|
||||
|
||||
function postProcessRenderedMessages(container) {
|
||||
highlightCode(container);
|
||||
addCopyButtons(container);
|
||||
loadDiffInline(container);
|
||||
loadCsvInline(container);
|
||||
loadExcalidrawInline(container);
|
||||
loadPdfInline(container);
|
||||
loadHtmlInline(container);
|
||||
renderMermaidBlocks(container);
|
||||
renderKatexBlocks(container);
|
||||
initTreeViews(container);
|
||||
}
|
||||
|
||||
function highlightCode(container) {
|
||||
// Apply Prism.js syntax highlighting to all code blocks in container (or whole messages area)
|
||||
if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return;
|
||||
@@ -5744,8 +5755,9 @@ function _loadJsyamlThen(cb){
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function initTreeViews(){
|
||||
document.querySelectorAll('.code-tree-wrap:not([data-tree-init])').forEach(wrap=>{
|
||||
function initTreeViews(container){
|
||||
const root=container||document;
|
||||
root.querySelectorAll('.code-tree-wrap:not([data-tree-init])').forEach(wrap=>{
|
||||
const rawText=wrap.dataset.raw;
|
||||
const lang=wrap.dataset.lang;
|
||||
let parsed=null;
|
||||
@@ -5902,9 +5914,10 @@ function addCopyButtons(container){
|
||||
let _mermaidLoading=false;
|
||||
let _mermaidReady=false;
|
||||
|
||||
function loadDiffInline(){
|
||||
function loadDiffInline(container){
|
||||
const DIFF_MAX_SIZE=512*1024; // 512 KB cap for inline diff rendering
|
||||
document.querySelectorAll('.diff-inline-load:not([data-loaded])').forEach(el=>{
|
||||
const root=container||document;
|
||||
root.querySelectorAll('.diff-inline-load:not([data-loaded])').forEach(el=>{
|
||||
el.setAttribute('data-loaded','1');
|
||||
const path=el.dataset.path;
|
||||
fetch('api/media?path='+encodeURIComponent(path))
|
||||
@@ -5929,9 +5942,10 @@ function loadDiffInline(){
|
||||
});
|
||||
}
|
||||
|
||||
function loadCsvInline(){
|
||||
function loadCsvInline(container){
|
||||
const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering
|
||||
document.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{
|
||||
const root=container||document;
|
||||
root.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{
|
||||
el.setAttribute('data-loaded','1');
|
||||
const path=el.dataset.path;
|
||||
fetch('api/media?path='+encodeURIComponent(path))
|
||||
@@ -5964,9 +5978,10 @@ function loadCsvInline(){
|
||||
});
|
||||
}
|
||||
|
||||
function loadExcalidrawInline(){
|
||||
function loadExcalidrawInline(container){
|
||||
const EXCALIDRAW_MAX_SIZE=512*1024; // 512 KB cap
|
||||
document.querySelectorAll('.excalidraw-inline-load:not([data-loaded])').forEach(el=>{
|
||||
const root=container||document;
|
||||
root.querySelectorAll('.excalidraw-inline-load:not([data-loaded])').forEach(el=>{
|
||||
el.setAttribute('data-loaded','1');
|
||||
const path=el.dataset.path;
|
||||
fetch('api/media?path='+encodeURIComponent(path))
|
||||
@@ -6090,9 +6105,10 @@ function _renderExcalidrawCanvases(){
|
||||
// the full buffer is received — ideally the server would enforce it before
|
||||
// streaming (out of scope for this client-side PR).
|
||||
let _pdfjsReady=false, _pdfjsLoading=false;
|
||||
function loadPdfInline(){
|
||||
function loadPdfInline(container){
|
||||
const PDF_MAX_SIZE=4*1024*1024; // 4 MB cap for inline PDF preview
|
||||
document.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{
|
||||
const root=container||document;
|
||||
root.querySelectorAll('.pdf-preview-load:not([data-loaded])').forEach(el=>{
|
||||
el.setAttribute('data-loaded','1');
|
||||
const path=el.dataset.path;
|
||||
const fname=path.split('/').pop()||path;
|
||||
@@ -6164,9 +6180,10 @@ function loadPdfInline(){
|
||||
}
|
||||
|
||||
// ── HTML inline preview (sandboxed iframe) ─────────────────────────────────
|
||||
function loadHtmlInline(){
|
||||
function loadHtmlInline(container){
|
||||
const HTML_MAX_SIZE=256*1024; // 256 KB cap for inline HTML preview
|
||||
document.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{
|
||||
const root=container||document;
|
||||
root.querySelectorAll('.html-preview-load:not([data-loaded])').forEach(el=>{
|
||||
el.setAttribute('data-loaded','1');
|
||||
const path=el.dataset.path;
|
||||
const fname=path.split('/').pop()||path;
|
||||
@@ -6189,8 +6206,9 @@ function loadHtmlInline(){
|
||||
});
|
||||
}
|
||||
|
||||
function renderMermaidBlocks(){
|
||||
const blocks=document.querySelectorAll('.mermaid-block:not([data-rendered])');
|
||||
function renderMermaidBlocks(container){
|
||||
const root=container||document;
|
||||
const blocks=root.querySelectorAll('.mermaid-block:not([data-rendered])');
|
||||
if(!blocks.length) return;
|
||||
if(!_mermaidReady){
|
||||
if(!_mermaidLoading){
|
||||
@@ -6239,8 +6257,9 @@ function renderMermaidBlocks(){
|
||||
let _katexLoading=false;
|
||||
let _katexReady=false;
|
||||
|
||||
function renderKatexBlocks(){
|
||||
const blocks=document.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])');
|
||||
function renderKatexBlocks(container){
|
||||
const root=container||document;
|
||||
const blocks=root.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])');
|
||||
if(!blocks.length) return;
|
||||
if(!_katexReady){
|
||||
if(!_katexLoading){
|
||||
|
||||
@@ -53,14 +53,14 @@ def test_loadCsvInline_function():
|
||||
"""Verify loadCsvInline lazy-load function exists."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert 'function loadCsvInline()' in src, "Missing loadCsvInline function"
|
||||
assert 'function loadCsvInline' in src, "Missing loadCsvInline function"
|
||||
|
||||
|
||||
def test_csv_inline_max_size():
|
||||
"""Verify CSV inline rendering has a size cap."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
|
||||
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
|
||||
assert 'CSV_MAX_SIZE' in csv_section, "Should have CSV_MAX_SIZE constant"
|
||||
assert 'csv_too_large' in csv_section, "Should use csv_too_large i18n for oversized files"
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_csv_auto_detect_separator():
|
||||
"""Verify CSV handler auto-detects separator."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
|
||||
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
|
||||
assert 'separators' in csv_section, "Should have separator detection"
|
||||
assert ';' in csv_section, "Should detect semicolon separator"
|
||||
assert 'tab' in csv_section.lower() or '\\t' in csv_section, "Should detect tab separator"
|
||||
@@ -86,24 +86,26 @@ def test_csv_error_handling():
|
||||
"""Verify CSV error and empty data handling."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2500]
|
||||
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2500]
|
||||
assert 'csv_error' in csv_section, "Should use csv_error i18n on fetch failure"
|
||||
assert 'csv_no_data' in csv_section, "Should use csv_no_data i18n for insufficient data"
|
||||
|
||||
|
||||
def test_csv_loadCsvInline_called_after_render():
|
||||
"""Verify loadCsvInline is called in requestAnimationFrame after rendering."""
|
||||
"""Verify loadCsvInline is called by the consolidated post-render pass."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert src.count('loadCsvInline()') >= 2, \
|
||||
"loadCsvInline should be called at least twice (initial render + cache restore)"
|
||||
assert 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' in src
|
||||
idx = src.find('function postProcessRenderedMessages')
|
||||
body = src[idx:idx + 500]
|
||||
assert 'loadCsvInline(container)' in body, "post-process should call loadCsvInline once per render"
|
||||
|
||||
|
||||
def test_csv_line_ending_normalization():
|
||||
"""Verify CSV handler normalizes line endings."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
csv_section = src[src.find('function loadCsvInline()'):src.find('function loadCsvInline()') + 2000]
|
||||
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
|
||||
assert '\\r\\n' in csv_section, "Should handle \\r\\n line endings"
|
||||
assert '\\r' in csv_section, "Should handle \\r line endings"
|
||||
|
||||
|
||||
@@ -22,14 +22,14 @@ 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"
|
||||
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]
|
||||
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'"
|
||||
@@ -39,7 +39,7 @@ 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]
|
||||
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"
|
||||
|
||||
@@ -48,7 +48,7 @@ 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]
|
||||
func = src[src.find('function loadExcalidrawInline'):src.find('function loadExcalidrawInline') + 3500]
|
||||
assert 'excalidraw_error' in func, "Should use excalidraw_error i18n on fetch failure"
|
||||
|
||||
|
||||
@@ -114,17 +114,21 @@ 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]
|
||||
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."""
|
||||
"""Verify loadExcalidrawInline is called by the consolidated post-render pass."""
|
||||
with open('static/ui.js') as f:
|
||||
src = f.read()
|
||||
assert src.count('loadExcalidrawInline()') >= 2, \
|
||||
"loadExcalidrawInline should be called at least twice"
|
||||
assert 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' in src
|
||||
idx = src.find('function postProcessRenderedMessages')
|
||||
body = src[idx:idx + 500]
|
||||
assert 'loadExcalidrawInline(container)' in body, (
|
||||
"post-process should call loadExcalidrawInline once per render"
|
||||
)
|
||||
|
||||
|
||||
def test_excalidraw_embed_wrap_structure():
|
||||
|
||||
+9
-12
@@ -166,7 +166,7 @@ def test_data_katex_attribute_present():
|
||||
|
||||
def test_render_katex_blocks_function_exists():
|
||||
"""renderKatexBlocks() function must exist in ui.js."""
|
||||
assert 'function renderKatexBlocks()' in UI_JS, \
|
||||
assert 'function renderKatexBlocks' in UI_JS, \
|
||||
'renderKatexBlocks() function not found in ui.js'
|
||||
|
||||
|
||||
@@ -202,21 +202,18 @@ def test_katex_throw_on_error_false():
|
||||
|
||||
|
||||
def test_render_katex_blocks_wired_into_raf():
|
||||
"""renderKatexBlocks() must be called in the same requestAnimationFrame as renderMermaidBlocks()."""
|
||||
# Check that renderKatexBlocks appears somewhere near requestAnimationFrame
|
||||
raf_idx = UI_JS.find('requestAnimationFrame')
|
||||
# Find the rAF call that also contains renderKatexBlocks
|
||||
has_katex_in_raf = any(
|
||||
'renderKatexBlocks' in UI_JS[m.start():m.start()+200]
|
||||
for m in re.finditer(r'requestAnimationFrame', UI_JS)
|
||||
)
|
||||
assert has_katex_in_raf, \
|
||||
'renderKatexBlocks() not found in any requestAnimationFrame call — math will not render'
|
||||
"""renderKatexBlocks() must run from the post-render requestAnimationFrame pass."""
|
||||
raf_call = 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))'
|
||||
assert raf_call in UI_JS, 'post-render requestAnimationFrame pass not found'
|
||||
idx = UI_JS.find('function postProcessRenderedMessages')
|
||||
body = UI_JS[idx:idx + 500]
|
||||
assert 'renderMermaidBlocks(container)' in body
|
||||
assert 'renderKatexBlocks(container)' in body
|
||||
|
||||
|
||||
def test_mermaid_render_failure_removes_temporary_error_dom():
|
||||
"""Failed Mermaid renders must not leave Mermaid's body-level syntax-error SVG visible."""
|
||||
fn_start = UI_JS.find('function renderMermaidBlocks()')
|
||||
fn_start = UI_JS.find('function renderMermaidBlocks')
|
||||
assert fn_start != -1, 'renderMermaidBlocks() function not found in ui.js'
|
||||
fn = UI_JS[fn_start:fn_start + 2200]
|
||||
cleanup = "const tmp=document.getElementById('d'+id);\n if(tmp) tmp.remove();"
|
||||
|
||||
@@ -56,14 +56,17 @@ class TestMediaDiffInline:
|
||||
"""loadDiffInline() function should be defined."""
|
||||
with open("static/ui.js", "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "function loadDiffInline()" in content
|
||||
assert "function loadDiffInline" in content
|
||||
|
||||
def test_loadDiffInline_called_in_post_render(self):
|
||||
"""loadDiffInline() should be called in post-render (after addCopyButtons)."""
|
||||
"""loadDiffInline() should be called by the consolidated post-render pass."""
|
||||
with open("static/ui.js", "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
count = content.count("loadDiffInline()")
|
||||
assert count >= 2, f"loadDiffInline() called {count} times, expected >= 2 (cached + fresh render)"
|
||||
assert "requestAnimationFrame(()=>postProcessRenderedMessages(inner))" in content
|
||||
start = content.find("function postProcessRenderedMessages")
|
||||
body = content[start:start + 500]
|
||||
assert "addCopyButtons(container)" in body
|
||||
assert "loadDiffInline(container)" in body
|
||||
|
||||
def test_diff_inline_error_class(self):
|
||||
"""Should have error state class."""
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestTreeRenderer:
|
||||
def test_initTreeViews_function_exists(self):
|
||||
with open("static/ui.js", "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "function initTreeViews()" in content
|
||||
assert "function initTreeViews" in content
|
||||
|
||||
def test_buildTreeDOM_function_exists(self):
|
||||
with open("static/ui.js", "r", encoding="utf-8") as f:
|
||||
@@ -30,8 +30,10 @@ class TestTreeRenderer:
|
||||
def test_initTreeViews_called_in_post_render(self):
|
||||
with open("static/ui.js", "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
count = content.count("initTreeViews()")
|
||||
assert count >= 2, f"initTreeViews() called {count} times, expected >= 2"
|
||||
assert "requestAnimationFrame(()=>postProcessRenderedMessages(inner))" in content
|
||||
start = content.find("function postProcessRenderedMessages")
|
||||
body = content[start:start + 500]
|
||||
assert "initTreeViews(container)" in body
|
||||
|
||||
def test_tree_handles_all_value_types(self):
|
||||
"""_buildTreeDOM should handle null, boolean, number, string, array, object."""
|
||||
|
||||
@@ -59,15 +59,14 @@ class TestLoadDirParallelPrefetch:
|
||||
)
|
||||
|
||||
|
||||
# ── 2. sessions.js: loadSession idle path overlaps loadDir and highlightCode ─
|
||||
# ── 2. sessions.js: loadSession idle path avoids duplicate highlighting ─
|
||||
|
||||
|
||||
class TestLoadSessionIdleOverlap:
|
||||
"""The idle path in loadSession() must start loadDir() before running
|
||||
highlightCode() so the network request is in-flight during the CPU-bound
|
||||
Prism.js pass."""
|
||||
"""The idle path in loadSession() should rely on renderMessages() for the
|
||||
post-render transcript pass instead of running another Prism.js pass."""
|
||||
|
||||
def test_idle_path_starts_loaddir_before_highlightcode(self):
|
||||
def test_idle_path_does_not_repeat_highlight_after_render_messages(self):
|
||||
idle_marker = "S.busy=false"
|
||||
positions = []
|
||||
start = 0
|
||||
@@ -81,25 +80,22 @@ class TestLoadSessionIdleOverlap:
|
||||
found = False
|
||||
for pos in positions:
|
||||
block = SESSIONS_JS[pos : pos + 600]
|
||||
has_highlight = "highlightCode()" in block
|
||||
has_loaddir = "loadDir('.')" in block
|
||||
if has_highlight and has_loaddir:
|
||||
has_render = "renderMessages()" in block
|
||||
if has_loaddir and has_render:
|
||||
found = True
|
||||
loaddir_idx = block.find("loadDir(")
|
||||
highlight_idx = block.find("highlightCode()")
|
||||
assert loaddir_idx < highlight_idx, (
|
||||
"In the idle path, loadDir() should be started before "
|
||||
"highlightCode() so the network request is dispatched first."
|
||||
assert "highlightCode()" not in block, (
|
||||
"The idle path should rely on renderMessages()'s consolidated "
|
||||
"post-render pass instead of running a second highlight pass."
|
||||
)
|
||||
assert "await" in block and "_dirP" in block, (
|
||||
"loadDir() result should be stored and awaited after "
|
||||
"highlightCode() completes."
|
||||
"loadDir() result should still be stored and awaited."
|
||||
)
|
||||
break
|
||||
|
||||
assert found, (
|
||||
"Could not find the idle path in loadSession that calls both "
|
||||
"loadDir and highlightCode."
|
||||
"renderMessages and loadDir."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -104,37 +104,37 @@ class TestLoadPdfInlineFunction:
|
||||
|
||||
def test_function_exists(self):
|
||||
ui = _read_js('ui.js')
|
||||
assert 'function loadPdfInline()' in ui, 'loadPdfInline() function must exist'
|
||||
assert 'function loadPdfInline' in ui, 'loadPdfInline() function must exist'
|
||||
|
||||
def test_selects_pdf_preview_load_elements(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadPdfInline()')
|
||||
idx = ui.find('function loadPdfInline')
|
||||
body = ui[idx:idx + 500]
|
||||
assert 'pdf-preview-load' in body, 'Must query .pdf-preview-load elements'
|
||||
assert 'data-loaded' in body, 'Must use data-loaded attribute to prevent double-processing'
|
||||
|
||||
def test_fetches_via_api_media(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadPdfInline()')
|
||||
idx = ui.find('function loadPdfInline')
|
||||
body = ui[idx:idx + 1500]
|
||||
assert 'api/media?path=' in body, 'Must fetch PDF via api/media endpoint'
|
||||
|
||||
def test_has_size_cap(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadPdfInline()')
|
||||
idx = ui.find('function loadPdfInline')
|
||||
body = ui[idx:idx + 1500]
|
||||
assert 'MAX_SIZE' in body or 'byteLength' in body, 'Must enforce a size cap on PDF files'
|
||||
|
||||
def test_fallback_on_error(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadPdfInline()')
|
||||
idx = ui.find('function loadPdfInline')
|
||||
body = ui[idx:idx + 3000]
|
||||
assert 'pdf_error' in body, 'Must show error fallback on failure'
|
||||
assert 'pdf_download' in body or 'download=' in body, 'Error fallback must include download link'
|
||||
|
||||
def test_lazy_loads_pdfjs_from_cdn(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadPdfInline()')
|
||||
idx = ui.find('function loadPdfInline')
|
||||
body = ui[idx:idx + 3000]
|
||||
assert 'pdfjs' in body, 'Must lazy-load PDF.js from CDN'
|
||||
|
||||
@@ -149,44 +149,44 @@ class TestLoadHtmlInlineFunction:
|
||||
|
||||
def test_function_exists(self):
|
||||
ui = _read_js('ui.js')
|
||||
assert 'function loadHtmlInline()' in ui, 'loadHtmlInline() function must exist'
|
||||
assert 'function loadHtmlInline' in ui, 'loadHtmlInline() function must exist'
|
||||
|
||||
def test_selects_html_preview_load_elements(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
idx = ui.find('function loadHtmlInline')
|
||||
body = ui[idx:idx + 500]
|
||||
assert 'html-preview-load' in body, 'Must query .html-preview-load elements'
|
||||
assert 'data-loaded' in body, 'Must use data-loaded attribute'
|
||||
|
||||
def test_fetches_via_api_media(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
idx = ui.find('function loadHtmlInline')
|
||||
body = ui[idx:idx + 1000]
|
||||
assert 'api/media?path=' in body, 'Must fetch HTML via api/media endpoint'
|
||||
|
||||
def test_has_size_cap(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
idx = ui.find('function loadHtmlInline')
|
||||
body = ui[idx:idx + 1000]
|
||||
assert 'MAX_SIZE' in body or 'html.length' in body, 'Must enforce a size cap on HTML files'
|
||||
|
||||
def test_fallback_on_error(self):
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
idx = ui.find('function loadHtmlInline')
|
||||
body = ui[idx:idx + 2000]
|
||||
assert 'html_error' in body, 'Must show error fallback on failure'
|
||||
|
||||
def test_uses_srcdoc_attribute(self):
|
||||
"""Must use srcdoc (not src) for HTML content to keep it same-origin sandboxed."""
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
idx = ui.find('function loadHtmlInline')
|
||||
body = ui[idx:idx + 1500]
|
||||
assert 'srcdoc=' in body, 'Must use srcdoc attribute for inline HTML rendering'
|
||||
|
||||
def test_escapes_html_for_srcdoc(self):
|
||||
"""HTML content must be escaped before embedding in srcdoc to prevent attribute injection."""
|
||||
ui = _read_js('ui.js')
|
||||
idx = ui.find('function loadHtmlInline()')
|
||||
idx = ui.find('function loadHtmlInline')
|
||||
body = ui[idx:idx + 1500]
|
||||
# Must escape &, <, >, " to prevent breaking out of srcdoc attribute
|
||||
assert '&' in body or 'replace' in body, 'Must escape HTML entities for srcdoc'
|
||||
@@ -195,31 +195,34 @@ class TestLoadHtmlInlineFunction:
|
||||
# ── requestAnimationFrame integration ──────────────────────────────────────
|
||||
|
||||
class TestRAFIntegration:
|
||||
"""Both lazy-load functions must be called in the requestAnimationFrame blocks."""
|
||||
"""Lazy-load functions must be called by the consolidated post-render pass."""
|
||||
|
||||
def test_loadPdfInline_called_after_render(self):
|
||||
ui = _read_js('ui.js')
|
||||
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
|
||||
load_blocks = [b for b in raf_blocks if 'loadDiffInline' in b]
|
||||
assert len(load_blocks) >= 2, 'Expected at least 2 rAF blocks with loadDiffInline'
|
||||
for block in load_blocks:
|
||||
assert 'loadPdfInline()' in block, 'loadPdfInline() must be called alongside loadDiffInline'
|
||||
idx = ui.find('function postProcessRenderedMessages')
|
||||
body = ui[idx:idx + 500]
|
||||
assert 'loadDiffInline(container)' in body, 'post-process must call loadDiffInline'
|
||||
assert 'loadPdfInline(container)' in body, 'post-process must call loadPdfInline alongside loadDiffInline'
|
||||
|
||||
def test_loadHtmlInline_called_after_render(self):
|
||||
ui = _read_js('ui.js')
|
||||
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
|
||||
load_blocks = [b for b in raf_blocks if 'loadDiffInline' in b]
|
||||
for block in load_blocks:
|
||||
assert 'loadHtmlInline()' in block, 'loadHtmlInline() must be called alongside loadDiffInline'
|
||||
idx = ui.find('function postProcessRenderedMessages')
|
||||
body = ui[idx:idx + 500]
|
||||
assert 'loadDiffInline(container)' in body, 'post-process must call loadDiffInline'
|
||||
assert 'loadHtmlInline(container)' in body, 'post-process must call loadHtmlInline alongside loadDiffInline'
|
||||
|
||||
def test_initTreeViews_blocks_also_call_loaders(self):
|
||||
"""rAF blocks with initTreeViews (not loadDiffInline) must also call PDF/HTML loaders."""
|
||||
"""Tree views and inline loaders must share the same post-process pass."""
|
||||
ui = _read_js('ui.js')
|
||||
raf_blocks = re.findall(r'requestAnimationFrame\(\(\)=>\{[^}]+\}\)', ui)
|
||||
tree_blocks = [b for b in raf_blocks if 'initTreeViews' in b and 'loadDiffInline' not in b]
|
||||
for block in tree_blocks:
|
||||
assert 'loadPdfInline()' in block, 'initTreeViews rAF block must also call loadPdfInline'
|
||||
assert 'loadHtmlInline()' in block, 'initTreeViews rAF block must also call loadHtmlInline'
|
||||
idx = ui.find('function postProcessRenderedMessages')
|
||||
body = ui[idx:idx + 500]
|
||||
assert 'initTreeViews(container)' in body, 'post-process must initialize tree views'
|
||||
assert 'loadPdfInline(container)' in body, 'post-process must also call loadPdfInline'
|
||||
assert 'loadHtmlInline(container)' in body, 'post-process must also call loadHtmlInline'
|
||||
|
||||
def test_message_render_uses_single_post_process_raf(self):
|
||||
ui = _read_js('ui.js')
|
||||
assert ui.count('requestAnimationFrame(()=>postProcessRenderedMessages(inner))') == 2
|
||||
|
||||
|
||||
# ── CSS classes ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user