243 lines
9.7 KiB
Python
243 lines
9.7 KiB
Python
import json
|
|
import subprocess
|
|
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:
|
|
harness = f"""
|
|
const loadWorkspace = require({json.dumps(str(BOOTSTRAP))});
|
|
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
completed = subprocess.run(
|
|
["node", "-e", harness], check=True, capture_output=True, text=True
|
|
)
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
def test_workspace_bootstrap_demand_loads_optional_features_after_work_core():
|
|
result = run_bootstrap("""
|
|
const status={textContent:''};
|
|
const document={
|
|
querySelector(selector) {
|
|
if (selector === 'meta[name="stackchain-feature-work-core"]') return {content:'feature-work-core-123.js'};
|
|
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
|
|
if (selector === 'meta[name="stackchain-feature-planning"]') return {content:'feature-planning-def.js'};
|
|
if (selector === '#my-work-action-status') return status;
|
|
return null;
|
|
}
|
|
};
|
|
const requested=[];
|
|
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
|
|
const lifecycle=await loadWorkspace({document,createLoader});
|
|
const before=requested.slice();
|
|
await lifecycle.hydrateWorkspace?.();
|
|
console.log(JSON.stringify({before,after:requested,status:status.textContent}));
|
|
""")
|
|
assert result == {
|
|
"before": ["work-core:feature-work-core-123.js"],
|
|
"after": [
|
|
"work-core:feature-work-core-123.js",
|
|
"today-timer:feature-workspace-abc.js",
|
|
"planning:feature-planning-def.js",
|
|
],
|
|
"status": "",
|
|
}
|
|
|
|
|
|
def test_workspace_bootstrap_hydrates_once_and_replays_dependent_mobile_action():
|
|
result = run_bootstrap("""
|
|
const listeners={}; const requested=[];
|
|
const document={
|
|
querySelector(selector) {
|
|
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
|
return match ? {content:'feature-' + match[1] + '.js'} : null;
|
|
},
|
|
addEventListener(name,callback,capture){listeners[name]={callback,capture};},
|
|
removeEventListener(name,callback,capture){
|
|
if(listeners[name]?.callback===callback && listeners[name]?.capture===capture) delete listeners[name];
|
|
},
|
|
};
|
|
const createLoader=()=>({load:async name=>{
|
|
requested.push(name);
|
|
if(name==='today-timer') setImmediate(()=>{
|
|
actionReady=true;
|
|
lifecycle.markWorkspaceReady?.();
|
|
});
|
|
}});
|
|
let lifecycle=await loadWorkspace({document,createLoader});
|
|
let prevented=0,stopped=0,replayed=0,actionReady=false,replayedReady=false;
|
|
const target={
|
|
closest(selector){return selector.includes('data-mobile-task') ? this : null;},
|
|
click(){replayed++; replayedReady=actionReady;},
|
|
};
|
|
await listeners.click?.callback({target,preventDefault(){prevented++;},stopImmediatePropagation(){stopped++;}});
|
|
console.log(JSON.stringify({requested,prevented,stopped,replayed,replayedReady,listening:Boolean(listeners.click)}));
|
|
""")
|
|
assert result == {
|
|
"requested": ["work-core", "today-timer", "planning"],
|
|
"prevented": 1,
|
|
"stopped": 1,
|
|
"replayed": 1,
|
|
"replayedReady": True,
|
|
"listening": False,
|
|
}
|
|
|
|
|
|
def test_workspace_bootstrap_hydrates_immediately_for_deep_link():
|
|
result = run_bootstrap("""
|
|
const requested=[];
|
|
const document={
|
|
querySelector(selector) {
|
|
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
|
return match ? {content:'feature-' + match[1] + '.js'} : null;
|
|
},
|
|
addEventListener(){},
|
|
};
|
|
const window={location:{hash:'#/my-work/today'},addEventListener(){},removeEventListener(){}};
|
|
const createLoader=()=>({load:async name=>{requested.push(name);}});
|
|
const lifecycle=await loadWorkspace({document,window,createLoader});
|
|
await lifecycle.deepLinkReady;
|
|
console.log(JSON.stringify({requested}));
|
|
""")
|
|
assert result == {"requested": ["work-core", "today-timer", "planning"]}
|
|
|
|
|
|
def test_workspace_bootstrap_returns_after_work_core_before_optional_features_hydrate():
|
|
result = run_bootstrap("""
|
|
const document={querySelector(selector) {
|
|
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
|
return match ? {content:'feature-' + match[1] + '.js'} : null;
|
|
}};
|
|
const requested=[]; const releases={};
|
|
const createLoader=()=>({load:name=>{
|
|
requested.push(name);
|
|
if (name === 'work-core') return Promise.resolve();
|
|
return new Promise(resolve=>{releases[name]=resolve;});
|
|
}});
|
|
const lifecycle=await loadWorkspace({document,createLoader});
|
|
const returned=requested.slice();
|
|
const hydration=lifecycle.hydrateWorkspace();
|
|
releases['today-timer'](); releases.planning();
|
|
await hydration;
|
|
console.log(JSON.stringify({returned,settled:requested}));
|
|
""")
|
|
assert result == {
|
|
"returned": ["work-core"],
|
|
"settled": ["work-core", "today-timer", "planning"],
|
|
}
|
|
|
|
|
|
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
|
|
result = run_bootstrap("""
|
|
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();};
|
|
const lifecycle=await loadWorkspace({document,createLoader,schedule});
|
|
await lifecycle.hydrateWorkspace();
|
|
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
|
|
""")
|
|
assert result == {"attempts": 4, "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={
|
|
location:{reload(){reloads++;}},
|
|
addEventListener(name,callback) { listeners[name]=callback; },
|
|
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
|
};
|
|
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();
|
|
await loading;
|
|
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
|
""")
|
|
assert result == {
|
|
"attempts": 3,
|
|
"waiting": True,
|
|
"reloads": 0,
|
|
"status": "",
|
|
}
|
|
|
|
|
|
def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers():
|
|
result = run_bootstrap("""
|
|
const status={textContent:''}; const listeners={};
|
|
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={
|
|
addEventListener(name,callback) { listeners[name]=callback; },
|
|
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
|
};
|
|
const createLoader=()=>({load:async()=>{listeners.online();}});
|
|
const lifecycle=await loadWorkspace({document,window,createLoader});
|
|
let reconnects=0;
|
|
lifecycle.replayOnline(()=>{reconnects++;});
|
|
lifecycle.replayOnline(()=>{reconnects++;});
|
|
console.log(JSON.stringify({reconnects,listening:Boolean(listeners.online)}));
|
|
""")
|
|
assert result == {"reconnects": 1, "listening": False} |