feat: explain mobile evidence screenshots (Closes #827)
This commit is contained in:
parent
807364c63b
commit
af5fe9b69d
|
|
@ -30,7 +30,9 @@ to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to
|
||||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
|
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
|
||||||
bundle when the operator later chooses a repository. A scrollable thumbnail tray lets the operator
|
bundle when the operator later chooses a repository. A scrollable thumbnail tray lets the operator
|
||||||
review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected**
|
review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected**
|
||||||
change the durable evidence order without changing the issue title or note. The source Draft remains
|
change the durable evidence order without changing the issue title or note. The active screenshot's
|
||||||
|
optional **Evidence note** stays paired with that image through reorder, Draft restore, offline delivery,
|
||||||
|
and retry, then appears as a Markdown-safe caption immediately before its uploaded image. The source Draft remains
|
||||||
available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
|
available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
|
||||||
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
|
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
|
||||||
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
|
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,13 @@ function createBackgroundIssueSync({
|
||||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evidenceMarkdown(attachment, markdown, index) {
|
||||||
|
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||||
|
if (!note) return markdown;
|
||||||
|
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||||
|
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
||||||
|
}
|
||||||
|
|
||||||
async function deliverReplyRead(item) {
|
async function deliverReplyRead(item) {
|
||||||
let current = item;
|
let current = item;
|
||||||
if (!current.replyConfirmed) {
|
if (!current.replyConfirmed) {
|
||||||
|
|
@ -563,7 +570,8 @@ function createBackgroundIssueSync({
|
||||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const attachmentMarkdown = attachmentMarkdowns.join('\n\n');
|
const attachmentMarkdown = attachmentMarkdowns.map((markdown, index) =>
|
||||||
|
evidenceMarkdown(attachments[index], markdown, index)).join('\n\n');
|
||||||
await requestStage(
|
await requestStage(
|
||||||
item,
|
item,
|
||||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
||||||
|
|
|
||||||
|
|
@ -630,6 +630,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.issue-evidence-thumbnail[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
.issue-evidence-thumbnail[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||||
.issue-evidence-thumbnail img { display:block; width:56px; height:56px; border-radius:6px; object-fit:cover; }
|
.issue-evidence-thumbnail img { display:block; width:56px; height:56px; border-radius:6px; object-fit:cover; }
|
||||||
.issue-evidence-thumbnail span { position:absolute; right:3px; bottom:3px; min-width:20px; padding:1px 4px; border-radius:999px; background:#07101d; color:#fff; font-size:12px; text-align:center; }
|
.issue-evidence-thumbnail span { position:absolute; right:3px; bottom:3px; min-width:20px; padding:1px 4px; border-radius:999px; background:#07101d; color:#fff; font-size:12px; text-align:center; }
|
||||||
|
.issue-evidence-note { display:grid; gap:6px; min-width:0; }
|
||||||
|
.issue-evidence-note textarea { box-sizing:border-box; width:100%; min-height:72px; padding:10px; resize:vertical; border:1px solid #2a496e; border-radius:8px; background:#07101d; color:#e5e7eb; font:inherit; }
|
||||||
|
.issue-evidence-note textarea:focus-visible { outline:2px solid #60a5fa; outline-offset:2px; }
|
||||||
.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
|
.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
|
||||||
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
|
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
|
||||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||||
|
|
|
||||||
|
|
@ -482,6 +482,8 @@
|
||||||
tray: qs('#create-issue-evidence-tray'),
|
tray: qs('#create-issue-evidence-tray'),
|
||||||
earlier: qs('#move-create-issue-attachment-earlier'),
|
earlier: qs('#move-create-issue-attachment-earlier'),
|
||||||
later: qs('#move-create-issue-attachment-later'),
|
later: qs('#move-create-issue-attachment-later'),
|
||||||
|
note: qs('#create-issue-evidence-note'),
|
||||||
|
noteLabel: qs('#create-issue-evidence-note-label'),
|
||||||
status: qs('#create-issue-attachment-status'),
|
status: qs('#create-issue-attachment-status'),
|
||||||
readyMessage: 'Screenshot ready to file with this issue.',
|
readyMessage: 'Screenshot ready to file with this issue.',
|
||||||
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
|
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
|
||||||
|
|
|
||||||
|
|
@ -763,6 +763,10 @@
|
||||||
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
|
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
|
||||||
<span class="small" id="create-issue-attachment-meta"></span>
|
<span class="small" id="create-issue-attachment-meta"></span>
|
||||||
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
|
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
|
||||||
|
<label class="issue-evidence-note" for="create-issue-evidence-note">
|
||||||
|
<span id="create-issue-evidence-note-label">Evidence note (optional)</span>
|
||||||
|
<textarea id="create-issue-evidence-note" maxlength="240" rows="2" disabled placeholder="What does this screenshot show?"></textarea>
|
||||||
|
</label>
|
||||||
<div class="issue-evidence-review-actions" aria-label="Reorder selected screenshot">
|
<div class="issue-evidence-review-actions" aria-label="Reorder selected screenshot">
|
||||||
<button id="move-create-issue-attachment-earlier" type="button">Move earlier</button>
|
<button id="move-create-issue-attachment-earlier" type="button">Move earlier</button>
|
||||||
<button id="remove-create-issue-attachment" type="button">Remove selected</button>
|
<button id="remove-create-issue-attachment" type="button">Remove selected</button>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@
|
||||||
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
|
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
|
||||||
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||||
|
|
||||||
|
function normalizeNote(value) {
|
||||||
|
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeMarkdown(value) {
|
||||||
|
return value.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||||
|
}
|
||||||
|
|
||||||
const optimizeImage = issueEvidenceReview.optimizeImage;
|
const optimizeImage = issueEvidenceReview.optimizeImage;
|
||||||
|
|
||||||
function multipart(attachment) {
|
function multipart(attachment) {
|
||||||
|
|
@ -48,7 +56,7 @@
|
||||||
if (maxFiles === 1) selected = [];
|
if (maxFiles === 1) selected = [];
|
||||||
else throw new Error(MAX_FILES_MESSAGE);
|
else throw new Error(MAX_FILES_MESSAGE);
|
||||||
}
|
}
|
||||||
selected.push({file, confirmed:null, serialized:null, operationId:createOperationId()});
|
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
|
||||||
return state();
|
return state();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +103,19 @@
|
||||||
return state();
|
return state();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setNote(index, value) {
|
||||||
|
const position = Number(index);
|
||||||
|
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||||
|
selected[position].note = normalizeNote(value);
|
||||||
|
selected[position].serialized = null;
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
|
function note(index) {
|
||||||
|
const position = Number(index);
|
||||||
|
return Number.isInteger(position) && selected[position] ? selected[position].note : '';
|
||||||
|
}
|
||||||
|
|
||||||
function restore(value) {
|
function restore(value) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
clear();
|
clear();
|
||||||
|
|
@ -118,7 +139,12 @@
|
||||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||||
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||||
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
||||||
selected[selected.length - 1].serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
|
const item = selected[selected.length - 1];
|
||||||
|
item.note = normalizeNote(value?.note);
|
||||||
|
item.serialized = {
|
||||||
|
...(blob ? { filename, contentType, blob } : { filename, contentType, data }),
|
||||||
|
...(item.note ? {note:item.note} : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function state() {
|
function state() {
|
||||||
|
|
@ -133,6 +159,7 @@
|
||||||
function serializeItem(item) {
|
function serializeItem(item) {
|
||||||
if (!item.serialized) item.serialized = {
|
if (!item.serialized) item.serialized = {
|
||||||
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
|
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
|
||||||
|
...(item.note ? {note:item.note} : {}),
|
||||||
};
|
};
|
||||||
return { ...item.serialized };
|
return { ...item.serialized };
|
||||||
}
|
}
|
||||||
|
|
@ -163,13 +190,16 @@
|
||||||
throw new Error('The server did not confirm the screenshot upload.');
|
throw new Error('The server did not confirm the screenshot upload.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
markdown.push(evidence.confirmed.markdown);
|
if (evidence.note) {
|
||||||
|
markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' +
|
||||||
|
evidence.confirmed.markdown);
|
||||||
|
} else markdown.push(evidence.confirmed.markdown);
|
||||||
}
|
}
|
||||||
const evidence = markdown.join('\n\n');
|
const evidence = markdown.join('\n\n');
|
||||||
return text ? text + '\n\n' + evidence : evidence;
|
return text ? text + '\n\n' + evidence : evidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { select, restore, remove, move, clear, state, serialize, prepareComment };
|
return { select, restore, remove, move, setNote, note, clear, state, serialize, prepareComment };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mount(options) {
|
function mount(options) {
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,20 @@
|
||||||
busy = Boolean(value);
|
busy = Boolean(value);
|
||||||
options.earlier.disabled = busy || activeIndex <= 0;
|
options.earlier.disabled = busy || activeIndex <= 0;
|
||||||
options.later.disabled = busy || activeIndex >= count() - 1;
|
options.later.disabled = busy || activeIndex >= count() - 1;
|
||||||
|
if (options.note) options.note.disabled = busy;
|
||||||
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
|
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateNote(values) {
|
||||||
|
if (!options.note) return;
|
||||||
|
options.note.value = controller.note(activeIndex);
|
||||||
|
options.note.disabled = busy;
|
||||||
|
if (options.noteLabel) {
|
||||||
|
options.noteLabel.textContent = 'Evidence note for screenshot ' + (activeIndex + 1) + ' of ' +
|
||||||
|
values.length + ' (optional)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function update(values, optimized = false) {
|
function update(values, optimized = false) {
|
||||||
if (!values.length) return;
|
if (!values.length) return;
|
||||||
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
|
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
|
||||||
|
|
@ -86,6 +97,7 @@
|
||||||
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
|
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
|
||||||
options.earlier.disabled = busy || activeIndex === 0;
|
options.earlier.disabled = busy || activeIndex === 0;
|
||||||
options.later.disabled = busy || activeIndex === values.length - 1;
|
options.later.disabled = busy || activeIndex === values.length - 1;
|
||||||
|
updateNote(values);
|
||||||
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
|
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
|
||||||
values.length + ' screenshots ready to file in this order.';
|
values.length + ' screenshots ready to file in this order.';
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +137,11 @@
|
||||||
options.tray.hidden = true;
|
options.tray.hidden = true;
|
||||||
options.earlier.disabled = true;
|
options.earlier.disabled = true;
|
||||||
options.later.disabled = true;
|
options.later.disabled = true;
|
||||||
|
if (options.note) {
|
||||||
|
options.note.value = '';
|
||||||
|
options.note.disabled = true;
|
||||||
|
}
|
||||||
|
if (options.noteLabel) options.noteLabel.textContent = 'Evidence note (optional)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function afterRemoval(removedIndex, previousCount) {
|
function afterRemoval(removedIndex, previousCount) {
|
||||||
|
|
@ -145,6 +162,9 @@
|
||||||
|
|
||||||
options.earlier.addEventListener('click', () => move(-1));
|
options.earlier.addEventListener('click', () => move(-1));
|
||||||
options.later.addEventListener('click', () => move(1));
|
options.later.addEventListener('click', () => move(1));
|
||||||
|
options.note?.addEventListener('input', event => {
|
||||||
|
controller.setNote(activeIndex, event.target.value);
|
||||||
|
});
|
||||||
clear();
|
clear();
|
||||||
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy };
|
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
const blob = value?.blob;
|
const blob = value?.blob;
|
||||||
const data = String(value?.data || '');
|
const data = String(value?.data || '');
|
||||||
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
|
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
|
||||||
if (blob) return { filename, contentType, blob };
|
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||||
if (!data && value?.stored === true) return { filename, contentType, stored: true };
|
const noteValue = note ? { note } : {};
|
||||||
|
if (blob) return { filename, contentType, blob, ...noteValue };
|
||||||
|
if (!data && value?.stored === true) return { filename, contentType, stored: true, ...noteValue };
|
||||||
if (!data) return undefined;
|
if (!data) return undefined;
|
||||||
return { filename, contentType, data };
|
return { filename, contentType, data, ...noteValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureAttachments(values) {
|
function captureAttachments(values) {
|
||||||
|
|
@ -90,6 +92,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
}} : {}),
|
}} : {}),
|
||||||
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
|
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
|
||||||
filename:value.filename, contentType:value.contentType, stored:true,
|
filename:value.filename, contentType:value.contentType, stored:true,
|
||||||
|
...(value.note ? {note:value.note} : {}),
|
||||||
}))} : {}),
|
}))} : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -247,6 +250,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evidenceMarkdown(attachment, markdown, index) {
|
||||||
|
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||||
|
if (!note) return markdown;
|
||||||
|
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||||
|
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
||||||
|
}
|
||||||
|
|
||||||
function attachmentMultipart(attachment) {
|
function attachmentMultipart(attachment) {
|
||||||
let blob = attachment?.blob;
|
let blob = attachment?.blob;
|
||||||
if (!blob && attachment?.data) {
|
if (!blob && attachment?.data) {
|
||||||
|
|
@ -275,30 +285,42 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
|
if (item.attachment || item.attachments) persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||||
}
|
}
|
||||||
if (!item.attachment) return issue;
|
const attachments = Array.isArray(item.attachments) ? item.attachments :
|
||||||
let markdown = item.attachmentMarkdown;
|
(item.attachment ? [item.attachment] : []);
|
||||||
if (!markdown) {
|
if (!attachments.length) return issue;
|
||||||
|
const markdowns = Array.isArray(item.attachmentMarkdowns)
|
||||||
|
? item.attachmentMarkdowns.slice(0, attachments.length)
|
||||||
|
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
||||||
|
for (let index = markdowns.length; index < attachments.length; index += 1) {
|
||||||
const uploaded = await fetchJson(
|
const uploaded = await fetchJson(
|
||||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
|
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
'Idempotency-Key': stageOperationId(
|
||||||
|
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
body: attachmentMultipart(item.attachment),
|
body: attachmentMultipart(attachments[index]),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
markdown = String(uploaded?.markdown || '');
|
const markdown = String(uploaded?.markdown || '');
|
||||||
if (!markdown) {
|
if (!markdown) {
|
||||||
const error = new Error('The server did not confirm the screenshot upload.');
|
const error = new Error('The server did not confirm the screenshot upload.');
|
||||||
error.status = 422;
|
error.status = 422;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
|
markdowns.push(markdown);
|
||||||
|
persistDeliveryStage(item.id, {
|
||||||
|
deliveredIssue: issue, attachmentMarkdowns:markdowns.slice(),
|
||||||
|
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
const markdown = markdowns.map((value, index) =>
|
||||||
|
evidenceMarkdown(attachments[index], value, index)).join('\n\n');
|
||||||
await fetchJson(
|
await fetchJson(
|
||||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ function createUnfiledCaptures({
|
||||||
attachments:prepared.attachments.map(value => ({
|
attachments:prepared.attachments.map(value => ({
|
||||||
filename:String(value.filename).slice(0, 255),
|
filename:String(value.filename).slice(0, 255),
|
||||||
contentType:String(value.contentType), blob:value.blob,
|
contentType:String(value.contentType), blob:value.blob,
|
||||||
|
...(String(value.note || '').trim() ? {note:String(value.note).trim().slice(0, 240)} : {}),
|
||||||
})),
|
})),
|
||||||
} : {
|
} : {
|
||||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ FEATURE_SOURCES = {
|
||||||
"static/assign-and-start.js", "static/queue-today.js",
|
"static/assign-and-start.js", "static/queue-today.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||||
"static/search-batch-plan.js", "static/issue-evidence-review.js",
|
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-attachment.js",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
CACHE_DECLARATION = re.compile(
|
CACHE_DECLARATION = re.compile(
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,8 @@ def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_com
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||||
let item={{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Journey',body:'',labelIds:[],attachments:[
|
let item={{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Journey',body:'',labelIds:[],attachments:[
|
||||||
{{filename:'one.png',contentType:'image/png',data:'b25l'}},
|
{{filename:'one.png',contentType:'image/png',data:'b25l',note:'First state'}},
|
||||||
{{filename:'two.png',contentType:'image/png',data:'dHdv'}},
|
{{filename:'two.png',contentType:'image/png',data:'dHdv',note:'Tap [Submit](unsafe)'}},
|
||||||
{{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
|
{{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
|
||||||
]}};
|
]}};
|
||||||
const calls=[];let twoAttempts=0;
|
const calls=[];let twoAttempts=0;
|
||||||
|
|
@ -195,7 +195,9 @@ const fetchJson=async(url,options={{}})=>{{
|
||||||
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
|
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
|
||||||
assert len(comments) == 1
|
assert len(comments) == 1
|
||||||
assert comments[0]["body"]["body"].split("\n\n") == [
|
assert comments[0]["body"]["body"].split("\n\n") == [
|
||||||
"", "", ""
|
"**Screenshot 1 — First state**", "",
|
||||||
|
"**Screenshot 2 — Tap \\[Submit\\]\\(unsafe\\)**", "",
|
||||||
|
""
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,37 @@ controller.move(0, 1);
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_evidence_notes_follow_images_and_render_as_safe_ordered_captions():
|
||||||
|
script = f"""
|
||||||
|
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||||
|
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||||
|
const controller=attachment.create({{
|
||||||
|
maxFiles:5,
|
||||||
|
upload:async payload=>({{markdown:''}}),
|
||||||
|
}});
|
||||||
|
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
|
||||||
|
controller.setNote(0, ' Login *token* is visible ');
|
||||||
|
controller.setNote(1, 'Keyboard hides [Submit](bad)');
|
||||||
|
controller.setNote(2, '');
|
||||||
|
controller.move(1, 0);
|
||||||
|
controller.remove(1);
|
||||||
|
(async()=>{{
|
||||||
|
const serialized=await controller.serialize();
|
||||||
|
const comment=await controller.prepareComment({{repository:'o/r',number:827}},'Evidence');
|
||||||
|
process.stdout.write(JSON.stringify({{serialized:serialized.map(x=>({{filename:x.filename,note:x.note||''}})),comment}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
output = json.loads(run_node(script))
|
||||||
|
assert output["serialized"] == [
|
||||||
|
{"filename": "two.png", "note": "Keyboard hides [Submit](bad)"},
|
||||||
|
{"filename": "three.png", "note": ""},
|
||||||
|
]
|
||||||
|
assert output["comment"] == (
|
||||||
|
"Evidence\n\n**Screenshot 1 — Keyboard hides \\[Submit\\]\\(bad\\)**\n\n"
|
||||||
|
"\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
|
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
|
||||||
script = f"""
|
script = f"""
|
||||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||||
|
|
@ -347,6 +378,55 @@ input.files=['one.png','two.png','three.png'].map(file);
|
||||||
assert output["status"] == "Screenshot removed. Your issue draft is unchanged."
|
assert output["status"] == "Screenshot removed. Your issue draft is unchanged."
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_review_edits_the_active_note_and_keeps_it_visible_after_reorder():
|
||||||
|
script = f"""
|
||||||
|
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||||
|
class Element{{
|
||||||
|
constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};}}
|
||||||
|
addEventListener(type,fn){{this.listeners[type]=fn;}}
|
||||||
|
dispatch(type){{return this.listeners[type]({{target:this}});}}
|
||||||
|
appendChild(child){{this.children.push(child);return child;}}
|
||||||
|
replaceChildren(...children){{this.children=children;}}
|
||||||
|
setAttribute(name,value){{this.attributes[name]=String(value);}}
|
||||||
|
}}
|
||||||
|
const document={{createElement:tag=>new Element(tag)}};
|
||||||
|
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button'),note=new Element('textarea'),noteLabel=new Element('label');
|
||||||
|
input.multiple=true;
|
||||||
|
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,note,noteLabel,document,
|
||||||
|
createObjectURL:blob=>'blob:'+blob.name,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||||
|
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||||
|
input.files=['one.png','two.png','three.png'].map(file);
|
||||||
|
(async()=>{{
|
||||||
|
await input.dispatch('change');
|
||||||
|
await tray.children[1].dispatch('click');
|
||||||
|
note.value='Keyboard hides Submit';
|
||||||
|
await note.dispatch('input');
|
||||||
|
await earlier.dispatch('click');
|
||||||
|
const afterMove={{value:note.value,label:noteLabel.textContent,disabled:note.disabled}};
|
||||||
|
await tray.children[1].dispatch('click');
|
||||||
|
const other={{value:note.value,label:noteLabel.textContent}};
|
||||||
|
process.stdout.write(JSON.stringify({{afterMove,other,serialized:(await controller.serialize()).map(x=>[x.filename,x.note||''])}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
output = json.loads(run_node(script))
|
||||||
|
assert output == {
|
||||||
|
"afterMove": {
|
||||||
|
"value": "Keyboard hides Submit",
|
||||||
|
"label": "Evidence note for screenshot 1 of 3 (optional)",
|
||||||
|
"disabled": False,
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"value": "",
|
||||||
|
"label": "Evidence note for screenshot 2 of 3 (optional)",
|
||||||
|
},
|
||||||
|
"serialized": [
|
||||||
|
["two.png", "Keyboard hides Submit"],
|
||||||
|
["one.png", ""],
|
||||||
|
["three.png", ""],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
|
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
|
||||||
script = f"""
|
script = f"""
|
||||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||||
|
|
@ -396,9 +476,14 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
||||||
assert 'aria-label="Evidence screenshots"' in html
|
assert 'aria-label="Evidence screenshots"' in html
|
||||||
assert 'id="move-create-issue-attachment-earlier"' in html
|
assert 'id="move-create-issue-attachment-earlier"' in html
|
||||||
assert 'id="move-create-issue-attachment-later"' in html
|
assert 'id="move-create-issue-attachment-later"' in html
|
||||||
|
assert 'id="create-issue-evidence-note-label"' in html
|
||||||
|
assert 'id="create-issue-evidence-note"' in html
|
||||||
|
assert 'maxlength="240"' in html
|
||||||
assert "tray: qs('#create-issue-evidence-tray')" in source
|
assert "tray: qs('#create-issue-evidence-tray')" in source
|
||||||
assert "earlier: qs('#move-create-issue-attachment-earlier')" in source
|
assert "earlier: qs('#move-create-issue-attachment-earlier')" in source
|
||||||
assert "later: qs('#move-create-issue-attachment-later')" in source
|
assert "later: qs('#move-create-issue-attachment-later')" in source
|
||||||
|
assert "note: qs('#create-issue-evidence-note')" in source
|
||||||
|
assert "noteLabel: qs('#create-issue-evidence-note-label')" in source
|
||||||
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
|
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
|
||||||
assert "attachments:evidence" in source
|
assert "attachments:evidence" in source
|
||||||
assert "hydrated.attachments || hydrated.attachment" in source
|
assert "hydrated.attachments || hydrated.attachment" in source
|
||||||
|
|
@ -406,6 +491,8 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
||||||
assert ".create-issue-attachment" in css
|
assert ".create-issue-attachment" in css
|
||||||
assert ".issue-evidence-tray" in css
|
assert ".issue-evidence-tray" in css
|
||||||
assert ".issue-evidence-review-actions" in css
|
assert ".issue-evidence-review-actions" in css
|
||||||
|
assert ".issue-evidence-note" in css
|
||||||
|
assert "resize:vertical" in css
|
||||||
assert "#create-issue-attachment-preview { grid-template-columns:1fr;" in css
|
assert "#create-issue-attachment-preview { grid-template-columns:1fr;" in css
|
||||||
assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css
|
assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css
|
||||||
assert "overflow-x:auto" in css
|
assert "overflow-x:auto" in css
|
||||||
|
|
@ -621,6 +708,8 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
|
||||||
assert "thumbnail tray" in readme
|
assert "thumbnail tray" in readme
|
||||||
assert "Move earlier" in readme
|
assert "Move earlier" in readme
|
||||||
assert "Remove selected" in readme
|
assert "Remove selected" in readme
|
||||||
|
assert "Evidence note" in readme
|
||||||
|
assert "caption" in readme
|
||||||
|
|
||||||
|
|
||||||
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
|
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ def test_issue_outbox_preserves_bounded_ordered_evidence_bundle():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
const image=name=>({{filename:name,contentType:'image/png',data:name}});
|
const image=name=>({{filename:name,contentType:'image/png',data:name,note:'Explain '+name}});
|
||||||
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle'}});
|
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle'}});
|
||||||
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:['one','two','three','four','five'].map(image)}});
|
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:['one','two','three','four','five'].map(image)}});
|
||||||
let limit='';try{{outbox.enqueue({{repository:'o/r',title:'Too many',attachments:['1','2','3','4','5','6'].map(image)}});}}catch(error){{limit=error.message;}}
|
let limit='';try{{outbox.enqueue({{repository:'o/r',title:'Too many',attachments:['1','2','3','4','5','6'].map(image)}});}}catch(error){{limit=error.message;}}
|
||||||
|
|
@ -86,9 +86,43 @@ process.stdout.write(JSON.stringify({{queued,list:outbox.list(),limit}}));
|
||||||
"one", "two", "three", "four", "five"
|
"one", "two", "three", "four", "five"
|
||||||
]
|
]
|
||||||
assert output["list"][0]["attachments"] == output["queued"]["attachments"]
|
assert output["list"][0]["attachments"] == output["queued"]["attachments"]
|
||||||
|
assert [item["note"] for item in output["queued"]["attachments"]] == [
|
||||||
|
"Explain one", "Explain two", "Explain three", "Explain four", "Explain five"
|
||||||
|
]
|
||||||
assert output["limit"] == "You can attach up to 5 screenshots."
|
assert output["limit"] == "You can attach up to 5 screenshots."
|
||||||
|
|
||||||
|
|
||||||
|
def test_foreground_issue_delivery_posts_the_same_captioned_evidence_bundle():
|
||||||
|
script = f"""
|
||||||
|
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||||
|
const values=new Map(),calls=[];
|
||||||
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
const fetchJson=async(url,options={{}})=>{{
|
||||||
|
let body=null;
|
||||||
|
if(options.body instanceof FormData)body={{filename:options.body.get('file').name}};
|
||||||
|
else if(options.body)body=JSON.parse(options.body);
|
||||||
|
calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||||
|
if(url.endsWith('/issues'))return{{number:827}};
|
||||||
|
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||||
|
return{{id:9}};
|
||||||
|
}};
|
||||||
|
let id=0;const outbox=createIssueOutbox({{storage,fetchJson,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++id)}});
|
||||||
|
const image=(name,note)=>({{filename:name,contentType:'image/png',data:'eA==',note}});
|
||||||
|
outbox.enqueue({{repository:'o/r',title:'Journey',attachments:[image('one.png','First'),image('two.png','Tap *Submit*')]}});
|
||||||
|
(async()=>{{const result=await outbox.flush('timmy');process.stdout.write(JSON.stringify({{result,calls}}));}})();
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
assert output["result"]["confirmed"][0]["number"] == 827
|
||||||
|
uploads = [call for call in output["calls"] if call["url"].endswith("/attachments")]
|
||||||
|
assert [call["body"]["filename"] for call in uploads] == ["one.png", "two.png"]
|
||||||
|
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
|
||||||
|
assert len(comments) == 1
|
||||||
|
assert comments[0]["body"]["body"] == (
|
||||||
|
"**Screenshot 1 — First**\n\n\n\n"
|
||||||
|
"**Screenshot 2 — Tap \\*Submit\\***\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_issue_outbox_replaces_evidence_bundle_when_editing_a_queued_issue():
|
def test_issue_outbox_replaces_evidence_bundle_when_editing_a_queued_issue():
|
||||||
script = f"""
|
script = f"""
|
||||||
|
|
|
||||||
|
|
@ -91,16 +91,17 @@ const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||||
const values=new Map(),blobs=new Map();
|
const values=new Map(),blobs=new Map();
|
||||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
|
const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
|
||||||
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}}),note:'Note '+name}});
|
||||||
(async()=>{{const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'bundle'}});
|
(async()=>{{const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'bundle'}});
|
||||||
const saved=await captures.save({{title:'Journey',body:'Steps',attachments:['one','two','three'].map(image)}});
|
const saved=await captures.save({{title:'Journey',body:'Steps',attachments:['one','two','three'].map(image)}});
|
||||||
const resumed=await captures.resume(saved.id,'timmy');
|
const resumed=await captures.resume(saved.id,'timmy');
|
||||||
process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.attachments.map(x=>x.filename),stored:blobs.get('bundle').attachments.map(x=>x.filename)}}));}})();
|
process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.attachments.map(x=>x.filename),notes:resumed.attachments.map(x=>x.note),stored:blobs.get('bundle').attachments.map(x=>x.note)}}));}})();
|
||||||
"""
|
"""
|
||||||
output = run_node(script)
|
output = run_node(script)
|
||||||
assert output["listed"]["attachmentCount"] == 3
|
assert output["listed"]["attachmentCount"] == 3
|
||||||
assert output["names"] == ["one", "two", "three"]
|
assert output["names"] == ["one", "two", "three"]
|
||||||
assert output["stored"] == output["names"]
|
assert output["notes"] == ["Note one", "Note two", "Note three"]
|
||||||
|
assert output["stored"] == output["notes"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user