""" Tests for BATON-ARCH-009: PWA frontend (manifest + SW + UUID auth + SOS button). Acceptance criteria verified: 1. manifest.json — required fields, icons 192+512, display:standalone, start_url=/ 2. SW — cache-first, skipWaiting, clients.claim, API bypass 3. Onboarding — crypto.randomUUID(), POST /api/register, UUID saved to storage 4. Storage fallback — real write probe, chain localStorage→sessionStorage→in-memory 5. Private mode banner — present in HTML with explicit instruction text 6. Main screen — SOS button sends POST /api/signal with UUID 7. Offline — error shown to user when navigator.onLine === false 8. Structure — all 7 required frontend files exist """ from __future__ import annotations import json import re import struct from pathlib import Path import pytest PROJECT_ROOT = Path(__file__).parent.parent FRONTEND = PROJECT_ROOT / "frontend" # --------------------------------------------------------------------------- # Criterion 8 — File structure # --------------------------------------------------------------------------- REQUIRED_FRONTEND_FILES = [ "frontend/index.html", "frontend/app.js", "frontend/style.css", "frontend/sw.js", "frontend/manifest.json", "frontend/icons/icon-192.png", "frontend/icons/icon-512.png", ] @pytest.mark.parametrize("rel_path", REQUIRED_FRONTEND_FILES) def test_frontend_file_exists(rel_path: str) -> None: """Every required frontend file must exist on disk.""" assert (PROJECT_ROOT / rel_path).is_file(), f"Required file missing: {rel_path}" # --------------------------------------------------------------------------- # Criterion 1 — manifest.json # --------------------------------------------------------------------------- def _manifest() -> dict: return json.loads((FRONTEND / "manifest.json").read_text(encoding="utf-8")) def test_manifest_is_valid_json() -> None: """manifest.json must be parseable as JSON.""" _manifest() # raises if invalid def test_manifest_has_name() -> None: """manifest.json must have a non-empty 'name' field.""" m = _manifest() assert m.get("name"), "manifest.json missing 'name'" def test_manifest_display_standalone() -> None: """manifest.json must have display: 'standalone'.""" assert _manifest().get("display") == "standalone" def test_manifest_start_url_root() -> None: """manifest.json must have start_url: '/'.""" assert _manifest().get("start_url") == "/" def test_manifest_has_icon_192() -> None: """manifest.json must include a 192x192 icon entry.""" icons = _manifest().get("icons", []) sizes = [icon.get("sizes") for icon in icons] assert "192x192" in sizes, f"No 192x192 icon in manifest icons: {sizes}" def test_manifest_has_icon_512() -> None: """manifest.json must include a 512x512 icon entry.""" icons = _manifest().get("icons", []) sizes = [icon.get("sizes") for icon in icons] assert "512x512" in sizes, f"No 512x512 icon in manifest icons: {sizes}" def test_manifest_icon_entries_have_src_and_type() -> None: """Every icon in manifest.json must have 'src' and 'type' fields.""" for icon in _manifest().get("icons", []): assert icon.get("src"), f"Icon missing 'src': {icon}" assert icon.get("type"), f"Icon missing 'type': {icon}" def test_manifest_icon_192_src_points_to_png() -> None: """The 192x192 manifest icon src must point to a .png file.""" icons = _manifest().get("icons", []) icon_192 = next((i for i in icons if i.get("sizes") == "192x192"), None) assert icon_192 is not None assert icon_192["src"].endswith(".png"), f"192 icon src is not .png: {icon_192['src']}" def test_manifest_icon_512_src_points_to_png() -> None: """The 512x512 manifest icon src must point to a .png file.""" icons = _manifest().get("icons", []) icon_512 = next((i for i in icons if i.get("sizes") == "512x512"), None) assert icon_512 is not None assert icon_512["src"].endswith(".png"), f"512 icon src is not .png: {icon_512['src']}" # --------------------------------------------------------------------------- # PNG icon validation # --------------------------------------------------------------------------- PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" def _png_dimensions(path: Path) -> tuple[int, int]: """Return (width, height) from a PNG file's IHDR chunk.""" data = path.read_bytes() assert data[:8] == PNG_SIGNATURE, f"Not a valid PNG: {path}" # IHDR chunk: 4 bytes length + 4 bytes 'IHDR' + 4 bytes width + 4 bytes height width = struct.unpack(">I", data[16:20])[0] height = struct.unpack(">I", data[20:24])[0] return width, height def test_icon_192_is_valid_png() -> None: """icons/icon-192.png must be a valid PNG file.""" path = FRONTEND / "icons" / "icon-192.png" data = path.read_bytes() assert data[:8] == PNG_SIGNATURE, "icon-192.png is not a valid PNG" def test_icon_192_has_correct_dimensions() -> None: """icons/icon-192.png must be exactly 192x192 pixels.""" w, h = _png_dimensions(FRONTEND / "icons" / "icon-192.png") assert w == 192, f"icon-192.png width is {w}, expected 192" assert h == 192, f"icon-192.png height is {h}, expected 192" def test_icon_512_is_valid_png() -> None: """icons/icon-512.png must be a valid PNG file.""" path = FRONTEND / "icons" / "icon-512.png" data = path.read_bytes() assert data[:8] == PNG_SIGNATURE, "icon-512.png is not a valid PNG" def test_icon_512_has_correct_dimensions() -> None: """icons/icon-512.png must be exactly 512x512 pixels.""" w, h = _png_dimensions(FRONTEND / "icons" / "icon-512.png") assert w == 512, f"icon-512.png width is {w}, expected 512" assert h == 512, f"icon-512.png height is {h}, expected 512" # --------------------------------------------------------------------------- # index.html — PWA meta tags and DOM structure # --------------------------------------------------------------------------- def _html() -> str: return (FRONTEND / "index.html").read_text(encoding="utf-8") def test_html_has_manifest_link() -> None: """index.html must link to /manifest.json.""" assert 'href="/manifest.json"' in _html() def test_html_has_theme_color_meta() -> None: """index.html must have theme-color meta tag.""" assert 'name="theme-color"' in _html() def test_html_has_apple_mobile_web_app_capable() -> None: """index.html must have apple-mobile-web-app-capable meta tag.""" assert "apple-mobile-web-app-capable" in _html() def test_html_has_viewport_meta() -> None: """index.html must have a viewport meta tag.""" assert 'name="viewport"' in _html() def test_html_has_private_mode_banner() -> None: """index.html must contain the private mode banner element.""" assert 'id="private-mode-banner"' in _html() def test_html_private_mode_banner_has_instruction() -> None: """Private mode banner must contain explicit instruction text.""" html = _html() # Find banner content — check for key guidance phrases assert "private" in html.lower() or "Private" in html, \ "Banner must reference private mode" # The banner must mention what to do (open in normal tab) assert "regular" in html or "normal" in html or "обычн" in html, \ "Banner must instruct user to open in a regular (non-private) tab" def test_html_has_sos_button() -> None: """index.html must have a SOS button (id=btn-sos).""" assert 'id="btn-sos"' in _html() def test_html_has_screen_onboarding() -> None: """index.html must have the onboarding screen element.""" assert 'id="screen-onboarding"' in _html() def test_html_has_screen_main() -> None: """index.html must have the main screen element.""" assert 'id="screen-main"' in _html() def test_html_loads_app_js() -> None: """index.html must include a script tag for app.js.""" assert "/app.js" in _html() def test_html_has_login_input() -> None: """index.html must have login input field for onboarding.""" assert 'id="login-input"' in _html() # --------------------------------------------------------------------------- # Criterion 2 — sw.js (Service Worker) # --------------------------------------------------------------------------- def _sw() -> str: return (FRONTEND / "sw.js").read_text(encoding="utf-8") def test_sw_has_skip_waiting() -> None: """sw.js must call skipWaiting() in install handler.""" assert "skipWaiting()" in _sw() def test_sw_skip_waiting_in_install_handler() -> None: """skipWaiting() must appear within the 'install' event handler.""" sw = _sw() install_match = re.search(r"addEventListener\(['\"]install['\"].*?}\s*\)", sw, re.DOTALL) assert install_match, "No install event handler found in sw.js" assert "skipWaiting()" in install_match.group(0), \ "skipWaiting() is not inside the install event handler" def test_sw_has_clients_claim() -> None: """sw.js must call clients.claim() in activate handler.""" assert "clients.claim()" in _sw() def test_sw_clients_claim_in_activate_handler() -> None: """clients.claim() must appear within the 'activate' event handler.""" sw = _sw() activate_match = re.search(r"addEventListener\(['\"]activate['\"].*?}\s*\)", sw, re.DOTALL) assert activate_match, "No activate event handler found in sw.js" assert "clients.claim()" in activate_match.group(0), \ "clients.claim() is not inside the activate event handler" def test_sw_has_cache_first_strategy() -> None: """sw.js must use cache-first strategy (caches.match before fetch).""" sw = _sw() # cache-first: check cache, if hit return, else network assert "caches.match" in sw, "sw.js does not use caches.match (no cache-first strategy)" def test_sw_api_bypass() -> None: """sw.js must not intercept /api/ requests.""" sw = _sw() assert "/api/" in sw, "sw.js missing /api/ bypass pattern" # The bypass must be a guard/return before respondWith # Check that /api/ check leads to a return assert re.search(r"/api/.*return", sw, re.DOTALL) or \ re.search(r"pathname.*startsWith.*['\"]\/api\/", sw), \ "sw.js must bypass /api/ requests (return before respondWith)" def test_sw_precaches_index_html() -> None: """sw.js must precache /index.html in the app shell.""" assert "/index.html" in _sw() def test_sw_precaches_app_js() -> None: """sw.js must precache /app.js in the app shell.""" assert "/app.js" in _sw() def test_sw_precaches_manifest() -> None: """sw.js must precache /manifest.json in the app shell.""" assert "/manifest.json" in _sw() def test_sw_precaches_icon_192() -> None: """sw.js must precache /icons/icon-192.png in the app shell.""" assert "/icons/icon-192.png" in _sw() def test_sw_precaches_icon_512() -> None: """sw.js must precache /icons/icon-512.png in the app shell.""" assert "/icons/icon-512.png" in _sw() # --------------------------------------------------------------------------- # Criterion 3 — Onboarding: UUID via crypto.randomUUID(), POST /api/register # --------------------------------------------------------------------------- def _app_js() -> str: return (FRONTEND / "app.js").read_text(encoding="utf-8") def test_app_posts_to_auth_login() -> None: """app.js must send POST to /api/auth/login during login.""" assert "/api/auth/login" in _app_js() def test_app_posts_to_auth_register() -> None: """app.js must send POST to /api/auth/register during registration.""" assert "/api/auth/register" in _app_js() def test_app_stores_auth_token() -> None: """app.js must persist JWT token to storage.""" assert "baton_auth_token" in _app_js() assert "setItem" in _app_js() # --------------------------------------------------------------------------- # Criterion 4 — Storage fallback (real write probe + chain) # --------------------------------------------------------------------------- def test_app_uses_real_write_probe() -> None: """app.js must probe storage with a real write (setItem), not typeof check.""" app = _app_js() # The probe key is defined as a variable, then setItem is called with it # Pattern: const k = '__baton_probe__'; s.setItem(k, ...) assert "__baton_probe__" in app, "app.js missing probe key __baton_probe__" # setItem must be called inside the probe function (not typeof/in check) probe_fn = re.search(r"function _probeStorage\(.*?\}\n", app, re.DOTALL) assert probe_fn, "app.js missing _probeStorage function" assert "setItem" in probe_fn.group(0), \ "app.js real write probe must call setItem inside _probeStorage" def test_app_falls_back_to_session_storage() -> None: """app.js must include sessionStorage as a fallback storage option.""" assert "sessionStorage" in _app_js() def test_app_falls_back_to_in_memory() -> None: """app.js must include in-memory storage as final fallback.""" app = _app_js() # In-memory fallback uses a plain object (_mem) assert "_mem" in app, "app.js missing in-memory fallback object" assert "_storageType" in app, "app.js missing _storageType tracking variable" def test_app_storage_chain_order() -> None: """app.js must try localStorage first, then sessionStorage, then in-memory.""" app = _app_js() local_pos = app.find("localStorage") session_pos = app.find("sessionStorage") mem_pos = app.find("_mem") assert local_pos < session_pos, "localStorage must come before sessionStorage in fallback chain" assert session_pos < mem_pos, "sessionStorage must come before in-memory in fallback chain" def test_app_storage_probe_removes_test_key() -> None: """app.js write probe must clean up after itself (removeItem).""" assert "removeItem" in _app_js(), "Write probe must call removeItem to clean up" # --------------------------------------------------------------------------- # Criterion 5 — Private mode banner # --------------------------------------------------------------------------- def test_app_shows_banner_when_storage_not_local() -> None: """app.js must show private-mode-banner when storageType is not 'local'.""" app = _app_js() assert "private-mode-banner" in app, \ "app.js missing reference to private-mode-banner element" def test_app_banner_triggered_by_storage_type_check() -> None: """app.js must show banner based on _storageType check (not 'local').""" app = _app_js() # Must check _storageType and conditionally show banner assert re.search(r"_storageType.*['\"]local['\"]|['\"]local['\"].*_storageType", app), \ "app.js must check _storageType to decide whether to show private-mode banner" def test_html_banner_has_action_guidance() -> None: """Private mode banner in index.html must tell user what to do (explicit instruction).""" html = _html() # Extract banner content between its opening and closing div banner_match = re.search( r'id="private-mode-banner"[^>]*>(.*?)', html, re.DOTALL ) assert banner_match, "Could not find private-mode-banner div in index.html" banner_text = banner_match.group(1).lower() # Must contain explicit action: open in regular/normal tab assert "open" in banner_text or "откр" in banner_text, \ "Banner must instruct user to open Baton in a regular tab" # --------------------------------------------------------------------------- # Criterion 6 — Main screen: SOS button sends POST /api/signal with UUID # --------------------------------------------------------------------------- def test_app_posts_to_api_signal() -> None: """app.js must send POST to /api/signal when SOS button clicked.""" assert "/api/signal" in _app_js() def test_app_signal_sends_auth_header() -> None: """app.js must include Authorization Bearer header in /api/signal request.""" app = _app_js() signal_area = re.search( r"_apiPost\(['\"]\/api\/signal['\"].*Authorization.*Bearer", app, re.DOTALL ) assert signal_area, \ "Authorization Bearer header must be set in _apiPost('/api/signal') call" def test_app_sos_button_click_calls_handle_signal() -> None: """app.js must attach click handler (_handleSignal) to btn-sos.""" app = _app_js() assert "btn-sos" in app, "app.js must reference btn-sos" assert "_handleSignal" in app, "app.js must define _handleSignal" # The SOS button must have _handleSignal as its click listener assert re.search(r"btn.*sos.*_handleSignal|_handleSignal.*btn.*sos", app, re.DOTALL), \ "btn-sos must be connected to _handleSignal" def test_app_signal_uses_token_from_storage() -> None: """app.js must retrieve auth token from storage before sending signal.""" app = _app_js() handle_signal = re.search( r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL ) assert handle_signal, "_handleSignal function not found" assert "_getAuthToken" in handle_signal.group(0), \ "_handleSignal must call _getAuthToken() to get JWT token" # --------------------------------------------------------------------------- # Criterion 7 — Offline error (navigator.onLine === false → user sees error) # --------------------------------------------------------------------------- def test_app_checks_navigator_online() -> None: """app.js must check navigator.onLine before sending signal.""" assert "navigator.onLine" in _app_js() def test_app_shows_error_when_offline() -> None: """app.js must call _setStatus with error message when offline.""" app = _app_js() handle_signal = re.search( r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL ) assert handle_signal, "_handleSignal function not found" fn_body = handle_signal.group(0) # Must check onLine and show error (not silent fail) assert "navigator.onLine" in fn_body, \ "_handleSignal must check navigator.onLine" assert "_setStatus" in fn_body, \ "_handleSignal must call _setStatus to show offline error" def test_app_offline_error_returns_early() -> None: """app.js must return early (not attempt fetch) when navigator.onLine is false.""" app = _app_js() handle_signal = re.search( r"async function _handleSignal\(\).*?^}", app, re.MULTILINE | re.DOTALL ) assert handle_signal, "_handleSignal function not found" fn_body = handle_signal.group(0) # The offline guard must be a return statement before any API call offline_guard = re.search( r"if\s*\(!navigator\.onLine\).*?return", fn_body, re.DOTALL ) assert offline_guard, \ "_handleSignal must have an early return when navigator.onLine is false" def test_app_offline_error_message_is_not_empty() -> None: """app.js must show a non-empty error message when offline.""" app = _app_js() # Find the offline error call pattern offline_block = re.search( r"if\s*\(!navigator\.onLine\)\s*\{([^}]+)\}", app, re.DOTALL ) assert offline_block, "Offline guard block not found" block_content = offline_block.group(1) assert "_setStatus(" in block_content, \ "Offline block must call _setStatus to display error" # Extract the message from _setStatus call status_call = re.search(r"_setStatus\(['\"]([^'\"]+)['\"]", block_content) assert status_call, "Offline _setStatus call has no message" assert len(status_call.group(1).strip()) > 0, \ "Offline error message must not be empty" # --------------------------------------------------------------------------- # SW registration in app.js # --------------------------------------------------------------------------- def test_app_registers_service_worker() -> None: """app.js must register a service worker via serviceWorker.register.""" assert "serviceWorker" in _app_js() assert ".register(" in _app_js() def test_app_registers_sw_js() -> None: """app.js must register specifically '/sw.js'.""" assert "'/sw.js'" in _app_js() or '"/sw.js"' in _app_js()