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_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('', 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('\n') assert '' 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-v126" in worker assert "BASE + 'static/mobile-insights.js'" in worker