From af5fe9b69d6c9bc171eb0795487eacd2d11252aa Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 13:41:02 +0000 Subject: [PATCH] feat: explain mobile evidence screenshots (Closes #827) --- README.md | 4 +- frontend/background-issue-sync.js | 10 +++- frontend/dashboard.css | 3 + frontend/dashboard.js | 2 + frontend/index.html | 4 ++ frontend/issue-attachment.js | 38 ++++++++++-- frontend/issue-evidence-review.js | 20 +++++++ frontend/issue-outbox.js | 44 ++++++++++---- frontend/unfiled-captures.js | 1 + src/frontend_bundle.py | 2 +- tests/test_background_issue_sync.py | 8 ++- tests/test_issue_attachment_ui.py | 89 +++++++++++++++++++++++++++++ tests/test_issue_outbox.py | 36 +++++++++++- tests/test_unfiled_captures.py | 7 ++- 14 files changed, 243 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 6adf2bb..55bbbd4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 38f748b..b087a4e 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -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', diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 16cb1a5..e89b25b 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -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%; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 936b18e..a5e1f39 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -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.', diff --git a/frontend/index.html b/frontend/index.html index 7f24c75..f239176 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -763,6 +763,10 @@ Selected screenshot preview +
diff --git a/frontend/issue-attachment.js b/frontend/issue-attachment.js index 35a68df..63e77ef 100644 --- a/frontend/issue-attachment.js +++ b/frontend/issue-attachment.js @@ -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) { diff --git a/frontend/issue-evidence-review.js b/frontend/issue-evidence-review.js index c59367e..238c08a 100644 --- a/frontend/issue-evidence-review.js +++ b/frontend/issue-evidence-review.js @@ -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 }; } diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index 04e12e1..27dee03 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -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', { diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js index 34e2026..b6e3295 100644 --- a/frontend/unfiled-captures.js +++ b/frontend/unfiled-captures.js @@ -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), diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 549564e..3dfeb42 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -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( diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 7eddc78..aef1ad1 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -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)" ] diff --git a/tests/test_issue_attachment_ui.py b/tests/test_issue_attachment_ui.py index 172456a..8348f35 100644 --- a/tests/test_issue_attachment_ui.py +++ b/tests/test_issue_attachment_ui.py @@ -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(): diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 754f83b..08a1319 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -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""" diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py index 571c72c..fad8045 100644 --- a/tests/test_unfiled_captures.py +++ b/tests/test_unfiled_captures.py @@ -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"] -- 2.43.0