diff --git a/frontend/issue-attachment.js b/frontend/issue-attachment.js
index 4b98e2a..bb161fa 100644
--- a/frontend/issue-attachment.js
+++ b/frontend/issue-attachment.js
@@ -6,6 +6,8 @@
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
+ const MAX_FILES = 5;
+ 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 namedBlob(blob, name) {
@@ -65,6 +67,7 @@
function create(options) {
const upload = options.upload;
+ const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1;
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
const createOperationId = options.createOperationId || (() => {
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
@@ -72,10 +75,7 @@
}
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
});
- let selected = null;
- let confirmed = null;
- let serialized = null;
- let operationId = null;
+ let selected = [];
let selectionGeneration = 0;
function commitSelection(file) {
@@ -83,14 +83,18 @@
file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
- selected = file;
- confirmed = null;
- serialized = null;
- operationId = createOperationId();
+ if (selected.length >= maxFiles) {
+ if (maxFiles === 1) selected = [];
+ else throw new Error(MAX_FILES_MESSAGE);
+ }
+ selected.push({file, confirmed:null, serialized:null, operationId:createOperationId()});
return state();
}
function select(file) {
+ if (selected.length >= maxFiles && maxFiles > 1) {
+ throw new Error(MAX_FILES_MESSAGE);
+ }
const generation = ++selectionGeneration;
if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
@@ -109,75 +113,100 @@
function clear() {
selectionGeneration += 1;
- selected = null;
- confirmed = null;
- serialized = null;
- operationId = null;
+ selected = [];
+ }
+
+ function remove(index) {
+ const position = Number(index);
+ if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
+ selectionGeneration += 1;
+ selected.splice(position, 1);
+ return state();
}
function restore(value) {
+ if (Array.isArray(value)) {
+ clear();
+ if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
+ value.forEach(attachment => restoreOne(attachment));
+ return state();
+ }
+ clear();
+ restoreOne(value);
+ return state();
+ }
+
+ function restoreOne(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '');
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) {
- clear();
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const padding = (data.match(/=*$/) || [''])[0].length;
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
- select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
- serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
- return state();
+ commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
+ selected[selected.length - 1].serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
}
function state() {
- return selected ? {
- name: selected.name,
- size: selected.size,
- uploaded: Boolean(confirmed),
- } : null;
+ const values = selected.map(item => ({
+ name: item.file.name,
+ size: item.file.size,
+ uploaded: Boolean(item.confirmed),
+ }));
+ return values.length > 1 ? values : (values[0] || null);
+ }
+
+ function serializeItem(item) {
+ if (!item.serialized) item.serialized = {
+ filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
+ };
+ return { ...item.serialized };
}
async function serialize() {
- if (!selected) return null;
- if (!serialized) {
- serialized = {
- filename: selected.name,
- contentType: selected.type,
- blob: selected.blob || selected,
- };
- }
- return { ...serialized };
+ if (!selected.length) return null;
+ const values = selected.map(serializeItem);
+ return values.length > 1 ? values : values[0];
}
- async function prepareComment(item, body) {
+ async function prepareComment(target, body) {
const text = String(body || '').trim();
- if (!selected) return text;
- if (!confirmed) {
- const attachment = await serialize();
- confirmed = await upload({
- repository: item.repository,
- number: item.number,
- filename: attachment.filename,
- content_type: attachment.contentType,
- ...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
- operation_id: operationId,
- });
- if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
- confirmed = null;
- throw new Error('The server did not confirm the screenshot upload.');
+ if (!selected.length) return text;
+ const markdown = [];
+ for (const evidence of selected) {
+ if (!evidence.confirmed) {
+ const attachment = serializeItem(evidence);
+ evidence.confirmed = await upload({
+ repository: target.repository,
+ number: target.number,
+ filename: attachment.filename,
+ content_type: attachment.contentType,
+ ...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
+ operation_id: evidence.operationId,
+ });
+ if (!evidence.confirmed || typeof evidence.confirmed.markdown !== 'string' || !evidence.confirmed.markdown) {
+ evidence.confirmed = null;
+ throw new Error('The server did not confirm the screenshot upload.');
+ }
}
+ markdown.push(evidence.confirmed.markdown);
}
- return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
+ const evidence = markdown.join('\n\n');
+ return text ? text + '\n\n' + evidence : evidence;
}
- return { select, restore, clear, state, serialize, prepareComment };
+ return { select, restore, remove, clear, state, serialize, prepareComment };
}
function mount(options) {
- const controller = create(options);
+ const controller = create({
+ ...options, maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
+ });
const clearSelection = controller.clear;
+ const restoreSelection = controller.restore;
let previewUrl = '';
let selectionSequence = 0;
@@ -208,24 +237,43 @@
}
options.input.addEventListener('change', event => {
- const file = event.target.files && event.target.files[0];
+ const files = Array.from(event.target.files || []);
const sequence = ++selectionSequence;
- let result;
- try {
- result = controller.select(file);
- } catch (error) {
+ if (!files.length) return;
+ let first;
+ try { first = controller.select(files[0]); }
+ catch (error) {
options.status.textContent = error.message;
options.input.value = '';
return;
}
- if (!result || typeof result.then !== 'function') {
- showPreview(file, false);
+ if (files.length === 1 && (!first || typeof first.then !== 'function')) {
+ showPreview(files[0], false);
return;
}
- options.status.textContent = 'Optimizing screenshot…';
- options.input.disabled = true;
- return result.then(() => controller.serialize()).then(value => {
- if (sequence === selectionSequence) showPreview(value.blob, true);
+ let optimized = Boolean(first && typeof first.then === 'function');
+ if (optimized) {
+ options.status.textContent = 'Optimizing screenshot…';
+ options.input.disabled = true;
+ }
+ const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => {
+ const result = controller.select(file);
+ if (result && typeof result.then === 'function') {
+ optimized = true;
+ options.status.textContent = 'Optimizing screenshot…';
+ options.input.disabled = true;
+ await result;
+ }
+ }), Promise.resolve(first));
+ return selectAll.then(() => controller.serialize()).then(value => {
+ if (sequence !== selectionSequence) return;
+ const values = Array.isArray(value) ? value : [value];
+ const latest = values[values.length - 1];
+ showPreview(latest.blob, optimized);
+ if (values.length > 1) {
+ options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
+ options.status.textContent = values.length + ' screenshots ready to file in this order.';
+ }
}).catch(error => {
if (sequence === selectionSequence) {
options.status.textContent = error.message;
@@ -236,19 +284,40 @@
});
});
options.remove.addEventListener('click', () => {
- clearPreview();
- options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
+ const current = controller.state();
+ const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
+ if (count <= 1) {
+ clearPreview();
+ } else {
+ controller.remove(count - 1);
+ selectionSequence += 1;
+ controller.serialize().then(value => {
+ const values = Array.isArray(value) ? value : [value];
+ const latest = values[values.length - 1];
+ showPreview(latest.blob, false);
+ options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
+ });
+ }
+ options.status.textContent = options.removedMessage || (count > 1 ?
+ 'Latest screenshot removed. Your text is unchanged.' :
+ 'Screenshot removed. Your comment is unchanged.');
});
function restorePreview(value) {
clearPreview();
- const restored = controller.restore(value);
- previewUrl = value.blob ? options.createObjectURL(value.blob) :
- 'data:' + value.contentType + ';base64,' + value.data;
+ const restored = restoreSelection(value);
+ const values = Array.isArray(value) ? value : [value];
+ const latest = values[values.length - 1];
+ previewUrl = latest.blob ? options.createObjectURL(latest.blob) :
+ 'data:' + latest.contentType + ';base64,' + latest.data;
options.image.src = previewUrl;
- options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
+ options.meta.textContent = values.length > 1 ?
+ values.length + ' screenshots ready · latest: ' + latest.filename :
+ restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.preview.hidden = false;
- options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
+ options.status.textContent = values.length > 1 ?
+ values.length + ' screenshots ready to file in this order.' :
+ (options.readyMessage || 'Screenshot ready to upload with this comment.');
return restored;
}
diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js
index 54782fb..04e12e1 100644
--- a/frontend/issue-outbox.js
+++ b/frontend/issue-outbox.js
@@ -17,6 +17,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return { filename, contentType, data };
}
+ function captureAttachments(values) {
+ if (!Array.isArray(values)) return undefined;
+ if (values.length > 5) throw new Error('You can attach up to 5 screenshots.');
+ const attachments = values.map(captureAttachment);
+ if (attachments.some(value => !value)) {
+ throw new Error('Some screenshots are unavailable. Choose them again before queueing.');
+ }
+ return attachments.length ? attachments : undefined;
+ }
+
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
@@ -56,6 +66,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
+ const attachments = captureAttachments(draft?.attachments);
+ if (attachments) item.attachments = attachments;
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
@@ -65,14 +77,20 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
function localIndexItem(item) {
- if (!item?.attachment?.data && !item?.attachment?.blob) return item;
+ const hasAttachmentBytes = item?.attachment?.data || item?.attachment?.blob;
+ const hasBundleBytes = Array.isArray(item?.attachments) &&
+ item.attachments.some(value => value?.data || value?.blob);
+ if (!hasAttachmentBytes && !hasBundleBytes) return item;
return {
...item,
- attachment: {
+ ...(hasAttachmentBytes ? {attachment: {
filename: item.attachment.filename,
contentType: item.attachment.contentType,
stored: true,
- },
+ }} : {}),
+ ...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
+ filename:value.filename, contentType:value.contentType, stored:true,
+ }))} : {}),
};
}
@@ -120,16 +138,25 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
async function hydrateForEdit(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return null;
- if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
+ const storedBundle = Array.isArray(item.attachments) && item.attachments.some(value => value?.stored);
+ const storedAttachment = item.attachment?.stored && !item.attachment.data && !item.attachment.blob;
+ if (!storedAttachment && !storedBundle) return { ...item };
if (!backgroundSync?.get) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const durable = await backgroundSync.get(id);
const attachment = captureAttachment(durable?.attachment);
- if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
+ const attachments = captureAttachments(durable?.attachments);
+ if (durable?.operationId !== item.operationId ||
+ (storedAttachment && (!attachment?.data && !attachment?.blob)) ||
+ (storedBundle && (!attachments || attachments.length !== item.attachments.length))) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
- return { ...item, attachment };
+ return {
+ ...item,
+ ...(attachment ? {attachment} : {}),
+ ...(attachments ? {attachments} : {}),
+ };
}
function prepareUpdate(id, draft) {
@@ -145,7 +172,9 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
? String(draft.dueDate) : undefined;
const nextAttachment = captureAttachment(draft?.attachment);
- const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
+ const nextAttachments = captureAttachments(draft?.attachments);
+ const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null) ||
+ JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
@@ -154,7 +183,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
...item,
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
- milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
+ milestoneId: nextMilestoneId, dueDate: nextDueDate,
+ attachment: nextAttachment, attachments:nextAttachments,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
@@ -163,7 +193,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
if (nextAttachment === undefined) delete updated.attachment;
- if (attachmentChanged) delete updated.attachmentMarkdown;
+ if (nextAttachments === undefined) delete updated.attachments;
+ if (attachmentChanged) {
+ delete updated.attachmentMarkdown;
+ delete updated.attachmentMarkdowns;
+ }
delete updated.error;
delete updated.deliveryState;
return updated;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 59a6e45..3287d8a 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -115,17 +115,28 @@ async function acceptSharedContent(request) {
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
let marker = '';
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
- if (images.length > 1) marker = 'multiple';
- else if (images.length === 1) {
- const image = images[0];
- if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
+ if (images.length > 5) marker = 'multiple';
+ else if (images.length > 0) {
+ const supported = images.every(image =>
+ SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
+ );
+ if (!supported) {
marker = 'unsupported';
- } else {
+ } else if (images.length === 1) {
+ const image = images[0];
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
filename:String(image.name || 'shared-screenshot').slice(0, 255),
contentType:String(image.type), blob:image,
});
marker = 'image';
+ } else {
+ await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
+ attachments:images.map(image => ({
+ filename:String(image.name || 'shared-screenshot').slice(0, 255),
+ contentType:String(image.type), blob:image,
+ })),
+ });
+ marker = 'images';
}
}
const target = new URL(BASE, self.location.origin);
diff --git a/frontend/shared-image-capture.js b/frontend/shared-image-capture.js
index 1b4f2b6..7c1b3b1 100644
--- a/frontend/shared-image-capture.js
+++ b/frontend/shared-image-capture.js
@@ -10,11 +10,23 @@
async function consume({marker, store, restore, status = () => {}}) {
if (!marker) return false;
- if (marker !== 'image') {
+ if (!['image', 'images'].includes(marker)) {
status('Share one PNG, JPEG, or WebP screenshot.');
return false;
}
const value = await store?.get(RECORD_ID);
+ const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
+ if (marker === 'images') {
+ if (!attachments?.length || attachments.length > 5 || attachments.some(attachment =>
+ !attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) {
+ status('The shared screenshots are unavailable. Share them again.');
+ return false;
+ }
+ restore(attachments);
+ await store.delete(RECORD_ID);
+ status(attachments.length + ' shared screenshots ready to file with this issue.');
+ return true;
+ }
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
status('The shared screenshot is unavailable. Share it again.');
return false;
diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js
index af780e2..34e2026 100644
--- a/frontend/unfiled-captures.js
+++ b/frontend/unfiled-captures.js
@@ -42,19 +42,35 @@ function createUnfiledCaptures({
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const attachment = note?.attachment;
+ const attachments = note?.attachments;
+ if (Array.isArray(attachments) && attachments.length > 5) {
+ throw new Error('You can attach up to 5 screenshots.');
+ }
+ const validAttachments = Array.isArray(attachments) && attachments.length > 0 &&
+ attachments.every(value => value?.blob && value?.filename &&
+ ['image/png', 'image/jpeg', 'image/webp'].includes(String(value?.contentType || '')));
+ if (attachments && !validAttachments) {
+ throw new Error('One or more screenshots are unavailable. Choose them again before saving.');
+ }
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
- if (hasAttachment && !attachmentStore) {
+ if ((hasAttachment || validAttachments) && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
- return {title, body, ownerLogin, attachment, hasAttachment};
+ return {
+ title, body, ownerLogin, attachment, attachments,
+ hasAttachment:hasAttachment || validAttachments,
+ attachmentCount:validAttachments ? attachments.length : (hasAttachment ? 1 : 0),
+ };
}
function persist(prepared, existing, removed = null) {
const item = {
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
- savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
+ savedAt:Number(now()), ...(prepared.hasAttachment ? {
+ hasAttachment:true, attachmentCount:prepared.attachmentCount,
+ } : {}),
};
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
const writeItems = () => {
@@ -66,7 +82,12 @@ function createUnfiledCaptures({
return item;
};
const stage = prepared.hasAttachment
- ? Promise.resolve(attachmentStore.put(item.id, {
+ ? Promise.resolve(attachmentStore.put(item.id, prepared.attachments ? {
+ attachments:prepared.attachments.map(value => ({
+ filename:String(value.filename).slice(0, 255),
+ contentType:String(value.contentType), blob:value.blob,
+ })),
+ } : {
filename:String(prepared.attachment.filename).slice(0, 255),
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
}))
@@ -137,6 +158,13 @@ function createUnfiledCaptures({
if (!item.hasAttachment) return draft;
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
+ if (Array.isArray(attachment?.attachments) && attachment.attachments.length) {
+ if (attachment.attachments.length > 5 || attachment.attachments.some(value =>
+ !value?.blob || !value?.filename || !value?.contentType)) {
+ throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
+ }
+ return {...draft, attachments:attachment.attachments};
+ }
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
}
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 0d240e7..f6a6771 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -22,7 +22,9 @@ COMMONJS_BROWSER_BRANCH = re.compile(
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
- "issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
+ "issue-capture": (
+ "static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
+ ),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py
index 108a039..7eddc78 100644
--- a/tests/test_background_issue_sync.py
+++ b/tests/test_background_issue_sync.py
@@ -164,6 +164,42 @@ const fetchJson=async(url,options={{}})=>{{
assert output["second"]["confirmed"][0]["number"] == 469
+def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment():
+ script = f"""
+const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
+let item={{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Journey',body:'',labelIds:[],attachments:[
+ {{filename:'one.png',contentType:'image/png',data:'b25l'}},
+ {{filename:'two.png',contentType:'image/png',data:'dHdv'}},
+ {{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
+]}};
+const calls=[];let twoAttempts=0;
+const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
+const fetchJson=async(url,options={{}})=>{{
+ if(url==='api/v1/background-identity')return{{login:'timmy'}};
+ let filename=null;if(options.body instanceof FormData)filename=options.body.get('file').name;
+ calls.push({{url,key:options.headers?.['Idempotency-Key'],filename,body:options.body instanceof FormData?null:JSON.parse(options.body)}});
+ if(url.endsWith('/issues'))return{{number:823}};
+ if(url.endsWith('/attachments')){{if(filename==='two.png' && twoAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}return{{markdown:''}};}}
+ return{{id:9}};
+}};
+(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls}}));}})();
+"""
+ output = run_node(script)
+ assert output["first"] == "offline"
+ uploads = [call for call in output["calls"] if call["filename"]]
+ assert [call["filename"] for call in uploads] == ["one.png", "two.png", "two.png", "three.png"]
+ assert [call["key"] for call in uploads] == [
+ "bundle:attachment-0", "bundle:attachment-1", "bundle:attachment-1", "bundle:attachment-2"
+ ]
+ assert output["checkpoint"]["attachmentMarkdowns"] == [""]
+ comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
+ assert len(comments) == 1
+ assert comments[0]["body"]["body"].split("\n\n") == [
+ "", "", ""
+ ]
+
+
+
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
@@ -633,22 +669,32 @@ const transaction=work=>{{const run=tail.then(()=>work({{
await store.reconcile([{{
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
- }}]);
+ }},{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
+ {{filename:'a.png',contentType:'image/png',blob:new Blob(['a-bytes'],{{type:'image/png'}})}},
+ {{filename:'b.png',contentType:'image/png',blob:new Blob(['b-bytes'],{{type:'image/png'}})}},
+ ]}}]);
await store.reconcile([
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
+ {{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
+ {{filename:'a.png',contentType:'image/png',stored:true}},
+ {{filename:'b.png',contentType:'image/png',stored:true}},
+ ]}},
]);
const snapshot=await store.snapshot();
- process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
- text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
+ const byId=Object.fromEntries(snapshot.map(value=>[value.id,value]));
+ process.stdout.write(JSON.stringify({{first:{{isBlob:byId.first.attachment.blob instanceof Blob,
+ text:await byId.first.attachment.blob?.text()}},second:byId.second.attachment.data,
+ bundle:await Promise.all(byId.bundle.attachments.map(value=>value.blob?.text()))}}));
}})();
"""
output = run_node(script)
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
+ assert output["bundle"] == ["a-bytes", "b-bytes"]
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
diff --git a/tests/test_issue_attachment_ui.py b/tests/test_issue_attachment_ui.py
index bb5f694..5fe8b32 100644
--- a/tests/test_issue_attachment_ui.py
+++ b/tests/test_issue_attachment_ui.py
@@ -58,6 +58,37 @@ controller.select(file);
}
+def test_mobile_evidence_bundle_keeps_order_limits_selection_and_uploads_each_image_once():
+ script = f"""
+const attachment = require({json.dumps(str(ATTACHMENT))});
+let sequence=0; const calls=[];
+const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
+const controller=attachment.create({{
+ maxFiles:5,
+ createOperationId:()=> 'evidence-' + (++sequence),
+ upload:async payload=>{{calls.push([payload.filename,payload.operation_id]);return {{markdown:''}};}},
+}});
+for(const name of ['one.png','two.png','three.png','four.png','five.png']) controller.select(image(name));
+let limit='';try{{controller.select(image('six.png'));}}catch(error){{limit=error.message;}}
+controller.remove(1);
+controller.select(image('replacement.png'));
+(async()=>{{
+ const serialized=await controller.serialize();
+ const comment=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
+ const second=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
+ process.stdout.write(JSON.stringify({{limit,state:controller.state(),names:serialized.map(x=>x.filename),comment,second,calls}}));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ output = json.loads(run_node(script))
+ assert output["limit"] == "Up to 5 screenshots. Remove one before adding another."
+ assert output["names"] == ["one.png", "three.png", "four.png", "five.png", "replacement.png"]
+ assert [item["name"] for item in output["state"]] == output["names"]
+ assert output["comment"] == output["second"]
+ assert output["comment"].startswith("Evidence sequence\n\n![one.png]")
+ assert len(output["calls"]) == 5
+ assert len({key for _, key in output["calls"]}) == 5
+
+
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
@@ -224,6 +255,24 @@ controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0K
assert output["removed"] is None
+def test_attachment_view_restores_ordered_evidence_bundle_preview():
+ script = f"""
+const attachment=require({json.dumps(str(ATTACHMENT))});
+class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}}}
+const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();input.multiple=true;
+const controller=attachment.mount({{input,preview,image,meta,remove,status,createObjectURL:blob=>'blob:'+blob.size,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
+const value=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
+const restored=controller.restore([value('one.png'),value('two.png')]);
+process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textContent,hidden:preview.hidden}}));
+"""
+ output = json.loads(run_node(script))
+ assert [item["name"] for item in output["restored"]] == ["one.png", "two.png"]
+ assert output["src"] == "blob:7"
+ assert output["meta"] == "2 screenshots ready · latest: two.png"
+ assert output["hidden"] is False
+
+
+
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
@@ -264,11 +313,13 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert 'id="create-issue-attachment"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
+ assert 'multiple' in html
+ assert 'Up to 5' in html
assert 'id="create-issue-attachment-preview"' in html
assert 'id="remove-create-issue-attachment"' in html
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
- assert "attachment: await createIssueAttachmentController.serialize()" in source
- assert "createIssueAttachmentController.restore(hydrated.attachment);" in source
+ assert "attachments:evidence" in source
+ assert "hydrated.attachments || hydrated.attachment" in source
assert "createIssueAttachmentController.clear();" in source
assert ".create-issue-attachment" in css
assert "overflow-x:hidden" in css
diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py
index 8787e44..754f83b 100644
--- a/tests/test_issue_outbox.py
+++ b/tests/test_issue_outbox.py
@@ -71,6 +71,40 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
assert output["stored"]["version"] == 3
+def test_issue_outbox_preserves_bounded_ordered_evidence_bundle():
+ script = f"""
+const createIssueOutbox=require({json.dumps(str(OUTBOX))});
+const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
+const image=name=>({{filename:name,contentType:'image/png',data:name}});
+const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle'}});
+const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:['one','two','three','four','five'].map(image)}});
+let limit='';try{{outbox.enqueue({{repository:'o/r',title:'Too many',attachments:['1','2','3','4','5','6'].map(image)}});}}catch(error){{limit=error.message;}}
+process.stdout.write(JSON.stringify({{queued,list:outbox.list(),limit}}));
+"""
+ output = run_node(script)
+ assert [item["filename"] for item in output["queued"]["attachments"]] == [
+ "one", "two", "three", "four", "five"
+ ]
+ assert output["list"][0]["attachments"] == output["queued"]["attachments"]
+ assert output["limit"] == "You can attach up to 5 screenshots."
+
+
+
+def test_issue_outbox_replaces_evidence_bundle_when_editing_a_queued_issue():
+ script = f"""
+const createIssueOutbox=require({json.dumps(str(OUTBOX))});const values=new Map();
+const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};const image=name=>({{filename:name,contentType:'image/png',data:name}});
+let sequence=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++sequence)}});
+const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:[image('old-one'),image('old-two')]}});
+const updated=outbox.update(queued.id,{{...queued,attachments:[image('new-one'),image('new-two'),image('new-three')]}});
+process.stdout.write(JSON.stringify(updated));
+"""
+ output = run_node(script)
+ assert [item["filename"] for item in output["attachments"]] == ["new-one", "new-two", "new-three"]
+ assert output["operationId"] != output["id"]
+
+
+
def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@@ -94,6 +128,27 @@ process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
}
+def test_issue_outbox_hydrates_durable_evidence_bundle_without_copying_blobs_to_localstorage():
+ script = f"""
+const createIssueOutbox=require({json.dumps(str(OUTBOX))});
+const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
+const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
+let durable=null;
+const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle',backgroundSync:{{
+ reconcile:async items=>{{durable=items[0];}},requestSync:async()=>{{}},get:async()=>durable,
+}}}});
+(async()=>{{const admitted=await outbox.enqueueDurably({{repository:'o/r',title:'Journey',attachments:['one','two'].map(image)}});
+const raw=values.get('stackchain.issue-outbox.v1');const hydrated=await outbox.hydrateForEdit(admitted.item.id);
+process.stdout.write(JSON.stringify({{raw,names:hydrated.attachments.map(x=>x.filename),sizes:hydrated.attachments.map(x=>x.blob.size)}}));}})();
+"""
+ output = run_node(script)
+ assert '"stored":true' in output["raw"]
+ assert '"blob"' not in output["raw"]
+ assert output["names"] == ["one", "two"]
+ assert output["sizes"] == [3, 3]
+
+
+
def test_issue_outbox_hydrates_a_metadata_only_screenshot_for_edit_without_copying_bytes_to_localstorage():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index bc4417b..73beeba 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -247,7 +247,7 @@ def test_image_share_target_stages_one_supported_image_and_redirects_to_capture(
}
-def test_image_share_target_rejects_multiple_images_without_staging_private_data():
+def test_image_share_target_stages_ordered_evidence_bundle():
result = run_worker_scenario(
"""
const form = new FormData();
@@ -256,16 +256,19 @@ def test_image_share_target_rejects_multiple_images_without_staging_private_data
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
Object.defineProperty(request, 'mode', {value:'navigate'});
const response = await dispatch('fetch', request);
+ const record=state.sharedRecords['shared-image'];
process.stdout.write(JSON.stringify({
- status:response.status, location:response.headers.get('Location'), records:Object.keys(state.sharedRecords),
+ status:response.status, location:response.headers.get('Location'),
+ names:record.attachments.map(value=>value.filename),sizes:record.attachments.map(value=>value.blob.size),
}));
"""
)
assert result == {
"status": 303,
- "location": "/dashboard/?launch=new&shared=multiple",
- "records": [],
+ "location": "/dashboard/?launch=new&shared=images",
+ "names": ["one.png", "two.jpg"],
+ "sizes": [3, 3],
}
diff --git a/tests/test_shared_image_capture.py b/tests/test_shared_image_capture.py
index 3ff78cd..c69122d 100644
--- a/tests/test_shared_image_capture.py
+++ b/tests/test_shared_image_capture.py
@@ -36,6 +36,23 @@ shared.consume({{
}
+def test_shared_evidence_bundle_is_consumed_once_in_order():
+ script = f"""
+const shared=require({json.dumps(str(MODULE))});const calls=[];
+const store={{get:async()=>({{attachments:[
+ {{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
+ {{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two'],{{type:'image/jpeg'}})}},
+]}}),delete:async id=>calls.push(['delete',id])}};
+(async()=>{{const result=await shared.consume({{marker:'images',store,restore:value=>calls.push(['restore',value.map(x=>x.filename)]),status:value=>calls.push(['status',value])}});process.stdout.write(JSON.stringify({{result,calls}}));}})();
+"""
+ output = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
+ assert output == {"result": True, "calls": [
+ ["restore", ["one.png", "two.jpg"]], ["delete", "shared-image"],
+ ["status", "2 shared screenshots ready to file with this issue."],
+ ]}
+
+
+
def test_invalid_shared_image_reports_error_without_touching_open_capture():
script = f"""
const shared = require({json.dumps(str(MODULE))});
diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py
index 0763660..571c72c 100644
--- a/tests/test_unfiled_captures.py
+++ b/tests/test_unfiled_captures.py
@@ -85,6 +85,25 @@ process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement
assert output["restored"][0]["ownerLogin"] == "timmy"
+def test_unfiled_capture_restores_ordered_evidence_bundle():
+ script = f"""
+const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
+const values=new Map(),blobs=new Map();
+const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
+const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
+const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
+(async()=>{{const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'bundle'}});
+const saved=await captures.save({{title:'Journey',body:'Steps',attachments:['one','two','three'].map(image)}});
+const resumed=await captures.resume(saved.id,'timmy');
+process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.attachments.map(x=>x.filename),stored:blobs.get('bundle').attachments.map(x=>x.filename)}}));}})();
+"""
+ output = run_node(script)
+ assert output["listed"]["attachmentCount"] == 3
+ assert output["names"] == ["one", "two", "three"]
+ assert output["stored"] == output["names"]
+
+
+
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});