fix: bound stalled dashboard navigations (#353)
This commit is contained in:
1 parent
391e8964c4
commit
0ff6b5c8fd
5 files changed
+124
-13
No files matched your search
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v42" in worker
|
||||
assert "stackchain-dashboard-shell-v43" in worker
|
||||
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v42" in worker
|
||||
assert "stackchain-dashboard-shell-v43" in worker
|
||||
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||
@@ -21,9 +21,10 @@ const cache = {{
|
||||
}};
|
||||
const context = {{
|
||||
URL, Request, Response, Headers, AbortController,
|
||||
console,
|
||||
setTimeout, clearTimeout, console,
|
||||
self: {{
|
||||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{
|
||||
@@ -45,9 +46,19 @@ const context = {{
|
||||
delete: async key => {{ state.deleted.push(key); return true; }},
|
||||
match: async request => cache.match(request),
|
||||
}},
|
||||
fetch: async request => {{
|
||||
fetch: async (request, options = {{}}) => {{
|
||||
state.fetches.push(String(request.url || request));
|
||||
if (state.failFetch) throw new Error('offline');
|
||||
if (state.stallFetch) return new Promise((resolve, reject) => {{
|
||||
options.signal?.addEventListener('abort', () => {{
|
||||
state.fetchAborted = true;
|
||||
reject(new Error('aborted'));
|
||||
}}, {{ once: true }});
|
||||
}});
|
||||
if (state.lateFetch) return new Promise(resolve => {{
|
||||
options.signal?.addEventListener('abort', () => {{ state.fetchAborted = true; }}, {{ once: true }});
|
||||
setTimeout(() => resolve(new Response('late network')), 50);
|
||||
}});
|
||||
const response = new Response('network', {{ status: state.fetchStatus }});
|
||||
Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }});
|
||||
return response;
|
||||
@@ -94,10 +105,10 @@ async function dispatchNotificationClick(route) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v42" in source
|
||||
assert "stackchain-dashboard-shell-v43" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -106,14 +117,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v42" in source
|
||||
assert "stackchain-dashboard-shell-v43" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v42" in source
|
||||
assert "stackchain-dashboard-shell-v43" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -360,6 +371,79 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
||||
]
|
||||
|
||||
|
||||
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.stallFetch = true;
|
||||
state.cachedBody = 'cached dashboard';
|
||||
const response = await Promise.race([
|
||||
dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
||||
}),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({
|
||||
timedOut: response === null,
|
||||
body: response ? await response.text() : null,
|
||||
state,
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["timedOut"] is False
|
||||
assert result["body"] == "cached dashboard"
|
||||
assert result["state"]["fetchAborted"] is True
|
||||
assert result["state"]["puts"] == []
|
||||
|
||||
|
||||
def test_stalled_navigation_without_cached_shell_returns_deterministic_504():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.stallFetch = true;
|
||||
const response = await Promise.race([
|
||||
dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
||||
}),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({
|
||||
status: response?.status || null,
|
||||
body: response ? await response.text() : null,
|
||||
contentType: response?.headers.get('Content-Type') || null,
|
||||
state,
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 504
|
||||
assert result["body"] == "Stackchain is offline and the dashboard is not cached yet. Reconnect and try again."
|
||||
assert result["contentType"] == "text/plain; charset=utf-8"
|
||||
assert result["state"]["fetchAborted"] is True
|
||||
|
||||
|
||||
def test_navigation_deadline_wins_when_fetch_ignores_abort_and_prevents_late_cache_write():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.lateFetch = true;
|
||||
state.cachedBody = 'cached dashboard';
|
||||
const response = await Promise.race([
|
||||
dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
||||
}),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 35)),
|
||||
]);
|
||||
const body = response ? await response.text() : null;
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
process.stdout.write(JSON.stringify({timedOut: response === null, body, state}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["timedOut"] is False
|
||||
assert result["body"] == "cached dashboard"
|
||||
assert result["state"]["fetchAborted"] is True
|
||||
assert result["state"]["puts"] == []
|
||||
|
||||
|
||||
def test_redirected_login_navigation_does_not_replace_cached_dashboard_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user