stackchain-dashboard/tests/test_mobile_insights.py
timmy 62f92105ec
All checks were successful
CI / lint (pull_request) Successful in 4m12s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 8m4s
CI / release-candidate (pull_request) Has been skipped
feat: keep Human Gate decisions in reach on mobile (Closes #1439)
2026-08-26 18:10:22 +00:00

279 lines
14 KiB
Python

import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-insights.js"
def run_node(script: str) -> dict:
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_mobile_insights_opens_as_addressable_modal_and_hides_home_chrome():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = () => ({{
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
focus() {{ this.focuses += 1; }},
}});
const root = element(); const launcher = element(); const closeButton = element();
const dock = element(); const hud = element(); const home = element(); const header = element();
const menu = {{open:true}};
const location = {{pathname:'/dashboard/', search:'', hash:'#/my-work/today'}};
const history = {{state:null, pushes:[], pushState(state,unused,url) {{
this.state=state; this.pushes.push(url); location.hash=url.slice(url.indexOf('#'));
}}, replaceState() {{}}, back() {{}}}};
const controller = createInsights({{
root, launcher, closeButton, dock, hud, backgrounds:[header,home], menu, history, location,
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
mediaQuery:{{matches:true, addEventListener() {{}}}},
}});
controller.start();
launcher.listeners.click();
process.stdout.write(JSON.stringify({{
hash:location.hash, pushes:history.pushes, open:root.attributes['data-mobile-open'],
role:root.attributes.role, modal:root.attributes['aria-modal'], rootInert:root.inert,
homeInert:home.inert, headerInert:header.inert, dockHidden:dock.hidden,
hudHidden:hud.attributes['data-overlay-hidden'], closeFocuses:closeButton.focuses,
menuOpen:menu.open,
}}));
"""
assert run_node(script) == {
"hash": "#/insights",
"pushes": ["/dashboard/#/insights"],
"open": "true",
"role": "dialog",
"modal": "true",
"rootInert": False,
"homeInert": True,
"headerInert": True,
"dockHidden": True,
"hudHidden": "true",
"closeFocuses": 1,
"menuOpen": False,
}
def test_direct_insights_route_closes_locally_and_restores_launcher_focus():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = () => ({{
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
focus() {{ this.focuses += 1; }},
}});
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), home=element();
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
const history={{state:null, backs:0, replacements:[], pushState() {{}}, back() {{ this.backs += 1; }},
replaceState(state,unused,url) {{ this.state=state; this.replacements.push(url); location.hash=url.slice(url.indexOf('#')); }}
}};
const controller=createInsights({{
root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
mediaQuery:{{matches:true,addEventListener() {{}}}},
}});
controller.start();
closeButton.listeners.click();
process.stdout.write(JSON.stringify({{
current:controller.current(), hash:location.hash, backs:history.backs,
replacements:history.replacements, rootInert:root.inert, homeInert:home.inert,
dockHidden:dock.hidden, hudHidden:hud.attributes['data-overlay-hidden'] || null,
launcherFocuses:launcher.focuses,
}}));
"""
assert run_node(script) == {
"current": False,
"hash": "#/my-work",
"backs": 0,
"replacements": ["/dashboard/#/my-work"],
"rootInert": True,
"homeInert": False,
"dockHidden": False,
"hudHidden": None,
"launcherFocuses": 1,
}
def test_browser_back_closes_insights_without_resetting_the_work_route():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=()=>({{hidden:false,inert:false,attributes:{{}},focuses:0,listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{this.focuses+=1;}}}});
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element(),home=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/update'}};
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); launcher.listeners.click();
location.hash='#/my-work/update'; history.state=null; listeners.popstate?.({{state:null}});
process.stdout.write(JSON.stringify({{current:controller.current(),hash:location.hash,launcherFocuses:launcher.focuses,homeInert:home.inert,dockHidden:dock.hidden}}));
"""
assert run_node(script) == {
"current": False,
"hash": "#/my-work/update",
"launcherFocuses": 1,
"homeInert": False,
"dockHidden": False,
}
def test_late_inactive_route_event_does_not_release_background_owned_by_next_overlay():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = () => ({{
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
focus() {{ this.focuses += 1; }},
}});
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), header=element();
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
const history={{state:null, pushState() {{}}, back() {{}}, replaceState(state,unused,url) {{
this.state=state; location.hash=url.slice(url.indexOf('#'));
}}}};
const controller=createInsights({{
root,launcher,closeButton,dock,hud,backgrounds:[header],history,location,
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
mediaQuery:{{matches:true,addEventListener() {{}}}},
}});
controller.start();
closeButton.listeners.click();
header.inert = true;
listeners.hashchange();
process.stdout.write(JSON.stringify({{current:controller.current(), headerInert:header.inert}}));
"""
assert run_node(script) == {"current": False, "headerInert": True}
def test_escape_requests_back_navigation_only_while_insights_is_open():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work'}};
const history={{state:null,backs:0,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{this.backs+=1;}}}};
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); launcher.listeners.click();
let prevented=0; listeners.keydown?.({{key:'Escape',preventDefault(){{prevented+=1;}}}}); listeners.keydown?.({{key:'Enter',preventDefault(){{prevented+=1;}}}});
process.stdout.write(JSON.stringify({{backs:history.backs,prevented}}));
"""
assert run_node(script) == {"backs": 1, "prevented": 1}
def test_insights_route_owns_the_today_detour_for_open_back_and_reload():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/today'}};
const calls=[];
const detour={{beginDetour(reason){{calls.push('begin:'+reason);return {{identity:'issue:r:42:',reason}};}},finishDetour(){{calls.push('finish');return {{identity:'issue:r:42:'}};}}}};
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start();
launcher.listeners.click();
location.hash='#/my-work/today'; history.state=null; listeners.popstate();
listeners.popstate();
location.hash='#/insights'; listeners.hashchange();
process.stdout.write(JSON.stringify({{calls,current:controller.current()}}));
"""
assert run_node(script) == {
"calls": ["begin:insights", "finish", "begin:insights"],
"current": True,
}
def test_return_to_today_closes_insights_and_finishes_the_detour_once():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/today'}};
const calls=[];
const detour={{beginDetour(reason){{calls.push('begin:'+reason);}},finishDetour(){{calls.push('finish');}}}};
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(){{}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); launcher.listeners.click(); returnButton.listeners.click();
process.stdout.write(JSON.stringify({{calls,current:controller.current(),hash:location.hash}}));
"""
assert run_node(script) == {
"calls": ["begin:insights", "finish"],
"current": False,
"hash": "#/my-work/today",
}
def test_direct_insights_route_retries_detour_after_identity_restores():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
const root=element(),launcher=element(),closeButton=element(),returnButton=element(),dock=element(),hud=element();
const location={{pathname:'/dashboard/',search:'',hash:'#/insights'}};
let attempts=0; let retry;
global.setTimeout=callback=>{{retry=callback;}};
const detour={{beginDetour(){{attempts+=1;return attempts > 1 ? {{identity:'issue:r:42:'}} : null;}},finishDetour(){{}}}};
const history={{state:null,replaceState(){{}},pushState(){{}},back(){{}}}};
const controller=createInsights({{root,launcher,closeButton,returnButton,dock,hud,backgrounds:[],history,location,detour:()=>detour,eventTarget:{{addEventListener(){{}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
controller.start(); retry();
process.stdout.write(JSON.stringify({{attempts,current:controller.current()}}));
"""
assert run_node(script) == {"attempts": 2, "current": True}
@pytest.mark.anyio
async def test_mobile_home_progressively_discloses_secondary_panels_as_insights():
html = await dashboard()
assert 'id="open-insights"' in html
assert 'id="insights-sheet"' in html
assert 'id="close-insights"' in html
insights = html[html.index('id="insights-sheet"'):html.index('</section>', html.index('id="insights-sheet"'))]
assert 'data-today-detour' in insights
assert 'data-return-from-detour' in insights
assert "detour:() => timerView" in html
assert html.index("const timerView = createTodayTimerView({") < html.index("mobileInsights.start();")
assert html.index('id="my-work"') < html.index('id="insights-sheet"')
assert html.index('id="insights-sheet"') < html.index('data-panel-key="context"')
assert html.index('data-panel-key="markdown"') < html.index('</section>\n</main>')
assert '<script src="static/mobile-insights.js"></script>' in html
assert "createMobileInsights({" in html
assert "mediaQuery: window.matchMedia('(max-width: 600px)')" in html
assert ".insights-shell { display:contents; }" in html
assert "#open-insights { display:none; }" in html
assert '.insights-shell[data-mobile-open="true"]' in html
assert "position:fixed; inset:0;" in html
assert "overflow-x:hidden" in html
assert ".insights-header button { min-height:44px;" in html
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v147" in worker
assert "BASE + 'static/mobile-insights.js'" in worker