Stage 325: PR #1891 — feat: add browser offline recovery and PWA cache hardening by @ai-ag2026

This commit is contained in:
nesquena-hermes
2026-05-08 21:16:33 +00:00
11 changed files with 320 additions and 25 deletions
+1 -1
View File
@@ -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',
})
+10 -3
View File
@@ -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){}
}
+56
View File
@@ -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',
+9 -1
View File
@@ -21,7 +21,7 @@
<meta name="theme-color" content="#FEFCF7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0D0D1A" media="(prefers-color-scheme: dark)">
<meta name="theme-color" id="hermes-theme-color" content="#0D0D1A">
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#0D0D1A':'#FEFCF7';var m=document.getElementById('hermes-theme-color');if(m)m.setAttribute('content',c);}catch(e){}})()</script>
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#0D0D1A':'#FEFCF7';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" integrity="sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh" crossorigin="anonymous">
@@ -347,6 +347,14 @@
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reload</button>
</div>
</div>
<div class="offline-banner" id="offlineBanner" role="status" aria-live="assertive" hidden>
<div class="offline-copy">
<strong id="offlineTitle" data-i18n="offline_title">Connection lost</strong>
<span id="offlineDetails" data-i18n="offline_browser_detail">Your browser reports that this device is offline.</span>
<span id="offlineAutorefresh" data-i18n="offline_autorefresh">I will refresh this page automatically when Hermes is reachable again.</span>
</div>
<button class="offline-action" id="offlineCheckNow" type="button" onclick="checkOfflineRecoveryNow()" data-i18n="offline_check_now">Check now</button>
</div>
<div class="agent-health-banner" id="agentHealthBanner" role="alert" aria-live="assertive" hidden>
<div class="agent-health-copy">
<strong id="agentHealthTitle">Hermes agent is not responding</strong>
+2 -2
View File
@@ -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": [
{
+19 -1
View File
@@ -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();
});
+8
View File
@@ -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;}
+18 -17
View File
@@ -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' },
})))
);
});
+99
View File
@@ -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.
+72
View File
@@ -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()")
+26
View File
@@ -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):