Compare commits
No commits in common. "af86fc1e9839ce67ae4ed511f164e340d3f1bf5d" and "11341ec5d90105852be787a3954d5d63121120db" have entirely different histories.
af86fc1e98
...
11341ec5d9
|
|
@ -21,11 +21,7 @@ inspect/comment on assigned pull
|
||||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
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.
|
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
|
The screenshot uploads before the comment is posted; validation or upload failures keep
|
||||||
both the typed comment and removable preview available for retry. The mobile **New issue**
|
both the typed comment and removable preview available for retry.
|
||||||
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
|
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||||
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
||||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||||
|
|
|
||||||
|
|
@ -141,13 +141,8 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||||
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||||
(current.status === 'attention' && item.status === 'attention') ||
|
(current.status === 'attention' && item.status === 'attention') ||
|
||||||
current.status === 'sent')) return current;
|
current.status === 'sent')) return current;
|
||||||
const next = current && current.operationId === item.operationId ? {
|
await records.put({ ...item });
|
||||||
...item,
|
return item;
|
||||||
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
|
||||||
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
|
||||||
} : { ...item };
|
|
||||||
await records.put(next);
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,7 +177,6 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||||
return {
|
return {
|
||||||
reconcile,
|
reconcile,
|
||||||
upsert,
|
upsert,
|
||||||
update,
|
|
||||||
claim,
|
claim,
|
||||||
claimNext,
|
claimNext,
|
||||||
claimBatch,
|
claimBatch,
|
||||||
|
|
@ -349,63 +343,10 @@ 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) {
|
async function deliver(item) {
|
||||||
const request = deliveryRequest(item);
|
const request = deliveryRequest(item);
|
||||||
try {
|
try {
|
||||||
const delivered = item.attachment && !item.kind ?
|
const delivered = await requestJson(request.url, request.options);
|
||||||
await deliverIssueCapture(item) : await requestJson(request.url, request.options);
|
|
||||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||||
const error = new Error('Issue closure was not confirmed.');
|
const error = new Error('Issue closure was not confirmed.');
|
||||||
error.status = 422;
|
error.status = 422;
|
||||||
|
|
|
||||||
|
|
@ -337,11 +337,10 @@ 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%; } }
|
@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 { 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-sheet.open { display:flex; }
|
||||||
.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-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-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
.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-header button, .create-issue-actions button { min-height:44px; }
|
||||||
.create-issue-form { display:grid; gap:12px; }
|
.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-repository-more { min-height:44px; width:100%; }
|
||||||
.create-issue-form label { display:grid; gap:6px; }
|
.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; }
|
.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,25 +303,6 @@
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
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({
|
const planningLoader = createIssueSheet.createPlanningLoader({
|
||||||
loadLabels: item => issueController.loadLabels(item),
|
loadLabels: item => issueController.loadLabels(item),
|
||||||
loadMilestones: item => issueController.loadMilestones(item),
|
loadMilestones: item => issueController.loadMilestones(item),
|
||||||
|
|
@ -1713,8 +1694,6 @@
|
||||||
editingOutboxId = queued.id;
|
editingOutboxId = queued.id;
|
||||||
issueCapture.saveDraft(queued);
|
issueCapture.saveDraft(queued);
|
||||||
openCreateIssueSheet();
|
openCreateIssueSheet();
|
||||||
if (queued.attachment) createIssueAttachmentController.restore(queued.attachment);
|
|
||||||
else createIssueAttachmentController.clear();
|
|
||||||
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
|
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -3563,11 +3542,7 @@
|
||||||
qs('#create-issue-status').textContent = createAndStartRequested ?
|
qs('#create-issue-status').textContent = createAndStartRequested ?
|
||||||
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
||||||
try {
|
try {
|
||||||
const durableDraft = {
|
const durableDraft = createAndStartRequested ? { ...captureDraft, completionIntent: 'create-and-start' } : captureDraft;
|
||||||
...captureDraft,
|
|
||||||
attachment: await createIssueAttachmentController.serialize(),
|
|
||||||
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
|
|
||||||
};
|
|
||||||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||||
await issueOutbox.enqueueDurably(durableDraft);
|
await issueOutbox.enqueueDurably(durableDraft);
|
||||||
const queued = admission.item;
|
const queued = admission.item;
|
||||||
|
|
@ -3581,7 +3556,6 @@
|
||||||
}
|
}
|
||||||
editingOutboxId = null;
|
editingOutboxId = null;
|
||||||
issueCapture.clearDraft();
|
issueCapture.clearDraft();
|
||||||
createIssueAttachmentController.clear();
|
|
||||||
suppressCreateDraftOnHistoryClose = true;
|
suppressCreateDraftOnHistoryClose = true;
|
||||||
taskOverlayHistory.leave();
|
taskOverlayHistory.leave();
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
|
|
|
||||||
|
|
@ -418,19 +418,6 @@
|
||||||
<label for="create-issue-body">Description <span class="small">Optional</span>
|
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||||
</label>
|
</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>
|
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
||||||
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||||
<option value="">No milestone</option>
|
<option value="">No milestone</option>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
const upload = options.upload;
|
const upload = options.upload;
|
||||||
let selected = null;
|
let selected = null;
|
||||||
let confirmed = null;
|
let confirmed = null;
|
||||||
let serialized = null;
|
|
||||||
|
|
||||||
function select(file) {
|
function select(file) {
|
||||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||||
|
|
@ -24,25 +23,12 @@
|
||||||
}
|
}
|
||||||
selected = file;
|
selected = file;
|
||||||
confirmed = null;
|
confirmed = null;
|
||||||
serialized = null;
|
|
||||||
return state();
|
return state();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
selected = null;
|
selected = null;
|
||||||
confirmed = 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() {
|
function state() {
|
||||||
|
|
@ -53,33 +39,20 @@
|
||||||
} : null;
|
} : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
async function prepareComment(item, body) {
|
||||||
const text = String(body || '').trim();
|
const text = String(body || '').trim();
|
||||||
if (!selected) return text;
|
if (!selected) return text;
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
const attachment = await serialize();
|
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.');
|
||||||
confirmed = await upload({
|
confirmed = await upload({
|
||||||
repository: item.repository,
|
repository: item.repository,
|
||||||
number: item.number,
|
number: item.number,
|
||||||
filename: attachment.filename,
|
filename: selected.name,
|
||||||
content_type: attachment.contentType,
|
content_type: selected.type,
|
||||||
data: attachment.data,
|
data: String(dataUrl).slice(markerAt + marker.length),
|
||||||
});
|
});
|
||||||
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
||||||
confirmed = null;
|
confirmed = null;
|
||||||
|
|
@ -89,7 +62,7 @@
|
||||||
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { select, restore, clear, state, serialize, prepareComment };
|
return { select, clear, state, prepareComment };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mount(options) {
|
function mount(options) {
|
||||||
|
|
@ -120,25 +93,14 @@
|
||||||
options.image.src = previewUrl;
|
options.image.src = previewUrl;
|
||||||
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
||||||
options.preview.hidden = false;
|
options.preview.hidden = false;
|
||||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
options.status.textContent = 'Screenshot ready to upload with this comment.';
|
||||||
});
|
});
|
||||||
options.remove.addEventListener('click', () => {
|
options.remove.addEventListener('click', () => {
|
||||||
clearPreview();
|
clearPreview();
|
||||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
options.status.textContent = 'Screenshot removed. Your comment is unchanged.';
|
||||||
});
|
});
|
||||||
|
|
||||||
function restorePreview(value) {
|
return Object.assign(controller, { clear: clearPreview });
|
||||||
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 };
|
return { create, mount, MAX_BYTES };
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,6 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
);
|
);
|
||||||
const pending = new Map();
|
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() {
|
function read() {
|
||||||
try {
|
try {
|
||||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||||
|
|
@ -48,8 +40,6 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
queuedAt: Number(now()),
|
queuedAt: Number(now()),
|
||||||
};
|
};
|
||||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||||
const attachment = captureAttachment(draft?.attachment);
|
|
||||||
if (attachment) item.attachment = attachment;
|
|
||||||
item.operationId = item.id;
|
item.operationId = item.id;
|
||||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||||
item.milestoneId = Number(draft.milestoneId);
|
item.milestoneId = Number(draft.milestoneId);
|
||||||
|
|
@ -86,17 +76,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
? Number(draft.milestoneId) : undefined;
|
? Number(draft.milestoneId) : undefined;
|
||||||
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||||
? String(draft.dueDate) : undefined;
|
? String(draft.dueDate) : undefined;
|
||||||
const nextAttachment = captureAttachment(draft?.attachment);
|
|
||||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
|
|
||||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|
||||||
|| attachmentChanged;
|
|
||||||
updated = {
|
updated = {
|
||||||
...item,
|
...item,
|
||||||
repository: nextRepository, title: nextTitle,
|
repository: nextRepository, title: nextTitle,
|
||||||
body: nextBody, labelIds: nextLabelIds,
|
body: nextBody, labelIds: nextLabelIds,
|
||||||
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
|
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
};
|
};
|
||||||
|
|
@ -104,8 +91,6 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
else delete updated.completionIntent;
|
else delete updated.completionIntent;
|
||||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||||
if (nextDueDate === undefined) delete updated.dueDate;
|
if (nextDueDate === undefined) delete updated.dueDate;
|
||||||
if (nextAttachment === undefined) delete updated.attachment;
|
|
||||||
if (attachmentChanged) delete updated.attachmentMarkdown;
|
|
||||||
delete updated.error;
|
delete updated.error;
|
||||||
delete updated.deliveryState;
|
delete updated.deliveryState;
|
||||||
return updated;
|
return updated;
|
||||||
|
|
@ -134,72 +119,6 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
return true;
|
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) {
|
async function sendItem(item, currentLogin) {
|
||||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||||
if (pending.has(item.id)) return pending.get(item.id);
|
if (pending.has(item.id)) return pending.get(item.id);
|
||||||
|
|
@ -216,7 +135,18 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
}
|
}
|
||||||
issue = delivery.issue;
|
issue = delivery.issue;
|
||||||
} else {
|
} else {
|
||||||
issue = await sendDirect(item, repository);
|
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 (!issue) return { blocked: true };
|
if (!issue) return { blocked: true };
|
||||||
discard(item.id);
|
discard(item.id);
|
||||||
|
|
@ -307,10 +237,6 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||||
const items = read().flatMap(item => {
|
const items = read().flatMap(item => {
|
||||||
const background = statuses.get(item.id);
|
const background = statuses.get(item.id);
|
||||||
const deliveryStage = {
|
|
||||||
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
|
|
||||||
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
|
|
||||||
};
|
|
||||||
if (background?.status === 'sent') {
|
if (background?.status === 'sent') {
|
||||||
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
||||||
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
||||||
|
|
@ -318,10 +244,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (background?.status === 'attention') return [{
|
if (background?.status === 'attention') return [{
|
||||||
...item, ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||||
}];
|
}];
|
||||||
return [{ ...item, ...deliveryStage }];
|
return [item];
|
||||||
});
|
});
|
||||||
write(items);
|
write(items);
|
||||||
return items;
|
return items;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v81';
|
const CACHE = 'stackchain-dashboard-shell-v80';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
|
||||||
|
|
@ -64,71 +64,6 @@ 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():
|
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||||
|
|
@ -354,25 +289,6 @@ 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():
|
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
assert "stackchain-dashboard-shell-v81" in worker
|
assert "stackchain-dashboard-shell-v80" in worker
|
||||||
|
|
|
||||||
|
|
@ -54,43 +54,6 @@ 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():
|
def test_issue_composer_renders_thumb_reachable_screenshot_preview():
|
||||||
html = INDEX.read_text()
|
html = INDEX.read_text()
|
||||||
css = CSS.read_text()
|
css = CSS.read_text()
|
||||||
|
|
@ -106,23 +69,6 @@ def test_issue_composer_renders_thumb_reachable_screenshot_preview():
|
||||||
assert 'min-height:44px' in css
|
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():
|
def test_attachment_view_keeps_invalid_draft_and_removes_preview():
|
||||||
script = f"""
|
script = f"""
|
||||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||||
|
|
@ -186,5 +132,3 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
|
||||||
assert "PNG, JPEG, or WebP" in readme
|
assert "PNG, JPEG, or WebP" in readme
|
||||||
assert "2 MB" in readme
|
assert "2 MB" in readme
|
||||||
assert "uploads before the comment is posted" 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,29 +71,6 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
||||||
assert output["stored"]["version"] == 3
|
assert output["stored"]["version"] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_issue_outbox_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():
|
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
|
@ -177,50 +154,6 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{resul
|
||||||
assert output["remaining"][0]["status"] == "queued"
|
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():
|
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
|
@ -501,21 +434,6 @@ 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():
|
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
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():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/later-sync.js'" 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 { 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 pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v81" in worker
|
assert "stackchain-dashboard-shell-v80" 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]))
|
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 local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v81" in worker
|
assert "stackchain-dashboard-shell-v80" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
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():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.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():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" 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():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.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():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.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():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.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():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" 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():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/queue-today.js'" 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():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
service_worker = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "const CACHE = 'stackchain-dashboard-shell-v81';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v80';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" 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():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v81" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user