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 <dso2ng@gmail.com>
This commit is contained in:
1 parent
09e12e3c60
commit
fbe84d26e6
10 files changed
+140
-28
No files matched your search
+7
-2
@@ -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)
|
||||
|
||||
+12
-12
@@ -47,7 +47,7 @@
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('sw.js').catch(function(err) {
|
||||
navigator.serviceWorker.register('sw.js?v=__WEBUI_VERSION__').catch(function(err) {
|
||||
console.warn('[pwa] Service worker registration failed:', err);
|
||||
});
|
||||
});
|
||||
@@ -951,16 +951,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="static/i18n.js" defer></script>
|
||||
<script src="static/icons.js" defer></script>
|
||||
<script src="static/ui.js" defer></script>
|
||||
<script src="static/workspace.js" defer></script>
|
||||
<script src="static/terminal.js" defer></script>
|
||||
<script src="static/sessions.js" defer></script>
|
||||
<script src="static/commands.js" defer></script>
|
||||
<script src="static/messages.js" defer></script>
|
||||
<script src="static/panels.js" defer></script>
|
||||
<script src="static/onboarding.js" defer></script>
|
||||
<script src="static/boot.js" defer></script>
|
||||
<script src="static/i18n.js?v=__WEBUI_VERSION__" defer></script>
|
||||
<script src="static/icons.js?v=__WEBUI_VERSION__" defer></script>
|
||||
<script src="static/ui.js?v=__WEBUI_VERSION__" defer></script>
|
||||
<script src="static/workspace.js?v=__WEBUI_VERSION__" defer></script>
|
||||
<script src="static/terminal.js?v=__WEBUI_VERSION__" defer></script>
|
||||
<script src="static/sessions.js?v=__WEBUI_VERSION__" defer></script>
|
||||
<script src="static/commands.js?v=__WEBUI_VERSION__" defer></script>
|
||||
<script src="static/messages.js?v=__WEBUI_VERSION__" defer></script>
|
||||
<script src="static/panels.js?v=__WEBUI_VERSION__" defer></script>
|
||||
<script src="static/onboarding.js?v=__WEBUI_VERSION__" defer></script>
|
||||
<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||
|
||||
+17
-5
@@ -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(`<div class="mermaid-block" data-mermaid-id="${id}">${esc(m[2].trim())}</div>`);
|
||||
_preBlock_stash.push(`<div class="mermaid-block" data-mermaid-id="${id}">${esc(code.trim())}</div>`);
|
||||
} else {
|
||||
const lang=m?(m[1]||'').trim().toLowerCase():'';
|
||||
const code=m?m[2]:raw.replace(/^\n?/,'');
|
||||
const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';
|
||||
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=`<div class="pre-header">mermaid</div><pre><code>${esc(code)}</code></pre>`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<id> 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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -503,6 +503,51 @@ class TestBlockquoteEntityEncodedInput:
|
||||
assert "<pre>" 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 '<div class="pre-header">mermaid</div>' in out
|
||||
assert '<pre><code class="language-mermaid">' 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 '<div class="pre-header">mermaid</div>' in out
|
||||
assert '<pre><code class="language-mermaid">' in out
|
||||
assert 'Some prose, not a diagram.' in out
|
||||
|
||||
|
||||
class TestRawPreCodePreservation:
|
||||
"""Raw <pre><code> HTML from model output should remain structurally intact."""
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user