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>
|
<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>
|
<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>
|
<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="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>
|
<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>
|
<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 cameOnline = false;
|
||||||
let replayed = false;
|
let replayed = false;
|
||||||
|
let retryInFlight = null;
|
||||||
const captureOnline = () => { cameOnline = true; };
|
const captureOnline = () => { cameOnline = true; };
|
||||||
window?.addEventListener('online', captureOnline);
|
window?.addEventListener('online', captureOnline);
|
||||||
const status = document.querySelector('#my-work-action-status');
|
const status = document.querySelector('#my-work-action-status');
|
||||||
|
const retryButton = document.querySelector('#retry-workspace');
|
||||||
const url = document.querySelector(
|
const url = document.querySelector(
|
||||||
'meta[name="stackchain-feature-today-timer"]'
|
'meta[name="stackchain-feature-today-timer"]'
|
||||||
)?.content || '';
|
)?.content || '';
|
||||||
const loader = createLoader({
|
const urls = {'today-timer':url};
|
||||||
document,
|
const loader = createLoader({document, urls});
|
||||||
urls: { 'today-timer': url },
|
|
||||||
});
|
let attempts = 0;
|
||||||
if (status) status.textContent = 'Starting workspace…';
|
const load = () => {
|
||||||
try {
|
if (attempts++) urls['today-timer'] = url + '?retry=' + attempts;
|
||||||
await loader.load('today-timer');
|
return loader.load('today-timer');
|
||||||
if (status) status.textContent = '';
|
};
|
||||||
return {
|
const waitForRecovery = () => new Promise(resolve => {
|
||||||
replayOnline(callback) {
|
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||||
if (replayed) return;
|
if (retryButton) {
|
||||||
replayed = true;
|
retryButton.hidden = retryButton.disabled = false;
|
||||||
window?.removeEventListener('online', captureOnline);
|
}
|
||||||
if (cameOnline) callback();
|
|
||||||
},
|
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);
|
window?.removeEventListener('online', captureOnline);
|
||||||
if (window?.location?.reload) {
|
window?.addEventListener('online', recover);
|
||||||
if (cameOnline) window.location.reload();
|
retryButton?.addEventListener('click', recover);
|
||||||
else window.addEventListener('online', () => window.location.reload(), { once: true });
|
});
|
||||||
|
|
||||||
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -181,6 +182,59 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
fake_thread.join(timeout=5)
|
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):
|
def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path):
|
||||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
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"
|
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:
|
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": ""}
|
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("""
|
result = run_bootstrap("""
|
||||||
const status={textContent:''};
|
const status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0;
|
||||||
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 document={querySelector(selector) {
|
const document={querySelector(selector) {
|
||||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||||
if (selector === '#my-work-action-status') return status;
|
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;
|
return null;
|
||||||
}};
|
}};
|
||||||
const window={
|
const window={
|
||||||
|
|
@ -69,39 +109,22 @@ const window={
|
||||||
addEventListener(name,callback) { listeners[name]=callback; },
|
addEventListener(name,callback) { listeners[name]=callback; },
|
||||||
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||||
};
|
};
|
||||||
const createLoader=()=>({load:async()=>{throw new Error('offline');}});
|
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||||
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
|
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||||
|
await new Promise(resolve=>setImmediate(resolve));
|
||||||
const waiting=Boolean(listeners.online);
|
const waiting=Boolean(listeners.online);
|
||||||
if (listeners.online) 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 == {
|
assert result == {
|
||||||
|
"attempts": 3,
|
||||||
"waiting": True,
|
"waiting": True,
|
||||||
"reloads": 1,
|
"reloads": 0,
|
||||||
"status": "Workspace could not load. Reconnect to retry automatically, or reload now.",
|
"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():
|
def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers():
|
||||||
result = run_bootstrap("""
|
result = run_bootstrap("""
|
||||||
const status={textContent:''}; const listeners={};
|
const status={textContent:''}; const listeners={};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user