feat: explain mobile evidence screenshots (Closes #827)
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 13:41:02 +00:00
1 parent 807364c63b
commit af5fe9b69d
14 files changed
+243 -25

No files matched your search

+5 -3
View File
@@ -168,8 +168,8 @@ def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_com
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:'one.png',contentType:'image/png',data:'b25l',note:'First state'}},
{{filename:'two.png',contentType:'image/png',data:'dHdv',note:'Tap [Submit](unsafe)'}},
{{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
]}};
const calls=[];let twoAttempts=0;
@@ -195,7 +195,9 @@ const fetchJson=async(url,options={{}})=>{{
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
assert len(comments) == 1
assert comments[0]["body"]["body"].split("\n\n") == [
"![one.png](url/one.png)", "![two.png](url/two.png)", "![three.png](url/three.png)"
"**Screenshot 1 — First state**", "![one.png](url/one.png)",
"**Screenshot 2 — Tap \\[Submit\\]\\(unsafe\\)**", "![two.png](url/two.png)",
"![three.png](url/three.png)"
]
+89
View File
@@ -116,6 +116,37 @@ controller.move(0, 1);
)
def test_mobile_evidence_notes_follow_images_and_render_as_safe_ordered_captions():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{
maxFiles:5,
upload:async payload=>({{markdown:'!['+payload.filename+'](url/'+payload.filename+')'}}),
}});
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
controller.setNote(0, ' Login *token* is visible ');
controller.setNote(1, 'Keyboard hides [Submit](bad)');
controller.setNote(2, '');
controller.move(1, 0);
controller.remove(1);
(async()=>{{
const serialized=await controller.serialize();
const comment=await controller.prepareComment({{repository:'o/r',number:827}},'Evidence');
process.stdout.write(JSON.stringify({{serialized:serialized.map(x=>({{filename:x.filename,note:x.note||''}})),comment}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["serialized"] == [
{"filename": "two.png", "note": "Keyboard hides [Submit](bad)"},
{"filename": "three.png", "note": ""},
]
assert output["comment"] == (
"Evidence\n\n**Screenshot 1 — Keyboard hides \\[Submit\\]\\(bad\\)**\n\n"
"![two.png](url/two.png)\n\n![three.png](url/three.png)"
)
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
@@ -347,6 +378,55 @@ input.files=['one.png','two.png','three.png'].map(file);
assert output["status"] == "Screenshot removed. Your issue draft is unchanged."
def test_evidence_review_edits_the_active_note_and_keeps_it_visible_after_reorder():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element{{
constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};}}
addEventListener(type,fn){{this.listeners[type]=fn;}}
dispatch(type){{return this.listeners[type]({{target:this}});}}
appendChild(child){{this.children.push(child);return child;}}
replaceChildren(...children){{this.children=children;}}
setAttribute(name,value){{this.attributes[name]=String(value);}}
}}
const document={{createElement:tag=>new Element(tag)}};
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button'),note=new Element('textarea'),noteLabel=new Element('label');
input.multiple=true;
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,note,noteLabel,document,
createObjectURL:blob=>'blob:'+blob.name,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
input.files=['one.png','two.png','three.png'].map(file);
(async()=>{{
await input.dispatch('change');
await tray.children[1].dispatch('click');
note.value='Keyboard hides Submit';
await note.dispatch('input');
await earlier.dispatch('click');
const afterMove={{value:note.value,label:noteLabel.textContent,disabled:note.disabled}};
await tray.children[1].dispatch('click');
const other={{value:note.value,label:noteLabel.textContent}};
process.stdout.write(JSON.stringify({{afterMove,other,serialized:(await controller.serialize()).map(x=>[x.filename,x.note||''])}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {
"afterMove": {
"value": "Keyboard hides Submit",
"label": "Evidence note for screenshot 1 of 3 (optional)",
"disabled": False,
},
"other": {
"value": "",
"label": "Evidence note for screenshot 2 of 3 (optional)",
},
"serialized": [
["two.png", "Keyboard hides Submit"],
["one.png", ""],
["three.png", ""],
],
}
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
@@ -396,9 +476,14 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert 'aria-label="Evidence screenshots"' in html
assert 'id="move-create-issue-attachment-earlier"' in html
assert 'id="move-create-issue-attachment-later"' in html
assert 'id="create-issue-evidence-note-label"' in html
assert 'id="create-issue-evidence-note"' in html
assert 'maxlength="240"' in html
assert "tray: qs('#create-issue-evidence-tray')" in source
assert "earlier: qs('#move-create-issue-attachment-earlier')" in source
assert "later: qs('#move-create-issue-attachment-later')" in source
assert "note: qs('#create-issue-evidence-note')" in source
assert "noteLabel: qs('#create-issue-evidence-note-label')" in source
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachments:evidence" in source
assert "hydrated.attachments || hydrated.attachment" in source
@@ -406,6 +491,8 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert ".create-issue-attachment" in css
assert ".issue-evidence-tray" in css
assert ".issue-evidence-review-actions" in css
assert ".issue-evidence-note" in css
assert "resize:vertical" in css
assert "#create-issue-attachment-preview { grid-template-columns:1fr;" in css
assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css
assert "overflow-x:auto" in css
@@ -621,6 +708,8 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
assert "thumbnail tray" in readme
assert "Move earlier" in readme
assert "Remove selected" in readme
assert "Evidence note" in readme
assert "caption" in readme
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
+35 -1
View File
@@ -75,7 +75,7 @@ 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 image=name=>({{filename:name,contentType:'image/png',data:name,note:'Explain '+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;}}
@@ -86,9 +86,43 @@ process.stdout.write(JSON.stringify({{queued,list:outbox.list(),limit}}));
"one", "two", "three", "four", "five"
]
assert output["list"][0]["attachments"] == output["queued"]["attachments"]
assert [item["note"] for item in output["queued"]["attachments"]] == [
"Explain one", "Explain two", "Explain three", "Explain four", "Explain five"
]
assert output["limit"] == "You can attach up to 5 screenshots."
def test_foreground_issue_delivery_posts_the_same_captioned_evidence_bundle():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map(),calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const fetchJson=async(url,options={{}})=>{{
let body=null;
if(options.body instanceof FormData)body={{filename:options.body.get('file').name}};
else if(options.body)body=JSON.parse(options.body);
calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
if(url.endsWith('/issues'))return{{number:827}};
if(url.endsWith('/attachments'))return{{markdown:'!['+body.filename+'](url/'+body.filename+')'}};
return{{id:9}};
}};
let id=0;const outbox=createIssueOutbox({{storage,fetchJson,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++id)}});
const image=(name,note)=>({{filename:name,contentType:'image/png',data:'eA==',note}});
outbox.enqueue({{repository:'o/r',title:'Journey',attachments:[image('one.png','First'),image('two.png','Tap *Submit*')]}});
(async()=>{{const result=await outbox.flush('timmy');process.stdout.write(JSON.stringify({{result,calls}}));}})();
"""
output = run_node(script)
assert output["result"]["confirmed"][0]["number"] == 827
uploads = [call for call in output["calls"] if call["url"].endswith("/attachments")]
assert [call["body"]["filename"] for call in uploads] == ["one.png", "two.png"]
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
assert len(comments) == 1
assert comments[0]["body"]["body"] == (
"**Screenshot 1 — First**\n\n![one.png](url/one.png)\n\n"
"**Screenshot 2 — Tap \\*Submit\\***\n\n![two.png](url/two.png)"
)
def test_issue_outbox_replaces_evidence_bundle_when_editing_a_queued_issue():
script = f"""
+4 -3
View File
@@ -91,16 +91,17 @@ 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'}})}});
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}}),note:'Note '+name}});
(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)}}));}})();
process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.attachments.map(x=>x.filename),notes:resumed.attachments.map(x=>x.note),stored:blobs.get('bundle').attachments.map(x=>x.note)}}));}})();
"""
output = run_node(script)
assert output["listed"]["attachmentCount"] == 3
assert output["names"] == ["one", "two", "three"]
assert output["stored"] == output["names"]
assert output["notes"] == ["Note one", "Note two", "Note three"]
assert output["stored"] == output["notes"]