diff --git a/frontend/index.html b/frontend/index.html index cfe8c4f..5fd93c6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -314,6 +314,7 @@
+
Today is saved on this device.
diff --git a/frontend/workspace-bootstrap.js b/frontend/workspace-bootstrap.js index 7a56902..e2fc6d1 100644 --- a/frontend/workspace-bootstrap.js +++ b/frontend/workspace-bootstrap.js @@ -1,41 +1,75 @@ -async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) { +async function loadWorkspace({ + document, + window = null, + createLoader = createFeatureLoader, + schedule = callback => setTimeout(callback,750), +}) { let cameOnline = false; let replayed = false; + let retryInFlight = null; const captureOnline = () => { cameOnline = true; }; window?.addEventListener('online', captureOnline); const status = document.querySelector('#my-work-action-status'); + const retryButton = document.querySelector('#retry-workspace'); const url = document.querySelector( 'meta[name="stackchain-feature-today-timer"]' )?.content || ''; - const loader = createLoader({ - document, - urls: { 'today-timer': url }, - }); - if (status) status.textContent = 'Starting workspace…'; - try { - await loader.load('today-timer'); - if (status) status.textContent = ''; - return { - replayOnline(callback) { - if (replayed) return; - replayed = true; - window?.removeEventListener('online', captureOnline); - if (cameOnline) callback(); - }, + const urls = {'today-timer':url}; + const loader = createLoader({document, urls}); + + let attempts = 0; + const load = () => { + if (attempts++) urls['today-timer'] = url + '?retry=' + attempts; + return loader.load('today-timer'); + }; + const waitForRecovery = () => new Promise(resolve => { + if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.'; + if (retryButton) { + retryButton.hidden = retryButton.disabled = false; + } + + const recover = () => { + if (retryInFlight) return retryInFlight; + if (retryButton) retryButton.disabled = true; + retryInFlight = load().then(() => { + window?.removeEventListener('online', recover); + resolve(); + }).catch(() => { + if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.'; + if (retryButton) retryButton.disabled = false; + }).finally(() => { + retryInFlight = null; + }); + return retryInFlight; }; - } catch (error) { + window?.removeEventListener('online', captureOnline); - if (window?.location?.reload) { - if (cameOnline) window.location.reload(); - else window.addEventListener('online', () => window.location.reload(), { once: true }); + window?.addEventListener('online', recover); + retryButton?.addEventListener('click', recover); + }); + + try { + await load(); + } catch { + await new Promise(resolve => schedule(resolve)); + try { + await load(); + } catch { + await waitForRecovery(); } - if (status) { - status.textContent = window - ? 'Workspace could not load. Reconnect to retry automatically, or reload now.' - : 'Workspace could not load. Check your connection, then reload to retry.'; - } - throw error; } + + window?.removeEventListener('online', captureOnline); + if (retryButton) retryButton.hidden = true; + if (status) status.textContent = ''; + return { + replayOnline(callback) { + if (replayed) return; + replayed = true; + window?.removeEventListener('online', captureOnline); + if (cameOnline) callback(); + }, + }; } -if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace; \ No newline at end of file +if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace; diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 176d8af..1f76acc 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re import threading from pathlib import Path @@ -181,6 +182,59 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( fake_thread.join(timeout=5) +def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_path: Path): + archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz")) + assert len(archives) == 1, "browser job must download exactly one assembled release archive" + + fake = FakeGiteaServer(("127.0.0.1", 0)) + fake_thread = threading.Thread(target=fake.serve_forever, daemon=True) + fake_thread.start() + fake_url = f"http://127.0.0.1:{fake.server_port}" + attempts: list[str] = [] + + try: + with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright: + browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) + page = browser.new_page(viewport={"width": 390, "height": 844}) + page.goto(origin + "/", wait_until="networkidle") + + def interrupt_once(route): + attempts.append(route.request.url) + if len(attempts) == 1: + route.abort("failed") + else: + route.continue_() + + page.route(re.compile(r"/feature-today-timer-[^/?]+\.js(?:\?.*)?$"), interrupt_once) + page.locator('input[name="device_label"]').fill("Workspace recovery release phone") + page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) + page.locator("#submit-sign-in").click() + page.wait_for_url(origin + "/", wait_until="networkidle") + + page.wait_for_timeout(1500) + resources = page.evaluate( + "performance.getEntriesByType('resource').map(entry => entry.name)" + ) + state = { + "attempts": attempts, + "resources": resources, + "action": page.locator("#my-work-action-status").text_content(), + "retry_hidden": page.locator("#retry-workspace").is_hidden(), + "work": page.locator("#my-work-status").text_content(), + } + assert len(attempts) == 2, state + assert "2" in (state["work"] or ""), state + expect(page.locator("#retry-workspace")).to_be_hidden() + expect(page.locator("#my-work-action-status")).not_to_contain_text("Workspace unavailable") + assert any("feature-today-timer-" in url and "retry=" in url for url in resources), state + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + browser.close() + finally: + fake.shutdown() + fake.server_close() + fake_thread.join(timeout=5) + + def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path): archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz")) assert len(archives) == 1, "browser job must download exactly one assembled release archive" diff --git a/tests/test_workspace_bootstrap.py b/tests/test_workspace_bootstrap.py index f13c73f..0023e16 100644 --- a/tests/test_workspace_bootstrap.py +++ b/tests/test_workspace_bootstrap.py @@ -4,6 +4,8 @@ from pathlib import Path BOOTSTRAP = Path(__file__).parents[1] / "frontend" / "workspace-bootstrap.js" +INDEX = Path(__file__).parents[1] / "frontend" / "index.html" +CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" def run_bootstrap(scenario: str) -> dict: @@ -35,33 +37,71 @@ console.log(JSON.stringify({requested,status:status.textContent})); assert result == {"requested": "today-timer:feature-workspace-abc.js", "status": ""} -def test_workspace_bootstrap_keeps_shell_and_announces_retry_when_feature_fails(): +def test_workspace_bootstrap_recovers_one_transient_failure_in_place(): result = run_bootstrap(""" -const status={textContent:''}; -const document={ - querySelector(selector) { - if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'}; - if (selector === '#my-work-action-status') return status; - return null; - } -}; -const createLoader=()=>({load:async()=>{throw new Error('network failed');}}); -let message=''; -try { await loadWorkspace({document,createLoader}); } catch (error) { message=error.message; } -console.log(JSON.stringify({message,status:status.textContent})); -""") - assert result == { - "message": "network failed", - "status": "Workspace could not load. Check your connection, then reload to retry.", - } - - -def test_workspace_bootstrap_reloads_after_a_failed_offline_load_reconnects(): - result = run_bootstrap(""" -const status={textContent:''}; const listeners={}; let reloads=0; +const status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0; const document={querySelector(selector) { if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'}; if (selector === '#my-work-action-status') return status; + if (selector === '#retry-workspace') return retry; + return null; +}}; +const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}}); +const schedule=callback=>{callback();}; +await loadWorkspace({document,createLoader,schedule}); +console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden})); +""") + assert result == {"attempts": 2, "status": "", "retryHidden": True} + + +def test_workspace_bootstrap_offers_single_flight_manual_retry_without_reloading(): + result = run_bootstrap(""" +const status={textContent:''}; const listeners={}; let reloads=0; let attempts=0; +const retry={hidden:true,disabled:false,addEventListener(name,callback){listeners[name]=callback;}}; +const document={querySelector(selector) { + if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'}; + if (selector === '#my-work-action-status') return status; + if (selector === '#retry-workspace') return retry; + return null; +}}; +const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}}; +const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}}); +const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()}); +await new Promise(resolve=>setImmediate(resolve)); +const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent}; +const first=listeners.click(); const second=listeners.click(); +await Promise.all([first,second,loading]); +console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden})); +""") + assert result == { + "attempts": 3, + "reloads": 0, + "offered": { + "hidden": False, + "disabled": False, + "status": "Workspace unavailable. Reconnect or retry.", + }, + "status": "", + "retryHidden": True, + } + + +def test_workspace_retry_control_is_accessible_and_touch_safe(): + index = INDEX.read_text() + css = CSS.read_text() + + assert '' in index + assert ".retry-work-route { min-height:44px;" in css + + +def test_workspace_bootstrap_reconnect_retries_in_place_without_reloading(): + result = run_bootstrap(""" +const status={textContent:''}; const listeners={}; let reloads=0; let attempts=0; +const retry={hidden:true,disabled:false,addEventListener(){},removeEventListener(){}}; +const document={querySelector(selector) { + if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'}; + if (selector === '#my-work-action-status') return status; + if (selector === '#retry-workspace') return retry; return null; }}; const window={ @@ -69,39 +109,22 @@ const window={ addEventListener(name,callback) { listeners[name]=callback; }, removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; }, }; -const createLoader=()=>({load:async()=>{throw new Error('offline');}}); -try { await loadWorkspace({document,window,createLoader}); } catch (error) {} +const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}}); +const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()}); +await new Promise(resolve=>setImmediate(resolve)); const waiting=Boolean(listeners.online); if (listeners.online) listeners.online(); -console.log(JSON.stringify({waiting,reloads,status:status.textContent})); +await loading; +console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent})); """) assert result == { + "attempts": 3, "waiting": True, - "reloads": 1, - "status": "Workspace could not load. Reconnect to retry automatically, or reload now.", + "reloads": 0, + "status": "", } -def test_workspace_bootstrap_reloads_when_reconnect_arrives_before_load_failure(): - result = run_bootstrap(""" -const status={textContent:''}; const listeners={}; let reloads=0; -const document={querySelector(selector) { - if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'}; - if (selector === '#my-work-action-status') return status; - return null; -}}; -const window={ - location:{reload(){reloads++;}}, - addEventListener(name,callback) { listeners[name]=callback; }, - removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; }, -}; -const createLoader=()=>({load:async()=>{listeners.online(); throw new Error('offline request');}}); -try { await loadWorkspace({document,window,createLoader}); } catch (error) {} -console.log(JSON.stringify({reloads,waiting:Boolean(listeners.online)})); -""") - assert result == {"reloads": 1, "waiting": False} - - def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers(): result = run_bootstrap(""" const status={textContent:''}; const listeners={};