feat: open mobile dashboard offline (#226)
CI / lint (pull_request) Successful in 19s
CI / build-frontend (pull_request) Successful in 6s

This commit is contained in:
timmy committed 2026-08-07 20:56:13 +00:00
1 parent 1c2dbd2168
commit 6bf5512b47
6 files changed
+252 -5

No files matched your search

+154
View File
@@ -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"] == []
+26 -1
View File
@@ -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
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"