diff --git a/README.md b/README.md index db19336..565237e 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,20 @@ API process is running and does not contact Gitea. GET `/readyz` is the readiness check: it validates the configured Gitea credentials and returns HTTP 503 with an error when Gitea is unavailable or authentication fails. +## Offline mobile shell + +After one successful online load, the installed dashboard precaches a versioned, +subpath-scoped application shell. During a network outage, navigation falls back +to that shell so local issue and review drafts remain reachable. A visible, +accessible offline notice distinguishes this mode from live Gitea data, and the +dashboard refreshes its live snapshot when connectivity returns. + +The offline guarantee covers only the HTML/JavaScript shell, manifest, icons, and +browser-local drafts. API responses and mutations are never cached or queued; +submissions still require connectivity. Service-worker upgrades are atomic and +remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on +the same origin. + Run the test suite with: ```bash diff --git a/frontend/index.html b/frontend/index.html index 5adb840..6a6b27e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -55,6 +55,8 @@ textarea { resize: vertical; min-height: 120px; } .small { font-size: 12px; color: #94a3b8; } .status { display:inline-flex; gap:6px; align-items:center; } .dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; } +.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; } +.offline-status[hidden] { display:none; } .widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); } .widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; } .event { padding: 8px 0; border-bottom: 1px solid #1b2d45; } @@ -273,6 +275,9 @@ textarea { resize: vertical; min-height: 120px; } +
@@ -2989,6 +2994,20 @@ textarea { resize: vertical; min-height: 120px; } }); function load() { return contextPoller.refresh(); } + const offlineStatus = qs('#offline-status'); + function showOfflineStatus() { + offlineStatus.hidden = false; + setStatus('Offline'); + } + function reconnectLiveData() { + offlineStatus.hidden = true; + setStatus('Reconnecting…'); + contextPoller.refresh(); + } + if (!navigator.onLine) showOfflineStatus(); + window.addEventListener('offline', showOfflineStatus); + window.addEventListener('online', reconnectLiveData); + qs('#refresh').addEventListener('click', load); qs('#start-work-session').addEventListener('click', () => { if (!filterMyWork(lastMyWork, selectedWorkFilter).length) { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index f4bff97..f4091f9 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,9 +1,24 @@ -const CACHE = 'stackchain-shell-v1'; +const CACHE = 'stackchain-dashboard-shell-v2'; const BASE = new URL('./', self.location.href).pathname; const SHELL = [ + BASE, BASE + 'manifest.webmanifest', BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-512.png', + BASE + 'static/markdown.js', + BASE + 'static/commands.js', + BASE + 'static/search-preview.js', + BASE + 'static/widgets.js', + BASE + 'static/drafts.js', + BASE + 'static/my-work.js', + BASE + 'static/pick-work.js', + BASE + 'static/conversation.js', + BASE + 'static/issue-sheet.js', + BASE + 'static/create-issue-sheet.js', + BASE + 'static/pull-sheet.js', + BASE + 'static/review-sheet.js', + BASE + 'static/work-route.js', + BASE + 'static/context-poller.js', ]; self.addEventListener('install', event => { @@ -12,7 +27,8 @@ self.addEventListener('install', event => { self.addEventListener('activate', event => { event.waitUntil(caches.keys().then(keys => Promise.all( - keys.filter(key => key !== CACHE).map(key => caches.delete(key)) + keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE) + .map(key => caches.delete(key)) )).then(() => self.clients.claim())); }); @@ -20,8 +36,20 @@ self.addEventListener('fetch', event => { const request = event.request; if (request.method !== 'GET' || request.url.includes('/api/')) return; const url = new URL(request.url); + if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return; if (request.mode === 'navigate') { - event.respondWith(fetch(request)); + event.respondWith( + fetch(request).then(async response => { + if (response.ok) { + const cache = await caches.open(CACHE); + await cache.put(BASE, response.clone()); + } + return response; + }).catch(async () => { + const cache = await caches.open(CACHE); + return cache.match(BASE); + }) + ); return; } if (url.origin === self.location.origin && SHELL.includes(url.pathname)) { diff --git a/src/views.py b/src/views.py index ce810e3..357ec7e 100644 --- a/src/views.py +++ b/src/views.py @@ -9,7 +9,14 @@ MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest" SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js" -@router.get("/", response_class=HTMLResponse) +class RevalidatingHTMLResponse(HTMLResponse): + def __init__(self, content, status_code=200, headers=None, media_type=None, background=None): + response_headers = dict(headers or {}) + response_headers["Cache-Control"] = "no-cache" + super().__init__(content, status_code, response_headers, media_type, background) + + +@router.get("/", response_class=RevalidatingHTMLResponse) async def dashboard() -> str: return DASHBOARD_FILE.read_text() diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py new file mode 100644 index 0000000..fae40a9 --- /dev/null +++ b/tests/test_service_worker.py @@ -0,0 +1,154 @@ +import json +import subprocess +from pathlib import Path + + +WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js" + + +def run_worker_scenario(scenario: str) -> dict: + harness = f""" +const fs = require('fs'); +const vm = require('vm'); +const listeners = {{}}; +const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], failFetch: false, fetchStatus: 200, cachedBody: null }}; +const cache = {{ + addAll: async urls => {{ state.added = urls; }}, + match: async request => state.cachedBody === null ? null : new Response(state.cachedBody), + put: async (request, response) => {{ state.puts.push(String(request.url || request)); }}, +}}; +const context = {{ + URL, Request, Response, + console, + self: {{ + location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }}, + addEventListener: (name, handler) => {{ listeners[name] = handler; }}, + skipWaiting: async () => {{ state.skipped = true; }}, + clients: {{ claim: async () => {{ state.claimed = true; }} }}, + }}, + caches: {{ + open: async () => cache, + keys: async () => ['stackchain-dashboard-old', 'another-app-cache'], + delete: async key => {{ state.deleted.push(key); return true; }}, + match: async request => cache.match(request), + }}, + fetch: async request => {{ + state.fetches.push(String(request.url || request)); + if (state.failFetch) throw new Error('offline'); + return new Response('network', {{ status: state.fetchStatus }}); + }}, +}}; +vm.createContext(context); +vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context); +async function dispatch(name, request) {{ + let pending; + let response; + listeners[name]({{ + request, + waitUntil: promise => {{ pending = promise; }}, + respondWith: promise => {{ response = promise; }}, + }}); + if (pending) await pending; + return response ? await response : null; +}} +(async () => {{ +{scenario} +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + completed = subprocess.run( + ["node", "-e", harness], capture_output=True, check=True, text=True + ) + return json.loads(completed.stdout) + + +def test_install_precaches_complete_subpath_scoped_app_shell(): + result = run_worker_scenario( + """ + await dispatch('install'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["skipped"] is True + assert result["added"][0] == "/dashboard/" + assert set(result["added"]) == { + "/dashboard/", + "/dashboard/manifest.webmanifest", + "/dashboard/static/icons/stackchain-192.png", + "/dashboard/static/icons/stackchain-512.png", + "/dashboard/static/markdown.js", + "/dashboard/static/commands.js", + "/dashboard/static/search-preview.js", + "/dashboard/static/widgets.js", + "/dashboard/static/drafts.js", + "/dashboard/static/my-work.js", + "/dashboard/static/pick-work.js", + "/dashboard/static/conversation.js", + "/dashboard/static/issue-sheet.js", + "/dashboard/static/create-issue-sheet.js", + "/dashboard/static/pull-sheet.js", + "/dashboard/static/review-sheet.js", + "/dashboard/static/work-route.js", + "/dashboard/static/context-poller.js", + } + + +def test_activate_deletes_only_stale_stackchain_caches(): + result = run_worker_scenario( + """ + await dispatch('activate'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["claimed"] is True + assert result["deleted"] == ["stackchain-dashboard-old"] + + +def test_offline_navigation_returns_cached_shell_for_share_target_url(): + result = run_worker_scenario( + """ + state.failFetch = true; + state.cachedBody = 'cached dashboard'; + const response = await dispatch('fetch', { + method: 'GET', mode: 'navigate', + url: 'https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com', + }); + process.stdout.write(JSON.stringify({ body: await response.text(), state })); +""" + ) + + assert result["body"] == "cached dashboard" + assert result["state"]["fetches"] == [ + "https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com" + ] + + +def test_cross_origin_navigation_is_not_intercepted_or_cached(): + result = run_worker_scenario( + """ + const response = await dispatch('fetch', { + method: 'GET', mode: 'navigate', url: 'https://outside.example/page', + }); + process.stdout.write(JSON.stringify({ intercepted: response !== null, state })); +""" + ) + + assert result["intercepted"] is False + assert result["state"]["fetches"] == [] + assert result["state"]["puts"] == [] + + +def test_failed_online_navigation_does_not_replace_working_cached_shell(): + result = run_worker_scenario( + """ + state.fetchStatus = 503; + const response = await dispatch('fetch', { + method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/', + }); + process.stdout.write(JSON.stringify({ status: response.status, state })); +""" + ) + + assert result["status"] == 503 + assert result["state"]["puts"] == [] diff --git a/tests/test_work_pages.py b/tests/test_work_pages.py index 033676c..fdce2dc 100644 --- a/tests/test_work_pages.py +++ b/tests/test_work_pages.py @@ -85,4 +85,29 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da assert worker.headers["service-worker-allowed"] == "/" assert "request.url.includes('/api/')" in worker.text assert "request.method !== 'GET'" in worker.text - assert "new URL('./', self.location.href).pathname" in worker.text \ No newline at end of file + assert "new URL('./', self.location.href).pathname" in worker.text + + +@pytest.mark.anyio +async def test_dashboard_announces_offline_mode_and_refreshes_after_reconnect(): + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/") + + assert response.status_code == 200 + assert 'id="offline-status"' in response.text + assert 'role="status"' in response.text + assert 'aria-live="polite"' in response.text + assert "window.addEventListener('offline'" in response.text + assert "window.addEventListener('online'" in response.text + assert "contextPoller.refresh()" in response.text + + +@pytest.mark.anyio +async def test_dashboard_shell_revalidates_instead_of_being_stored_as_fresh(): + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-cache" \ No newline at end of file