Merge pull request 'Recover mobile workspace loading in place' (#1125) from timmy/1124-workspace-load-recovery into main
This commit is contained in:
commit
3d4be0a913
|
|
@ -314,6 +314,7 @@
|
|||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||
<button class="retry-work-route" id="retry-workspace" type="button" hidden>Retry workspace</button>
|
||||
<button class="release-receipt-launcher" id="release-receipt-launcher" type="button" hidden>Merged · tracking release</button>
|
||||
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 '<button class="retry-work-route" id="retry-workspace" type="button" hidden>Retry workspace</button>' 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={};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user