Skip to content

Commit ecf2f93

Browse files
nesquenaclaude
andcommitted
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) <[email protected]>
1 parent d6b62ca commit ecf2f93

2 files changed

Lines changed: 158 additions & 3 deletions

File tree

static/sw.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,18 @@ self.addEventListener('fetch', (event) => {
8787
}
8888
return response;
8989
}).catch(() => {
90-
// Offline fallback for navigation requests
90+
// Offline fallback for navigation requests.
91+
// Note: caches.match() returns a Promise (always truthy in a `||` check),
92+
// so we must await/then to unwrap it — otherwise the `new Response(...)`
93+
// branch is dead code and the browser falls back to its default offline page.
9194
if (event.request.mode === 'navigate') {
92-
return caches.match('./') || new Response(
95+
return caches.match('./').then((cached) => cached || new Response(
9396
'<html><body style="font-family:sans-serif;padding:2rem;background:#1a1a1a;color:#ccc">' +
9497
'<h2>You are offline</h2>' +
9598
'<p>Hermes requires a server connection. Please check your network and try again.</p>' +
9699
'</body></html>',
97100
{ headers: { 'Content-Type': 'text/html' } }
98-
);
101+
));
99102
}
100103
});
101104
})

tests/test_pwa_manifest_sw.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Regression tests for PWA support (manifest + service worker).
2+
3+
Covers:
4+
- manifest.json is valid JSON with required PWA fields
5+
- sw.js has the `__CACHE_VERSION__` placeholder the server replaces at request time
6+
- sw.js offline-fallback uses a resolved promise (not `caches.match() || fallback`
7+
which is broken — Promise objects are always truthy in `||` checks, so the
8+
fallback Response would never be used)
9+
- /manifest.json, /manifest.webmanifest, /sw.js routes serve correct Content-Type
10+
"""
11+
import json
12+
import re
13+
from pathlib import Path
14+
15+
16+
ROOT = Path(__file__).resolve().parent.parent
17+
MANIFEST = ROOT / "static" / "manifest.json"
18+
SW = ROOT / "static" / "sw.js"
19+
INDEX = ROOT / "static" / "index.html"
20+
ROUTES = ROOT / "api" / "routes.py"
21+
22+
23+
class TestManifest:
24+
def test_manifest_is_valid_json(self):
25+
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
26+
assert isinstance(data, dict)
27+
28+
def test_manifest_has_required_pwa_fields(self):
29+
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
30+
for field in ("name", "start_url", "display", "icons"):
31+
assert field in data, f"manifest.json missing required field: {field}"
32+
assert data["display"] == "standalone", (
33+
"manifest.display must be 'standalone' for installable PWA"
34+
)
35+
assert isinstance(data["icons"], list) and len(data["icons"]) > 0, (
36+
"manifest.icons must be a non-empty list"
37+
)
38+
39+
def test_manifest_icons_reference_existing_files(self):
40+
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
41+
for icon in data["icons"]:
42+
src = icon.get("src", "")
43+
if src.startswith("http"):
44+
continue # external icon, skip
45+
# Paths are relative to the app root (where manifest is served)
46+
# 'static/favicon.svg' or './static/favicon.svg' both valid
47+
clean = src.lstrip("./")
48+
p = ROOT / clean
49+
assert p.exists(), f"manifest.json references missing icon: {src}"
50+
51+
52+
class TestServiceWorker:
53+
def test_sw_has_cache_version_placeholder(self):
54+
src = SW.read_text(encoding="utf-8")
55+
assert "__CACHE_VERSION__" in src, (
56+
"sw.js must contain __CACHE_VERSION__ placeholder for the server "
57+
"handler at /sw.js to replace with WEBUI_VERSION at request time"
58+
)
59+
60+
def test_sw_bypasses_api_and_stream(self):
61+
src = SW.read_text(encoding="utf-8")
62+
assert "/api/" in src, "SW must bypass /api/* (no cached auth/session responses)"
63+
assert "/stream" in src, "SW must bypass streaming endpoints"
64+
65+
def test_sw_offline_fallback_awaits_caches_match(self):
66+
"""caches.match() returns a Promise (always truthy in `||`), so the pattern
67+
`caches.match('./') || new Response(...)` is broken — the fallback Response
68+
is dead code and the browser falls back to its default offline page.
69+
70+
The correct pattern chains the match through .then() or awaits it so the
71+
resolved value is what gets the `||` fallback.
72+
"""
73+
src = SW.read_text(encoding="utf-8")
74+
# Must not use the broken shape
75+
broken_pattern = re.compile(
76+
r"caches\.match\([^)]*\)\s*\|\|\s*new\s+Response",
77+
re.DOTALL,
78+
)
79+
assert not broken_pattern.search(src), (
80+
"sw.js offline fallback uses `caches.match('./') || new Response(...)` "
81+
"which is dead code — caches.match() returns a Promise that's always "
82+
"truthy. Use `.then((cached) => cached || new Response(...))` instead."
83+
)
84+
# Positive assertion that SOME form of the working pattern is present
85+
has_then = ".then(" in src and "cached" in src
86+
has_await = "await caches.match" in src
87+
assert has_then or has_await, (
88+
"sw.js must await/then the caches.match() result before applying the fallback"
89+
)
90+
91+
def test_sw_never_caches_api_responses(self):
92+
"""Defensive: the SW must not cache responses from /api/* paths.
93+
Currently enforced by early-return before the shell-asset cache block."""
94+
src = SW.read_text(encoding="utf-8")
95+
# Look for the early-return pattern in the fetch handler
96+
assert "return;" in src and "/api/" in src, (
97+
"SW fetch handler must early-return for /api/* paths (no caching)"
98+
)
99+
100+
101+
class TestPWARoutes:
102+
def test_manifest_route_serves_correct_content_type(self):
103+
src = ROUTES.read_text(encoding="utf-8")
104+
# The handler block for /manifest.json
105+
idx = src.find('"/manifest.json"')
106+
assert idx != -1, "routes.py must handle /manifest.json"
107+
block = src[idx:idx + 800]
108+
assert "application/manifest+json" in block, (
109+
"manifest.json route must serve Content-Type: application/manifest+json"
110+
)
111+
assert "no-store" in block or "Cache-Control" in block, (
112+
"manifest.json should have Cache-Control: no-store so updates are picked up"
113+
)
114+
115+
def test_sw_route_injects_cache_version(self):
116+
src = ROUTES.read_text(encoding="utf-8")
117+
idx = src.find('"/sw.js"')
118+
assert idx != -1, "routes.py must handle /sw.js"
119+
block = src[idx:idx + 1000]
120+
assert "__CACHE_VERSION__" in block, (
121+
"sw.js route must replace __CACHE_VERSION__ with the current WEBUI_VERSION"
122+
)
123+
assert "WEBUI_VERSION" in block, (
124+
"sw.js route must import and use WEBUI_VERSION for cache busting"
125+
)
126+
127+
def test_sw_route_sets_service_worker_allowed(self):
128+
src = ROUTES.read_text(encoding="utf-8")
129+
idx = src.find('"/sw.js"')
130+
block = src[idx:idx + 1000]
131+
assert "Service-Worker-Allowed" in block, (
132+
"sw.js route must set Service-Worker-Allowed header so the SW can control "
133+
"the expected scope"
134+
)
135+
136+
137+
class TestIndexHtmlIntegration:
138+
def test_index_links_manifest(self):
139+
src = INDEX.read_text(encoding="utf-8")
140+
assert 'rel="manifest"' in src, "index.html must link to manifest.json"
141+
142+
def test_index_registers_service_worker(self):
143+
src = INDEX.read_text(encoding="utf-8")
144+
assert "serviceWorker" in src and "register" in src, (
145+
"index.html must register the service worker"
146+
)
147+
148+
def test_index_has_ios_pwa_meta_tags(self):
149+
src = INDEX.read_text(encoding="utf-8")
150+
assert "apple-mobile-web-app-capable" in src, (
151+
"index.html should include Apple PWA meta tags for iOS home-screen support"
152+
)

0 commit comments

Comments
 (0)