|
| 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