Merge pull request 'Capture mobile evidence bundles with one issue' (#824)
Closes #823
This commit is contained in:
commit
6a9271cabb
20
README.md
20
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -754,15 +754,15 @@
|
|||
<button id="file-new-issue" type="button">File now</button>
|
||||
</div>
|
||||
<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">
|
||||
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label>
|
||||
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<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" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
|
||||
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
|
||||
<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 class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -164,6 +164,42 @@ const fetchJson=async(url,options={{}})=>{{
|
|||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Journey',body:'',labelIds:[],attachments:[
|
||||
{{filename:'one.png',contentType:'image/png',data:'b25l'}},
|
||||
{{filename:'two.png',contentType:'image/png',data:'dHdv'}},
|
||||
{{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
|
||||
]}};
|
||||
const calls=[];let twoAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
let filename=null;if(options.body instanceof FormData)filename=options.body.get('file').name;
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename,body:options.body instanceof FormData?null:JSON.parse(options.body)}});
|
||||
if(url.endsWith('/issues'))return{{number:823}};
|
||||
if(url.endsWith('/attachments')){{if(filename==='two.png' && twoAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}return{{markdown:''}};}}
|
||||
return{{id:9}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["first"] == "offline"
|
||||
uploads = [call for call in output["calls"] if call["filename"]]
|
||||
assert [call["filename"] for call in uploads] == ["one.png", "two.png", "two.png", "three.png"]
|
||||
assert [call["key"] for call in uploads] == [
|
||||
"bundle:attachment-0", "bundle:attachment-1", "bundle:attachment-1", "bundle:attachment-2"
|
||||
]
|
||||
assert output["checkpoint"]["attachmentMarkdowns"] == [""]
|
||||
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["body"]["body"].split("\n\n") == [
|
||||
"", "", ""
|
||||
]
|
||||
|
||||
|
||||
|
||||
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
|
|
@ -633,22 +669,32 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
|||
await store.reconcile([{{
|
||||
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
|
||||
}}]);
|
||||
}},{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
|
||||
{{filename:'a.png',contentType:'image/png',blob:new Blob(['a-bytes'],{{type:'image/png'}})}},
|
||||
{{filename:'b.png',contentType:'image/png',blob:new Blob(['b-bytes'],{{type:'image/png'}})}},
|
||||
]}}]);
|
||||
await store.reconcile([
|
||||
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
|
||||
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
|
||||
{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
|
||||
{{filename:'a.png',contentType:'image/png',stored:true}},
|
||||
{{filename:'b.png',contentType:'image/png',stored:true}},
|
||||
]}},
|
||||
]);
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
|
||||
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
|
||||
const byId=Object.fromEntries(snapshot.map(value=>[value.id,value]));
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:byId.first.attachment.blob instanceof Blob,
|
||||
text:await byId.first.attachment.blob?.text()}},second:byId.second.attachment.data,
|
||||
bundle:await Promise.all(byId.bundle.attachments.map(value=>value.blob?.text()))}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
|
||||
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
|
||||
assert output["bundle"] == ["a-bytes", "b-bytes"]
|
||||
|
||||
|
||||
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
|
||||
|
|
|
|||
|
|
@ -58,6 +58,37 @@ controller.select(file);
|
|||
}
|
||||
|
||||
|
||||
def test_mobile_evidence_bundle_keeps_order_limits_selection_and_uploads_each_image_once():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
let sequence=0; const calls=[];
|
||||
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
const controller=attachment.create({{
|
||||
maxFiles:5,
|
||||
createOperationId:()=> 'evidence-' + (++sequence),
|
||||
upload:async payload=>{{calls.push([payload.filename,payload.operation_id]);return {{markdown:''}};}},
|
||||
}});
|
||||
for(const name of ['one.png','two.png','three.png','four.png','five.png']) controller.select(image(name));
|
||||
let limit='';try{{controller.select(image('six.png'));}}catch(error){{limit=error.message;}}
|
||||
controller.remove(1);
|
||||
controller.select(image('replacement.png'));
|
||||
(async()=>{{
|
||||
const serialized=await controller.serialize();
|
||||
const comment=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
|
||||
const second=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
|
||||
process.stdout.write(JSON.stringify({{limit,state:controller.state(),names:serialized.map(x=>x.filename),comment,second,calls}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["limit"] == "Up to 5 screenshots. Remove one before adding another."
|
||||
assert output["names"] == ["one.png", "three.png", "four.png", "five.png", "replacement.png"]
|
||||
assert [item["name"] for item in output["state"]] == output["names"]
|
||||
assert output["comment"] == output["second"]
|
||||
assert output["comment"].startswith("Evidence sequence\n\n![one.png]")
|
||||
assert len(output["calls"]) == 5
|
||||
assert len({key for _, key in output["calls"]}) == 5
|
||||
|
||||
|
||||
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
|
|
@ -224,6 +255,24 @@ controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0K
|
|||
assert output["removed"] is None
|
||||
|
||||
|
||||
def test_attachment_view_restores_ordered_evidence_bundle_preview():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();input.multiple=true;
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,createObjectURL:blob=>'blob:'+blob.size,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
const value=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
const restored=controller.restore([value('one.png'),value('two.png')]);
|
||||
process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textContent,hidden:preview.hidden}}));
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert [item["name"] for item in output["restored"]] == ["one.png", "two.png"]
|
||||
assert output["src"] == "blob:7"
|
||||
assert output["meta"] == "2 screenshots ready · latest: two.png"
|
||||
assert output["hidden"] is False
|
||||
|
||||
|
||||
|
||||
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
|
|
@ -264,11 +313,13 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
|||
|
||||
assert 'id="create-issue-attachment"' in html
|
||||
assert 'accept="image/png,image/jpeg,image/webp"' in html
|
||||
assert 'multiple' in html
|
||||
assert 'Up to 5' in html
|
||||
assert 'id="create-issue-attachment-preview"' in html
|
||||
assert 'id="remove-create-issue-attachment"' in html
|
||||
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
|
||||
assert "attachment: await createIssueAttachmentController.serialize()" in source
|
||||
assert "createIssueAttachmentController.restore(hydrated.attachment);" in source
|
||||
assert "attachments:evidence" in source
|
||||
assert "hydrated.attachments || hydrated.attachment" in source
|
||||
assert "createIssueAttachmentController.clear();" in source
|
||||
assert ".create-issue-attachment" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user