feat: attach screenshots during issue capture (#469)
This commit is contained in:
parent
11341ec5d9
commit
5bf491c725
|
|
@ -21,7 +21,11 @@ inspect/comment on assigned pull
|
|||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Assigned-issue comments can include one PNG, JPEG, or WebP screenshot up to 2 MB.
|
||||
The screenshot uploads before the comment is posted; validation or upload failures keep
|
||||
both the typed comment and removable preview available for retry.
|
||||
both the typed comment and removable preview available for retry. The mobile **New issue**
|
||||
sheet accepts the same image formats and stores the screenshot with its account-bound
|
||||
outbox capture. 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.
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -141,8 +141,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
|||
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && item.status === 'attention') ||
|
||||
current.status === 'sent')) return current;
|
||||
await records.put({ ...item });
|
||||
return item;
|
||||
const next = current && current.operationId === item.operationId ? {
|
||||
...item,
|
||||
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
||||
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
||||
} : { ...item };
|
||||
await records.put(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -177,6 +182,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
|||
return {
|
||||
reconcile,
|
||||
upsert,
|
||||
update,
|
||||
claim,
|
||||
claimNext,
|
||||
claimBatch,
|
||||
|
|
@ -343,10 +349,63 @@ function createBackgroundIssueSync({
|
|||
};
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function deliverIssueCapture(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let deliveredIssue = item.deliveredIssue;
|
||||
if (!deliveredIssue) {
|
||||
const request = deliveryRequest(item);
|
||||
deliveredIssue = await requestJson(request.url, request.options);
|
||||
await store.update?.(item.id, current => ({ ...current, deliveredIssue }));
|
||||
}
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestJson(
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: item.attachment.filename,
|
||||
content_type: item.attachment.contentType,
|
||||
data: item.attachment.data,
|
||||
}),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await store.update?.(item.id, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
|
||||
}
|
||||
await requestJson(
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: attachmentMarkdown }),
|
||||
},
|
||||
);
|
||||
return deliveredIssue;
|
||||
}
|
||||
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
const delivered = item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestJson(request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
const error = new Error('Issue closure was not confirmed.');
|
||||
error.status = 422;
|
||||
|
|
|
|||
|
|
@ -337,10 +337,11 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
|
|
|
|||
|
|
@ -303,6 +303,25 @@
|
|||
);
|
||||
},
|
||||
});
|
||||
const createIssueAttachmentController = issueAttachment.mount({
|
||||
input: qs('#create-issue-attachment'),
|
||||
preview: qs('#create-issue-attachment-preview'),
|
||||
image: qs('#create-issue-attachment-image'),
|
||||
meta: qs('#create-issue-attachment-meta'),
|
||||
remove: qs('#remove-create-issue-attachment'),
|
||||
status: qs('#create-issue-attachment-status'),
|
||||
readyMessage: 'Screenshot ready to file with this issue.',
|
||||
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
|
||||
createObjectURL: file => URL.createObjectURL(file),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
readDataUrl: file => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
|
||||
reader.readAsDataURL(file);
|
||||
}),
|
||||
upload: async () => { throw new Error('Create the issue before uploading its screenshot.'); },
|
||||
});
|
||||
const planningLoader = createIssueSheet.createPlanningLoader({
|
||||
loadLabels: item => issueController.loadLabels(item),
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
|
|
@ -1694,6 +1713,8 @@
|
|||
editingOutboxId = queued.id;
|
||||
issueCapture.saveDraft(queued);
|
||||
openCreateIssueSheet();
|
||||
if (queued.attachment) createIssueAttachmentController.restore(queued.attachment);
|
||||
else createIssueAttachmentController.clear();
|
||||
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
|
||||
});
|
||||
});
|
||||
|
|
@ -3542,7 +3563,11 @@
|
|||
qs('#create-issue-status').textContent = createAndStartRequested ?
|
||||
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
||||
try {
|
||||
const durableDraft = createAndStartRequested ? { ...captureDraft, completionIntent: 'create-and-start' } : captureDraft;
|
||||
const durableDraft = {
|
||||
...captureDraft,
|
||||
attachment: await createIssueAttachmentController.serialize(),
|
||||
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
|
||||
};
|
||||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||
await issueOutbox.enqueueDurably(durableDraft);
|
||||
const queued = admission.item;
|
||||
|
|
@ -3556,6 +3581,7 @@
|
|||
}
|
||||
editingOutboxId = null;
|
||||
issueCapture.clearDraft();
|
||||
createIssueAttachmentController.clear();
|
||||
suppressCreateDraftOnHistoryClose = true;
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
|
|
|
|||
|
|
@ -418,6 +418,19 @@
|
|||
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
|
||||
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
|
||||
<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" />
|
||||
</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>
|
||||
</div>
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
||||
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||
<option value="">No milestone</option>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
const upload = options.upload;
|
||||
let selected = null;
|
||||
let confirmed = null;
|
||||
let serialized = null;
|
||||
|
||||
function select(file) {
|
||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||
|
|
@ -23,12 +24,25 @@
|
|||
}
|
||||
selected = file;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
return state();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selected = null;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
}
|
||||
|
||||
function restore(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '');
|
||||
const data = String(value?.data || '');
|
||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||
const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||
select({ name: filename, type: contentType, size });
|
||||
serialized = { filename, contentType, data };
|
||||
return state();
|
||||
}
|
||||
|
||||
function state() {
|
||||
|
|
@ -39,20 +53,33 @@
|
|||
} : null;
|
||||
}
|
||||
|
||||
async function prepareComment(item, body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!selected) return text;
|
||||
if (!confirmed) {
|
||||
async function serialize() {
|
||||
if (!selected) return null;
|
||||
if (!serialized) {
|
||||
const dataUrl = await readDataUrl(selected);
|
||||
const marker = ';base64,';
|
||||
const markerAt = String(dataUrl).indexOf(marker);
|
||||
if (markerAt < 0) throw new Error('The screenshot could not be read. Choose it again.');
|
||||
serialized = {
|
||||
filename: selected.name,
|
||||
contentType: selected.type,
|
||||
data: String(dataUrl).slice(markerAt + marker.length),
|
||||
};
|
||||
}
|
||||
return { ...serialized };
|
||||
}
|
||||
|
||||
async function prepareComment(item, 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: selected.name,
|
||||
content_type: selected.type,
|
||||
data: String(dataUrl).slice(markerAt + marker.length),
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
data: attachment.data,
|
||||
});
|
||||
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
||||
confirmed = null;
|
||||
|
|
@ -62,7 +89,7 @@
|
|||
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
||||
}
|
||||
|
||||
return { select, clear, state, prepareComment };
|
||||
return { select, restore, clear, state, serialize, prepareComment };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
|
|
@ -93,14 +120,25 @@
|
|||
options.image.src = previewUrl;
|
||||
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = 'Screenshot ready to upload with this comment.';
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
});
|
||||
options.remove.addEventListener('click', () => {
|
||||
clearPreview();
|
||||
options.status.textContent = 'Screenshot removed. Your comment is unchanged.';
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
});
|
||||
|
||||
return Object.assign(controller, { clear: clearPreview });
|
||||
function restorePreview(value) {
|
||||
clearPreview();
|
||||
const restored = controller.restore(value);
|
||||
previewUrl = 'data:' + value.contentType + ';base64,' + value.data;
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
return restored;
|
||||
}
|
||||
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview });
|
||||
}
|
||||
|
||||
return { create, mount, MAX_BYTES };
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
);
|
||||
const pending = new Map();
|
||||
|
||||
function captureAttachment(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '').slice(0, 255);
|
||||
const data = String(value?.data || '');
|
||||
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType) || !data) return undefined;
|
||||
return { filename, contentType, data };
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
|
|
@ -40,6 +48,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
queuedAt: Number(now()),
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
|
|
@ -76,14 +86,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
? Number(draft.milestoneId) : undefined;
|
||||
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 changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||
|| attachmentChanged;
|
||||
updated = {
|
||||
...item,
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
|
|
@ -91,6 +104,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
else delete updated.completionIntent;
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
if (nextAttachment === undefined) delete updated.attachment;
|
||||
if (attachmentChanged) delete updated.attachmentMarkdown;
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
|
|
@ -119,6 +134,72 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
return true;
|
||||
}
|
||||
|
||||
function persistDeliveryStage(id, stage) {
|
||||
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function sendDirect(item, repository) {
|
||||
let issue = item.deliveredIssue;
|
||||
if (!issue) {
|
||||
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
}
|
||||
if (!item.attachment) return issue;
|
||||
let markdown = item.attachmentMarkdown;
|
||||
if (!markdown) {
|
||||
const uploaded = await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: item.attachment.filename,
|
||||
content_type: item.attachment.contentType,
|
||||
data: item.attachment.data,
|
||||
}),
|
||||
},
|
||||
);
|
||||
markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
|
||||
}
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: markdown }),
|
||||
},
|
||||
);
|
||||
return issue;
|
||||
}
|
||||
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
|
|
@ -135,18 +216,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
}
|
||||
issue = delivery.issue;
|
||||
} else {
|
||||
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
issue = await sendDirect(item, repository);
|
||||
}
|
||||
if (!issue) return { blocked: true };
|
||||
discard(item.id);
|
||||
|
|
@ -237,6 +307,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const background = statuses.get(item.id);
|
||||
const deliveryStage = {
|
||||
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
|
||||
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
|
||||
};
|
||||
if (background?.status === 'sent') {
|
||||
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
||||
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
||||
|
|
@ -244,10 +318,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
return [];
|
||||
}
|
||||
if (background?.status === 'attention') return [{
|
||||
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...item, ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [item];
|
||||
return [{ ...item, ...deliveryStage }];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v80';
|
||||
const CACHE = 'stackchain-dashboard-shell-v81';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,71 @@ const fetchJson = async (url, options = {{}}) => {{
|
|||
}
|
||||
|
||||
|
||||
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'capture-image',operationId:'capture-image',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',title:'Broken mobile layout',body:'At 320px',labelIds:[],
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}},
|
||||
}};
|
||||
const state = {{calls:[],completed:0,released:0}};
|
||||
const store = {{
|
||||
claimNext:async()=>item ? {{...item}} : null,
|
||||
update:async(_id,transform)=>{{item=transform(item);}},
|
||||
complete:async()=>{{state.completed+=1;item=null;}},
|
||||
release:async()=>{{state.released+=1;item={{...item,status:'queued'}};}},
|
||||
fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
let uploadAttempts=0;
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
if(url.endsWith('/issues'))return{{repository:'stackchain/dashboard',number:469,title:'Broken mobile layout'}};
|
||||
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('Upload unavailable');error.status=503;throw error;}}
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(url.endsWith('/comments'))return{{id:91}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson}});
|
||||
let firstError='';try{{await sync.flush();}}catch(error){{firstError=error.message;}}
|
||||
const afterFirst={{...item}};
|
||||
const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,firstError,afterFirst,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["firstError"] == "Upload unavailable"
|
||||
assert output["afterFirst"]["deliveredIssue"]["number"] == 469
|
||||
assert [call["url"] for call in output["state"]["calls"]].count(
|
||||
"api/v1/repos/stackchain/dashboard/issues"
|
||||
) == 1
|
||||
assert output["state"]["calls"][-2]["key"] == "capture-image:attachment"
|
||||
assert output["state"]["calls"][-1]["key"] == "capture-image:attachment-comment"
|
||||
assert output["state"]["calls"][-1]["body"] == {
|
||||
"body": ""
|
||||
}
|
||||
assert output["state"]["completed"] == 1
|
||||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_capture_attachment_stage_keys_remain_within_idempotency_limit():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const item={{id:'long',operationId:'x'.repeat(128),ownerLogin:'timmy',repository:'o/r',title:'Bug',body:'',labelIds:[],attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}};
|
||||
const keys=[];let queued=true;
|
||||
const store={{claimNext:async()=>queued?(queued=false,item):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};if(options.headers?.['Idempotency-Key'])keys.push(options.headers['Idempotency-Key']);if(url.endsWith('/issues'))return{{repository:'o/r',number:1}};if(url.endsWith('/attachments'))return{{markdown:''}};return{{id:1}};}};
|
||||
createBackgroundIssueSync({{store,fetchJson}}).flush().then(()=>process.stdout.write(JSON.stringify(keys)));
|
||||
"""
|
||||
keys = run_node(script)
|
||||
|
||||
assert len(keys) == 3
|
||||
assert len(set(keys)) == 3
|
||||
assert all(len(key) <= 128 for key in keys)
|
||||
|
||||
|
||||
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
|
@ -289,6 +354,25 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
|||
]
|
||||
|
||||
|
||||
def test_stale_foreground_upsert_preserves_confirmed_attachment_delivery_stages():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...records.values()].map(value=>({{...value}})),put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id)}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
|
||||
await store.update('capture',item=>({{...item,deliveredIssue:{{number:7}},attachmentMarkdown:''}}));
|
||||
const result=await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["deliveredIssue"]["number"] == 7
|
||||
assert output["attachmentMarkdown"] == ""
|
||||
|
||||
|
||||
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
|
|
|||
|
|
@ -281,4 +281,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read()
|
|||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v80" in worker
|
||||
assert "stackchain-dashboard-shell-v81" in worker
|
||||
|
|
|
|||
|
|
@ -54,6 +54,43 @@ controller.select(file);
|
|||
}
|
||||
|
||||
|
||||
def test_selected_screenshot_serializes_for_durable_issue_capture_without_uploading():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const calls=[];
|
||||
const controller=attachment.create({{
|
||||
readDataUrl:async file=>{{calls.push('read:'+file.name);return 'data:image/webp;base64,UklGRg==';}},
|
||||
upload:async()=>{{calls.push('upload');}},
|
||||
}});
|
||||
controller.select({{name:'phone.webp',type:'image/webp',size:8}});
|
||||
controller.serialize().then(value=>process.stdout.write(JSON.stringify({{value,calls}})));
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"value": {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"data": "UklGRg==",
|
||||
},
|
||||
"calls": ["read:phone.webp"],
|
||||
}
|
||||
|
||||
|
||||
def test_serialized_capture_attachment_can_be_restored_or_removed_while_editing():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
const controller=attachment.create({{readDataUrl:async()=>{{throw new Error('must not reread');}},upload:async()=>{{}}}});
|
||||
controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0KGgo='}});
|
||||
(async()=>{{const restored={{state:controller.state(),value:await controller.serialize()}};controller.clear();process.stdout.write(JSON.stringify({{restored,removed:await controller.serialize()}}));}})();
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output["restored"]["state"]["name"] == "saved.png"
|
||||
assert output["restored"]["value"]["data"] == "iVBORw0KGgo="
|
||||
assert output["removed"] is None
|
||||
|
||||
|
||||
def test_issue_composer_renders_thumb_reachable_screenshot_preview():
|
||||
html = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
|
|
@ -69,6 +106,23 @@ def test_issue_composer_renders_thumb_reachable_screenshot_preview():
|
|||
assert 'min-height:44px' in css
|
||||
|
||||
|
||||
def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
||||
html = INDEX.read_text()
|
||||
source = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="create-issue-attachment"' in html
|
||||
assert 'accept="image/png,image/jpeg,image/webp"' 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(queued.attachment);" in source
|
||||
assert "createIssueAttachmentController.clear();" in source
|
||||
assert ".create-issue-attachment" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
|
||||
def test_attachment_view_keeps_invalid_draft_and_removes_preview():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
|
|
@ -132,3 +186,5 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
|
|||
assert "PNG, JPEG, or WebP" in readme
|
||||
assert "2 MB" in readme
|
||||
assert "uploads before the comment is posted" in readme
|
||||
assert "New issue" in readme
|
||||
assert "retry resumes with the confirmed issue" in readme
|
||||
|
|
|
|||
|
|
@ -71,6 +71,29 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
|||
assert output["stored"]["version"] == 3
|
||||
|
||||
|
||||
def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
|
||||
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 outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'capture-image-1',
|
||||
}});
|
||||
outbox.enqueue({{
|
||||
repository:'stackchain/dashboard',title:'Layout breaks',body:'At 320px',
|
||||
attachment:{{filename:'phone.webp',contentType:'image/webp',data:'UklGRg=='}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["attachment"] == {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"data": "UklGRg==",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
|
@ -154,6 +177,50 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{resul
|
|||
assert output["remaining"][0]["status"] == "queued"
|
||||
|
||||
|
||||
def test_foreground_attachment_retry_does_not_create_a_second_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 calls=[];let uploadAttempts=0;
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'image-op',
|
||||
fetchJson:async(url,options={{}})=>{{
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
|
||||
if(url.endsWith('/issues'))return{{repository:'o/r',number:7,title:'Visual bug'}};
|
||||
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
return{{id:8}};
|
||||
}},
|
||||
}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'abc'}}}});
|
||||
(async()=>{{await outbox.flush('timmy');const partial=outbox.list()[0];const result=await outbox.retry(queued.id,'timmy');process.stdout.write(JSON.stringify({{calls,partial,result,remaining:outbox.list()}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["partial"]["deliveredIssue"]["number"] == 7
|
||||
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
|
||||
assert output["calls"][-1]["url"] == "api/v1/repos/o/r/issues/7/comments"
|
||||
assert output["result"]["confirmed"][0]["number"] == 7
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage():
|
||||
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)}};let sequence=0;
|
||||
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++sequence)}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Visual',attachment:{{filename:'old.png',contentType:'image/png',data:'old'}}}});
|
||||
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{...queued,deliveredIssue:{{repository:'o/r',number:7}},attachmentMarkdown:''}}]}}));
|
||||
const updated=outbox.update(queued.id,{{...queued,attachment:{{filename:'new.png',contentType:'image/png',data:'new'}}}});
|
||||
process.stdout.write(JSON.stringify(updated));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["deliveredIssue"]["number"] == 7
|
||||
assert output["attachment"]["filename"] == "new.png"
|
||||
assert "attachmentMarkdown" not in output
|
||||
|
||||
|
||||
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
|
@ -434,6 +501,21 @@ process.stdout.write(JSON.stringify(outbox.list()));
|
|||
]
|
||||
|
||||
|
||||
def test_page_reconciliation_keeps_confirmed_attachment_delivery_stages():
|
||||
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 outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'staged'}});
|
||||
outbox.enqueue({{repository:'o/r',title:'Visual',attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}});
|
||||
outbox.reconcileBackground([{{id:'staged',status:'queued',deliveredIssue:{{repository:'o/r',number:7}},attachmentMarkdown:''}}]);
|
||||
process.stdout.write(JSON.stringify(outbox.list()[0]));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["deliveredIssue"]["number"] == 7
|
||||
assert output["attachmentMarkdown"] == ""
|
||||
|
||||
|
||||
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
|
|
|||
|
|
@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
|||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v80" in worker
|
||||
assert "stackchain-dashboard-shell-v81" in worker
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
|||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v80" in worker
|
||||
assert "stackchain-dashboard-shell-v81" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
|
|
|||
|
|
@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
|||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
|
|||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
|||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
|||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -385,7 +385,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
|||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
|||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v80';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v81';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v80" in source
|
||||
assert "stackchain-dashboard-shell-v81" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user