From 8f58a8c94e580efe87c2f9322de5c7303eae469f Mon Sep 17 00:00:00 2001
From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Date: Fri, 8 May 2026 12:40:21 +0200
Subject: [PATCH] feat: add browser offline recovery and PWA cache hardening
---
api/auth.py | 2 +-
static/boot.js | 13 +++--
static/i18n.js | 56 ++++++++++++++++++++
static/index.html | 10 +++-
static/manifest.json | 4 +-
static/messages.js | 20 ++++++-
static/style.css | 8 +++
static/sw.js | 35 +++++++------
static/ui.js | 99 +++++++++++++++++++++++++++++++++++
tests/test_offline_banner.py | 72 +++++++++++++++++++++++++
tests/test_pwa_manifest_sw.py | 26 +++++++++
11 files changed, 320 insertions(+), 25 deletions(-)
create mode 100644 tests/test_offline_banner.py
diff --git a/api/auth.py b/api/auth.py
index 5e9c6c4e..b6187455 100644
--- a/api/auth.py
+++ b/api/auth.py
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
# ── Public paths (no auth required) ─────────────────────────────────────────
PUBLIC_PATHS = frozenset({
- '/login', '/health', '/favicon.ico',
+ '/login', '/health', '/favicon.ico', '/sw.js',
'/api/auth/login', '/api/auth/status',
'/manifest.json', '/manifest.webmanifest',
})
diff --git a/static/boot.js b/static/boot.js
index e6f1b7e7..beb4f38e 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -1130,10 +1130,17 @@ function _normalizeAppearance(theme,skin){
// the meta tag.
function _syncThemeColorMeta(){
try{
- const meta=document.getElementById('hermes-theme-color');
- if(!meta) return;
const bg=getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
- if(bg) meta.setAttribute('content',bg);
+ if(!bg) return;
+ const known=document.getElementById('hermes-theme-color');
+ if(known){
+ known.setAttribute('content',bg);
+ known.removeAttribute('media');
+ }
+ document.querySelectorAll('meta[name="theme-color"]').forEach(meta=>{
+ meta.setAttribute('content',bg);
+ meta.removeAttribute('media');
+ });
}catch(e){}
}
diff --git a/static/i18n.js b/static/i18n.js
index d32cfa84..df5fe54f 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -5,6 +5,13 @@
const LOCALES = {
en: {
+ offline_title: 'Connection lost',
+ offline_browser_detail: 'Your browser reports that this device is offline.',
+ offline_network_detail: 'Hermes is unreachable from this browser right now.',
+ offline_autorefresh: 'I will refresh this page automatically when Hermes is reachable again.',
+ offline_check_now: 'Check now',
+ offline_checking: 'Checking…',
+ offline_stream_waiting: 'Connection lost. Waiting to refresh…',
_lang: 'en',
_label: 'English',
_speech: 'en-US',
@@ -1026,6 +1033,13 @@ const LOCALES = {
},
ja: {
+ offline_title: '接続が切断されました',
+ offline_browser_detail: 'ブラウザはこのデバイスがオフラインだと報告しています。',
+ offline_network_detail: '現在、このブラウザからHermesに到達できません。',
+ offline_autorefresh: 'Hermesに再接続できたら、このページを自動的に更新します。',
+ offline_check_now: '今すぐ確認',
+ offline_checking: '確認中…',
+ offline_stream_waiting: '接続が切断されました。更新を待っています…',
_lang: 'ja',
_label: '日本語',
_speech: 'ja-JP',
@@ -2047,6 +2061,13 @@ const LOCALES = {
},
ru: {
+ offline_title: 'Соединение потеряно',
+ offline_browser_detail: 'Браузер сообщает, что это устройство офлайн.',
+ offline_network_detail: 'Hermes сейчас недоступен из этого браузера.',
+ offline_autorefresh: 'Я автоматически обновлю страницу, когда Hermes снова станет доступен.',
+ offline_check_now: 'Проверить сейчас',
+ offline_checking: 'Проверка…',
+ offline_stream_waiting: 'Соединение потеряно. Ожидаю обновления…',
_lang: 'ru',
_label: 'Русский',
_speech: 'ru-RU',
@@ -3006,6 +3027,13 @@ const LOCALES = {
},
es: {
+ offline_title: 'Conexión perdida',
+ offline_browser_detail: 'Tu navegador indica que este dispositivo está sin conexión.',
+ offline_network_detail: 'Hermes no está disponible desde este navegador ahora mismo.',
+ offline_autorefresh: 'Actualizaré esta página automáticamente cuando Hermes vuelva a estar disponible.',
+ offline_check_now: 'Comprobar ahora',
+ offline_checking: 'Comprobando…',
+ offline_stream_waiting: 'Conexión perdida. Esperando para actualizar…',
_lang: 'es',
_label: 'Español',
_speech: 'es-ES',
@@ -3959,6 +3987,13 @@ const LOCALES = {
},
de: {
+ offline_title: 'Verbindung verloren',
+ offline_browser_detail: 'Dein Browser meldet, dass dieses Gerät offline ist.',
+ offline_network_detail: 'Hermes ist von diesem Browser aus gerade nicht erreichbar.',
+ offline_autorefresh: 'Ich aktualisiere diese Seite automatisch, sobald Hermes wieder erreichbar ist.',
+ offline_check_now: 'Jetzt prüfen',
+ offline_checking: 'Prüfe…',
+ offline_stream_waiting: 'Verbindung verloren. Warte auf Aktualisierung…',
_lang: 'de',
_label: 'Deutsch',
_speech: 'de-DE',
@@ -4916,6 +4951,13 @@ const LOCALES = {
},
zh: {
+ offline_title: '连接已断开',
+ offline_browser_detail: '浏览器报告此设备当前离线。',
+ offline_network_detail: '此浏览器当前无法连接到 Hermes。',
+ offline_autorefresh: '当 Hermes 可访问时,我会自动刷新此页面。',
+ offline_check_now: '立即检查',
+ offline_checking: '正在检查…',
+ offline_stream_waiting: '连接已断开。正在等待刷新…',
_lang: 'zh',
_label: '\u7b80\u4f53\u4e2d\u6587',
_speech: 'zh-CN',
@@ -6857,6 +6899,13 @@ const LOCALES = {
},
pt: {
+ offline_title: 'Conexão perdida',
+ offline_browser_detail: 'O navegador informa que este dispositivo está offline.',
+ offline_network_detail: 'O Hermes está inacessível neste navegador agora.',
+ offline_autorefresh: 'Vou atualizar esta página automaticamente quando o Hermes voltar a responder.',
+ offline_check_now: 'Verificar agora',
+ offline_checking: 'Verificando…',
+ offline_stream_waiting: 'Conexão perdida. Aguardando para atualizar…',
_lang: 'pt',
_label: 'Português',
_speech: 'pt-BR',
@@ -7706,6 +7755,13 @@ const LOCALES = {
disable_auth_confirm_title: 'Desativar proteção por senha',
},
ko: {
+ offline_title: '연결이 끊겼습니다',
+ offline_browser_detail: '브라우저가 이 장치가 오프라인이라고 보고합니다.',
+ offline_network_detail: '현재 이 브라우저에서 Hermes에 연결할 수 없습니다.',
+ offline_autorefresh: 'Hermes에 다시 연결되면 이 페이지를 자동으로 새로고침합니다.',
+ offline_check_now: '지금 확인',
+ offline_checking: '확인 중…',
+ offline_stream_waiting: '연결이 끊겼습니다. 새로고침을 기다리는 중…',
_lang: 'ko',
_label: '한국어',
_speech: 'ko-KR',
diff --git a/static/index.html b/static/index.html
index d21c64b4..e8a3ea2b 100644
--- a/static/index.html
+++ b/static/index.html
@@ -21,7 +21,7 @@
-
+
@@ -347,6 +347,14 @@
+
+
+ Connection lost
+ Your browser reports that this device is offline.
+ I will refresh this page automatically when Hermes is reachable again.
+
+
+
Hermes agent is not responding
diff --git a/static/manifest.json b/static/manifest.json
index 2e337271..caa9570f 100644
--- a/static/manifest.json
+++ b/static/manifest.json
@@ -4,8 +4,8 @@
"description": "Hermes AI Agent Web UI",
"start_url": "./",
"display": "standalone",
- "background_color": "#1a1a1a",
- "theme_color": "#1a1a1a",
+ "background_color": "#0D0D1A",
+ "theme_color": "#0D0D1A",
"orientation": "portrait-primary",
"icons": [
{
diff --git a/static/messages.js b/static/messages.js
index 3c196937..d7122b9f 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -32,6 +32,19 @@ function _markActiveSessionViewedOnReturn() {
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
}
+function _deferStreamErrorIfOffline(){
+ if(typeof isOfflineBannerVisible==='function' && isOfflineBannerVisible()){
+ setComposerStatus(t('offline_stream_waiting'));
+ return true;
+ }
+ if(typeof showOfflineBanner==='function' && navigator.onLine===false){
+ showOfflineBanner('browser');
+ setComposerStatus(t('offline_stream_waiting'));
+ return true;
+ }
+ return false;
+}
+
document.addEventListener('visibilitychange', _markActiveSessionViewedOnReturn);
window.addEventListener('focus', _markActiveSessionViewedOnReturn);
// TTS: pause speech synthesis when user focuses the composer (#499)
@@ -1207,6 +1220,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('error',async e=>{
source.close();
+ if(_deferStreamErrorIfOffline()) return;
if(_terminalStateReached || _streamFinalized){
_closeSource();
return;
@@ -1223,13 +1237,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true}));
return;
}
- }catch(_){}
+ }catch(_){
+ if(_deferStreamErrorIfOffline()) return;
+ }
if(await _restoreSettledSession()) return;
+ if(_deferStreamErrorIfOffline()) return;
_handleStreamError();
},1500);
return;
}
if(await _restoreSettledSession()) return;
+ if(_deferStreamErrorIfOffline()) return;
_handleStreamError();
});
diff --git a/static/style.css b/static/style.css
index 26e32b05..02fc389e 100644
--- a/static/style.css
+++ b/static/style.css
@@ -536,6 +536,14 @@
.reconnect-banner.visible{display:flex;}
.reconnect-btn{padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;background:var(--accent-bg-strong);border:1px solid var(--accent-bg-strong);color:var(--accent-text);cursor:pointer;}
.reconnect-btn:hover{background:var(--accent-bg-strong);}
+ .offline-banner{position:fixed;left:0;right:0;top:0;z-index:1200;display:none;align-items:center;justify-content:space-between;gap:14px;padding:12px 18px;border-bottom:1px solid color-mix(in srgb,var(--warning,#f6c343) 55%,var(--surface));background:color-mix(in srgb,var(--bg-1,#0d0d1a) 88%,var(--warning,#f6c343));color:var(--text);box-shadow:0 12px 40px rgba(0,0,0,.22);backdrop-filter:blur(10px);}
+ .offline-banner.visible{display:flex;}
+ .offline-copy{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:13px;line-height:1.35;}
+ .offline-copy strong{color:var(--warning,#f6c343);font-size:13px;letter-spacing:.08em;text-transform:uppercase;}
+ .offline-copy span{color:var(--muted);}
+ .offline-action{flex-shrink:0;padding:7px 13px;border-radius:9px;border:1px solid color-mix(in srgb,var(--warning,#f6c343) 48%,var(--surface));background:color-mix(in srgb,var(--warning,#f6c343) 12%,var(--surface));color:var(--warning,#f6c343);font-size:12px;font-weight:700;cursor:pointer;}
+ .offline-action:hover{background:color-mix(in srgb,var(--warning,#f6c343) 20%,var(--surface));}
+ .offline-action[disabled]{cursor:wait;opacity:.65;}
.agent-health-banner{position:sticky;bottom:0;z-index:4;display:none;align-items:center;justify-content:space-between;gap:12px;margin:10px auto 0;max-width:var(--msg-max);width:calc(100% - 40px);padding:12px 16px;border:1px solid color-mix(in srgb,var(--error) 55%,var(--surface));border-radius:12px;background:color-mix(in srgb,var(--error) 14%,var(--surface));color:var(--text);box-shadow:0 10px 32px rgba(0,0,0,.16);}
.agent-health-banner.visible{display:flex;}
.agent-health-copy{display:flex;flex-direction:column;gap:3px;min-width:0;font-size:13px;line-height:1.35;}
diff --git a/static/sw.js b/static/sw.js
index 3fe629e4..ebfccf35 100644
--- a/static/sw.js
+++ b/static/sw.js
@@ -68,7 +68,7 @@ self.addEventListener('activate', (event) => {
// - API calls (/api/*, /stream) → always network (never cache)
// - Login assets → always network (never cache stale auth code)
// - Page navigations → network-first so auth redirects/cookies are honored
-// - Shell assets → cache-first with network fallback
+// - Shell assets → network-first with cache fallback
// - Everything else → network-only
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
@@ -131,7 +131,7 @@ self.addEventListener('fetch', (event) => {
return;
}
- // Only explicit shell assets use cache-first. Everything else should hit the
+ // Only explicit shell assets are cached. Everything else should hit the
// network so stale one-off files (especially auth/login scripts) do not get
// trapped in CacheStorage until a manual cache clear.
const scopePath = new URL(self.registration.scope).pathname;
@@ -141,21 +141,22 @@ self.addEventListener('fetch', (event) => {
const shellPath = './' + relPath.replace(/^\/+/, '') + url.search;
if (!SHELL_ASSETS.includes(shellPath)) return;
- // Shell assets: cache-first
+ // Shell assets: network-first with cache fallback. This keeps offline support
+ // but avoids executing stale JS/CSS after a local hotfix when WEBUI_VERSION
+ // has not changed yet (e.g. before a guarded restart updates the ?v token).
event.respondWith(
- caches.match(event.request).then((cached) => {
- if (cached) return cached;
- return fetch(event.request).then((response) => {
- // Cache successful GET responses for shell assets
- if (
- event.request.method === 'GET' &&
- response.status === 200
- ) {
- const clone = response.clone();
- caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
- }
- return response;
- });
- })
+ fetch(event.request).then((response) => {
+ if (
+ event.request.method === 'GET' &&
+ response.status === 200
+ ) {
+ const clone = response.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
+ }
+ return response;
+ }).catch(() => caches.match(event.request).then((cached) => cached || new Response('Offline', {
+ status: 503,
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+ })))
);
});
diff --git a/static/ui.js b/static/ui.js
index 3e95e1b0..1c2d92d8 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -11,6 +11,105 @@ const MAX_UPLOAD_MB=Math.round(MAX_UPLOAD_BYTES/1024/1024);
// single-threaded so only one done event fires at a time in practice.
let _queueDrainSid=null;
const $=id=>document.getElementById(id);
+const OFFLINE_RECHECK_MS=2500;
+let _offlineVisible=false;
+let _offlineReason='browser';
+let _offlineProbeTimer=null;
+let _offlineChecking=false;
+let _offlineProbePromise=null;
+let _offlineHealthProbePromise=null;
+let _offlineRawFetch=null;
+let _offlineFetchPatched=false;
+function _browserReportsOnline(){return !('onLine' in navigator)||navigator.onLine!==false;}
+function _offlineHealthUrl(){const url=new URL('health',document.baseURI||location.href);url.searchParams.set('offline_probe',String(Date.now()));return url.href;}
+function _setOfflineChecking(checking){
+ _offlineChecking=!!checking;
+ const btn=$('offlineCheckNow');
+ if(btn){btn.disabled=_offlineChecking;btn.textContent=_offlineChecking?t('offline_checking'):t('offline_check_now');}
+}
+function _renderOfflineBanner(){
+ const banner=$('offlineBanner');
+ if(!banner)return;
+ const detail=$('offlineDetails');
+ if(detail)detail.textContent=t(_offlineReason==='browser'?'offline_browser_detail':'offline_network_detail');
+ const title=$('offlineTitle');
+ if(title)title.textContent=t('offline_title');
+ const auto=$('offlineAutorefresh');
+ if(auto)auto.textContent=t('offline_autorefresh');
+ _setOfflineChecking(_offlineChecking);
+ banner.hidden=false;
+ banner.classList.add('visible');
+}
+function _startOfflineProbeTimer(){
+ if(_offlineProbeTimer)return;
+ _offlineProbeTimer=setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS);
+}
+function _stopOfflineProbeTimer(){
+ if(_offlineProbeTimer){clearInterval(_offlineProbeTimer);_offlineProbeTimer=null;}
+}
+function showOfflineBanner(reason){
+ _offlineVisible=true;
+ _offlineReason=reason||(_browserReportsOnline()?'network':'browser');
+ _renderOfflineBanner();
+ _startOfflineProbeTimer();
+}
+function isOfflineBannerVisible(){return _offlineVisible;}
+function _hideOfflineBanner(){
+ _offlineVisible=false;
+ _stopOfflineProbeTimer();
+ _setOfflineChecking(false);
+ const banner=$('offlineBanner');
+ if(banner){banner.classList.remove('visible');banner.hidden=true;}
+}
+async function _probeOfflineRecovery(){
+ if(_offlineHealthProbePromise)return _offlineHealthProbePromise;
+ _offlineHealthProbePromise=(async()=>{
+ const fetcher=_offlineRawFetch||window.fetch.bind(window);
+ try{
+ const res=await fetcher(_offlineHealthUrl(),{cache:'no-store',credentials:'include'});
+ return !!(res&&res.ok);
+ }catch(_){return false;}
+ })();
+ try{return await _offlineHealthProbePromise;}
+ finally{_offlineHealthProbePromise=null;}
+}
+async function checkOfflineRecoveryNow(){
+ if(_offlineProbePromise)return _offlineProbePromise;
+ _offlineProbePromise=(async()=>{
+ if(!_offlineVisible)return false;
+ if(!_browserReportsOnline()){showOfflineBanner('browser');return false;}
+ _setOfflineChecking(true);
+ const ok=await _probeOfflineRecovery();
+ _setOfflineChecking(false);
+ if(ok){_stopOfflineProbeTimer();window.location.reload();return true;}
+ showOfflineBanner('network');
+ return false;
+ })();
+ try{return await _offlineProbePromise;}
+ finally{_offlineProbePromise=null;}
+}
+function _isAbortError(e){return !!(e&&(e.name==='AbortError'||e.code===20));}
+function _patchOfflineFetch(){
+ if(_offlineFetchPatched||typeof window.fetch!=='function')return;
+ _offlineFetchPatched=true;
+ _offlineRawFetch=window.fetch.bind(window);
+ window.fetch=async function(...args){
+ try{return await _offlineRawFetch(...args);}
+ catch(e){
+ if(!_browserReportsOnline())showOfflineBanner('browser');
+ else if(e instanceof TypeError&&!_isAbortError(e))void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');});
+ throw e;
+ }
+ };
+}
+function initOfflineMonitor(){
+ _patchOfflineFetch();
+ window.addEventListener('offline',()=>showOfflineBanner('browser'));
+ window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();});
+ if(!_browserReportsOnline())showOfflineBanner('browser');
+}
+if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',initOfflineMonitor,{once:true});
+else initOfflineMonitor();
// Redirect to login when the server responds with 401 (auth session expired).
// Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from
// escaping to the personal site root /login.
diff --git a/tests/test_offline_banner.py b/tests/test_offline_banner.py
new file mode 100644
index 00000000..4942d8fe
--- /dev/null
+++ b/tests/test_offline_banner.py
@@ -0,0 +1,72 @@
+"""Regression coverage for the browser-offline banner and auto-refresh loop."""
+
+from __future__ import annotations
+
+import pathlib
+
+
+REPO_ROOT = pathlib.Path(__file__).parent.parent
+UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
+MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
+INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
+STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
+I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
+
+
+def test_offline_banner_markup_styles_and_copy_exist():
+ assert 'id="offlineBanner"' in INDEX_HTML
+ assert 'role="status"' in INDEX_HTML
+ assert 'aria-live="assertive"' in INDEX_HTML
+ assert 'onclick="checkOfflineRecoveryNow()"' in INDEX_HTML
+ assert ".offline-banner" in STYLE_CSS
+ assert ".offline-banner.visible" in STYLE_CSS
+ assert ".offline-action[disabled]" in STYLE_CSS
+ for key in (
+ "offline_title",
+ "offline_browser_detail",
+ "offline_network_detail",
+ "offline_autorefresh",
+ "offline_check_now",
+ "offline_checking",
+ "offline_stream_waiting",
+ ):
+ assert key in I18N_JS
+
+
+def test_offline_monitor_patches_fetch_and_auto_reloads_after_health_probe():
+ assert "const OFFLINE_RECHECK_MS=2500" in UI_JS
+ assert "window.fetch=async function(...args)" in UI_JS
+ assert "window.addEventListener('offline',()=>showOfflineBanner('browser'))" in UI_JS
+ assert "window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();})" in UI_JS
+ assert "setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS)" in UI_JS
+ assert "new URL('health',document.baseURI||location.href)" in UI_JS
+ assert "window.location.reload()" in UI_JS
+
+
+def test_offline_recovery_probe_is_serialized_and_stops_timer_before_reload():
+ assert "let _offlineProbePromise=null" in UI_JS
+ assert "let _offlineHealthProbePromise=null" in UI_JS
+ assert "if(!_offlineVisible)return false;" in UI_JS
+ assert "if(!_offlineVisible&&!_offlineFetchPatched)return false;" not in UI_JS
+ assert "finally{_offlineProbePromise=null;}" in UI_JS
+ assert "finally{_offlineHealthProbePromise=null;}" in UI_JS
+ reload_idx = UI_JS.find("window.location.reload()")
+ assert reload_idx != -1
+ assert UI_JS.rfind("_stopOfflineProbeTimer();", 0, reload_idx) != -1
+
+
+def test_fetch_typeerror_is_gated_by_health_probe_not_blind_banner():
+ fetch_patch = UI_JS.split("window.fetch=async function(...args){", 1)[1].split("function initOfflineMonitor", 1)[0]
+ assert "function _isAbortError(e)" in UI_JS
+ assert "e instanceof TypeError&&!_isAbortError(e)" in fetch_patch
+ assert "void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');})" in fetch_patch
+ assert "if(!_browserReportsOnline())showOfflineBanner('browser');" in fetch_patch
+ assert "e instanceof TypeError||!_browserReportsOnline()" not in fetch_patch
+
+
+def test_sse_network_error_defers_to_offline_banner_instead_of_inline_error():
+ assert "function _deferStreamErrorIfOffline()" in MESSAGES_JS
+ assert "t('offline_stream_waiting')" in MESSAGES_JS
+ assert "if(_deferStreamErrorIfOffline()) return;" in MESSAGES_JS
+ error_handler = MESSAGES_JS.split("source.addEventListener('error',async e=>{", 1)[1].split("source.addEventListener('cancel'", 1)[0]
+ assert error_handler.find("_deferStreamErrorIfOffline()") < error_handler.rfind("_handleStreamError()")
diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py
index 730d4a9a..8d1769f3 100644
--- a/tests/test_pwa_manifest_sw.py
+++ b/tests/test_pwa_manifest_sw.py
@@ -18,6 +18,7 @@ MANIFEST = ROOT / "static" / "manifest.json"
SW = ROOT / "static" / "sw.js"
INDEX = ROOT / "static" / "index.html"
ROUTES = ROOT / "api" / "routes.py"
+AUTH = ROOT / "api" / "auth.py"
class TestManifest:
@@ -107,6 +108,22 @@ class TestServiceWorker:
"sw.js must await/then the caches.match() result before applying the fallback"
)
+ def test_sw_shell_assets_are_network_first_with_cache_fallback(self):
+ """Local hotfixes can change JS/CSS while WEBUI_VERSION stays unchanged.
+
+ If shell assets are cache-first, the browser can keep executing stale
+ sessions.js even though the server/curl already returns patched source.
+ Network-first preserves offline fallback without hiding local fixes.
+ """
+ src = SW.read_text(encoding="utf-8")
+ assert "Shell assets: network-first with cache fallback" in src
+ assert "fetch(event.request).then((response)" in src
+ assert "caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))" in src
+ assert ".catch(() => caches.match(event.request)" in src
+ assert "if (cached) return cached;" not in src, (
+ "shell assets must not be cache-first; stale JS can survive hard refresh"
+ )
+
def test_sw_never_caches_api_responses(self):
"""Defensive: the SW must not cache responses from /api/* paths.
Currently enforced by early-return before the shell-asset cache block."""
@@ -162,6 +179,15 @@ class TestPWARoutes:
"the expected scope"
)
+ def test_sw_is_public_auth_path(self):
+ src = AUTH.read_text(encoding="utf-8")
+ public_idx = src.find("PUBLIC_PATHS")
+ assert public_idx != -1, "auth.py must define PUBLIC_PATHS"
+ block = src[public_idx:public_idx + 400]
+ assert "'/sw.js'" in block, (
+ "/sw.js must be public so service-worker updates never return login HTML"
+ )
+
class TestIndexHtmlIntegration:
def test_index_links_manifest(self):