feat: add PWA support (manifest, service worker, install prompt) (#920)
* feat: add PWA support (manifest, service worker, install prompt) (v0.50.178, #911) Co-authored-by: bsgdigital Closes #685 * fix(sw): await caches.match() before `|| fallback` so offline HTML actually shows The offline-navigation fallback was dead code: return caches.match('./') || new Response('<html>...</html>', ...); `caches.match()` returns a Promise, and Promise objects are always truthy in a `||` check — so the `new Response(...)` branch was never taken. On actual offline, `caches.match('./')` resolves to undefined (no cache hit for the root), the SW returns undefined, and the browser falls back to its own default offline page. The custom "Hermes requires a server connection" HTML was unreachable. Fix by threading the match through `.then()` so the resolved value (not the Promise object) feeds the `||`: return caches.match('./').then((cached) => cached || new Response(...)); Added 13 regression tests in tests/test_pwa_manifest_sw.py covering: - manifest.json validity + required PWA fields + icon existence - sw.js cache-version placeholder + API/stream bypass + correct offline pattern (explicitly rejects the broken `|| new Response` shape so it can't regress) - /manifest.json + /sw.js routes serve correct Content-Type, Cache-Control, Service-Worker-Allowed headers and inject WEBUI_VERSION - index.html links manifest, registers SW, has iOS PWA meta tags Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,10 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Settings dialog and message controls unusable on mobile** — three mobile usability fixes: (1) settings tab strip replaced by a native `<select>` dropdown on narrow viewports, panel goes full-width; (2) provider card Save/Remove buttons become icon-only on mobile so the API key input fills the available width; (3) message timestamps, copy, and edit buttons are always visible on touch screens (no hover state on mobile). (`static/index.html`, `static/panels.js`, `static/style.css`) Co-authored by @bsgdigital.
|
- **Settings dialog and message controls unusable on mobile** — three mobile usability fixes: (1) settings tab strip replaced by a native `<select>` dropdown on narrow viewports, panel goes full-width; (2) provider card Save/Remove buttons become icon-only on mobile so the API key input fills the available width; (3) message timestamps, copy, and edit buttons are always visible on touch screens (no hover state on mobile). (`static/index.html`, `static/panels.js`, `static/style.css`) Co-authored by @bsgdigital.
|
||||||
|
## [v0.50.178] — 2026-04-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **PWA support — installable as a standalone app** — adds a Web App Manifest (`manifest.json`) and a minimal service worker (`sw.js`) with cache-first strategy for app shell assets and network-bypass for all `/api/*` and `/stream` endpoints. Cache name auto-busts on every deploy via git-derived version injection. Enables "Add to Home Screen" on Android, iOS, and desktop Chrome without any offline API response caching (live backend always required). (`static/manifest.json`, `static/sw.js`, `static/index.html`, `api/routes.py`) Closes #685. Co-authored by @bsgdigital.
|
||||||
|
|
||||||
## [v0.50.176] — 2026-04-23
|
## [v0.50.176] — 2026-04-23
|
||||||
|
|
||||||
|
|||||||
@@ -576,6 +576,41 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
logged_in = bool(cv and verify_session(cv))
|
logged_in = bool(cv and verify_session(cv))
|
||||||
return j(handler, {"auth_enabled": is_auth_enabled(), "logged_in": logged_in})
|
return j(handler, {"auth_enabled": is_auth_enabled(), "logged_in": logged_in})
|
||||||
|
|
||||||
|
if parsed.path in ("/manifest.json", "/manifest.webmanifest"):
|
||||||
|
static_root = Path(__file__).parent.parent / "static"
|
||||||
|
manifest_path = (static_root / "manifest.json").resolve()
|
||||||
|
if manifest_path.exists():
|
||||||
|
data = manifest_path.read_bytes()
|
||||||
|
handler.send_response(200)
|
||||||
|
handler.send_header("Content-Type", "application/manifest+json; charset=utf-8")
|
||||||
|
handler.send_header("Cache-Control", "no-store")
|
||||||
|
handler.send_header("Content-Length", str(len(data)))
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(data)
|
||||||
|
return True
|
||||||
|
return j(handler, {"error": "not found"}, status=404)
|
||||||
|
|
||||||
|
if parsed.path == "/sw.js":
|
||||||
|
static_root = Path(__file__).parent.parent / "static"
|
||||||
|
sw_path = (static_root / "sw.js").resolve()
|
||||||
|
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 api.updates import WEBUI_VERSION
|
||||||
|
text = sw_path.read_text(encoding="utf-8").replace(
|
||||||
|
"__CACHE_VERSION__", WEBUI_VERSION
|
||||||
|
)
|
||||||
|
data = text.encode("utf-8")
|
||||||
|
handler.send_response(200)
|
||||||
|
handler.send_header("Content-Type", "application/javascript; charset=utf-8")
|
||||||
|
handler.send_header("Cache-Control", "no-store")
|
||||||
|
handler.send_header("Service-Worker-Allowed", "/")
|
||||||
|
handler.send_header("Content-Length", str(len(data)))
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(data)
|
||||||
|
return True
|
||||||
|
return j(handler, {"error": "not found"}, status=404)
|
||||||
|
|
||||||
if parsed.path == "/favicon.ico":
|
if parsed.path == "/favicon.ico":
|
||||||
static_root = Path(__file__).parent.parent / "static"
|
static_root = Path(__file__).parent.parent / "static"
|
||||||
ico_path = (static_root / "favicon.ico").resolve()
|
ico_path = (static_root / "favicon.ico").resolve()
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="static/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="static/favicon.svg">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
|
||||||
<link rel="shortcut icon" href="static/favicon.ico">
|
<link rel="shortcut icon" href="static/favicon.ico">
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Hermes">
|
||||||
|
<link rel="apple-touch-icon" href="static/favicon.svg">
|
||||||
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
|
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
|
||||||
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
|
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
|
||||||
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
|
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
|
||||||
@@ -19,6 +25,16 @@
|
|||||||
<link id="prism-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
|
<link id="prism-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
|
||||||
|
<!-- PWA service worker registration -->
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
navigator.serviceWorker.register('sw.js').catch(function(err) {
|
||||||
|
console.warn('[pwa] Service worker registration failed:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
|
|||||||
23
static/manifest.json
Normal file
23
static/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "Hermes",
|
||||||
|
"short_name": "Hermes",
|
||||||
|
"description": "Hermes AI Agent Web UI",
|
||||||
|
"start_url": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#1a1a1a",
|
||||||
|
"theme_color": "#1a1a1a",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "static/favicon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "static/favicon-32.png",
|
||||||
|
"sizes": "32x32",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
106
static/sw.js
Normal file
106
static/sw.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Hermes WebUI Service Worker
|
||||||
|
* Minimal PWA service worker — enables "Add to Home Screen".
|
||||||
|
* No offline caching of API responses (the UI requires a live backend).
|
||||||
|
* Caches only static shell assets so the app shell loads fast on repeat visits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Cache version is injected by the server at request time (routes.py /sw.js handler).
|
||||||
|
// Bumps automatically whenever the git commit changes — no manual edits needed.
|
||||||
|
const CACHE_NAME = 'hermes-shell-__CACHE_VERSION__';
|
||||||
|
|
||||||
|
// Static assets that form the app shell
|
||||||
|
const SHELL_ASSETS = [
|
||||||
|
'./',
|
||||||
|
'./static/style.css',
|
||||||
|
'./static/boot.js',
|
||||||
|
'./static/ui.js',
|
||||||
|
'./static/messages.js',
|
||||||
|
'./static/sessions.js',
|
||||||
|
'./static/panels.js',
|
||||||
|
'./static/commands.js',
|
||||||
|
'./static/icons.js',
|
||||||
|
'./static/i18n.js',
|
||||||
|
'./static/workspace.js',
|
||||||
|
'./static/onboarding.js',
|
||||||
|
'./static/favicon.svg',
|
||||||
|
'./static/favicon-32.png',
|
||||||
|
'./manifest.json',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install: pre-cache the app shell
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
return cache.addAll(SHELL_ASSETS).catch((err) => {
|
||||||
|
// Non-fatal: if any asset fails, still activate
|
||||||
|
console.warn('[sw] Shell pre-cache partial failure:', err);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate: clean up old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch strategy:
|
||||||
|
// - API calls (/api/*, /stream) → always network (never cache)
|
||||||
|
// - Shell assets → cache-first with network fallback
|
||||||
|
// - Everything else → network-first, fall back to offline page
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// Never intercept cross-origin requests
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// API and streaming endpoints — always go to network
|
||||||
|
if (
|
||||||
|
url.pathname.startsWith('/api/') ||
|
||||||
|
url.pathname.includes('/stream') ||
|
||||||
|
url.pathname.startsWith('/health')
|
||||||
|
) {
|
||||||
|
return; // let browser handle normally
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shell assets: cache-first
|
||||||
|
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;
|
||||||
|
}).catch(() => {
|
||||||
|
// Offline fallback for navigation requests.
|
||||||
|
// Note: caches.match() returns a Promise (always truthy in a `||` check),
|
||||||
|
// so we must await/then to unwrap it — otherwise the `new Response(...)`
|
||||||
|
// branch is dead code and the browser falls back to its default offline page.
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
return caches.match('./').then((cached) => cached || new Response(
|
||||||
|
'<html><body style="font-family:sans-serif;padding:2rem;background:#1a1a1a;color:#ccc">' +
|
||||||
|
'<h2>You are offline</h2>' +
|
||||||
|
'<p>Hermes requires a server connection. Please check your network and try again.</p>' +
|
||||||
|
'</body></html>',
|
||||||
|
{ headers: { 'Content-Type': 'text/html' } }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
152
tests/test_pwa_manifest_sw.py
Normal file
152
tests/test_pwa_manifest_sw.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Regression tests for PWA support (manifest + service worker).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- manifest.json is valid JSON with required PWA fields
|
||||||
|
- sw.js has the `__CACHE_VERSION__` placeholder the server replaces at request time
|
||||||
|
- sw.js offline-fallback uses a resolved promise (not `caches.match() || fallback`
|
||||||
|
which is broken — Promise objects are always truthy in `||` checks, so the
|
||||||
|
fallback Response would never be used)
|
||||||
|
- /manifest.json, /manifest.webmanifest, /sw.js routes serve correct Content-Type
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
MANIFEST = ROOT / "static" / "manifest.json"
|
||||||
|
SW = ROOT / "static" / "sw.js"
|
||||||
|
INDEX = ROOT / "static" / "index.html"
|
||||||
|
ROUTES = ROOT / "api" / "routes.py"
|
||||||
|
|
||||||
|
|
||||||
|
class TestManifest:
|
||||||
|
def test_manifest_is_valid_json(self):
|
||||||
|
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
def test_manifest_has_required_pwa_fields(self):
|
||||||
|
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||||
|
for field in ("name", "start_url", "display", "icons"):
|
||||||
|
assert field in data, f"manifest.json missing required field: {field}"
|
||||||
|
assert data["display"] == "standalone", (
|
||||||
|
"manifest.display must be 'standalone' for installable PWA"
|
||||||
|
)
|
||||||
|
assert isinstance(data["icons"], list) and len(data["icons"]) > 0, (
|
||||||
|
"manifest.icons must be a non-empty list"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manifest_icons_reference_existing_files(self):
|
||||||
|
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||||
|
for icon in data["icons"]:
|
||||||
|
src = icon.get("src", "")
|
||||||
|
if src.startswith("http"):
|
||||||
|
continue # external icon, skip
|
||||||
|
# Paths are relative to the app root (where manifest is served)
|
||||||
|
# 'static/favicon.svg' or './static/favicon.svg' both valid
|
||||||
|
clean = src.lstrip("./")
|
||||||
|
p = ROOT / clean
|
||||||
|
assert p.exists(), f"manifest.json references missing icon: {src}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceWorker:
|
||||||
|
def test_sw_has_cache_version_placeholder(self):
|
||||||
|
src = SW.read_text(encoding="utf-8")
|
||||||
|
assert "__CACHE_VERSION__" in src, (
|
||||||
|
"sw.js must contain __CACHE_VERSION__ placeholder for the server "
|
||||||
|
"handler at /sw.js to replace with WEBUI_VERSION at request time"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sw_bypasses_api_and_stream(self):
|
||||||
|
src = SW.read_text(encoding="utf-8")
|
||||||
|
assert "/api/" in src, "SW must bypass /api/* (no cached auth/session responses)"
|
||||||
|
assert "/stream" in src, "SW must bypass streaming endpoints"
|
||||||
|
|
||||||
|
def test_sw_offline_fallback_awaits_caches_match(self):
|
||||||
|
"""caches.match() returns a Promise (always truthy in `||`), so the pattern
|
||||||
|
`caches.match('./') || new Response(...)` is broken — the fallback Response
|
||||||
|
is dead code and the browser falls back to its default offline page.
|
||||||
|
|
||||||
|
The correct pattern chains the match through .then() or awaits it so the
|
||||||
|
resolved value is what gets the `||` fallback.
|
||||||
|
"""
|
||||||
|
src = SW.read_text(encoding="utf-8")
|
||||||
|
# Must not use the broken shape
|
||||||
|
broken_pattern = re.compile(
|
||||||
|
r"caches\.match\([^)]*\)\s*\|\|\s*new\s+Response",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
assert not broken_pattern.search(src), (
|
||||||
|
"sw.js offline fallback uses `caches.match('./') || new Response(...)` "
|
||||||
|
"which is dead code — caches.match() returns a Promise that's always "
|
||||||
|
"truthy. Use `.then((cached) => cached || new Response(...))` instead."
|
||||||
|
)
|
||||||
|
# Positive assertion that SOME form of the working pattern is present
|
||||||
|
has_then = ".then(" in src and "cached" in src
|
||||||
|
has_await = "await caches.match" in src
|
||||||
|
assert has_then or has_await, (
|
||||||
|
"sw.js must await/then the caches.match() result before applying the fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
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."""
|
||||||
|
src = SW.read_text(encoding="utf-8")
|
||||||
|
# Look for the early-return pattern in the fetch handler
|
||||||
|
assert "return;" in src and "/api/" in src, (
|
||||||
|
"SW fetch handler must early-return for /api/* paths (no caching)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPWARoutes:
|
||||||
|
def test_manifest_route_serves_correct_content_type(self):
|
||||||
|
src = ROUTES.read_text(encoding="utf-8")
|
||||||
|
# The handler block for /manifest.json
|
||||||
|
idx = src.find('"/manifest.json"')
|
||||||
|
assert idx != -1, "routes.py must handle /manifest.json"
|
||||||
|
block = src[idx:idx + 800]
|
||||||
|
assert "application/manifest+json" in block, (
|
||||||
|
"manifest.json route must serve Content-Type: application/manifest+json"
|
||||||
|
)
|
||||||
|
assert "no-store" in block or "Cache-Control" in block, (
|
||||||
|
"manifest.json should have Cache-Control: no-store so updates are picked up"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sw_route_injects_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 + 1000]
|
||||||
|
assert "__CACHE_VERSION__" in block, (
|
||||||
|
"sw.js route must replace __CACHE_VERSION__ with the current WEBUI_VERSION"
|
||||||
|
)
|
||||||
|
assert "WEBUI_VERSION" in block, (
|
||||||
|
"sw.js route must import and use WEBUI_VERSION for cache busting"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sw_route_sets_service_worker_allowed(self):
|
||||||
|
src = ROUTES.read_text(encoding="utf-8")
|
||||||
|
idx = src.find('"/sw.js"')
|
||||||
|
block = src[idx:idx + 1000]
|
||||||
|
assert "Service-Worker-Allowed" in block, (
|
||||||
|
"sw.js route must set Service-Worker-Allowed header so the SW can control "
|
||||||
|
"the expected scope"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIndexHtmlIntegration:
|
||||||
|
def test_index_links_manifest(self):
|
||||||
|
src = INDEX.read_text(encoding="utf-8")
|
||||||
|
assert 'rel="manifest"' in src, "index.html must link to manifest.json"
|
||||||
|
|
||||||
|
def test_index_registers_service_worker(self):
|
||||||
|
src = INDEX.read_text(encoding="utf-8")
|
||||||
|
assert "serviceWorker" in src and "register" in src, (
|
||||||
|
"index.html must register the service worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_index_has_ios_pwa_meta_tags(self):
|
||||||
|
src = INDEX.read_text(encoding="utf-8")
|
||||||
|
assert "apple-mobile-web-app-capable" in src, (
|
||||||
|
"index.html should include Apple PWA meta tags for iOS home-screen support"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user