import json import subprocess from pathlib import Path SEARCH_BATCH_PLAN = Path(__file__).parents[1] / "frontend" / "search-batch-plan.js" HTML = Path(__file__).parents[1] / "frontend" / "index.html" DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css" def run_node(script): return json.loads(subprocess.run( ["node", "-e", script], check=True, capture_output=True, text=True ).stdout) def test_search_batch_selection_keeps_open_issues_across_pages_and_rejects_ineligible_results(): script = f""" const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))}); const states=[]; const flow=createSearchBatchPlan({{onChange:state=>states.push(state)}}); const first={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:809,title:'First'}}; const second={{kind:'issue',state:'open',repository:'stackchain/api',number:17,title:'Second'}}; const pull={{kind:'pull',state:'open',repository:'stackchain/dashboard',number:810}}; const closed={{kind:'issue',state:'closed',repository:'stackchain/dashboard',number:808}}; flow.start(); const outcomes=[flow.toggle(first),flow.toggle(pull),flow.toggle(closed),flow.toggle(second)]; process.stdout.write(JSON.stringify({{outcomes,snapshot:flow.snapshot(),states}})); """ result = run_node(script) assert result["outcomes"] == ["selected", "ineligible", "ineligible", "selected"] assert result["snapshot"] == { "active": True, "count": 2, "items": [ {"kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 809, "title": "First"}, {"kind": "issue", "state": "open", "repository": "stackchain/api", "number": 17, "title": "Second"}, ], } assert result["states"][-1]["count"] == 2 def test_search_batch_prepares_owned_issues_without_reclaiming_and_claims_unassigned_issues(): script = f""" const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))}); const calls=[]; const details={{ 809:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:809,assigned_to_me:true}}, 810:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:810,claimable:true}}, 811:{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:811,claimable:false}}, }}; const flow=createSearchBatchPlan({{ resolve:item=>{{calls.push('resolve:'+item.number);return Promise.resolve(details[item.number]);}}, claim:detail=>{{calls.push('claim:'+detail.number);return Promise.resolve({{...detail,assigned_to_me:true}});}}, }}); Promise.all([ flow.prepare({{number:809}}), flow.prepare({{number:810}}), flow.prepare({{number:811}}).catch(error=>error.message), ]).then(results=>process.stdout.write(JSON.stringify({{results,calls}}))); """ result = run_node(script) assert result["calls"] == ["resolve:809", "resolve:810", "resolve:811", "claim:810"] assert result["results"][0]["assigned_to_me"] is True assert result["results"][1]["assigned_to_me"] is True assert result["results"][2] == "This issue is no longer available to assign." def test_release_planning_requires_selected_issues_from_one_repository(): script = f""" const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))}); const flow=createSearchBatchPlan(); flow.start(); flow.toggle({{kind:'issue',state:'open',repository:'stackchain/dashboard',number:815}}); const one=flow.releaseRepository(); flow.toggle({{kind:'issue',state:'open',repository:'stackchain/api',number:17}}); process.stdout.write(JSON.stringify({{one,mixed:flow.releaseRepository()}})); """ assert run_node(script) == {"one": "stackchain/dashboard", "mixed": None} def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls(): html = HTML.read_text() dashboard = DASHBOARD.read_text() css = CSS.read_text() worker = (HTML.parent / "service-worker.js").read_text() controller = SEARCH_BATCH_PLAN.read_text() assert 'id="select-search-results"' in html assert 'id="search-batch-actions"' in html assert 'id="queue-selected-search-results"' in html assert 'id="defer-selected-search-results"' in html assert 'id="plan-selected-search-results"' in html assert 'id="search-release-review"' in html assert 'id="search-release-milestone"' in html assert 'id="search-release-due-date"' in html assert 'id="resume-search-batch"' in html assert 'src="static/search-batch-plan.js"' in html assert "mountSearchBatchPlanning(" in dashboard assert "escapeHtml, escAttr, laterWork, laterPicker" in dashboard assert "journalName:'search-today-batch'" in controller assert "journalName:'search-later-batch'" in controller assert "journalName:'search-release-batch'" in controller assert "autoMount:false" in controller assert "plan.toggle(result)" in controller assert "processor.run(plan.snapshot().items, estimateValues())" in controller assert "timeBudget:()" in controller assert "persistEstimate" in controller assert 'id="search-batch-estimate-review"' in html assert 'data-search-batch-estimate' in controller assert "processor.resume()" in controller assert 'aria-label="Select ' in controller assert "result.kind === 'issue' && result.state === 'open'" in controller assert ".search-batch-actions" in css assert "position:sticky" in css assert "env(safe-area-inset-bottom)" in css assert ".cmd-select-result" in css and "min-height:44px" in css assert ".search-release-review" in css and "100dvh" in css assert ".search-release-actions" in css and "env(safe-area-inset-bottom)" in css assert "BASE + 'static/search-batch-plan.js'" in worker def test_selected_search_issues_choose_one_future_time_before_durable_batch_defer(): script = f""" const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))}); const listeners={{}}; const elements={{}}; for (const id of [ 'search-batch-actions','select-search-results','queue-selected-search-results', 'defer-selected-search-results','plan-selected-search-results','search-selection-status','cmd-search-action-status', 'resume-search-batch','open-palette','cmd-results','cancel-search-selection', 'search-batch-estimate-review','search-batch-estimate-summary','search-batch-estimate-list', 'cancel-search-batch-estimates','confirm-search-batch-estimates','search-release-review', 'search-release-summary','search-release-milestone','search-release-due-date','search-release-list', 'cancel-search-release','confirm-search-release' ]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'', addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}}; const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}}; const processorOptions=[]; const runs=[]; const processors=[]; const batchFactory=options=>{{ processorOptions.push(options); const processor={{ run:(items,estimates,context)=>{{runs.push({{items,estimates,context}});return Promise.resolve();}}, resume:()=>Promise.resolve(),pending:()=>0, }}; processors.push(processor); return processor; }}; const planning={{capacity_minutes:null,estimates:{{}}}}; const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number, replacePlanning:()=>{{}}}}; const laterCalls=[]; const laterWork={{defer:(item,until)=>{{laterCalls.push([item.number,until]);return 'deferred';}}}}; let pickerRequest=null; const laterPicker={{open:(payload,trigger,context)=>{{pickerRequest={{payload,trigger,context}};return true;}}}}; const item={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:813,title:'Batch defer'}}; const mounted=createSearchBatchPlan.mount( document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'), {{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},value=>({{...value,accepted:true}}),()=>item,()=>{{}}, value=>value,value=>value,laterWork,laterPicker ); mounted.plan.start(); mounted.plan.toggle(item); listeners['defer-selected-search-results:click']({{currentTarget:elements['#defer-selected-search-results']}}); const before={{runs:runs.slice(),count:pickerRequest.payload.items.length,context:pickerRequest.context,journals:processorOptions.map(value=>value.journalName)}}; pickerRequest.payload.confirm(new Date('2026-08-15T09:00:00.000Z'))(); const laterResult=processorOptions[1].queue({{...item,assigned_to_me:true}},{{until:'2026-08-15T09:00:00.000Z'}}); process.stdout.write(JSON.stringify({{before,runs,laterResult,laterCalls}})); """ result = run_node(script) assert result["before"] == { "runs": [], "count": 1, "context": "search-batch", "journals": ["search-today-batch", "search-later-batch", "search-release-batch"], } assert result["runs"] == [{ "items": [{ "kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 813, "title": "Batch defer", }], "estimates": {}, "context": {"until": "2026-08-15T09:00:00.000Z"}, }] assert result["laterResult"] == "queued" assert result["laterCalls"] == [[813, "2026-08-15T09:00:00.000Z"]] def test_search_batch_reviews_time_estimates_before_any_assignment_and_persists_queued_estimates(): script = f""" const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))}); const listeners={{}}; const elements={{}}; for (const id of [ 'search-batch-actions','select-search-results','queue-selected-search-results', 'defer-selected-search-results','plan-selected-search-results', 'search-selection-status','cmd-search-action-status','resume-search-batch', 'open-palette','cmd-results','cancel-search-selection','search-batch-estimate-review', 'search-batch-estimate-summary','search-batch-estimate-list', 'cancel-search-batch-estimates','confirm-search-batch-estimates','search-release-review', 'search-release-summary','search-release-milestone','search-release-due-date','search-release-list', 'cancel-search-release','confirm-search-release' ]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'', addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}}; const estimateInputs=[ {{dataset:{{searchBatchEstimate:'stackchain/dashboard#811'}},value:'25'}}, {{dataset:{{searchBatchEstimate:'stackchain/api#12'}},value:'30'}}, ]; const document={{ querySelector:selector=>elements[selector] || null, querySelectorAll:selector=>selector==='[data-search-batch-estimate]' ? estimateInputs : [], }}; const calls=[]; const processorOptions=[]; const processor={{ run:(items,estimates)=>{{calls.push({{type:'run',items,estimates}});return Promise.resolve();}}, resume:()=>Promise.resolve(),pending:()=>0, }}; const batchFactory=options=>{{processorOptions.push(options);return processor;}}; const planning={{capacity_minutes:90,estimates:{{'stackchain/existing#1':20}}}}; const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number, replacePlanning:value=>Object.assign(planning,value)}}; const todaySync={{enqueueConfiguration:()=>{{}},flush:()=>{{}}}}; const items=[ {{kind:'issue',state:'open',repository:'stackchain/dashboard',number:811,title:'Capacity'}}, {{kind:'issue',state:'open',repository:'stackchain/api',number:12,title:'API'}}, ]; let selected=items; const mounted=createSearchBatchPlan.mount( document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'), todaySync,item=>item,index=>selected[index],()=>{{}},value=>value,value=>value ); mounted.plan.start(); items.forEach(item=>mounted.plan.toggle(item)); listeners['queue-selected-search-results:click'](); const before={{ calls:calls.slice(), reviewHidden:elements['#search-batch-estimate-review'].hidden, actionsHidden:elements['#search-batch-actions'].hidden, markup:elements['#search-batch-estimate-list'].innerHTML, budget:processorOptions[0].timeBudget(), }}; listeners['confirm-search-batch-estimates:click'](); processorOptions[0].persistEstimate(items[0],25); processorOptions[0].onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}}); const afterCompleteHidden=elements['#search-batch-estimate-review'].hidden; process.stdout.write(JSON.stringify({{before,calls,afterCompleteHidden}})); """ result = run_node(script) assert result["before"]["calls"] == [] assert result["before"]["reviewHidden"] is False assert result["before"]["actionsHidden"] is True assert "Capacity" in result["before"]["markup"] assert "data-search-batch-estimate" in result["before"]["markup"] assert result["before"]["budget"] == {"capacity_minutes": 90, "planned_minutes": 20} assert result["calls"] == [{ "type": "run", "items": [ {"kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 811, "title": "Capacity"}, {"kind": "issue", "state": "open", "repository": "stackchain/api", "number": 12, "title": "API"}, ], "estimates": {"stackchain/dashboard#811": 25, "stackchain/api#12": 30}, }] assert result["afterCompleteHidden"] is True def test_search_batch_reviews_and_applies_one_repository_release_plan_before_assignment(): script = f""" const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))}); const listeners={{}}; const elements={{}}; for (const id of [ 'search-batch-actions','select-search-results','queue-selected-search-results', 'defer-selected-search-results','plan-selected-search-results','search-selection-status', 'cmd-search-action-status','resume-search-batch','open-palette','cmd-results', 'cancel-search-selection','search-batch-estimate-review','search-batch-estimate-summary', 'search-batch-estimate-list','cancel-search-batch-estimates','confirm-search-batch-estimates', 'search-release-review','search-release-summary','search-release-milestone', 'search-release-due-date','search-release-list','cancel-search-release','confirm-search-release' ]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',value:'', addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}}; const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}}; const calls=[]; const fetchJson=(url,options={{}})=>{{ calls.push({{url,method:options.method || 'GET',body:options.body ? JSON.parse(options.body) : null}}); if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}},{{id:7,title:'Old',state:'closed'}}]); const number=Number(url.split('/issues/')[1].split('/')[0]); if (url.endsWith('/release-plan')) return Promise.resolve({{ number,milestone:{{id:9,title:'August RC'}},due_date:'2026-08-31T23:59:59Z' }}); return Promise.resolve(); }}; const processorOptions=[]; const processors=[]; const batchFactory=options=>{{processorOptions.push(options);const processor={{ run:(items,estimates,context)=>{{calls.push({{run:items.map(item=>item.number),context}});return Promise.resolve();}}, resume:()=>Promise.resolve(),pending:()=>0, }};processors.push(processor);return processor;}}; const planning={{capacity_minutes:null,estimates:{{}}}}; const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,replacePlanning:()=>{{}}}}; const items=[ {{kind:'issue',state:'open',repository:'stackchain/dashboard',number:815,title:'Release plan'}}, {{kind:'issue',state:'open',repository:'stackchain/dashboard',number:816,title:'Second issue'}}, ]; const mounted=createSearchBatchPlan.mount( document,batchFactory,todayWork,()=> 'timmy',fetchJson,()=>'',()=>Promise.resolve('queued'), {{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},item=>item,index=>items[index],()=>{{}}, value=>value,value=>value,null,null ); mounted.plan.start();items.forEach(item=>mounted.plan.toggle(item)); listeners['plan-selected-search-results:click']().then(async()=>{{ const before={{calls:calls.slice(),reviewHidden:elements['#search-release-review'].hidden, actionsHidden:elements['#search-batch-actions'].hidden,options:elements['#search-release-milestone'].innerHTML, list:elements['#search-release-list'].innerHTML}}; elements['#search-release-milestone'].value='9'; elements['#search-release-milestone'].selectedIndex=0; elements['#search-release-milestone'].options=[{{textContent:'August RC'}}]; elements['#search-release-due-date'].value='2026-08-31'; listeners['confirm-search-release:click'](); const queued=await processorOptions[2].queue({{...items[0],assigned_to_me:true}}, {{milestone_id:9,milestone_title:'August RC',due_date:'2026-08-31T23:59:59Z'}}); process.stdout.write(JSON.stringify({{before,calls,queued,journals:processorOptions.map(option=>option.journalName)}})); }}); """ result = run_node(script) assert result["before"]["calls"] == [{ "url": "api/v1/repos/stackchain/dashboard/milestones", "method": "GET", "body": None, }] assert result["before"]["reviewHidden"] is False assert result["before"]["actionsHidden"] is True assert "August RC" in result["before"]["options"] assert "Old" not in result["before"]["options"] assert "Release plan" in result["before"]["list"] assert result["calls"][1] == { "run": [815, 816], "context": { "milestone_id": 9, "milestone_title": "August RC", "due_date": "2026-08-31T23:59:59Z", }, } assert result["calls"][2:] == [{ "url": "api/v1/repos/stackchain/dashboard/issues/815/release-plan", "method": "PATCH", "body": {"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"}, }] assert result["queued"] == "queued" assert result["journals"] == [ "search-today-batch", "search-later-batch", "search-release-batch", ]