feat: capture mobile evidence bundles (Closes #823)
This commit is contained in:
1 parent
ebc3b9ee4d
commit
a1c56bd644
16 files changed
+493
-122
No files matched your search
@@ -164,6 +164,42 @@ const fetchJson=async(url,options={{}})=>{{
|
||||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Journey',body:'',labelIds:[],attachments:[
|
||||
{{filename:'one.png',contentType:'image/png',data:'b25l'}},
|
||||
{{filename:'two.png',contentType:'image/png',data:'dHdv'}},
|
||||
{{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
|
||||
]}};
|
||||
const calls=[];let twoAttempts=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'}};
|
||||
let filename=null;if(options.body instanceof FormData)filename=options.body.get('file').name;
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename,body:options.body instanceof FormData?null:JSON.parse(options.body)}});
|
||||
if(url.endsWith('/issues'))return{{number:823}};
|
||||
if(url.endsWith('/attachments')){{if(filename==='two.png' && twoAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}return{{markdown:''}};}}
|
||||
return{{id:9}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["first"] == "offline"
|
||||
uploads = [call for call in output["calls"] if call["filename"]]
|
||||
assert [call["filename"] for call in uploads] == ["one.png", "two.png", "two.png", "three.png"]
|
||||
assert [call["key"] for call in uploads] == [
|
||||
"bundle:attachment-0", "bundle:attachment-1", "bundle:attachment-1", "bundle:attachment-2"
|
||||
]
|
||||
assert output["checkpoint"]["attachmentMarkdowns"] == [""]
|
||||
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["body"]["body"].split("\n\n") == [
|
||||
"", "", ""
|
||||
]
|
||||
|
||||
|
||||
|
||||
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
@@ -633,22 +669,32 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
await store.reconcile([{{
|
||||
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
|
||||
}}]);
|
||||
}},{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
|
||||
{{filename:'a.png',contentType:'image/png',blob:new Blob(['a-bytes'],{{type:'image/png'}})}},
|
||||
{{filename:'b.png',contentType:'image/png',blob:new Blob(['b-bytes'],{{type:'image/png'}})}},
|
||||
]}}]);
|
||||
await store.reconcile([
|
||||
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
|
||||
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
|
||||
{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
|
||||
{{filename:'a.png',contentType:'image/png',stored:true}},
|
||||
{{filename:'b.png',contentType:'image/png',stored:true}},
|
||||
]}},
|
||||
]);
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
|
||||
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
|
||||
const byId=Object.fromEntries(snapshot.map(value=>[value.id,value]));
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:byId.first.attachment.blob instanceof Blob,
|
||||
text:await byId.first.attachment.blob?.text()}},second:byId.second.attachment.data,
|
||||
bundle:await Promise.all(byId.bundle.attachments.map(value=>value.blob?.text()))}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
|
||||
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
|
||||
assert output["bundle"] == ["a-bytes", "b-bytes"]
|
||||
|
||||
|
||||
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
|
||||
|
||||
@@ -58,6 +58,37 @@ controller.select(file);
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_evidence_bundle_keeps_order_limits_selection_and_uploads_each_image_once():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
let sequence=0; const calls=[];
|
||||
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
const controller=attachment.create({{
|
||||
maxFiles:5,
|
||||
createOperationId:()=> 'evidence-' + (++sequence),
|
||||
upload:async payload=>{{calls.push([payload.filename,payload.operation_id]);return {{markdown:''}};}},
|
||||
}});
|
||||
for(const name of ['one.png','two.png','three.png','four.png','five.png']) controller.select(image(name));
|
||||
let limit='';try{{controller.select(image('six.png'));}}catch(error){{limit=error.message;}}
|
||||
controller.remove(1);
|
||||
controller.select(image('replacement.png'));
|
||||
(async()=>{{
|
||||
const serialized=await controller.serialize();
|
||||
const comment=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
|
||||
const second=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
|
||||
process.stdout.write(JSON.stringify({{limit,state:controller.state(),names:serialized.map(x=>x.filename),comment,second,calls}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["limit"] == "Up to 5 screenshots. Remove one before adding another."
|
||||
assert output["names"] == ["one.png", "three.png", "four.png", "five.png", "replacement.png"]
|
||||
assert [item["name"] for item in output["state"]] == output["names"]
|
||||
assert output["comment"] == output["second"]
|
||||
assert output["comment"].startswith("Evidence sequence\n\n![one.png]")
|
||||
assert len(output["calls"]) == 5
|
||||
assert len({key for _, key in output["calls"]}) == 5
|
||||
|
||||
|
||||
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
@@ -224,6 +255,24 @@ controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0K
|
||||
assert output["removed"] is None
|
||||
|
||||
|
||||
def test_attachment_view_restores_ordered_evidence_bundle_preview():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();input.multiple=true;
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,createObjectURL:blob=>'blob:'+blob.size,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
const value=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
const restored=controller.restore([value('one.png'),value('two.png')]);
|
||||
process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textContent,hidden:preview.hidden}}));
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert [item["name"] for item in output["restored"]] == ["one.png", "two.png"]
|
||||
assert output["src"] == "blob:7"
|
||||
assert output["meta"] == "2 screenshots ready · latest: two.png"
|
||||
assert output["hidden"] is False
|
||||
|
||||
|
||||
|
||||
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
@@ -264,11 +313,13 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
||||
|
||||
assert 'id="create-issue-attachment"' in html
|
||||
assert 'accept="image/png,image/jpeg,image/webp"' in html
|
||||
assert 'multiple' in html
|
||||
assert 'Up to 5' in html
|
||||
assert 'id="create-issue-attachment-preview"' in html
|
||||
assert 'id="remove-create-issue-attachment"' in html
|
||||
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
|
||||
assert "attachment: await createIssueAttachmentController.serialize()" in source
|
||||
assert "createIssueAttachmentController.restore(hydrated.attachment);" in source
|
||||
assert "attachments:evidence" in source
|
||||
assert "hydrated.attachments || hydrated.attachment" in source
|
||||
assert "createIssueAttachmentController.clear();" in source
|
||||
assert ".create-issue-attachment" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -71,6 +71,40 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
||||
assert output["stored"]["version"] == 3
|
||||
|
||||
|
||||
def test_issue_outbox_preserves_bounded_ordered_evidence_bundle():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const image=name=>({{filename:name,contentType:'image/png',data:name}});
|
||||
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle'}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:['one','two','three','four','five'].map(image)}});
|
||||
let limit='';try{{outbox.enqueue({{repository:'o/r',title:'Too many',attachments:['1','2','3','4','5','6'].map(image)}});}}catch(error){{limit=error.message;}}
|
||||
process.stdout.write(JSON.stringify({{queued,list:outbox.list(),limit}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert [item["filename"] for item in output["queued"]["attachments"]] == [
|
||||
"one", "two", "three", "four", "five"
|
||||
]
|
||||
assert output["list"][0]["attachments"] == output["queued"]["attachments"]
|
||||
assert output["limit"] == "You can attach up to 5 screenshots."
|
||||
|
||||
|
||||
|
||||
def test_issue_outbox_replaces_evidence_bundle_when_editing_a_queued_issue():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};const image=name=>({{filename:name,contentType:'image/png',data:name}});
|
||||
let sequence=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++sequence)}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:[image('old-one'),image('old-two')]}});
|
||||
const updated=outbox.update(queued.id,{{...queued,attachments:[image('new-one'),image('new-two'),image('new-three')]}});
|
||||
process.stdout.write(JSON.stringify(updated));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert [item["filename"] for item in output["attachments"]] == ["new-one", "new-two", "new-three"]
|
||||
assert output["operationId"] != output["id"]
|
||||
|
||||
|
||||
|
||||
def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -94,6 +128,27 @@ process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
|
||||
}
|
||||
|
||||
|
||||
def test_issue_outbox_hydrates_durable_evidence_bundle_without_copying_blobs_to_localstorage():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
let durable=null;
|
||||
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle',backgroundSync:{{
|
||||
reconcile:async items=>{{durable=items[0];}},requestSync:async()=>{{}},get:async()=>durable,
|
||||
}}}});
|
||||
(async()=>{{const admitted=await outbox.enqueueDurably({{repository:'o/r',title:'Journey',attachments:['one','two'].map(image)}});
|
||||
const raw=values.get('stackchain.issue-outbox.v1');const hydrated=await outbox.hydrateForEdit(admitted.item.id);
|
||||
process.stdout.write(JSON.stringify({{raw,names:hydrated.attachments.map(x=>x.filename),sizes:hydrated.attachments.map(x=>x.blob.size)}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert '"stored":true' in output["raw"]
|
||||
assert '"blob"' not in output["raw"]
|
||||
assert output["names"] == ["one", "two"]
|
||||
assert output["sizes"] == [3, 3]
|
||||
|
||||
|
||||
|
||||
def test_issue_outbox_hydrates_a_metadata_only_screenshot_for_edit_without_copying_bytes_to_localstorage():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -247,7 +247,7 @@ def test_image_share_target_stages_one_supported_image_and_redirects_to_capture(
|
||||
}
|
||||
|
||||
|
||||
def test_image_share_target_rejects_multiple_images_without_staging_private_data():
|
||||
def test_image_share_target_stages_ordered_evidence_bundle():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const form = new FormData();
|
||||
@@ -256,16 +256,19 @@ def test_image_share_target_rejects_multiple_images_without_staging_private_data
|
||||
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
|
||||
Object.defineProperty(request, 'mode', {value:'navigate'});
|
||||
const response = await dispatch('fetch', request);
|
||||
const record=state.sharedRecords['shared-image'];
|
||||
process.stdout.write(JSON.stringify({
|
||||
status:response.status, location:response.headers.get('Location'), records:Object.keys(state.sharedRecords),
|
||||
status:response.status, location:response.headers.get('Location'),
|
||||
names:record.attachments.map(value=>value.filename),sizes:record.attachments.map(value=>value.blob.size),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": 303,
|
||||
"location": "/dashboard/?launch=new&shared=multiple",
|
||||
"records": [],
|
||||
"location": "/dashboard/?launch=new&shared=images",
|
||||
"names": ["one.png", "two.jpg"],
|
||||
"sizes": [3, 3],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,23 @@ shared.consume({{
|
||||
}
|
||||
|
||||
|
||||
def test_shared_evidence_bundle_is_consumed_once_in_order():
|
||||
script = f"""
|
||||
const shared=require({json.dumps(str(MODULE))});const calls=[];
|
||||
const store={{get:async()=>({{attachments:[
|
||||
{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
|
||||
{{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two'],{{type:'image/jpeg'}})}},
|
||||
]}}),delete:async id=>calls.push(['delete',id])}};
|
||||
(async()=>{{const result=await shared.consume({{marker:'images',store,restore:value=>calls.push(['restore',value.map(x=>x.filename)]),status:value=>calls.push(['status',value])}});process.stdout.write(JSON.stringify({{result,calls}}));}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
|
||||
assert output == {"result": True, "calls": [
|
||||
["restore", ["one.png", "two.jpg"]], ["delete", "shared-image"],
|
||||
["status", "2 shared screenshots ready to file with this issue."],
|
||||
]}
|
||||
|
||||
|
||||
|
||||
def test_invalid_shared_image_reports_error_without_touching_open_capture():
|
||||
script = f"""
|
||||
const shared = require({json.dumps(str(MODULE))});
|
||||
|
||||
@@ -85,6 +85,25 @@ process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement
|
||||
assert output["restored"][0]["ownerLogin"] == "timmy"
|
||||
|
||||
|
||||
def test_unfiled_capture_restores_ordered_evidence_bundle():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
const values=new Map(),blobs=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
|
||||
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
(async()=>{{const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'bundle'}});
|
||||
const saved=await captures.save({{title:'Journey',body:'Steps',attachments:['one','two','three'].map(image)}});
|
||||
const resumed=await captures.resume(saved.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.attachments.map(x=>x.filename),stored:blobs.get('bundle').attachments.map(x=>x.filename)}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["listed"]["attachmentCount"] == 3
|
||||
assert output["names"] == ["one", "two", "three"]
|
||||
assert output["stored"] == output["names"]
|
||||
|
||||
|
||||
|
||||
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
|
||||
Reference in new issue
Block a user