feat: explain mobile evidence screenshots (Closes #827)
All checks were successful
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 2026-08-14 13:41:02 +00:00
parent 807364c63b
commit af5fe9b69d
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 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 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** 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. 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 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 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; 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) { async function deliverReplyRead(item) {
let current = item; let current = item;
if (!current.replyConfirmed) { if (!current.replyConfirmed) {
@ -563,7 +570,8 @@ function createBackgroundIssueSync({
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}), ...(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( await requestStage(
item, item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments', 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[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 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-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 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.issue-evidence-review-actions button { min-width:44px; min-height:44px; } .issue-evidence-review-actions button { min-width:44px; min-height:44px; }
.create-issue-repository-more { min-height:44px; width:100%; } .create-issue-repository-more { min-height:44px; width:100%; }

View File

@ -482,6 +482,8 @@
tray: qs('#create-issue-evidence-tray'), tray: qs('#create-issue-evidence-tray'),
earlier: qs('#move-create-issue-attachment-earlier'), earlier: qs('#move-create-issue-attachment-earlier'),
later: qs('#move-create-issue-attachment-later'), 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'), status: qs('#create-issue-attachment-status'),
readyMessage: 'Screenshot ready to file with this issue.', readyMessage: 'Screenshot ready to file with this issue.',
removedMessage: 'Screenshot removed. Your issue draft is unchanged.', removedMessage: 'Screenshot removed. Your issue draft is unchanged.',

View File

@ -763,6 +763,10 @@
<img id="create-issue-attachment-image" alt="Selected screenshot preview" /> <img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span> <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> <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"> <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="move-create-issue-attachment-earlier" type="button">Move earlier</button>
<button id="remove-create-issue-attachment" type="button">Remove selected</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 MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); 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; const optimizeImage = issueEvidenceReview.optimizeImage;
function multipart(attachment) { function multipart(attachment) {
@ -48,7 +56,7 @@
if (maxFiles === 1) selected = []; if (maxFiles === 1) selected = [];
else throw new Error(MAX_FILES_MESSAGE); 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(); return state();
} }
@ -95,6 +103,19 @@
return state(); 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) { function restore(value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
clear(); clear();
@ -118,7 +139,12 @@
const padding = (data.match(/=*$/) || [''])[0].length; const padding = (data.match(/=*$/) || [''])[0].length;
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding); const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) }); 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() { function state() {
@ -133,6 +159,7 @@
function serializeItem(item) { function serializeItem(item) {
if (!item.serialized) item.serialized = { if (!item.serialized) item.serialized = {
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file, filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
...(item.note ? {note:item.note} : {}),
}; };
return { ...item.serialized }; return { ...item.serialized };
} }
@ -163,13 +190,16 @@
throw new Error('The server did not confirm the screenshot upload.'); 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'); const evidence = markdown.join('\n\n');
return text ? text + '\n\n' + evidence : evidence; 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) { function mount(options) {

View File

@ -68,9 +68,20 @@
busy = Boolean(value); busy = Boolean(value);
options.earlier.disabled = busy || activeIndex <= 0; options.earlier.disabled = busy || activeIndex <= 0;
options.later.disabled = busy || activeIndex >= count() - 1; options.later.disabled = busy || activeIndex >= count() - 1;
if (options.note) options.note.disabled = busy;
Array.from(options.tray.children || []).forEach(button => { button.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) { function update(values, optimized = false) {
if (!values.length) return; if (!values.length) return;
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1)); activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
@ -86,6 +97,7 @@
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false')); button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
options.earlier.disabled = busy || activeIndex === 0; options.earlier.disabled = busy || activeIndex === 0;
options.later.disabled = busy || activeIndex === values.length - 1; options.later.disabled = busy || activeIndex === values.length - 1;
updateNote(values);
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' : options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
values.length + ' screenshots ready to file in this order.'; values.length + ' screenshots ready to file in this order.';
} }
@ -125,6 +137,11 @@
options.tray.hidden = true; options.tray.hidden = true;
options.earlier.disabled = true; options.earlier.disabled = true;
options.later.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) { function afterRemoval(removedIndex, previousCount) {
@ -145,6 +162,9 @@
options.earlier.addEventListener('click', () => move(-1)); options.earlier.addEventListener('click', () => move(-1));
options.later.addEventListener('click', () => move(1)); options.later.addEventListener('click', () => move(1));
options.note?.addEventListener('input', event => {
controller.setNote(activeIndex, event.target.value);
});
clear(); clear();
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy }; 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 blob = value?.blob;
const data = String(value?.data || ''); const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined; if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
if (blob) return { filename, contentType, blob }; const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
if (!data && value?.stored === true) return { filename, contentType, stored: true }; 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; if (!data) return undefined;
return { filename, contentType, data }; return { filename, contentType, data, ...noteValue };
} }
function captureAttachments(values) { function captureAttachments(values) {
@ -90,6 +92,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}} : {}), }} : {}),
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({ ...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
filename:value.filename, contentType:value.contentType, stored:true, 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; 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) { function attachmentMultipart(attachment) {
let blob = attachment?.blob; let blob = attachment?.blob;
if (!blob && attachment?.data) { if (!blob && attachment?.data) {
@ -275,30 +285,42 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), ...(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; const attachments = Array.isArray(item.attachments) ? item.attachments :
let markdown = item.attachmentMarkdown; (item.attachment ? [item.attachment] : []);
if (!markdown) { 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( const uploaded = await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments', 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
{ {
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', 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) { if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.'); const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422; error.status = 422;
throw error; 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( await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments', 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
{ {

View File

@ -86,6 +86,7 @@ function createUnfiledCaptures({
attachments:prepared.attachments.map(value => ({ attachments:prepared.attachments.map(value => ({
filename:String(value.filename).slice(0, 255), filename:String(value.filename).slice(0, 255),
contentType:String(value.contentType), blob:value.blob, 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), 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/assign-and-start.js", "static/queue-today.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.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/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( 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""" script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))}); 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:[ 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:'one.png',contentType:'image/png',data:'b25l',note:'First state'}},
{{filename:'two.png',contentType:'image/png',data:'dHdv'}}, {{filename:'two.png',contentType:'image/png',data:'dHdv',note:'Tap [Submit](unsafe)'}},
{{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}}, {{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
]}}; ]}};
const calls=[];let twoAttempts=0; 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")] comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
assert len(comments) == 1 assert len(comments) == 1
assert comments[0]["body"]["body"].split("\n\n") == [ 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(): def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
script = f""" script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))}); 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." 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(): def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
script = f""" script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))}); 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 'aria-label="Evidence screenshots"' in html
assert 'id="move-create-issue-attachment-earlier"' in html assert 'id="move-create-issue-attachment-earlier"' in html
assert 'id="move-create-issue-attachment-later"' 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 "tray: qs('#create-issue-evidence-tray')" in source
assert "earlier: qs('#move-create-issue-attachment-earlier')" in source assert "earlier: qs('#move-create-issue-attachment-earlier')" in source
assert "later: qs('#move-create-issue-attachment-later')" 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 "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachments:evidence" in source assert "attachments:evidence" in source
assert "hydrated.attachments || hydrated.attachment" 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 ".create-issue-attachment" in css
assert ".issue-evidence-tray" in css assert ".issue-evidence-tray" in css
assert ".issue-evidence-review-actions" 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-preview { grid-template-columns:1fr;" in css
assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css
assert "overflow-x:auto" 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 "thumbnail tray" in readme
assert "Move earlier" in readme assert "Move earlier" in readme
assert "Remove selected" 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(): 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""" script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))}); 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 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 outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle'}});
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:['one','two','three','four','five'].map(image)}}); 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;}} 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" "one", "two", "three", "four", "five"
] ]
assert output["list"][0]["attachments"] == output["queued"]["attachments"] 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." 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(): def test_issue_outbox_replaces_evidence_bundle_when_editing_a_queued_issue():
script = f""" script = f"""

View File

@ -91,16 +91,17 @@ const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map(),blobs=new Map(); const values=new Map(),blobs=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; 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 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'}}); (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 saved=await captures.save({{title:'Journey',body:'Steps',attachments:['one','two','three'].map(image)}});
const resumed=await captures.resume(saved.id,'timmy'); 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) output = run_node(script)
assert output["listed"]["attachmentCount"] == 3 assert output["listed"]["attachmentCount"] == 3
assert output["names"] == ["one", "two", "three"] 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"]