import json import subprocess from pathlib import Path MODULE = Path(__file__).parents[1] / "frontend" / "human-gates.js" PROGRESSIVE = Path(__file__).parents[1] / "frontend" / "progressive-human-gates.js" INDEX = Path(__file__).parents[1] / "frontend" / "index.html" DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js" LIVE_SNAPSHOT = Path(__file__).parents[1] / "frontend" / "progressive-live-snapshot.js" PROGRESSIVE_MY_WORK = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js" MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js" def run_node(body): script = f"const createHumanGates=require({json.dumps(str(MODULE))});\n" + body result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True) return json.loads(result.stdout) def test_progressive_my_work_and_human_gates_share_one_cold_live_snapshot(): script = f""" const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))}); globalThis.buildMyWork=require({json.dumps(str(MY_WORK))}); const createProgressiveMyWork=require({json.dumps(str(PROGRESSIVE_MY_WORK))}); const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))}); let liveCalls=0, directMyWorkCalls=0, resolveLive; const broker=createProgressiveLiveSnapshot({{fetchSnapshot:()=>{{ liveCalls += 1; return new Promise(resolve=>{{resolveLive=resolve;}}); }}}}); const workDocument={{ hidden:false, querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null, querySelectorAll:()=>[], }}; const work=createProgressiveMyWork({{ document:workDocument, liveSnapshot:broker, fetchSnapshot:async()=>{{directMyWorkCalls += 1; return {{}};}}, }}); const nodes={{ '#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:'',addEventListener(){{}}}}, '#human-gates-status':{{}}, '#human-gates':{{hidden:true}}, '#human-gate-detail':{{innerHTML:'',addEventListener(){{}},querySelectorAll:()=>[]}}, '#open-human-gates':{{addEventListener(){{}}}}, '#close-human-gates':{{addEventListener(){{}}}}, }}; const gates=createProgressiveHumanGates({{ document:{{querySelector:selector=>nodes[selector]||null}}, location:{{hash:'#/my-work/human-gates'}}, history:{{replaceState(){{}}}}, storage:{{getItem:()=>null,setItem(){{}}}}, isOnline:()=>true, liveSnapshot:broker, fetchJson:async path=>({{pending_count:1,items:[{{id:'g1',title:'Shared identity',candidate_hash:'abc',revision:1,checks:[]}}]}}), }}); (async()=>{{ const workStart=work.start(); const gateStart=gates.start(); await Promise.resolve(); await Promise.resolve(); const callsWhilePending=liveCalls; resolveLive({{context:{{user:{{id:7,login:'timmy'}},issues:[],pull_requests:[]}},events:[],notifications:[]}}); await Promise.all([workStart,gateStart]); process.stdout.write(JSON.stringify({{ callsWhilePending,liveCalls,directMyWorkCalls, login:work.login(),gateStarted:gates.handoff().started, }})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "callsWhilePending": 1, "liveCalls": 1, "directMyWorkCalls": 0, "login": "timmy", "gateStarted": True, } def test_progressive_human_gates_retries_partial_snapshot_identity_without_hydration(): script = f""" const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))}); const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))}); let liveCalls=0, gateCalls=0; const responses=[ {{context:null,events:[],notifications:[]}}, {{context:{{user:{{id:7,login:'timmy'}}}},events:[],notifications:[]}}, ]; const broker=createProgressiveLiveSnapshot({{fetchSnapshot:async()=>{{liveCalls+=1;return responses.shift();}}}}); const nodes={{ '#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:'',addEventListener(){{}}}}, '#human-gates-status':{{}}, '#human-gates':{{hidden:true}}, '#human-gate-detail':{{innerHTML:'',addEventListener(){{}},querySelectorAll:()=>[]}}, '#open-human-gates':{{addEventListener(){{}}}}, '#close-human-gates':{{addEventListener(){{}}}}, }}; const gates=createProgressiveHumanGates({{ document:{{querySelector:selector=>nodes[selector]||null}}, location:{{hash:'#/my-work/human-gates'}}, history:{{replaceState(){{}}}}, storage:{{getItem:()=>null,setItem(){{}}}}, isOnline:()=>true, liveSnapshot:broker, fetchJson:async()=>{{gateCalls+=1;return {{pending_count:1,items:[{{id:'g1',title:'Recovered',candidate_hash:'abc',revision:1,checks:[]}}]}};}}, }}); (async()=>{{ let firstError=''; try{{await gates.start();}}catch(error){{firstError=error.message;}} const recovered=await gates.start(); process.stdout.write(JSON.stringify({{ firstError,recovered,liveCalls,gateCalls,started:gates.handoff().started, }})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "firstError": "Authenticated account identity is unavailable.", "recovered": True, "liveCalls": 2, "gateCalls": 2, "started": True, } def test_queue_loads_pending_count_uses_account_cache_and_renders_inbox_zero(): output = run_node(r""" const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}; let response={pending_count:1,items:[{id:'g1',title:'Candidate',candidate_hash:'abc123',priority:4,state:'pending',revision:1,checks:[]}]}; const nodes={count:{textContent:''},list:{innerHTML:''},status:{textContent:''},panel:{hidden:true}}; const gates=createHumanGates({storage,getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>response}); (async()=>{ await gates.load(); const first={snapshot:gates.snapshot(),count:nodes.count.textContent,html:nodes.list.innerHTML,keys:[...values.keys()]}; response={pending_count:0,items:[]}; await gates.load(); process.stdout.write(JSON.stringify({first,zero:{snapshot:gates.snapshot(),status:nodes.status.textContent,html:nodes.list.innerHTML}})); })(); """) assert output["first"]["count"] == "1" assert "abc123" in output["first"]["html"] assert output["first"]["keys"] == ["stackchain.human-gates.v1:timmy"] assert output["zero"]["snapshot"]["pending_count"] == 0 assert "Inbox zero" in output["zero"]["html"] def test_live_history_lists_decisions_and_opens_the_durable_receipt_without_caching(): output = run_node(r""" const writes=[]; const requests=[]; const nodes={ count:{},list:{innerHTML:''},status:{textContent:''},panel:{},detail:{innerHTML:''}, pendingTab:{setAttribute(){},disabled:false},historyTab:{setAttribute(){},disabled:false}, }; const pending={pending_count:1,items:[{id:'pending',title:'Waiting',candidate_hash:'aaa',state:'pending',revision:1,checks:[]}]}; const history={pending_count:1,items:[ {id:'held',title:'Held candidate',candidate_hash:'bbb',state:'held',updated_at:200,reason:'Needs mobile evidence',receipt_id:'receipt-2'}, {id:'released',title:'Released candidate',candidate_hash:'ccc',state:'released',updated_at:100,receipt_id:'receipt-1'}, ],next_cursor:null}; const detail={id:'held',title:'Held candidate',project:'stackchain/dashboard',candidate_hash:'bbb',state:'held',updated_at:200,reason:'Needs mobile evidence',receipt_id:'receipt-2',history:[{action:'held',at:200}]}; const receipt={receipt_id:'receipt-2',gate_id:'held',candidate_hash:'bbb',state:'held',decided_at:200,reason:'Needs mobile evidence',override_reason:'',checklist:{}}; const gates=createHumanGates({ storage:{getItem:()=>null,setItem:(key,value)=>writes.push({key,value})}, getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''}, fetchJson:async path=>{ requests.push(path); if(path==='api/v1/human-gates?state=history&limit=20') return history; if(path==='api/v1/human-gates/held') return detail; if(path==='api/v1/human-gate-receipts/receipt-2') return receipt; return pending; }, }); (async()=>{ await gates.load(); const writesAfterPending=writes.length; const decisions=await gates.showHistory(); const historyHtml=nodes.list.innerHTML; await gates.selectHistory('held'); process.stdout.write(JSON.stringify({ writesAfterPending,writesAfterHistory:writes.length,requests,decisions, historyHtml,detailHtml:nodes.detail.innerHTML,status:nodes.status.textContent, })); })(); """) assert output["writesAfterPending"] == 1 assert output["writesAfterHistory"] == 1 assert output["requests"] == [ "api/v1/human-gates", "api/v1/human-gates?state=history&limit=20", "api/v1/human-gates/held", "api/v1/human-gate-receipts/receipt-2", ] assert [item["id"] for item in output["decisions"]] == ["held", "released"] assert "Waiting" not in output["historyHtml"] assert "Held" in output["historyHtml"] assert "Released" in output["historyHtml"] assert "Needs mobile evidence" in output["detailHtml"] assert "receipt-2" in output["detailHtml"] assert "data-gate-decision" not in output["detailHtml"] assert output["status"] == "2 past Human Gate decisions." def test_mobile_history_loads_older_decisions_without_losing_the_open_receipt(): output = run_node(r""" const requests=[]; const nodes={ count:{},list:{innerHTML:'',querySelectorAll:()=>[]},status:{textContent:''},panel:{},detail:{innerHTML:''}, pendingTab:{setAttribute(){}},historyTab:{setAttribute(){}}, }; const first={pending_count:0,items:[ {id:'new',title:'Newest decision',candidate_hash:'aaa',state:'released',updated_at:300,receipt_id:'receipt-new'}, {id:'middle',title:'Middle decision',candidate_hash:'bbb',state:'held',updated_at:200,receipt_id:'receipt-middle'}, ],next_cursor:'page-two'}; const second={pending_count:0,items:[ {id:'old',title:'Oldest decision',candidate_hash:'ccc',state:'released',updated_at:100,receipt_id:'receipt-old'}, ],next_cursor:null}; const detail={id:'new',title:'Newest decision',project:'stackchain/dashboard',candidate_hash:'aaa',state:'released',receipt_id:'receipt-new'}; const receipt={receipt_id:'receipt-new',gate_id:'new',candidate_hash:'aaa',state:'released',decided_at:300,checklist:{}}; const gates=createHumanGates({ storage:{getItem:()=>null,setItem(){throw new Error('history must not be cached')}}, getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''}, fetchJson:async path=>{ requests.push(path); if(path==='api/v1/human-gates?state=history&limit=20') return first; if(path.includes('cursor=page-two')) return second; if(path==='api/v1/human-gates/new') return detail; if(path==='api/v1/human-gate-receipts/receipt-new') return receipt; throw new Error('unexpected '+path); }, }); (async()=>{ await gates.showHistory(); await gates.selectHistory('new'); const selectedHtml=nodes.detail.innerHTML; await gates.loadMoreHistory(); process.stdout.write(JSON.stringify({requests,listHtml:nodes.list.innerHTML,selectedHtml,detailHtml:nodes.detail.innerHTML,status:nodes.status.textContent})); })(); """) assert output["requests"] == [ "api/v1/human-gates?state=history&limit=20", "api/v1/human-gates/new", "api/v1/human-gate-receipts/receipt-new", "api/v1/human-gates?state=history&limit=20&cursor=page-two", ] assert all(title in output["listHtml"] for title in ("Newest decision", "Middle decision", "Oldest decision")) assert "Load older decisions" not in output["listHtml"] assert output["detailHtml"] == output["selectedHtml"] assert output["status"] == "All 3 Human Gate decisions loaded." def test_failed_older_history_page_preserves_loaded_decisions_and_retries_in_place(): output = run_node(r""" let attempts=0; const nodes={ count:{},list:{innerHTML:'',querySelectorAll:()=>[],querySelector:()=>null},status:{textContent:''},panel:{},detail:{innerHTML:'receipt remains'}, pendingTab:{setAttribute(){}},historyTab:{setAttribute(){}}, }; const gates=createHumanGates({ storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''}, fetchJson:async path=>{ if(!path.includes('cursor=')) return {pending_count:0,items:[{id:'new',title:'Newest',candidate_hash:'aaa',state:'released',updated_at:300}],next_cursor:'older'}; attempts += 1; if(attempts===1) throw new Error('History service timed out'); return {pending_count:0,items:[{id:'old',title:'Oldest',candidate_hash:'bbb',state:'held',updated_at:100}],next_cursor:null}; }, }); (async()=>{ await gates.showHistory(); nodes.detail.innerHTML='receipt remains'; let message=''; try{await gates.loadMoreHistory()}catch(error){message=error.message} const failed={message,html:nodes.list.innerHTML,status:nodes.status.textContent,detail:nodes.detail.innerHTML}; await gates.loadMoreHistory(); process.stdout.write(JSON.stringify({failed,attempts,html:nodes.list.innerHTML,status:nodes.status.textContent})); })(); """) assert output["failed"]["message"] == "History service timed out" assert "Newest" in output["failed"]["html"] assert "Oldest" not in output["failed"]["html"] assert "Retry older decisions" in output["failed"]["html"] assert output["failed"]["status"] == "History service timed out Loaded decisions are still available." assert output["failed"]["detail"] == "receipt remains" assert output["attempts"] == 2 assert "Oldest" in output["html"] assert output["status"] == "All 2 Human Gate decisions loaded." def test_queue_changes_publish_mobile_counts_and_authoritative_decision_completion(): output = run_node(r""" const changes=[]; const item={id:'g1',title:'Candidate',candidate_hash:'abc123',revision:1,checks:[]}; const gates=createHumanGates({ storage:{getItem:()=>null,setItem(){}}, getLogin:()=> 'timmy', isOnline:()=>true, nodes:{count:{},list:{},status:{},panel:{},detail:{}}, location:{hash:''}, onChange:(snapshot, state)=>changes.push({count:snapshot.pending_count,...state}), fetchJson:async(path, options={})=>options.method==='POST' ? {receipt_id:'r1'} : {pending_count:1,items:[item]}, }); (async()=>{await gates.load();gates.reviewNext();await gates.decideAndNext('release',{checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}});process.stdout.write(JSON.stringify(changes));})(); """) assert output == [ {"count": 1, "available": True, "authoritative": True}, {"count": 0, "available": True, "authoritative": True, "decision": True}, ] def test_queue_load_failure_distinguishes_cached_read_only_data_from_unavailable(): output = run_node(r""" const cached={pending_count:1,items:[{id:'g1',title:'Cached',candidate_hash:'abc'}]}; const changes=[]; const make=(value,label)=>createHumanGates({ storage:{getItem:()=>value ? JSON.stringify(value) : null,setItem(){}}, getLogin:()=> 'timmy', isOnline:()=>false, nodes:{count:{},list:{},status:{},panel:{}}, location:{hash:''}, onChange:(snapshot,state)=>changes.push({label,count:snapshot.pending_count,...state}), fetchJson:async()=>{throw new Error('network')}, }); (async()=>{await make(cached,'cached').load();try{await make(null,'empty').load()}catch(_){}process.stdout.write(JSON.stringify(changes));})(); """) assert output == [ {"label": "cached", "count": 1, "available": True, "authoritative": False, "cached": True}, {"label": "empty", "count": 0, "available": False, "authoritative": False}, ] def test_review_next_is_a_fixed_snapshot_and_decision_and_next_advances_without_new_arrivals(): output = run_node(r""" const calls=[]; const nodes={count:{textContent:''},list:{innerHTML:''},status:{textContent:''},panel:{hidden:true},detail:{innerHTML:''}}; const initial={pending_count:2,items:[{id:'old',title:'Old',candidate_hash:'a1',revision:1,checks:[]},{id:'next',title:'Next',candidate_hash:'b2',revision:1,checks:[]}]}; const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:'#/my-work/human-gates'},fetchJson:async(path,options={})=>{calls.push({path,options}); if(options.method==='POST') return {receipt_id:'r1',state:'released'}; return initial;}}); (async()=>{await gates.load(); const reviewed=gates.reviewNext(); initial.items.unshift({id:'new',title:'New arrival',candidate_hash:'c3',revision:1,checks:[]}); const result=await gates.decideAndNext('release',{checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}}); process.stdout.write(JSON.stringify({reviewed,result,current:gates.current(),calls,hash:gates.route()}));})(); """) assert output["reviewed"]["id"] == "old" assert output["result"]["receipt"]["receipt_id"] == "r1" assert output["current"]["id"] == "next" assert output["hash"] == "#/my-work/human-gates" post = next(call for call in output["calls"] if call["options"].get("method") == "POST") assert post["path"] == "api/v1/human-gates/old/decision" assert "Idempotency-Key" in post["options"]["headers"] def test_review_loads_exact_detail_with_links_provenance_and_history(): output = run_node(r""" const detail={id:'g1',title:'Candidate',project:'stackchain/dashboard',candidate_hash:'abc123',revision:1,checks:[{name:'unit',state:'success',required:true}],artifacts:[{name:'manifest',url:'https://forge.example/manifest'}],links:[{label:'pull',url:'https://forge.example/pull/1'}],score:{value:98,provenance:'eval/v1'},provenance:{producer:'bot',run_id:'9'},history:[{action:'intake',at:100}]}; const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:''}}; const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async path=>path.endsWith('/g1')?detail:{pending_count:1,items:[detail]}}); (async()=>{await gates.load();gates.reviewNext();await new Promise(resolve=>setTimeout(resolve,0));process.stdout.write(JSON.stringify({html:nodes.detail.innerHTML}));})(); """) assert "stackchain/dashboard" in output["html"] assert "https://forge.example/pull/1" in output["html"] assert output["html"].count('target="_blank"') == 2 assert output["html"].count('rel="noreferrer noopener"') == 2 assert "bot" in output["html"] assert "intake" in output["html"] def test_unfinished_review_restores_after_reload_for_same_account_gate_and_revision(): output = run_node(r""" const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; const item={id:'g1',title:'Candidate',project:'stackchain/dashboard',candidate_hash:'abc123',revision:4,checks:[]}; const make=()=>{ const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:'',addEventListener(){}}}; return {nodes,gates:createHumanGates({ storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''}, fetchJson:async path=>path.endsWith('/g1')?item:{pending_count:1,items:[item]}, })}; }; (async()=>{ const first=make(); await first.gates.load(); first.gates.reviewNext(); first.gates.saveProgress({ checklist:{exact_hash:true,artifacts_reviewed:false,provenance_reviewed:true}, reason:'Need the signed manifest',override_reason:'Approved exception', }); const reloaded=make(); await reloaded.gates.load(); reloaded.gates.reviewNext(); await new Promise(resolve=>setTimeout(resolve,0)); process.stdout.write(JSON.stringify({keys:[...values.keys()],html:reloaded.nodes.detail.innerHTML})); })(); """) assert "stackchain.human-gate-review.v1:7:timmy:g1:4" in output["keys"] assert 'data-gate-checklist="exact_hash" checked' in output["html"] assert 'data-gate-checklist="artifacts_reviewed" checked' not in output["html"] assert 'data-gate-checklist="provenance_reviewed" checked' in output["html"] assert "Need the signed manifest" in output["html"] assert "Approved exception" in output["html"] def test_review_form_changes_are_saved_without_a_decision_tap(): output = run_node(r""" const values=new Map(), listeners={}; const inputs=[ {dataset:{gateChecklist:'exact_hash'},checked:true}, {dataset:{gateChecklist:'artifacts_reviewed'},checked:true}, {dataset:{gateChecklist:'provenance_reviewed'},checked:false}, ]; const reason={value:'Waiting for mobile evidence'}, override={value:'Temporary exception'}; const detail={ innerHTML:'', addEventListener:(name,listener)=>listeners[name]=listener, querySelectorAll:()=>inputs, querySelector:selector=>selector==='[data-gate-reason]'?reason:override, }; const item={id:'g1',title:'Candidate',candidate_hash:'abc',revision:2,checks:[]}; const gates=createHumanGates({ storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}, getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true, nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''}, fetchJson:async()=>({pending_count:1,items:[item]}), }); (async()=>{ await gates.load(); gates.reviewNext(); listeners.input(); const saved=JSON.parse(values.get('stackchain.human-gate-review.v1:7:timmy:g1:2')); process.stdout.write(JSON.stringify(saved)); })(); """) assert output == { "gate_id": "g1", "revision": 2, "checklist": { "exact_hash": True, "artifacts_reviewed": True, "provenance_reviewed": False, }, "reason": "Waiting for mobile evidence", "override_reason": "Temporary exception", } def test_mobile_decision_tray_reports_readiness_and_targets_the_first_missing_confirmation(): output = run_node(r""" let posts=0; const focused=[]; const checklist=[ {dataset:{gateChecklist:'exact_hash'},checked:true,focus(){focused.push('exact_hash')},scrollIntoView(){focused.push('scroll-exact_hash')}}, {dataset:{gateChecklist:'artifacts_reviewed'},checked:false,focus(){focused.push('artifacts_reviewed')},scrollIntoView(){focused.push('scroll-artifacts_reviewed')}}, {dataset:{gateChecklist:'provenance_reviewed'},checked:false,focus(){focused.push('provenance_reviewed')},scrollIntoView(){focused.push('scroll-provenance_reviewed')}}, ]; const error={textContent:'',hidden:true}; const readiness={textContent:''}; const buttons=[{disabled:false},{disabled:false}]; const detail={ innerHTML:'', addEventListener(){}, querySelectorAll:selector=>selector==='[data-gate-checklist]'?checklist:selector==='[data-gate-decision]'?buttons:[], querySelector:selector=>selector==='[data-gate-error]'?error:selector==='[data-gate-readiness]'?readiness:selector==='[data-gate-reason]'?{value:''}:selector==='[data-gate-override]'?{value:''}:null, }; const item={id:'g1',title:'Long evidence review',candidate_hash:'abc',revision:1,checks:[]}; const gates=createHumanGates({ storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true, nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''}, fetchJson:async(path,options={})=>{if(options.method==='POST')posts+=1;return options.method==='POST'?{receipt_id:'r1'}:{pending_count:1,items:[item]};}, }); (async()=>{ await gates.load(); gates.reviewNext(); const rendered=detail.innerHTML; let message=''; try { await gates.submitDecision('release'); } catch(error) { message=error.message; } process.stdout.write(JSON.stringify({rendered,message,error,readiness,focused,posts})); })(); """) assert 'class="human-gate-decision-tray"' in output["rendered"] assert 'data-gate-readiness' in output["rendered"] assert output["message"] == "Complete the release checklist before deciding." assert output["error"] == { "textContent": "Complete the release checklist before deciding.", "hidden": False, } assert output["readiness"]["textContent"] == "1 of 3 confirmations complete" assert output["focused"] == ["scroll-artifacts_reviewed", "artifacts_reviewed"] assert output["posts"] == 0 def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_form_fields(): css = CSS.read_text() assert ".human-gate-decision-tray" in css assert "position:sticky" in css assert "bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)))" in css assert "padding-bottom:max(16px,env(safe-area-inset-bottom))" in css assert ".human-gate-decision-actions button{min-height:44px}" in css assert ".human-gate-detail{padding-bottom:" in css def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache(): worker = WORKER.read_text() assert "stackchain-dashboard-shell-v150" in worker def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason(): output = run_node(r""" const focused=[]; const checklist=['exact_hash','artifacts_reviewed','provenance_reviewed'].map(key=>({dataset:{gateChecklist:key},checked:true})); const override={value:'',focus(){focused.push('override')},scrollIntoView(){focused.push('scroll-override')}}; const error={textContent:'',hidden:true}; const detail={innerHTML:'',addEventListener(){},querySelectorAll:selector=>selector==='[data-gate-checklist]'?checklist:[],querySelector:selector=>selector==='[data-gate-error]'?error:selector==='[data-gate-override]'?override:selector==='[data-gate-reason]'?{value:''}:null}; const item={id:'g1',title:'Candidate',candidate_hash:'abc',revision:1,checks:[{name:'Mobile browser journey',state:'failure',required:true}]}; const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},fetchJson:async()=>({pending_count:1,items:[item]})}); (async()=>{await gates.load();gates.reviewNext();let message='';try{await gates.submitDecision('release')}catch(error){message=error.message}process.stdout.write(JSON.stringify({message,inline:error.textContent,focused}));})(); """) assert "Mobile browser journey" in output["message"] assert output["inline"] == output["message"] assert output["focused"] == ["scroll-override", "override"] def test_release_requires_override_for_unmet_required_checks_and_decisions_require_online_identity(): output = run_node(r""" let online=true, login='timmy', posts=0; const item={id:'g1',title:'Failing',candidate_hash:'a1',revision:1,checks:[{name:'browser',state:'failure',required:true}]}; const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=>login,isOnline:()=>online,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){posts++;return {receipt_id:'r1'}};return {pending_count:1,items:[item]};}}); (async()=>{await gates.load();gates.reviewNext();let override,offline,identity;try{await gates.decideAndNext('release',{checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){override=e.message} online=false;try{await gates.decideAndNext('hold',{reason:'wait',checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){offline=e.message} online=true;login='';try{await gates.decideAndNext('hold',{reason:'wait',checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){identity=e.message}process.stdout.write(JSON.stringify({override,offline,identity,posts}));})(); """) assert "override reason" in output["override"] assert "online" in output["offline"] assert "identity" in output["identity"] assert output["posts"] == 0 def test_offline_cache_is_scoped_to_immutable_account_identity(): output = run_node(r""" const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}; let account='1:timmy'; const nodes={count:{},list:{},status:{},panel:{}}; const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>({pending_count:1,items:[{id:'g1',title:'Private',candidate_hash:'a1'}]})}); (async()=>{await gates.load();account='2:timmy';process.stdout.write(JSON.stringify({keys:[...values.keys()],restored:gates.restoreCached()}));})(); """) assert output["keys"] == ["stackchain.human-gates.v1:1:timmy"] assert output["restored"] is None def test_account_switch_clears_in_memory_gate_data_before_failed_load(): output = run_node(r""" const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}; let account='1:timmy', fail=false; const nodes={count:{},list:{innerHTML:''},status:{},panel:{}}; const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>{if(fail)throw new Error('offline');return {pending_count:1,items:[{id:'private-1',title:'Principal 1 private',candidate_hash:'secret'}]}}}); (async()=>{await gates.load();account='2:timmy';fail=true;try{await gates.load()}catch(_){}process.stdout.write(JSON.stringify({snapshot:gates.snapshot(),html:nodes.list.innerHTML}));})(); """) assert output["snapshot"] == {"pending_count": 0, "items": []} assert "Principal 1 private" not in output["html"] assert "secret" not in output["html"] def test_stale_account_load_cannot_overwrite_new_account_queue_or_cache(): output = run_node(r""" const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}; let account='1:timmy', resolveA, resolveB; const responseA=new Promise(resolve=>resolveA=resolve), responseB=new Promise(resolve=>resolveB=resolve); const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes:{count:{},list:{innerHTML:''},status:{},panel:{}},location:{hash:''},fetchJson:()=>account.startsWith('1:')?responseA:responseB}); (async()=>{const loadA=gates.load();account='2:timmy';const loadB=gates.load();resolveB({pending_count:1,items:[{id:'b',title:'B gate',candidate_hash:'bhash'}]});await loadB;resolveA({pending_count:1,items:[{id:'a-secret',title:'A secret',candidate_hash:'asecret'}]});await loadA;process.stdout.write(JSON.stringify({snapshot:gates.snapshot(),cached:JSON.parse(values.get('stackchain.human-gates.v1:2:timmy'))}));})(); """) assert [item["id"] for item in output["snapshot"]["items"]] == ["b"] assert [item["id"] for item in output["cached"]["items"]] == ["b"] def test_decision_retry_reuses_the_same_idempotency_key(): output = run_node(r""" let attempts=0; const keys=[]; const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:1,checks:[]}; const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){keys.push(options.headers['Idempotency-Key']);attempts++;if(attempts===1)throw new Error('network');return {receipt_id:'r1'}};return {pending_count:1,items:[item]};}}); (async()=>{await gates.load();gates.reviewNext();const values={reason:'wait',checklist:{}};try{await gates.decideAndNext('hold',values)}catch(_){}await gates.decideAndNext('hold',values);process.stdout.write(JSON.stringify({keys}));})(); """) assert len(output["keys"]) == 2 assert output["keys"][0] == output["keys"][1] def test_interrupted_decision_is_recovered_after_reload_with_the_exact_operation(): output = run_node(r""" const stored=new Map(); const requests=[]; let failResponse=true, committed=false; const storage={getItem:key=>stored.get(key)||null,setItem:(key,value)=>stored.set(key,value),removeItem:key=>stored.delete(key)}; const item={id:'g1',title:'Release candidate',candidate_hash:'a1',revision:3,checks:[]}; const createController=()=>{ const detail={innerHTML:'',addEventListener(){},querySelectorAll:()=>[],querySelector:()=>null}; const gates=createHumanGates({ storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true, nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''}, fetchJson:async(path,options={})=>{ if(options.method==='POST') { requests.push({key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}); if(failResponse) throw new Error('response interrupted'); return {receipt_id:'r1',state:'held'}; } return committed ? {pending_count:0,items:[]} : {pending_count:1,items:[item]}; }, }); return {gates,detail}; }; (async()=>{ const first=createController(); await first.gates.load(); first.gates.reviewNext(); first.gates.saveProgress({reason:'Awaiting approval',checklist:{}}); try { await first.gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); } catch (_) {} const immediateHtml=first.detail.innerHTML; const decisionStorageKey=[...stored.keys()].find(key=>key.startsWith('stackchain.human-gate-decision.v1:')); const pendingBefore=JSON.parse(stored.get(decisionStorageKey)); committed=true; failResponse=false; const second=createController(); await second.gates.load(); second.gates.reviewNext(); const recoveryHtml=second.detail.innerHTML; const result=await second.gates.recoverDecision(); process.stdout.write(JSON.stringify({ decisionStorageKey,pendingBefore,immediateHtml,recoveryHtml,requests,result, pendingCleared:!stored.has(decisionStorageKey), progressCleared:![...stored.keys()].some(key=>key.startsWith('stackchain.human-gate-review.v1:')), snapshot:second.gates.snapshot(), })); })().catch(error=>process.stdout.write(JSON.stringify({fatal:error.stack}))); """) assert "fatal" not in output, output.get("fatal") assert output["decisionStorageKey"] == "stackchain.human-gate-decision.v1:7:timmy" assert output["pendingBefore"]["gate_id"] == "g1" assert output["pendingBefore"]["revision"] == 3 assert output["pendingBefore"]["decision"] == "hold" assert "Decision outcome unknown" in output["immediateHtml"] assert "Decision outcome unknown" in output["recoveryHtml"] assert "data-gate-recover" in output["recoveryHtml"] assert len(output["requests"]) == 2 assert output["requests"][0] == output["requests"][1] assert output["pendingCleared"] is True assert output["progressCleared"] is True assert output["snapshot"] == {"pending_count": 0, "items": []} assert output["result"]["receipt"]["receipt_id"] == "r1" def test_interrupted_decision_recovery_is_wired_for_progressive_and_hydrated_mobile_shells(): dashboard = DASHBOARD.read_text() progressive = PROGRESSIVE.read_text() css = CSS.read_text() assert "closest('[data-gate-recover]')" in dashboard assert "humanGates.recoverDecision()" in dashboard assert "closest?.('[data-gate-recover]')" in progressive assert "controller.recoverDecision()" in progressive assert ".human-gate-decision-recovery" in css assert ".human-gate-decision-recovery button{min-height:44px" in css assert "stackchain-dashboard-shell-v150" in WORKER.read_text() def test_failed_decision_keeps_review_progress_and_successful_retry_clears_it(): output = run_node(r""" const stored=new Map(); let attempts=0; const storage={getItem:key=>stored.get(key)||null,setItem:(key,value)=>stored.set(key,value),removeItem:key=>stored.delete(key)}; const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:3,checks:[]}; const gates=createHumanGates({ storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true, nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''}, fetchJson:async(path,options={})=>{ if(options.method==='POST') { attempts += 1; if(attempts===1) throw new Error('offline'); return {receipt_id:'r1'}; } return {pending_count:1,items:[item]}; }, }); (async()=>{ await gates.load(); gates.reviewNext(); gates.saveProgress({reason:'Awaiting approval',checklist:{}}); const key='stackchain.human-gate-review.v1:7:timmy:g1:3'; try { await gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); } catch (_) {} const retained=stored.has(key); await gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); process.stdout.write(JSON.stringify({retained,cleared:!stored.has(key)})); })(); """) assert output == {"retained": True, "cleared": True} def test_concurrent_decision_taps_submit_once_and_advance_once(): output = run_node(r""" let posts=0, releasePost; const posted=new Promise(resolve=>releasePost=resolve); const items=[{id:'g1',title:'One',candidate_hash:'a1',revision:1,checks:[]},{id:'g2',title:'Two',candidate_hash:'b2',revision:1,checks:[]}]; const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){posts++;await posted;return {receipt_id:'r1'}};return {pending_count:2,items};}}); (async()=>{await gates.load();gates.reviewNext();const values={reason:'wait',checklist:{}};const first=gates.decideAndNext('hold',values);const second=gates.decideAndNext('hold',values);releasePost();await Promise.all([first,second]);process.stdout.write(JSON.stringify({posts,current:gates.current()?.id,snapshot:gates.snapshot()}));})(); """) assert output["posts"] == 1 assert output["current"] == "g2" assert output["snapshot"]["pending_count"] == 1 assert [item["id"] for item in output["snapshot"]["items"]] == ["g2"] def test_concurrent_reopen_calls_share_one_fresh_list_request(): output = run_node(r""" let listCalls=0, releaseList; const pending=new Promise(resolve=>releaseList=resolve); const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:1,checks:[]}; const gates=createHumanGates({ storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true, nodes:{count:{},list:{},status:{},panel:{hidden:true},detail:{}},location:{hash:''}, fetchJson:async path=>{ if(path.endsWith('/g1')) return item; listCalls += 1; await pending; return {pending_count:1,items:[item]}; }, }); (async()=>{ const first=gates.open(); const second=gates.open(); releaseList(); const reviewed=await Promise.all([first,second]); process.stdout.write(JSON.stringify({listCalls,ids:reviewed.map(item=>item.id)})); })(); """) assert output == {"listCalls": 1, "ids": ["g1", "g1"]} def test_failed_reopen_clears_single_flight_and_can_retry(): output = run_node(r""" let listCalls=0; const item={id:'g1',title:'Recovered',candidate_hash:'a1',revision:1,checks:[]}; const gates=createHumanGates({ storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true, nodes:{count:{},list:{},status:{},panel:{hidden:true},detail:{}},location:{hash:''}, fetchJson:async path=>{ if(path.endsWith('/g1')) return item; listCalls += 1; if(listCalls === 1) throw new Error('offline'); return {pending_count:1,items:[item]}; }, }); (async()=>{ let firstError=''; try { await gates.open(); } catch(error) { firstError=error.message; } const recovered=await gates.open(); process.stdout.write(JSON.stringify({firstError,listCalls,recovered:recovered.id})); })(); """) assert output == {"firstError": "offline", "listCalls": 2, "recovered": "g1"} def test_reopen_refreshes_queue_and_starts_a_fresh_atomic_review_snapshot(): output = run_node(r""" const first={id:'g1',title:'First candidate',project:'p/one',candidate_hash:'a1',revision:1,checks:[]}; const second={id:'g2',title:'Second candidate',project:'p/two',candidate_hash:'b2',revision:1,checks:[]}; let listCalls=0; const nodes={count:{},list:{innerHTML:''},status:{},panel:{hidden:true},detail:{innerHTML:''}}; const gates=createHumanGates({ storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''}, fetchJson:async path=>{ if(path.endsWith('/g1')) return first; if(path.endsWith('/g2')) return second; listCalls += 1; return {pending_count:1,items:[listCalls === 1 ? first : second]}; }, }); (async()=>{ await gates.open(); await new Promise(resolve=>setTimeout(resolve,0)); const before=gates.current().id; await gates.open(); const selected=gates.select('g2'); await new Promise(resolve=>setTimeout(resolve,0)); process.stdout.write(JSON.stringify({ before,listCalls,selected:selected.id,current:gates.current().id, listHtml:nodes.list.innerHTML,detailHtml:nodes.detail.innerHTML, })); })(); """) assert output["before"] == "g1" assert output["listCalls"] == 2 assert output["selected"] == "g2" assert output["current"] == "g2" assert "Second candidate" in output["listHtml"] assert "Second candidate" in output["detailHtml"] def test_selecting_a_queue_card_opens_that_exact_gate(): output = run_node(r""" const details={ g1:{id:'g1',title:'First',project:'p/one',candidate_hash:'a1',revision:1,checks:[]}, g2:{id:'g2',title:'Second',project:'p/two',candidate_hash:'b2',revision:1,checks:[]}, }; const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:''}}; const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async path=>path.includes('/g')?details[path.split('/').pop()]:{pending_count:2,items:Object.values(details)}}); (async()=>{await gates.load();gates.reviewNext();gates.select('g2');await new Promise(resolve=>setTimeout(resolve,0));process.stdout.write(JSON.stringify({current:gates.current().id,html:nodes.detail.innerHTML}));})(); """) assert output["current"] == "g2" assert "Second" in output["html"] assert "p/two" in output["html"] def test_human_gate_mobile_shell_and_deep_route_are_wired(): index = INDEX.read_text() dashboard = DASHBOARD.read_text() assert 'id="human-gates"' in index assert 'id="human-gates-count"' in index assert 'Review next openHumanGates()" in dashboard assert "const humanGatesOnChange = (snapshot, state)=>" in dashboard assert "queueCounts.gate = snapshot.pending_count" in dashboard assert "preparationItems.gate = snapshot.items" in dashboard assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard assert "counts.gate = queueCounts.gate" in dashboard assert "gate:preparationItems.gate || []" in dashboard assert "stackchain-dashboard-shell-v150" in WORKER.read_text() def test_human_gate_history_tabs_are_touch_sized_wired_and_invalidate_the_shell(): index = INDEX.read_text() dashboard = DASHBOARD.read_text() progressive = PROGRESSIVE.read_text() css = CSS.read_text() assert 'class="human-gate-views" role="group" aria-label="Human Gate view"' in index assert 'id="human-gates-pending"' in index assert 'id="human-gates-history"' in index assert "pendingTab:qs('#human-gates-pending')" in dashboard assert "historyTab:qs('#human-gates-history')" in dashboard assert "humanGates.showHistory()" in dashboard assert "card.addEventListener('click', () => selectHistory" in MODULE.read_text() assert "pendingTab:query('#human-gates-pending')" in progressive assert "historyTab:query('#human-gates-history')" in progressive assert ".human-gate-views button{min-height:44px" in css assert ".human-gate-history-card time" in css assert ".human-gate-history-more{min-height:44px;width:100%" in css assert "stackchain-dashboard-shell-v150" in WORKER.read_text() def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace(): script = f""" const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))}); const listeners={{}}; const requests=[]; const nodes={{ '#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:''}}, '#human-gates-status':{{}}, '#human-gates':{{hidden:true}}, '#human-gate-detail':{{innerHTML:'',querySelectorAll:()=>[]}}, '#open-human-gates':{{addEventListener:(name,fn)=>listeners.open=fn}}, '#close-human-gates':{{addEventListener:(name,fn)=>listeners.close=fn}}, }}; nodes['#human-gates-list'].addEventListener=(name,fn)=>listeners.list=fn; nodes['#human-gate-detail'].addEventListener=(name,fn)=>listeners.detail=fn; const document={{querySelector:selector=>nodes[selector]||null}}; const app=createProgressiveHumanGates({{ document, location:{{hash:'#/my-work/human-gates'}}, history:{{replaceState(){{}}}}, storage:{{getItem:()=>null,setItem(){{}}}}, isOnline:()=>true, getIdentity:async()=>({{login:'timmy',accountKey:'7:timmy'}}), fetchJson:async path=>{{requests.push(path);return {{pending_count:1,items:[{{id:'g1',title:'Ship it',candidate_hash:'abc',revision:1,checks:[]}}]}};}}, }}); (async()=>{{const started=await app.start();process.stdout.write(JSON.stringify({{ started,hidden:nodes['#human-gates'].hidden,html:nodes['#human-gates-list'].innerHTML, requests,listeners:Object.keys(listeners).sort(),handoff:app.handoff().started, }}));}})(); """ result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True) output = json.loads(result.stdout) assert output == { "started": True, "hidden": False, "html": '', "requests": ["api/v1/human-gates", "api/v1/human-gates/g1"], "listeners": ["close", "detail", "list", "open"], "handoff": True, } def test_progressive_reopen_refreshes_the_review_session_before_hydration(): script = f""" const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))}); const listeners={{}}; const nodes={{ '#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:'',addEventListener(){{}}}}, '#human-gates-status':{{}}, '#human-gates':{{hidden:true}}, '#human-gate-detail':{{innerHTML:'',addEventListener(){{}},querySelectorAll:()=>[]}}, '#open-human-gates':{{addEventListener:(name,fn)=>listeners.open=fn}}, '#close-human-gates':{{addEventListener:(name,fn)=>listeners.close=fn}}, }}; let listCalls=0; const first={{id:'g1',title:'First',candidate_hash:'a1',revision:1,checks:[]}}; const second={{id:'g2',title:'Second',candidate_hash:'b2',revision:1,checks:[]}}; const app=createProgressiveHumanGates({{ document:{{querySelector:selector=>nodes[selector]||null}}, location:{{hash:'#/my-work/human-gates'}}, history:{{replaceState(){{}}}}, storage:{{getItem:()=>null,setItem(){{}}}}, isOnline:()=>true, getIdentity:async()=>({{login:'timmy',accountKey:'7:timmy'}}), fetchJson:async path=>{{ if(path.endsWith('/g1')) return first; if(path.endsWith('/g2')) return second; listCalls += 1; return {{pending_count:1,items:[listCalls === 1 ? first : second]}}; }}, }}); (async()=>{{ await app.start(); listeners.close(); await listeners.open(); await new Promise(resolve=>setTimeout(resolve,0)); const controller=app.handoff().controller; process.stdout.write(JSON.stringify({{ listCalls,current:controller.current().id,hidden:nodes['#human-gates'].hidden, listHtml:nodes['#human-gates-list'].innerHTML, detailHasSecond:nodes['#human-gate-detail'].innerHTML.includes('Second'), }})); }})().catch(error=>{{console.error(error);process.exit(1);}}); """ result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True) assert json.loads(result.stdout) == { "listCalls": 2, "current": "g2", "hidden": False, "listHtml": '', "detailHasSecond": True, } def test_dashboard_adopts_progressive_human_gates_without_a_second_list_load(): dashboard = DASHBOARD.read_text() index = INDEX.read_text() assert 'static/progressive-human-gates.js' in index assert "window.stackchainProgressiveHumanGates?.handoff?.()" in dashboard assert "progressiveHumanGatesHandoff?.controller || createHumanGates" in dashboard assert "if (!progressiveHumanGatesHandoff?.started) humanGates.load()" in dashboard assert "if (window.location.hash === '#/my-work/human-gates' && !progressiveHumanGatesHandoff?.started) openHumanGates()" in dashboard