Merge pull request 'Capture mobile evidence bundles with one issue' (#824)
All checks were successful
CI / lint (push) Successful in 1m49s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s

Closes #823
This commit is contained in:
timmy 2026-08-14 11:45:28 +00:00
commit 6a9271cabb
16 changed files with 493 additions and 122 deletions

View File

@ -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 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 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 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 either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
preview when the operator later chooses a repository. The source Draft remains available until its confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob. bundle when the operator later chooses a repository. The source Draft remains available until its
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox 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 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 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 expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
delivery time. delivery time.
Delivery creates the issue exactly once, then uploads and comments with Delivery creates the issue exactly once, then uploads each image under a checkpointed per-image
the image; after a partial failure, retry resumes with the confirmed issue instead of identity and posts one ordered Markdown evidence comment. After a partial failure, retry resumes with the confirmed issue and from
creating a duplicate. 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 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 issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions; at least two characters after `@` offers repository-scoped teammate suggestions;

View File

@ -91,11 +91,18 @@ function createIssueSyncStore({
} }
continue; 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 && if (current.operationId === replacement.operationId &&
(current.attachment?.data || current.attachment?.blob) && (current.attachment?.data || current.attachment?.blob) &&
replacement.attachment?.stored && replacement.attachment?.stored &&
!replacement.attachment.data && !replacement.attachment.blob) { !replacement.attachment.data && !replacement.attachment.blob || preservedBundle) {
replacement = { ...replacement, attachment: current.attachment }; replacement = {
...replacement,
...(preservedBundle ? {attachments:preservedBundle} : {attachment:current.attachment}),
};
incoming.set(current.id, replacement); incoming.set(current.id, replacement);
} }
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) || if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
@ -525,8 +532,11 @@ function createBackgroundIssueSync({
deliveredIssue = await requestStage(item, request.url, request.options); deliveredIssue = await requestStage(item, request.url, request.options);
await checkpointClaim(item, current => ({ ...current, deliveredIssue })); await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
} }
let attachmentMarkdown = item.attachmentMarkdown; const attachments = Array.isArray(item.attachments) ? item.attachments : [item.attachment];
if (!attachmentMarkdown) { 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( const uploaded = await requestStage(
item, item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments', base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
@ -534,19 +544,26 @@ function createBackgroundIssueSync({
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]),
}, },
); );
attachmentMarkdown = String(uploaded?.markdown || ''); const markdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) { 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;
} }
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( await requestStage(
item, item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments', 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.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ? item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !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') { if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.'); const error = new Error('Issue closure was not confirmed.');
error.status = 422; error.status = 422;

View File

@ -2652,7 +2652,9 @@
editingOutboxId = hydrated.id; editingOutboxId = hydrated.id;
issueCapture.saveDraft(hydrated); issueCapture.saveDraft(hydrated);
openCreateIssueSheet(); openCreateIssueSheet();
if (hydrated.attachment) createIssueAttachmentController.restore(hydrated.attachment); if (hydrated.attachments || hydrated.attachment) {
createIssueAttachmentController.restore(hydrated.attachments || hydrated.attachment);
}
else createIssueAttachmentController.clear(); else createIssueAttachmentController.clear();
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.'; qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
} catch (error) { } catch (error) {
@ -4935,10 +4937,11 @@
}); });
qs('#save-unfiled-issue').addEventListener('click', async () => { qs('#save-unfiled-issue').addEventListener('click', async () => {
try { try {
const evidence = await createIssueAttachmentController.serialize();
const captureDraft = { const captureDraft = {
title: qs('#create-issue-title').value.trim(), title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(), body: qs('#create-issue-body').value.trim(),
attachment: await createIssueAttachmentController.serialize(), ...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}),
}; };
if (showDraftCapacityDialog(unfiledCaptures)) return; if (showDraftCapacityDialog(unfiledCaptures)) return;
const savedCapture = await unfiledCaptures.save(captureDraft); const savedCapture = await unfiledCaptures.save(captureDraft);
@ -5089,9 +5092,10 @@
qs('#create-issue-status').textContent = createAndStartRequested ? qs('#create-issue-status').textContent = createAndStartRequested ?
'Creating issue and adding it to Today…' : 'Saving for background delivery…'; 'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try { try {
const evidence = await createIssueAttachmentController.serialize();
const durableDraft = { const durableDraft = {
...captureDraft, ...captureDraft,
attachment: await createIssueAttachmentController.serialize(), ...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}),
...(rUC ? { sourceCaptureId: rUC } : {}), ...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}), ...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
}; };
@ -5104,7 +5108,7 @@
await issueOutbox.enqueueDurably(durableDraft)); await issueOutbox.enqueueDurably(durableDraft));
const queued = admission.item; const queued = admission.item;
const fS = dFS.current(); const fS = dFS.current();
if (rUC && (!durableDraft.attachment || admission.background)) { if (rUC && ((!durableDraft.attachment && !durableDraft.attachments) || admission.background)) {
await unfiledCaptures.completeResume(rUC); await unfiledCaptures.completeResume(rUC);
rUC = ''; rUC = '';
} }

View File

@ -754,15 +754,15 @@
<button id="file-new-issue" type="button">File now</button> <button id="file-new-issue" type="button">File now</button>
</div> </div>
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label"> <section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong> <strong id="create-issue-attachment-label">Evidence screenshots <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
<div class="issue-attachment-controls"> <div class="issue-attachment-controls">
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label> <label class="issue-attachment-trigger" for="create-issue-attachment">Add screenshots</label>
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" /> <input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div> </div>
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden> <div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
<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>
<button id="remove-create-issue-attachment" type="button">Remove</button> <button id="remove-create-issue-attachment" type="button">Remove latest</button>
</div> </div>
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div> <div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section> </section>

View File

@ -6,6 +6,8 @@
'use strict'; 'use strict';
const MAX_BYTES = 2 * 1024 * 1024; 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']); const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function namedBlob(blob, name) { function namedBlob(blob, name) {
@ -65,6 +67,7 @@
function create(options) { function create(options) {
const upload = options.upload; const upload = options.upload;
const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1;
const optimizeSelectedImage = options.optimizeImage || optimizeImage; const optimizeSelectedImage = options.optimizeImage || optimizeImage;
const createOperationId = options.createOperationId || (() => { const createOperationId = options.createOperationId || (() => {
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) { if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
@ -72,10 +75,7 @@
} }
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}); });
let selected = null; let selected = [];
let confirmed = null;
let serialized = null;
let operationId = null;
let selectionGeneration = 0; let selectionGeneration = 0;
function commitSelection(file) { function commitSelection(file) {
@ -83,14 +83,18 @@
file.size <= 0 || file.size > MAX_BYTES) { 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.'); throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
} }
selected = file; if (selected.length >= maxFiles) {
confirmed = null; if (maxFiles === 1) selected = [];
serialized = null; else throw new Error(MAX_FILES_MESSAGE);
operationId = createOperationId(); }
selected.push({file, confirmed:null, serialized:null, operationId:createOperationId()});
return state(); return state();
} }
function select(file) { function select(file) {
if (selected.length >= maxFiles && maxFiles > 1) {
throw new Error(MAX_FILES_MESSAGE);
}
const generation = ++selectionGeneration; const generation = ++selectionGeneration;
if (!file || !IMAGE_TYPES.has(file.type)) { if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.'); throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
@ -109,75 +113,100 @@
function clear() { function clear() {
selectionGeneration += 1; selectionGeneration += 1;
selected = null; selected = [];
confirmed = null; }
serialized = null;
operationId = null; 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) { 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 contentType = String(value?.contentType || '');
const filename = String(value?.filename || ''); const filename = String(value?.filename || '');
const blob = value?.blob; const blob = value?.blob;
const data = String(value?.data || ''); const data = String(value?.data || '');
if (!blob && !data) { if (!blob && !data) {
clear();
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.'); throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
} }
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);
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) }); commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data }; selected[selected.length - 1].serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
return state();
} }
function state() { function state() {
return selected ? { const values = selected.map(item => ({
name: selected.name, name: item.file.name,
size: selected.size, size: item.file.size,
uploaded: Boolean(confirmed), uploaded: Boolean(item.confirmed),
} : null; }));
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() { async function serialize() {
if (!selected) return null; if (!selected.length) return null;
if (!serialized) { const values = selected.map(serializeItem);
serialized = { return values.length > 1 ? values : values[0];
filename: selected.name,
contentType: selected.type,
blob: selected.blob || selected,
};
}
return { ...serialized };
} }
async function prepareComment(item, body) { async function prepareComment(target, body) {
const text = String(body || '').trim(); const text = String(body || '').trim();
if (!selected) return text; if (!selected.length) return text;
if (!confirmed) { const markdown = [];
const attachment = await serialize(); for (const evidence of selected) {
confirmed = await upload({ if (!evidence.confirmed) {
repository: item.repository, const attachment = serializeItem(evidence);
number: item.number, evidence.confirmed = await upload({
filename: attachment.filename, repository: target.repository,
content_type: attachment.contentType, number: target.number,
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }), filename: attachment.filename,
operation_id: operationId, content_type: attachment.contentType,
}); ...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) { operation_id: evidence.operationId,
confirmed = null; });
throw new Error('The server did not confirm the screenshot upload.'); 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) { 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 clearSelection = controller.clear;
const restoreSelection = controller.restore;
let previewUrl = ''; let previewUrl = '';
let selectionSequence = 0; let selectionSequence = 0;
@ -208,24 +237,43 @@
} }
options.input.addEventListener('change', event => { options.input.addEventListener('change', event => {
const file = event.target.files && event.target.files[0]; const files = Array.from(event.target.files || []);
const sequence = ++selectionSequence; const sequence = ++selectionSequence;
let result; if (!files.length) return;
try { let first;
result = controller.select(file); try { first = controller.select(files[0]); }
} catch (error) { catch (error) {
options.status.textContent = error.message; options.status.textContent = error.message;
options.input.value = ''; options.input.value = '';
return; return;
} }
if (!result || typeof result.then !== 'function') { if (files.length === 1 && (!first || typeof first.then !== 'function')) {
showPreview(file, false); showPreview(files[0], false);
return; return;
} }
options.status.textContent = 'Optimizing screenshot…'; let optimized = Boolean(first && typeof first.then === 'function');
options.input.disabled = true; if (optimized) {
return result.then(() => controller.serialize()).then(value => { options.status.textContent = 'Optimizing screenshot…';
if (sequence === selectionSequence) showPreview(value.blob, true); 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 => { }).catch(error => {
if (sequence === selectionSequence) { if (sequence === selectionSequence) {
options.status.textContent = error.message; options.status.textContent = error.message;
@ -236,19 +284,40 @@
}); });
}); });
options.remove.addEventListener('click', () => { options.remove.addEventListener('click', () => {
clearPreview(); const current = controller.state();
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.'; 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) { function restorePreview(value) {
clearPreview(); clearPreview();
const restored = controller.restore(value); const restored = restoreSelection(value);
previewUrl = value.blob ? options.createObjectURL(value.blob) : const values = Array.isArray(value) ? value : [value];
'data:' + value.contentType + ';base64,' + value.data; const latest = values[values.length - 1];
previewUrl = latest.blob ? options.createObjectURL(latest.blob) :
'data:' + latest.contentType + ';base64,' + latest.data;
options.image.src = previewUrl; 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.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; return restored;
} }

View File

@ -17,6 +17,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return { filename, contentType, data }; 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() { function read() {
try { try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null'); 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'; if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment); const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment; if (attachment) item.attachment = attachment;
const attachments = captureAttachments(draft?.attachments);
if (attachments) item.attachments = attachments;
item.operationId = item.id; item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) { if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId); item.milestoneId = Number(draft.milestoneId);
@ -65,14 +77,20 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
} }
function localIndexItem(item) { 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 { return {
...item, ...item,
attachment: { ...(hasAttachmentBytes ? {attachment: {
filename: item.attachment.filename, filename: item.attachment.filename,
contentType: item.attachment.contentType, contentType: item.attachment.contentType,
stored: true, 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) { async function hydrateForEdit(id) {
const item = read().find(candidate => candidate.id === id); const item = read().find(candidate => candidate.id === id);
if (!item) return null; 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) { if (!backgroundSync?.get) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.'); throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
} }
const durable = await backgroundSync.get(id); const durable = await backgroundSync.get(id);
const attachment = captureAttachment(durable?.attachment); 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.'); 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) { 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 || '')) const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
? String(draft.dueDate) : undefined; ? String(draft.dueDate) : undefined;
const nextAttachment = captureAttachment(draft?.attachment); 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 const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds) || JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate || item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
@ -154,7 +183,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
...item, ...item,
repository: nextRepository, title: nextTitle, repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds, 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, operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued', status: 'queued',
}; };
@ -163,7 +193,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (nextMilestoneId === undefined) delete updated.milestoneId; if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate; if (nextDueDate === undefined) delete updated.dueDate;
if (nextAttachment === undefined) delete updated.attachment; 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.error;
delete updated.deliveryState; delete updated.deliveryState;
return updated; return updated;

View File

@ -115,17 +115,28 @@ async function acceptSharedContent(request) {
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0); const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
let marker = ''; let marker = '';
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {}); await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
if (images.length > 1) marker = 'multiple'; if (images.length > 5) marker = 'multiple';
else if (images.length === 1) { else if (images.length > 0) {
const image = images[0]; const supported = images.every(image =>
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) { SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
);
if (!supported) {
marker = 'unsupported'; marker = 'unsupported';
} else { } else if (images.length === 1) {
const image = images[0];
await sharedAttachmentStore.put(SHARED_IMAGE_ID, { await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
filename:String(image.name || 'shared-screenshot').slice(0, 255), filename:String(image.name || 'shared-screenshot').slice(0, 255),
contentType:String(image.type), blob:image, contentType:String(image.type), blob:image,
}); });
marker = '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); const target = new URL(BASE, self.location.origin);

View File

@ -10,11 +10,23 @@
async function consume({marker, store, restore, status = () => {}}) { async function consume({marker, store, restore, status = () => {}}) {
if (!marker) return false; if (!marker) return false;
if (marker !== 'image') { if (!['image', 'images'].includes(marker)) {
status('Share one PNG, JPEG, or WebP screenshot.'); status('Share one PNG, JPEG, or WebP screenshot.');
return false; return false;
} }
const value = await store?.get(RECORD_ID); 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 || ''))) { if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
status('The shared screenshot is unavailable. Share it again.'); status('The shared screenshot is unavailable. Share it again.');
return false; return false;

View File

@ -42,19 +42,35 @@ function createUnfiledCaptures({
const ownerLogin = String(getCaptureLogin() || '').trim(); const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.'); if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const attachment = note?.attachment; 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 && const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || ''))); ['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 (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.'); 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) { function persist(prepared, existing, removed = null) {
const item = { const item = {
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body, 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 items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
const writeItems = () => { const writeItems = () => {
@ -66,7 +82,12 @@ function createUnfiledCaptures({
return item; return item;
}; };
const stage = prepared.hasAttachment 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), filename:String(prepared.attachment.filename).slice(0, 255),
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob, contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
})) }))
@ -137,6 +158,13 @@ function createUnfiledCaptures({
if (!item.hasAttachment) return draft; if (!item.hasAttachment) return draft;
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.'); if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
return Promise.resolve(attachmentStore.get(id)).then(attachment => { 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) { if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.'); throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
} }

View File

@ -22,7 +22,9 @@ COMMONJS_BROWSER_BRANCH = re.compile(
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = { FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"), "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"), "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",), "push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),

View File

@ -164,6 +164,42 @@ const fetchJson=async(url,options={{}})=>{{
assert output["second"]["confirmed"][0]["number"] == 469 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(): def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
script = f""" script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))}); const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
@ -633,22 +669,32 @@ const transaction=work=>{{const run=tail.then(()=>work({{
await store.reconcile([{{ await store.reconcile([{{
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued', id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}}, 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([ await store.reconcile([
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued', {{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}}, attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued', {{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}}, 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(); const snapshot=await store.snapshot();
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob, const byId=Object.fromEntries(snapshot.map(value=>[value.id,value]));
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}})); 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) output = run_node(script)
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"} assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz" 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(): def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():

View File

@ -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(): 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))});
@ -224,6 +255,24 @@ controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0K
assert output["removed"] is None 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(): 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))});
@ -264,11 +313,13 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert 'id="create-issue-attachment"' in html assert 'id="create-issue-attachment"' in html
assert 'accept="image/png,image/jpeg,image/webp"' 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="create-issue-attachment-preview"' in html
assert 'id="remove-create-issue-attachment"' in html assert 'id="remove-create-issue-attachment"' in html
assert "const createIssueAttachmentController = issueAttachment.mount({" in source assert "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachment: await createIssueAttachmentController.serialize()" in source assert "attachments:evidence" in source
assert "createIssueAttachmentController.restore(hydrated.attachment);" in source assert "hydrated.attachments || hydrated.attachment" in source
assert "createIssueAttachmentController.clear();" in source assert "createIssueAttachmentController.clear();" in source
assert ".create-issue-attachment" in css assert ".create-issue-attachment" in css
assert "overflow-x:hidden" in css assert "overflow-x:hidden" in css

View File

@ -71,6 +71,40 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
assert output["stored"]["version"] == 3 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(): def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
script = f""" script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))}); 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(): def test_issue_outbox_hydrates_a_metadata_only_screenshot_for_edit_without_copying_bytes_to_localstorage():
script = f""" script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))}); const createIssueOutbox = require({json.dumps(str(OUTBOX))});

View File

@ -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( result = run_worker_scenario(
""" """
const form = new FormData(); 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}); const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
Object.defineProperty(request, 'mode', {value:'navigate'}); Object.defineProperty(request, 'mode', {value:'navigate'});
const response = await dispatch('fetch', request); const response = await dispatch('fetch', request);
const record=state.sharedRecords['shared-image'];
process.stdout.write(JSON.stringify({ 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 == { assert result == {
"status": 303, "status": 303,
"location": "/dashboard/?launch=new&shared=multiple", "location": "/dashboard/?launch=new&shared=images",
"records": [], "names": ["one.png", "two.jpg"],
"sizes": [3, 3],
} }

View File

@ -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(): def test_invalid_shared_image_reports_error_without_touching_open_capture():
script = f""" script = f"""
const shared = require({json.dumps(str(MODULE))}); const shared = require({json.dumps(str(MODULE))});

View File

@ -85,6 +85,25 @@ process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement
assert output["restored"][0]["ownerLogin"] == "timmy" 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(): def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
script = f""" script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); const createUnfiledCaptures = require({json.dumps(str(UNFILED))});