592 lines
32 KiB
Python
592 lines
32 KiB
Python
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"
|
|
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_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_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_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 <span id="human-gates-count"' in index
|
|
assert 'static/human-gates.js' in index
|
|
assert "#/my-work/human-gates" in dashboard
|
|
assert "createHumanGates" in dashboard
|
|
assert "planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id" in dashboard
|
|
assert 'data-mobile-queue="gate"' in index
|
|
assert 'data-mobile-queue-count="gate"' in index
|
|
assert "openHumanGates: () => 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-v146" 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": '<button class="human-gate-card" type="button" data-human-gate-id="g1"><strong>Ship it</strong><code>abc</code><span>Priority 0</span></button>',
|
|
"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": '<button class="human-gate-card" type="button" data-human-gate-id="g2"><strong>Second</strong><code>b2</code><span>Priority 0</span></button>',
|
|
"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
|