From fbe84d26e6af49bd1f80f66074615b4ebb4123c7 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 30 Apr 2026 16:18:01 +0000 Subject: [PATCH] fix(ui+pwa): avoid stale Mermaid render errors and bust cached static asset URLs on every release From PR #1337. Co-authored-by: Dennis Soong --- api/routes.py | 9 ++++-- static/index.html | 24 +++++++------- static/sw.js | 4 +++ static/ui.js | 22 ++++++++++--- tests/test_issue347.py | 15 +++++++++ tests/test_onboarding_static.py | 2 +- tests/test_pwa_manifest_sw.py | 25 ++++++++++++++ tests/test_renderer_js_behaviour.py | 45 ++++++++++++++++++++++++++ tests/test_service_worker_api_cache.py | 6 ++++ tests/test_sprint9.py | 16 ++++----- 10 files changed, 140 insertions(+), 28 deletions(-) diff --git a/api/routes.py b/api/routes.py index 81815204..871a5783 100644 --- a/api/routes.py +++ b/api/routes.py @@ -717,9 +717,12 @@ def handle_get(handler, parsed) -> bool: """Handle all GET routes. Returns True if handled, False for 404.""" if parsed.path in ("/", "/index.html"): + from urllib.parse import quote + from api.updates import WEBUI_VERSION + version_token = quote(WEBUI_VERSION, safe="") return t( handler, - _INDEX_HTML_PATH.read_text(encoding="utf-8"), + _INDEX_HTML_PATH.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", version_token), content_type="text/html; charset=utf-8", ) @@ -776,9 +779,11 @@ def handle_get(handler, parsed) -> bool: if sw_path.exists(): # Inject the current git-derived version as the cache name so the # service worker cache busts automatically on every new deploy. + from urllib.parse import quote from api.updates import WEBUI_VERSION + version_token = quote(WEBUI_VERSION, safe="") text = sw_path.read_text(encoding="utf-8").replace( - "__CACHE_VERSION__", WEBUI_VERSION + "__CACHE_VERSION__", version_token ) data = text.encode("utf-8") handler.send_response(200) diff --git a/static/index.html b/static/index.html index b4441ceb..8b34284a 100644 --- a/static/index.html +++ b/static/index.html @@ -47,7 +47,7 @@ - - - - - - - - - - + + + + + + + + + + + diff --git a/static/sw.js b/static/sw.js index a141d4d3..58fa2c46 100644 --- a/static/sw.js +++ b/static/sw.js @@ -64,6 +64,10 @@ self.addEventListener('fetch', (event) => { // Never intercept cross-origin requests if (url.origin !== self.location.origin) return; + // Never intercept the service worker script itself. Returning a cached sw.js + // prevents the browser from seeing a new cache version after local patches. + if (url.pathname.endsWith('/sw.js')) return; + // API and streaming endpoints — always go to network. // The WebUI may be mounted under a subpath such as /hermes/, so API // requests can look like /hermes/api/sessions rather than /api/sessions. diff --git a/static/ui.js b/static/ui.js index cffc8a31..cdd95429 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1085,12 +1085,17 @@ function renderMd(raw){ const fence_stash=[]; s=s.replace(/```([\s\S]*?)```/g,(_,raw)=>{ const m=raw.match(/^(\w[\w+-]*)\n?([\s\S]*)$/); - if(m&&m[1].trim().toLowerCase()==='mermaid'){ + const lang=m?(m[1]||'').trim().toLowerCase():''; + const code=m?m[2]:raw.replace(/^\n?/,''); + const codeLines=code.split('\n'); + const firstCodeLine=codeLines.find(line=>line.trim())||''; + const firstMermaidLine=codeLines.map(line=>line.trim()).find(line=>line&&!line.startsWith('%%'))||''; + const looksLikeLineNumberedToolOutput=/^\s*\d+\|/.test(firstCodeLine); + const looksLikeMermaidStart=firstMermaidLine==='---'||/^(graph|flowchart|sequenceDiagram|classDiagram|classDiagram-v2|stateDiagram|stateDiagram-v2|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|quadrantChart|requirementDiagram|C4Context|C4Container|C4Component|C4Dynamic|c4Context|c4Container|c4Component|c4Dynamic|sankey-beta|block-beta|packet-beta|xychart-beta|kanban|architecture-beta)\b/.test(firstMermaidLine); + if(lang==='mermaid'&&!looksLikeLineNumberedToolOutput&&looksLikeMermaidStart){ const id='mermaid-'+Math.random().toString(36).slice(2,10); - _preBlock_stash.push(`
${esc(m[2].trim())}
`); + _preBlock_stash.push(`
${esc(code.trim())}
`); } else { - const lang=m?(m[1]||'').trim().toLowerCase():''; - const code=m?m[2]:raw.replace(/^\n?/,''); const h=lang?`
${esc(lang)}
`:''; const langAttr=lang?` class="language-${esc(lang)}"`:''; // For diff/patch blocks, wrap each line in a colored span @@ -4130,10 +4135,17 @@ function renderMermaidBlocks(){ const id=block.dataset.mermaidId||('m-'+Math.random().toString(36).slice(2)); try{ const {svg}=await mermaid.render(id,code); + const tmp=document.getElementById('d'+id); + if(tmp) tmp.remove(); block.innerHTML=svg; block.classList.add('mermaid-rendered'); }catch(e){ - // Fall back to showing as a code block + const tmp=document.getElementById('d'+id); + if(tmp) tmp.remove(); + // Fall back to showing as a code block. Remove the mermaid marker so a + // later render pass cannot retry this already-failed block. + block.classList.remove('mermaid-block'); + block.classList.add('prewrap'); block.innerHTML=`
mermaid
${esc(code)}
`; } }); diff --git a/tests/test_issue347.py b/tests/test_issue347.py index f1c05ac3..9a0c65d0 100644 --- a/tests/test_issue347.py +++ b/tests/test_issue347.py @@ -105,6 +105,21 @@ def test_render_katex_blocks_wired_into_raf(): 'renderKatexBlocks() not found in any requestAnimationFrame call — math will not render' +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()') + 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();" + assert cleanup in fn, ( + "renderMermaidBlocks() must remove Mermaid's temporary d container; " + "otherwise rejected renders leave a visible 'Syntax error in text' SVG in every tab." + ) + assert fn.count(cleanup) >= 2, ( + "Mermaid temporary DOM cleanup must run after both successful and failed renders." + ) + + # ── index.html ──────────────────────────────────────────────────────────────── def test_katex_css_in_index_html(): diff --git a/tests/test_onboarding_static.py b/tests/test_onboarding_static.py index f61f3a99..b0d7bb95 100644 --- a/tests/test_onboarding_static.py +++ b/tests/test_onboarding_static.py @@ -13,7 +13,7 @@ def test_index_contains_onboarding_overlay_markup(): assert 'id="onboardingOverlay"' in html assert 'id="onboardingBody"' in html assert 'id="onboardingNextBtn"' in html - assert 'src="static/onboarding.js"' in html + assert 'src="static/onboarding.js?v=__WEBUI_VERSION__"' in html def test_onboarding_css_rules_exist(): diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py index 34897335..82e956ff 100644 --- a/tests/test_pwa_manifest_sw.py +++ b/tests/test_pwa_manifest_sw.py @@ -124,6 +124,16 @@ class TestPWARoutes: "sw.js route must import and use WEBUI_VERSION for cache busting" ) + def test_sw_route_url_encodes_cache_version(self): + src = ROUTES.read_text(encoding="utf-8") + idx = src.find('"/sw.js"') + assert idx != -1, "routes.py must handle /sw.js" + block = src[idx:idx + 1200] + assert "quote(WEBUI_VERSION, safe=\"\")" in block, ( + "sw.js route must URL-encode the injected cache version so unusual git tags " + "cannot break the JavaScript string literal" + ) + def test_sw_route_sets_service_worker_allowed(self): src = ROUTES.read_text(encoding="utf-8") idx = src.find('"/sw.js"') @@ -145,6 +155,21 @@ class TestIndexHtmlIntegration: "index.html must register the service worker" ) + def test_index_uses_version_placeholders_for_static_assets(self): + src = INDEX.read_text(encoding="utf-8") + assert "sw.js?v=__WEBUI_VERSION__" in src + assert "static/ui.js?v=__WEBUI_VERSION__" in src + + def test_index_route_url_encodes_asset_version(self): + src = ROUTES.read_text(encoding="utf-8") + idx = src.find('parsed.path in ("/", "/index.html")') + assert idx != -1, "routes.py must handle / and /index.html" + block = src[idx:idx + 800] + assert "quote(WEBUI_VERSION, safe=\"\")" in block, ( + "index route must URL-encode the cache-busting version token before " + "injecting it into script src attributes and service worker registration" + ) + def test_index_has_ios_pwa_meta_tags(self): src = INDEX.read_text(encoding="utf-8") assert "apple-mobile-web-app-capable" in src, ( diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index 6b7c3f09..102c69df 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -503,6 +503,51 @@ class TestBlockquoteEntityEncodedInput: assert "
" in out, f"Fenced code inside entity-encoded blockquote must render: {out!r}"
 
 
+class TestMermaidToolOutputGuard:
+    """Line-numbered tool excerpts must not be auto-rendered as Mermaid."""
+
+    def test_line_numbered_mermaid_fence_renders_as_code_block(self, driver_path):
+        src = "```mermaid\n23|flowchart TB\n24|    A --> B\n```"
+        out = _render(driver_path, src)
+        assert 'class="mermaid-block"' not in out, (
+            f"Line-numbered read_file excerpts are not valid Mermaid and must not auto-render: {out!r}"
+        )
+        assert '
mermaid
' in out + assert '
' in out
+        assert '23|flowchart TB' in out
+
+    def test_valid_mermaid_fence_still_creates_mermaid_block(self, driver_path):
+        out = _render(driver_path, "```mermaid\nflowchart TB\n    A --> B\n```")
+        assert 'class="mermaid-block"' in out, (
+            f"Valid Mermaid fences should still be queued for Mermaid rendering: {out!r}"
+        )
+        assert 'flowchart TB' in out
+
+    def test_valid_mermaid_c4_fence_still_creates_mermaid_block(self, driver_path):
+        out = _render(driver_path, "```mermaid\nC4Context\n    title System Context\n```")
+        assert 'class="mermaid-block"' in out, (
+            f"Valid C4 Mermaid fences should still be queued for Mermaid rendering: {out!r}"
+        )
+        assert 'C4Context' in out
+
+    def test_valid_mermaid_frontmatter_fence_still_creates_mermaid_block(self, driver_path):
+        out = _render(driver_path, "```mermaid\n---\ntitle: Demo\n---\nflowchart TB\n    A --> B\n```")
+        assert 'class="mermaid-block"' in out, (
+            f"Valid Mermaid fences with frontmatter should still be queued for Mermaid rendering: {out!r}"
+        )
+        assert 'title: Demo' in out
+
+    def test_prose_mention_of_mermaid_fence_renders_as_code_block(self, driver_path):
+        src = "```mermaid\n` fence should not be auto-rendered too aggressively.\n\nSome prose, not a diagram.\n```"
+        out = _render(driver_path, src)
+        assert 'class="mermaid-block"' not in out, (
+            f"Prose captured by a mermaid fence is not valid Mermaid and must not auto-render: {out!r}"
+        )
+        assert '
mermaid
' in out + assert '
' in out
+        assert 'Some prose, not a diagram.' in out
+
+
 class TestRawPreCodePreservation:
     """Raw 
 HTML from model output should remain structurally intact."""
 
diff --git a/tests/test_service_worker_api_cache.py b/tests/test_service_worker_api_cache.py
index 6900524f..3118357f 100644
--- a/tests/test_service_worker_api_cache.py
+++ b/tests/test_service_worker_api_cache.py
@@ -29,3 +29,9 @@ def test_service_worker_excludes_subpath_mounted_health_routes_from_cache():
 def test_service_worker_documents_api_routes_are_never_cached():
     assert "API and streaming endpoints" in SW_SRC
     assert "always go to network" in SW_SRC
+
+
+def test_service_worker_does_not_intercept_its_own_script():
+    assert "url.pathname.endsWith('/sw.js')" in SW_SRC, (
+        "service worker must bypass /sw.js so a stale cached worker cannot block cache-version updates"
+    )
diff --git a/tests/test_sprint9.py b/tests/test_sprint9.py
index 78711972..7a74e29a 100644
--- a/tests/test_sprint9.py
+++ b/tests/test_sprint9.py
@@ -68,19 +68,19 @@ def test_app_js_no_longer_referenced_in_html(cleanup_test_sessions):
     """index.html must not reference the old monolithic app.js."""
     html = get_text("/")
     assert 'src="static/app.js"' not in html
-    # All 6 modules must be present
+    # All split modules must be present with the server-injected cache-busting version query.
     for module in ["ui.js", "workspace.js", "sessions.js", "messages.js", "panels.js", "boot.js"]:
-        assert f'src="static/{module}"' in html, f"Missing {module} in index.html"
+        assert f'src="static/{module}?v=' in html, f"Missing versioned {module} in index.html"
 
 def test_module_load_order_correct(cleanup_test_sessions):
     """ui.js must appear before sessions.js which must appear before boot.js."""
     html = get_text("/")
-    ui_pos = html.find('src="static/ui.js"')
-    ws_pos = html.find('src="static/workspace.js"')
-    sess_pos = html.find('src="static/sessions.js"')
-    msg_pos = html.find('src="static/messages.js"')
-    panels_pos = html.find('src="static/panels.js"')
-    boot_pos = html.find('src="static/boot.js"')
+    ui_pos = html.find('src="static/ui.js?v=')
+    ws_pos = html.find('src="static/workspace.js?v=')
+    sess_pos = html.find('src="static/sessions.js?v=')
+    msg_pos = html.find('src="static/messages.js?v=')
+    panels_pos = html.find('src="static/panels.js?v=')
+    boot_pos = html.find('src="static/boot.js?v=')
     assert ui_pos < ws_pos < sess_pos < msg_pos < panels_pos < boot_pos
 
 def test_no_duplicate_function_definitions(cleanup_test_sessions):