Explain each mobile evidence screenshot before filing #828

Merged
timmy merged 1 commits from timmy/827-evidence-screenshot-notes into main 2026-08-14 13:44:14 +00:00
14 changed files with 243 additions and 25 deletions

View File

@ -30,7 +30,9 @@ to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
bundle when the operator later chooses a repository. A scrollable thumbnail tray lets the operator
review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected**
change the durable evidence order without changing the issue title or note. The source Draft remains
change the durable evidence order without changing the issue title or note. The active screenshot's
optional **Evidence note** stays paired with that image through reorder, Draft restore, offline delivery,
and retry, then appears as a Markdown-safe caption immediately before its uploaded image. The source Draft remains
available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background

View File

@ -473,6 +473,13 @@ function createBackgroundIssueSync({
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function evidenceMarkdown(attachment, markdown, index) {
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
if (!note) return markdown;
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
}
async function deliverReplyRead(item) {
let current = item;
if (!current.replyConfirmed) {
@ -563,7 +570,8 @@ function createBackgroundIssueSync({
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
}));
}
const attachmentMarkdown = attachmentMarkdowns.join('\n\n');
const attachmentMarkdown = attachmentMarkdowns.map((markdown, index) =>
evidenceMarkdown(attachments[index], markdown, index)).join('\n\n');
await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',

View File

@ -630,6 +630,9 @@ textarea { resize: vertical; min-height: 120px; }
.issue-evidence-thumbnail[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.issue-evidence-thumbnail img { display:block; width:56px; height:56px; border-radius:6px; object-fit:cover; }
.issue-evidence-thumbnail span { position:absolute; right:3px; bottom:3px; min-width:20px; padding:1px 4px; border-radius:999px; background:#07101d; color:#fff; font-size:12px; text-align:center; }
.issue-evidence-note { display:grid; gap:6px; min-width:0; }
.issue-evidence-note textarea { box-sizing:border-box; width:100%; min-height:72px; padding:10px; resize:vertical; border:1px solid #2a496e; border-radius:8px; background:#07101d; color:#e5e7eb; font:inherit; }
.issue-evidence-note textarea:focus-visible { outline:2px solid #60a5fa; outline-offset:2px; }
.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
.create-issue-repository-more { min-height:44px; width:100%; }

View File

@ -482,6 +482,8 @@
tray: qs('#create-issue-evidence-tray'),
earlier: qs('#move-create-issue-attachment-earlier'),
later: qs('#move-create-issue-attachment-later'),
note: qs('#create-issue-evidence-note'),
noteLabel: qs('#create-issue-evidence-note-label'),
status: qs('#create-issue-attachment-status'),
readyMessage: 'Screenshot ready to file with this issue.',
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',

View File

@ -763,6 +763,10 @@
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span>
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
<label class="issue-evidence-note" for="create-issue-evidence-note">
<span id="create-issue-evidence-note-label">Evidence note (optional)</span>
<textarea id="create-issue-evidence-note" maxlength="240" rows="2" disabled placeholder="What does this screenshot show?"></textarea>
</label>
<div class="issue-evidence-review-actions" aria-label="Reorder selected screenshot">
<button id="move-create-issue-attachment-earlier" type="button">Move earlier</button>
<button id="remove-create-issue-attachment" type="button">Remove selected</button>

View File

@ -11,6 +11,14 @@
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function normalizeNote(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
}
function escapeMarkdown(value) {
return value.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
}
const optimizeImage = issueEvidenceReview.optimizeImage;
function multipart(attachment) {
@ -48,7 +56,7 @@
if (maxFiles === 1) selected = [];
else throw new Error(MAX_FILES_MESSAGE);
}
selected.push({file, confirmed:null, serialized:null, operationId:createOperationId()});
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
return state();
}
@ -95,6 +103,19 @@
return state();
}
function setNote(index, value) {
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selected[position].note = normalizeNote(value);
selected[position].serialized = null;
return state();
}
function note(index) {
const position = Number(index);
return Number.isInteger(position) && selected[position] ? selected[position].note : '';
}
function restore(value) {
if (Array.isArray(value)) {
clear();
@ -118,7 +139,12 @@
const padding = (data.match(/=*$/) || [''])[0].length;
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
selected[selected.length - 1].serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
const item = selected[selected.length - 1];
item.note = normalizeNote(value?.note);
item.serialized = {
...(blob ? { filename, contentType, blob } : { filename, contentType, data }),
...(item.note ? {note:item.note} : {}),
};
}
function state() {
@ -133,6 +159,7 @@
function serializeItem(item) {
if (!item.serialized) item.serialized = {
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
...(item.note ? {note:item.note} : {}),
};
return { ...item.serialized };
}
@ -163,13 +190,16 @@
throw new Error('The server did not confirm the screenshot upload.');
}
}
markdown.push(evidence.confirmed.markdown);
if (evidence.note) {
markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' +
evidence.confirmed.markdown);
} else markdown.push(evidence.confirmed.markdown);
}
const evidence = markdown.join('\n\n');
return text ? text + '\n\n' + evidence : evidence;
}
return { select, restore, remove, move, clear, state, serialize, prepareComment };
return { select, restore, remove, move, setNote, note, clear, state, serialize, prepareComment };
}
function mount(options) {

View File

@ -68,9 +68,20 @@
busy = Boolean(value);
options.earlier.disabled = busy || activeIndex <= 0;
options.later.disabled = busy || activeIndex >= count() - 1;
if (options.note) options.note.disabled = busy;
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
}
function updateNote(values) {
if (!options.note) return;
options.note.value = controller.note(activeIndex);
options.note.disabled = busy;
if (options.noteLabel) {
options.noteLabel.textContent = 'Evidence note for screenshot ' + (activeIndex + 1) + ' of ' +
values.length + ' (optional)';
}
}
function update(values, optimized = false) {
if (!values.length) return;
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
@ -86,6 +97,7 @@
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
options.earlier.disabled = busy || activeIndex === 0;
options.later.disabled = busy || activeIndex === values.length - 1;
updateNote(values);
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
values.length + ' screenshots ready to file in this order.';
}
@ -125,6 +137,11 @@
options.tray.hidden = true;
options.earlier.disabled = true;
options.later.disabled = true;
if (options.note) {
options.note.value = '';
options.note.disabled = true;
}
if (options.noteLabel) options.noteLabel.textContent = 'Evidence note (optional)';
}
function afterRemoval(removedIndex, previousCount) {
@ -145,6 +162,9 @@
options.earlier.addEventListener('click', () => move(-1));
options.later.addEventListener('click', () => move(1));
options.note?.addEventListener('input', event => {
controller.setNote(activeIndex, event.target.value);
});
clear();
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy };
}

View File

@ -11,10 +11,12 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const blob = value?.blob;
const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
if (blob) return { filename, contentType, blob };
if (!data && value?.stored === true) return { filename, contentType, stored: true };
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
const noteValue = note ? { note } : {};
if (blob) return { filename, contentType, blob, ...noteValue };
if (!data && value?.stored === true) return { filename, contentType, stored: true, ...noteValue };
if (!data) return undefined;
return { filename, contentType, data };
return { filename, contentType, data, ...noteValue };
}
function captureAttachments(values) {
@ -90,6 +92,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}} : {}),
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
filename:value.filename, contentType:value.contentType, stored:true,
...(value.note ? {note:value.note} : {}),
}))} : {}),
};
}
@ -247,6 +250,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function evidenceMarkdown(attachment, markdown, index) {
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
if (!note) return markdown;
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
@ -275,30 +285,42 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
if (item.attachment || item.attachments) persistDeliveryStage(item.id, { deliveredIssue: issue });
}
if (!item.attachment) return issue;
let markdown = item.attachmentMarkdown;
if (!markdown) {
const attachments = Array.isArray(item.attachments) ? item.attachments :
(item.attachment ? [item.attachment] : []);
if (!attachments.length) return issue;
const markdowns = Array.isArray(item.attachmentMarkdowns)
? item.attachmentMarkdowns.slice(0, attachments.length)
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
for (let index = markdowns.length; index < attachments.length; index += 1) {
const uploaded = await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
'Idempotency-Key': stageOperationId(
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
),
},
body: attachmentMultipart(item.attachment),
body: attachmentMultipart(attachments[index]),
},
);
markdown = String(uploaded?.markdown || '');
const markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
markdowns.push(markdown);
persistDeliveryStage(item.id, {
deliveredIssue: issue, attachmentMarkdowns:markdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
});
}
const markdown = markdowns.map((value, index) =>
evidenceMarkdown(attachments[index], value, index)).join('\n\n');
await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
{

View File

@ -86,6 +86,7 @@ function createUnfiledCaptures({
attachments:prepared.attachments.map(value => ({
filename:String(value.filename).slice(0, 255),
contentType:String(value.contentType), blob:value.blob,
...(String(value.note || '').trim() ? {note:String(value.note).trim().slice(0, 240)} : {}),
})),
} : {
filename:String(prepared.attachment.filename).slice(0, 255),

View File

@ -35,7 +35,7 @@ FEATURE_SOURCES = {
"static/assign-and-start.js", "static/queue-today.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
"static/search-batch-plan.js", "static/issue-evidence-review.js",
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-attachment.js",
),
}
CACHE_DECLARATION = re.compile(

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)"
]

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():

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"""

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"]