From a1c56bd644d08e9c2ca87336f7aa012919e87ffb Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 11:43:01 +0000 Subject: [PATCH] feat: capture mobile evidence bundles (Closes #823) --- README.md | 20 +-- frontend/background-issue-sync.js | 38 ++++-- frontend/dashboard.js | 12 +- frontend/index.html | 8 +- frontend/issue-attachment.js | 201 +++++++++++++++++++--------- frontend/issue-outbox.js | 52 +++++-- frontend/service-worker.js | 21 ++- frontend/shared-image-capture.js | 14 +- frontend/unfiled-captures.js | 36 ++++- src/frontend_bundle.py | 4 +- tests/test_background_issue_sync.py | 52 ++++++- tests/test_issue_attachment_ui.py | 55 +++++++- tests/test_issue_outbox.py | 55 ++++++++ tests/test_service_worker.py | 11 +- tests/test_shared_image_capture.py | 17 +++ tests/test_unfiled_captures.py | 19 +++ 16 files changed, 493 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index c435ecd..80bcf36 100644 --- a/README.md +++ b/README.md @@ -24,19 +24,21 @@ For online delivery, the screenshot uploads before the comment is posted—to th failures keep both the typed comment and removable preview available for retry. Offline screenshot comments admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate -either stage. The mobile **New issue** capture-first stage accepts the same image formats before a -repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before -confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact -preview when the operator later chooses a repository. The source Draft remains available until its -screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob. -Repository-aware durable admission likewise stores the screenshot with its account-bound outbox +either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to +five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently +to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before +confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered +bundle when the operator later chooses a repository. 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 delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing queued base64 screenshot payloads remain readable and are converted only at delivery time. -Delivery creates the issue exactly once, then uploads and comments with -the image; after a partial failure, retry resumes with the confirmed issue instead of -creating a duplicate. +Delivery creates the issue exactly once, then uploads each image under a checkpointed per-image +identity and posts one ordered Markdown evidence comment. After a partial failure, retry resumes with the confirmed issue and from +the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA +Share Target accepts the same bounded multi-image bundle through sign-in continuation. Pull-request replies and mobile My Work issue and PR comments use Gitea's issue-comment API. In issue, pull-request, and unread-update conversations, typing at least two characters after `@` offers repository-scoped teammate suggestions; diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 0dd51de..38f748b 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -91,11 +91,18 @@ function createIssueSyncStore({ } continue; } + const preservedBundle = current.operationId === replacement.operationId && + current.attachments?.every(value => value?.data || value?.blob) && + replacement.attachments?.every(value => value?.stored && !value.data && !value.blob) + ? current.attachments : null; if (current.operationId === replacement.operationId && (current.attachment?.data || current.attachment?.blob) && replacement.attachment?.stored && - !replacement.attachment.data && !replacement.attachment.blob) { - replacement = { ...replacement, attachment: current.attachment }; + !replacement.attachment.data && !replacement.attachment.blob || preservedBundle) { + replacement = { + ...replacement, + ...(preservedBundle ? {attachments:preservedBundle} : {attachment:current.attachment}), + }; incoming.set(current.id, replacement); } if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) || @@ -525,8 +532,11 @@ function createBackgroundIssueSync({ deliveredIssue = await requestStage(item, request.url, request.options); await checkpointClaim(item, current => ({ ...current, deliveredIssue })); } - let attachmentMarkdown = item.attachmentMarkdown; - if (!attachmentMarkdown) { + const attachments = Array.isArray(item.attachments) ? item.attachments : [item.attachment]; + const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns) + ? item.attachmentMarkdowns.slice(0, attachments.length) + : (item.attachmentMarkdown ? [item.attachmentMarkdown] : []); + for (let index = attachmentMarkdowns.length; index < attachments.length; index += 1) { const uploaded = await requestStage( item, base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments', @@ -534,19 +544,26 @@ function createBackgroundIssueSync({ 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]), }, ); - attachmentMarkdown = String(uploaded?.markdown || ''); - if (!attachmentMarkdown) { + const markdown = String(uploaded?.markdown || ''); + if (!markdown) { const error = new Error('The server did not confirm the screenshot upload.'); error.status = 422; throw error; } - await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown })); + attachmentMarkdowns.push(markdown); + await checkpointClaim(item, current => ({ + ...current, deliveredIssue, attachmentMarkdowns:attachmentMarkdowns.slice(), + ...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}), + })); } + const attachmentMarkdown = attachmentMarkdowns.join('\n\n'); await requestStage( item, base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments', @@ -638,7 +655,8 @@ function createBackgroundIssueSync({ item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) : item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ? await deliverScreenshotComment(item) : item.attachment && !item.kind ? - await deliverIssueCapture(item) : await requestStage(item, request.url, request.options); + await deliverIssueCapture(item) : item.attachments?.length && !item.kind ? + await deliverIssueCapture(item) : await requestStage(item, request.url, request.options); if (item.kind === 'issue-close' && delivered?.state !== 'closed') { const error = new Error('Issue closure was not confirmed.'); error.status = 422; diff --git a/frontend/dashboard.js b/frontend/dashboard.js index f7acc12..3dcd1af 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -2652,7 +2652,9 @@ editingOutboxId = hydrated.id; issueCapture.saveDraft(hydrated); openCreateIssueSheet(); - if (hydrated.attachment) createIssueAttachmentController.restore(hydrated.attachment); + if (hydrated.attachments || hydrated.attachment) { + createIssueAttachmentController.restore(hydrated.attachments || hydrated.attachment); + } else createIssueAttachmentController.clear(); qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.'; } catch (error) { @@ -4935,10 +4937,11 @@ }); qs('#save-unfiled-issue').addEventListener('click', async () => { try { + const evidence = await createIssueAttachmentController.serialize(); const captureDraft = { title: qs('#create-issue-title').value.trim(), body: qs('#create-issue-body').value.trim(), - attachment: await createIssueAttachmentController.serialize(), + ...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}), }; if (showDraftCapacityDialog(unfiledCaptures)) return; const savedCapture = await unfiledCaptures.save(captureDraft); @@ -5089,9 +5092,10 @@ qs('#create-issue-status').textContent = createAndStartRequested ? 'Creating issue and adding it to Today…' : 'Saving for background delivery…'; try { + const evidence = await createIssueAttachmentController.serialize(); const durableDraft = { ...captureDraft, - attachment: await createIssueAttachmentController.serialize(), + ...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}), ...(rUC ? { sourceCaptureId: rUC } : {}), ...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}), }; @@ -5104,7 +5108,7 @@ await issueOutbox.enqueueDurably(durableDraft)); const queued = admission.item; const fS = dFS.current(); - if (rUC && (!durableDraft.attachment || admission.background)) { + if (rUC && ((!durableDraft.attachment && !durableDraft.attachments) || admission.background)) { await unfiledCaptures.completeResume(rUC); rUC = ''; } diff --git a/frontend/index.html b/frontend/index.html index ec70fc7..a38dbf4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -754,15 +754,15 @@
- Screenshot Optional · PNG, JPEG, or WebP · 2 MB max + Evidence screenshots Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each
- - + +
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:'!['+filename+'](url/'+filename+')'}};}} + 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"] == ["![one.png](url/one.png)"] + 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)" + ] + + + 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:'!['+payload.filename+'](url/'+payload.filename+')'}};}}, +}}); +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))}); -- 2.43.0