feat: capture blockers while filing mobile issues (Closes #841)
This commit is contained in:
1 parent
35747cf9fb
commit
9ab22b97f8
13 files changed
+390
-10
No files matched your search
@@ -164,6 +164,33 @@ const fetchJson=async(url,options={{}})=>{{
|
||||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_closed_app_capture_retries_only_unfinished_blockers_after_creation():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'blocked',operationId:'blocked',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Blocked',body:'',labelIds:[],blockers:[
|
||||
{{repository:'o/api',number:7,title:'API'}},{{repository:'o/web',number:8,title:'Web'}}
|
||||
]}};
|
||||
const calls=[];let secondAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const body=options.body?JSON.parse(options.body):null;calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url.endsWith('/issues'))return{{number:42,repository:'o/r'}};
|
||||
if(body.number===8 && secondAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}
|
||||
return{{number:42,dependencies_available:true,dependencies:[body]}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};const result=await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["first"] == "offline"
|
||||
assert output["checkpoint"]["deliveredIssue"]["number"] == 42
|
||||
assert output["checkpoint"]["deliveredBlockers"] == 1
|
||||
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
|
||||
blockers = [call for call in output["calls"] if call["url"].endswith("/blockers")]
|
||||
assert [call["body"]["number"] for call in blockers] == [7, 8, 8]
|
||||
assert output["result"]["confirmed"][0]["number"] == 42
|
||||
|
||||
|
||||
def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -69,6 +69,22 @@ setImmediate(()=>process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_review_lists_selected_blockers_in_filing_order():
|
||||
script = f"""
|
||||
const createReview=require({json.dumps(str(MODULE))});
|
||||
function target(){{const listeners={{}};return{{hidden:true,disabled:false,textContent:'',children:[],addEventListener:(n,f)=>listeners[n]=f,replaceChildren(...items){{this.children=items;}},focus(){{}}}};}}
|
||||
const blockerList=target();
|
||||
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),blockerList,
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),
|
||||
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Blocked',blockers:[
|
||||
{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}
|
||||
]}},intent:'create-and-assign'}},target());
|
||||
process.stdout.write(JSON.stringify(blockerList.children.map(item=>item.textContent)));
|
||||
"""
|
||||
assert run_node(script) == ["o/api #7 — API ready", "o/web #8 — Web ready"]
|
||||
|
||||
|
||||
def test_escape_returns_to_the_unchanged_issue_form():
|
||||
script = f"""
|
||||
const createReview = require({json.dumps(str(MODULE))});
|
||||
|
||||
@@ -349,6 +349,49 @@ const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{f
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_foreground_blocker_retry_checkpoints_each_relationship_without_recreating_issue():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const calls=[];let secondAttempts=0;
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'blocked-op',
|
||||
fetchJson:async(url,options={{}})=>{{
|
||||
const body=options.body ? JSON.parse(options.body) : null;
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url.endsWith('/issues'))return{{repository:'o/r',number:42,title:'Blocked work'}};
|
||||
if(body?.number===8 && secondAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
return{{number:42,dependencies_available:true,dependencies:[body]}};
|
||||
}},
|
||||
}});
|
||||
const blockers=[
|
||||
{{repository:'o/api',number:7,title:'API ready'}},
|
||||
{{repository:'o/web',number:8,title:'Web ready'}},
|
||||
];
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Blocked work',body:'',blockers}});
|
||||
(async()=>{{
|
||||
await outbox.flush('timmy');const partial=outbox.list()[0];
|
||||
const result=await outbox.retry(queued.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{queued,partial,result,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["blockers"] == [
|
||||
{"repository": "o/api", "number": 7, "title": "API ready"},
|
||||
{"repository": "o/web", "number": 8, "title": "Web ready"},
|
||||
]
|
||||
assert output["partial"]["deliveredIssue"]["number"] == 42
|
||||
assert output["partial"]["deliveredBlockers"] == 1
|
||||
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
|
||||
blocker_calls = [call for call in output["calls"] if call["url"].endswith("/blockers")]
|
||||
assert [call["body"]["number"] for call in blocker_calls] == [7, 8, 8]
|
||||
assert blocker_calls[0]["key"] == "blocked-op:blocker-0"
|
||||
assert blocker_calls[-1]["key"] == "blocked-op:blocker-1"
|
||||
assert output["result"]["confirmed"][0]["number"] == 42
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -4023,6 +4023,52 @@ async def test_mobile_issue_capture_requires_explicit_searchable_repository_sele
|
||||
assert '.create-issue-repository-picker { min-width:0;' in html
|
||||
|
||||
|
||||
def test_issue_capture_persists_bounded_blockers_and_searches_open_issues():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values=new Map();const calls=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const capture=createIssueCapture({{storage,fetchJson:async url=>{{calls.push(url);return{{items:[
|
||||
{{kind:'issue',state:'open',repository:'o/api',number:7,title:'API ready'}},
|
||||
{{kind:'pull',state:'open',repository:'o/web',number:8,title:'Not an issue'}},
|
||||
{{kind:'issue',state:'closed',repository:'o/old',number:9,title:'Closed'}}
|
||||
]}};}}}});
|
||||
const blockers=[
|
||||
{{repository:'o/api',number:7,title:'API ready'}},
|
||||
{{repository:'o/api',number:7,title:'duplicate'}},
|
||||
{{repository:'bad',number:2,title:'invalid'}},
|
||||
];
|
||||
capture.saveDraft({{repository:'o/r',title:'Blocked work',body:'',blockers}});
|
||||
(async()=>{{const found=await capture.searchBlockers('api');process.stdout.write(JSON.stringify({{draft:capture.loadDraft(),found,calls}}));}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
assert output["draft"]["blockers"] == [
|
||||
{"repository": "o/api", "number": 7, "title": "API ready"}
|
||||
]
|
||||
assert output["found"]["items"] == [
|
||||
{"kind": "issue", "state": "open", "repository": "o/api", "number": 7, "title": "API ready"}
|
||||
]
|
||||
assert output["calls"] == ["api/v1/search?q=api&limit=20"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_work():
|
||||
html = await dashboard()
|
||||
assert 'id="create-issue-blocker-search" type="search"' in html
|
||||
assert 'id="create-issue-blocker-results" role="listbox"' in html
|
||||
assert 'id="create-issue-blocker-selected"' in html
|
||||
assert 'id="create-issue-blocker-status" class="small" aria-live="polite"' in html
|
||||
assert "issueCapture.searchBlockers(query)" in html
|
||||
assert "blockers: issueCaptureBlockers" in html
|
||||
assert "renderIssueCaptureBlockers(captureDraft.blockers || [])" in html
|
||||
assert "const hasBlockers = issueCaptureBlockers.length > 0;" in html
|
||||
assert ".create-issue-blocker-result" in html
|
||||
assert "min-height:44px" in html.split(".create-issue-blocker-result", 1)[1]
|
||||
assert "@media(max-width:320px)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -104,6 +104,23 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at
|
||||
assert output["stored"] == output["notes"]
|
||||
|
||||
|
||||
def test_unfiled_capture_restores_selected_blockers():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const captures=createUnfiledCaptures({{storage,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'blocked'}});
|
||||
const blockers=[{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}];
|
||||
const saved=captures.save({{title:'Blocked work',body:'Context',blockers}});
|
||||
const resumed=captures.resume(saved.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{listed:captures.list()[0],resumed}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["listed"]["blockerCount"] == 2
|
||||
assert output["resumed"]["blockers"] == [
|
||||
{"repository": "o/api", "number": 7, "title": "API ready"},
|
||||
{"repository": "o/web", "number": 8, "title": "Web ready"},
|
||||
]
|
||||
|
||||
|
||||
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
|
||||
script = f"""
|
||||
|
||||
Reference in new issue
Block a user