feat: reply, mark read, and continue updates (Closes #503)
This commit is contained in:
parent
4f0e6544b3
commit
1052ce2c84
|
|
@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||||
);
|
);
|
||||||
const pending = new Map();
|
const pending = new Map();
|
||||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']);
|
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close']);
|
||||||
|
|
||||||
function reviewFingerprint(message) {
|
function reviewFingerprint(message) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
|
|
@ -74,6 +74,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
ownerLogin,
|
ownerLogin,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
queuedAt: Number(now()),
|
queuedAt: Number(now()),
|
||||||
|
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||||
...(message.kind === 'issue-comment' && message.attachment ? {
|
...(message.kind === 'issue-comment' && message.attachment ? {
|
||||||
attachment: {
|
attachment: {
|
||||||
filename: String(message.attachment.filename || ''),
|
filename: String(message.attachment.filename || ''),
|
||||||
|
|
@ -183,7 +184,24 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
}
|
}
|
||||||
result = delivery.message;
|
result = delivery.message;
|
||||||
} else {
|
} else {
|
||||||
if (item.kind === 'issue-close') {
|
if (item.kind === 'update-reply-read') {
|
||||||
|
if (!item.replyConfirmed) {
|
||||||
|
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||||
|
'Idempotency-Key': item.operationId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ body: item.body }),
|
||||||
|
});
|
||||||
|
write(read().map(candidate => candidate.id === item.id ? {
|
||||||
|
...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt,
|
||||||
|
} : candidate), false);
|
||||||
|
}
|
||||||
|
result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
|
||||||
|
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
} else if (item.kind === 'issue-close') {
|
||||||
result = await fetchJson(endpoint(item), {
|
result = await fetchJson(endpoint(item), {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,7 @@ function createBackgroundIssueSync({
|
||||||
if (item.kind === 'notification-read') {
|
if (item.kind === 'notification-read') {
|
||||||
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
||||||
}
|
}
|
||||||
if (item.kind === 'update-reply') {
|
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
|
||||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||||
}
|
}
|
||||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
|
@ -344,6 +344,12 @@ function createBackgroundIssueSync({
|
||||||
if (item.kind === 'update-reply') {
|
if (item.kind === 'update-reply') {
|
||||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||||
}
|
}
|
||||||
|
if (item.kind === 'update-reply-read') {
|
||||||
|
return {
|
||||||
|
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
||||||
|
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
||||||
|
};
|
||||||
|
}
|
||||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||||
if (item.kind === 'issue-close') {
|
if (item.kind === 'issue-close') {
|
||||||
return {
|
return {
|
||||||
|
|
@ -420,6 +426,22 @@ function createBackgroundIssueSync({
|
||||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deliverReplyRead(item) {
|
||||||
|
let current = item;
|
||||||
|
if (!current.replyConfirmed) {
|
||||||
|
await requestStage(
|
||||||
|
current,
|
||||||
|
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||||
|
authoredRequest('', current).options,
|
||||||
|
);
|
||||||
|
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
||||||
|
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
|
||||||
|
current = { ...current, replyConfirmed: true };
|
||||||
|
}
|
||||||
|
const request = deliveryRequest(current);
|
||||||
|
return requestStage(current, request.url, request.options);
|
||||||
|
}
|
||||||
|
|
||||||
function attachmentMultipart(attachment) {
|
function attachmentMultipart(attachment) {
|
||||||
let blob = attachment?.blob;
|
let blob = attachment?.blob;
|
||||||
if (!blob && attachment?.data) {
|
if (!blob && attachment?.data) {
|
||||||
|
|
@ -520,7 +542,8 @@ function createBackgroundIssueSync({
|
||||||
async function deliver(item) {
|
async function deliver(item) {
|
||||||
const request = deliveryRequest(item);
|
const request = deliveryRequest(item);
|
||||||
try {
|
try {
|
||||||
const delivered = item.attachment && item.kind === 'issue-comment' ?
|
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||||
|
item.attachment && item.kind === 'issue-comment' ?
|
||||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||||
|
|
|
||||||
|
|
@ -615,8 +615,7 @@
|
||||||
qs('#update-reply').value = notificationReplier.loadDraft(item);
|
qs('#update-reply').value = notificationReplier.loadDraft(item);
|
||||||
qs('#update-reply-status').textContent = '';
|
qs('#update-reply-status').textContent = '';
|
||||||
qs('#send-update-reply').disabled = false;
|
qs('#send-update-reply').disabled = false;
|
||||||
qs('#send-update-reply-next').disabled = false;
|
qs('#send-update-reply-read-next').disabled = false;
|
||||||
setUpdateReplyNextVisibility();
|
|
||||||
qs('#update-ownership-action').hidden = true;
|
qs('#update-ownership-action').hidden = true;
|
||||||
qs('#retry-update-load').hidden = true;
|
qs('#retry-update-load').hidden = true;
|
||||||
setOfflineUpdateControls(false);
|
setOfflineUpdateControls(false);
|
||||||
|
|
@ -1019,9 +1018,7 @@
|
||||||
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
||||||
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
|
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
|
||||||
}
|
}
|
||||||
function setUpdateReplyNextVisibility() {
|
|
||||||
qs('#send-update-reply-next').hidden = !selectedUpdate || !workSession.checkpointed(selectedUpdate);
|
|
||||||
}
|
|
||||||
const issueCommentNext = createCommentNext({
|
const issueCommentNext = createCommentNext({
|
||||||
post: async (item, body) => {
|
post: async (item, body) => {
|
||||||
const comment = await issueController.comment(item, body);
|
const comment = await issueController.comment(item, body);
|
||||||
|
|
@ -1056,25 +1053,16 @@
|
||||||
failureMessage: 'Comment saved, but Today still needs completion.',
|
failureMessage: 'Comment saved, but Today still needs completion.',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const updateReplyNext = createCommentNext({
|
const updateReplyReadNext = createUpdateReplyReadNext({
|
||||||
queueKind: 'update-reply',
|
|
||||||
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
|
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
|
||||||
|
markRead: markNotificationRead,
|
||||||
queue: message => authoredOutbox.enqueueDurably(message),
|
queue: message => authoredOutbox.enqueueDurably(message),
|
||||||
canQueue: canQueueMessage,
|
canQueue: canQueueMessage,
|
||||||
accept: (item, result) => {
|
accept: item => {
|
||||||
notificationReplier.saveDraft(item, '');
|
notificationReplier.saveDraft(item, '');
|
||||||
if (selectedUpdate === item) {
|
if (selectedUpdate === item) qs('#update-reply').value = '';
|
||||||
qs('#update-reply').value = '';
|
|
||||||
if (result.comment) notificationReader.appendReply(result.comment);
|
|
||||||
qs('#update-reply-status').textContent = result.delivery === 'queued' ?
|
|
||||||
'Reply queued for background delivery.' : result.delivery === 'saved' ?
|
|
||||||
'Reply saved for next-launch delivery.' : 'Reply posted.';
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
complete: item => completeTodayItem(item, {
|
next: item => notificationReader.acceptReadAndNext(lastMyWork, item),
|
||||||
successMessage: 'Reply saved. Next Today item opened.',
|
|
||||||
failureMessage: 'Reply saved, but Today still needs completion.',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const closeOfflineIssue = createOfflineIssueClose({
|
const closeOfflineIssue = createOfflineIssueClose({
|
||||||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||||
|
|
@ -4326,7 +4314,7 @@
|
||||||
qs('#update-reply').focus();
|
qs('#update-reply').focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#send-update-reply-next').addEventListener('click', async () => {
|
qs('#send-update-reply-read-next').addEventListener('click', async () => {
|
||||||
if (!selectedUpdate) return;
|
if (!selectedUpdate) return;
|
||||||
const item = selectedUpdate;
|
const item = selectedUpdate;
|
||||||
const body = qs('#update-reply').value.trim();
|
const body = qs('#update-reply').value.trim();
|
||||||
|
|
@ -4335,14 +4323,17 @@
|
||||||
qs('#update-reply').focus();
|
qs('#update-reply').focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const button = qs('#send-update-reply-next');
|
const button = qs('#send-update-reply-read-next');
|
||||||
const sendButton = qs('#send-update-reply');
|
const sendButton = qs('#send-update-reply');
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
sendButton.disabled = true;
|
sendButton.disabled = true;
|
||||||
qs('#update-reply-status').textContent = 'Sending reply…';
|
qs('#update-reply-status').textContent = 'Replying, then marking read…';
|
||||||
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
|
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
|
||||||
try {
|
try {
|
||||||
await updateReplyNext.submit(item, body, operationId);
|
const result = await updateReplyReadNext.submit(item, body, operationId);
|
||||||
|
if (result?.accepted) qs('#my-work-action-status').textContent =
|
||||||
|
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
|
||||||
|
'Reply and read acknowledgement queued for sync.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
|
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||||
qs('#update-reply').focus();
|
qs('#update-reply').focus();
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||||
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||||
.map(item => {
|
.map(item => {
|
||||||
const isUpdate = item.kind === 'update-reply';
|
const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
|
||||||
const isReview = item.kind === 'pull-review';
|
const isReview = item.kind === 'pull-review';
|
||||||
const isClosure = item.kind === 'issue-close';
|
const isClosure = item.kind === 'issue-close';
|
||||||
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
||||||
|
|
|
||||||
|
|
@ -518,7 +518,7 @@
|
||||||
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
|
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
|
||||||
<div class="update-reply-actions">
|
<div class="update-reply-actions">
|
||||||
<button id="send-update-reply" type="button">Send reply</button>
|
<button id="send-update-reply" type="button">Send reply</button>
|
||||||
<button id="send-update-reply-next" type="button" hidden>Reply & next</button>
|
<button id="send-update-reply-read-next" type="button">Reply, mark read & next</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -696,6 +696,7 @@
|
||||||
<script src="static/today-completion.js"></script>
|
<script src="static/today-completion.js"></script>
|
||||||
<script src="static/today-readiness.js"></script>
|
<script src="static/today-readiness.js"></script>
|
||||||
<script src="static/comment-next.js"></script>
|
<script src="static/comment-next.js"></script>
|
||||||
|
<script src="static/update-reply-read-next.js"></script>
|
||||||
<script src="static/plan-today.js"></script>
|
<script src="static/plan-today.js"></script>
|
||||||
<script src="static/plan-today-preview.js"></script>
|
<script src="static/plan-today-preview.js"></script>
|
||||||
<script src="static/today-sync.js"></script>
|
<script src="static/today-sync.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,22 @@ function createNotificationReader({
|
||||||
let conversationPager = null;
|
let conversationPager = null;
|
||||||
let offlineHydrated = false;
|
let offlineHydrated = false;
|
||||||
|
|
||||||
|
async function advanceAfterRead(items, current, queueing = false) {
|
||||||
|
const updated = acknowledgeNotification(items, current.notification_id);
|
||||||
|
onItems(updated);
|
||||||
|
const currentIndex = items.indexOf(current);
|
||||||
|
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||||
|
const next = remaining.find(item => item && item.has_update &&
|
||||||
|
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||||
|
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||||
|
else {
|
||||||
|
selected = null;
|
||||||
|
onClose();
|
||||||
|
onStatus('Inbox cleared.');
|
||||||
|
}
|
||||||
|
return { items: updated, next: next || null };
|
||||||
|
}
|
||||||
|
|
||||||
async function open(item, savedDetail = null) {
|
async function open(item, savedDetail = null) {
|
||||||
selected = item;
|
selected = item;
|
||||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||||
|
|
@ -327,6 +343,10 @@ function createNotificationReader({
|
||||||
onConversation(conversationPager.append(comment));
|
onConversation(conversationPager.append(comment));
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
acceptReadAndNext(items, item = selected) {
|
||||||
|
if (!item || selected !== item) return false;
|
||||||
|
return advanceAfterRead(items, item);
|
||||||
|
},
|
||||||
async loadOlder() {
|
async loadOlder() {
|
||||||
if (!conversationPager || !selected || offlineHydrated) return false;
|
if (!conversationPager || !selected || offlineHydrated) return false;
|
||||||
const pager = conversationPager;
|
const pager = conversationPager;
|
||||||
|
|
@ -354,19 +374,7 @@ function createNotificationReader({
|
||||||
try {
|
try {
|
||||||
if (queueing) await queueRead(current.notification_id);
|
if (queueing) await queueRead(current.notification_id);
|
||||||
else await markRead(current.notification_id);
|
else await markRead(current.notification_id);
|
||||||
const updated = acknowledgeNotification(items, current.notification_id);
|
return await advanceAfterRead(items, current, queueing);
|
||||||
onItems(updated);
|
|
||||||
const currentIndex = items.indexOf(current);
|
|
||||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
|
||||||
const next = remaining.find(item => item && item.has_update &&
|
|
||||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
|
||||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
|
||||||
else {
|
|
||||||
selected = null;
|
|
||||||
onClose();
|
|
||||||
onStatus('Inbox cleared.');
|
|
||||||
}
|
|
||||||
return { items: updated, next: next || null };
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
|
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -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-v83';
|
const CACHE = 'stackchain-dashboard-shell-v84';
|
||||||
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;
|
||||||
|
|
@ -31,6 +31,7 @@ const SHELL = [
|
||||||
BASE + 'static/today-completion.js',
|
BASE + 'static/today-completion.js',
|
||||||
BASE + 'static/today-readiness.js',
|
BASE + 'static/today-readiness.js',
|
||||||
BASE + 'static/comment-next.js',
|
BASE + 'static/comment-next.js',
|
||||||
|
BASE + 'static/update-reply-read-next.js',
|
||||||
BASE + 'static/plan-today.js',
|
BASE + 'static/plan-today.js',
|
||||||
BASE + 'static/plan-today-preview.js',
|
BASE + 'static/plan-today-preview.js',
|
||||||
BASE + 'static/today-sync.js',
|
BASE + 'static/today-sync.js',
|
||||||
|
|
|
||||||
50
frontend/update-reply-read-next.js
Normal file
50
frontend/update-reply-read-next.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
function createUpdateReplyReadNext({
|
||||||
|
post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined,
|
||||||
|
}) {
|
||||||
|
let inFlight = null;
|
||||||
|
|
||||||
|
async function admit(item, body, operationId, replyConfirmed) {
|
||||||
|
const admission = await queue({
|
||||||
|
kind: 'update-reply-read',
|
||||||
|
notificationId: item.notification_id,
|
||||||
|
body,
|
||||||
|
operationId,
|
||||||
|
replyConfirmed,
|
||||||
|
});
|
||||||
|
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||||
|
throw new Error('Reply and acknowledgement were not saved for delivery.');
|
||||||
|
}
|
||||||
|
accept(item);
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
delivery: admission.background ? 'queued' : 'saved',
|
||||||
|
next: await next(item),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(item, body, operationId) {
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
inFlight = (async () => {
|
||||||
|
let replyConfirmed = false;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await post(item, body, operationId);
|
||||||
|
replyConfirmed = true;
|
||||||
|
await markRead(item.notification_id);
|
||||||
|
} catch (error) {
|
||||||
|
if (!canQueue(error)) throw error;
|
||||||
|
return await admit(item, body, operationId, replyConfirmed);
|
||||||
|
}
|
||||||
|
accept(item);
|
||||||
|
return { accepted: true, delivery: 'posted', next: await next(item) };
|
||||||
|
} finally {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { submit, busy: () => Boolean(inFlight) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateReplyReadNext;
|
||||||
|
|
@ -292,17 +292,15 @@ async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_current_today_update_offers_reply_and_next_without_marking_read():
|
async def test_unread_update_offers_reply_mark_read_and_next_independent_of_today():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
||||||
assert 'id="send-update-reply-next"' in html
|
assert 'id="send-update-reply-read-next"' in html
|
||||||
assert '>Reply & next</button>' in html
|
assert '>Reply, mark read & next</button>' in html
|
||||||
assert "setUpdateReplyNextVisibility();" in html
|
assert "const updateReplyReadNext = createUpdateReplyReadNext({" in html
|
||||||
assert "const updateReplyNext = createCommentNext({" in html
|
assert "markRead: markNotificationRead" in html
|
||||||
assert "notificationReplier.saveDraft(item, '');" in html
|
assert "next: item => notificationReader.acceptReadAndNext(lastMyWork, item)" in html
|
||||||
assert "successMessage: 'Reply saved. Next Today item opened.'" in html
|
|
||||||
assert "qs('#mark-update-read-next').click()" not in html
|
|
||||||
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-v83" in worker
|
assert "stackchain-dashboard-shell-v84" in worker
|
||||||
|
|
|
||||||
|
|
@ -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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in worker
|
assert "stackchain-dashboard-shell-v84" in worker
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,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-v83" in worker
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -426,6 +426,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/today-completion.js",
|
"/dashboard/static/today-completion.js",
|
||||||
"/dashboard/static/today-readiness.js",
|
"/dashboard/static/today-readiness.js",
|
||||||
"/dashboard/static/comment-next.js",
|
"/dashboard/static/comment-next.js",
|
||||||
|
"/dashboard/static/update-reply-read-next.js",
|
||||||
"/dashboard/static/plan-today.js",
|
"/dashboard/static/plan-today.js",
|
||||||
"/dashboard/static/plan-today-preview.js",
|
"/dashboard/static/plan-today-preview.js",
|
||||||
"/dashboard/static/today-sync.js",
|
"/dashboard/static/today-sync.js",
|
||||||
|
|
|
||||||
|
|
@ -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-v83';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v84';" 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-v83" in source
|
assert "stackchain-dashboard-shell-v84" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
176
tests/test_update_reply_read_next.py
Normal file
176
tests/test_update_reply_read_next.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
CONTROLLER = ROOT / "frontend" / "update-reply-read-next.js"
|
||||||
|
SYNC = ROOT / "frontend" / "background-issue-sync.js"
|
||||||
|
DRAFTS = ROOT / "frontend" / "drafts.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script):
|
||||||
|
return json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_every_unread_update_offers_one_reply_read_next_mobile_action():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="send-update-reply-read-next"' in html
|
||||||
|
assert '>Reply, mark read & next</button>' in html
|
||||||
|
assert "workSession.checkpointed(selectedUpdate)" not in html
|
||||||
|
assert '<script src="static/update-reply-read-next.js"></script>' in html
|
||||||
|
assert ".update-reply-actions button { min-height:44px; width:100%; }" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_read_next_orders_online_delivery_and_is_single_flight():
|
||||||
|
script = f"""
|
||||||
|
const createController = require({json.dumps(str(CONTROLLER))});
|
||||||
|
const calls=[]; let releaseReply;
|
||||||
|
const controller=createController({{
|
||||||
|
post:(item,body,id)=>{{calls.push(['reply',item.notification_id,body,id]);return new Promise(resolve=>releaseReply=resolve);}},
|
||||||
|
markRead:id=>{{calls.push(['read',id]);return Promise.resolve({{ok:true}});}},
|
||||||
|
queue:()=>{{throw new Error('must not queue');}}, canQueue:()=>false,
|
||||||
|
accept:item=>calls.push(['accept',item.notification_id]),
|
||||||
|
next:item=>{{calls.push(['next',item.notification_id]);return {{next:true}};}},
|
||||||
|
}});
|
||||||
|
const item={{notification_id:91}};
|
||||||
|
const first=controller.submit(item,'Done','op-91');
|
||||||
|
const second=controller.submit(item,'Done','op-91');
|
||||||
|
releaseReply({{id:7}});
|
||||||
|
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{same:first===second,calls,results}})));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output == {
|
||||||
|
"same": True,
|
||||||
|
"calls": [
|
||||||
|
["reply", 91, "Done", "op-91"],
|
||||||
|
["read", 91],
|
||||||
|
["accept", 91],
|
||||||
|
["next", 91],
|
||||||
|
],
|
||||||
|
"results": [
|
||||||
|
{"accepted": True, "delivery": "posted", "next": {"next": True}},
|
||||||
|
{"accepted": True, "delivery": "posted", "next": {"next": True}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_read_next_waits_until_the_next_update_is_open():
|
||||||
|
script = f"""
|
||||||
|
const createController=require({json.dumps(str(CONTROLLER))});
|
||||||
|
let openNext;
|
||||||
|
const controller=createController({{
|
||||||
|
post:async()=>({{id:1}}),markRead:async()=>({{ok:true}}),queue:async()=>null,canQueue:()=>false,
|
||||||
|
next:()=>new Promise(resolve=>openNext=()=>resolve({{notification_id:2}})),
|
||||||
|
}});
|
||||||
|
let settled=false;
|
||||||
|
const result=controller.submit({{notification_id:1}},'Done','op-1').then(value=>{{settled=true;return value;}});
|
||||||
|
setTimeout(()=>{{
|
||||||
|
const before=settled;
|
||||||
|
openNext();
|
||||||
|
result.then(value=>process.stdout.write(JSON.stringify({{before,value}})));
|
||||||
|
}},0);
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output == {
|
||||||
|
"before": False,
|
||||||
|
"value": {
|
||||||
|
"accepted": True,
|
||||||
|
"delivery": "posted",
|
||||||
|
"next": {"notification_id": 2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_read_next_durably_checkpoints_partial_delivery_without_reposting():
|
||||||
|
script = f"""
|
||||||
|
const createController = require({json.dumps(str(CONTROLLER))});
|
||||||
|
const calls=[];
|
||||||
|
const offline=Object.assign(new Error('offline'),{{status:503}});
|
||||||
|
const controller=createController({{
|
||||||
|
post:async()=>{{calls.push('reply');return{{id:8}};}},
|
||||||
|
markRead:async()=>{{calls.push('read');throw offline;}},
|
||||||
|
canQueue:error=>error.status===503,
|
||||||
|
queue:async message=>{{calls.push(['queue',message]);return{{item:{{id:'saved'}},background:true}};}},
|
||||||
|
accept:item=>calls.push(['accept',item.notification_id]),
|
||||||
|
next:item=>{{calls.push(['next',item.notification_id]);return true;}},
|
||||||
|
}});
|
||||||
|
controller.submit({{notification_id:22}},'Ship it','stable-op').then(result=>
|
||||||
|
process.stdout.write(JSON.stringify({{calls,result}})));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["calls"] == [
|
||||||
|
"reply",
|
||||||
|
"read",
|
||||||
|
["queue", {
|
||||||
|
"kind": "update-reply-read",
|
||||||
|
"notificationId": 22,
|
||||||
|
"body": "Ship it",
|
||||||
|
"operationId": "stable-op",
|
||||||
|
"replyConfirmed": True,
|
||||||
|
}],
|
||||||
|
["accept", 22],
|
||||||
|
["next", 22],
|
||||||
|
]
|
||||||
|
assert output["result"] == {"accepted": True, "delivery": "queued", "next": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_retry_resumes_at_read_after_reply_checkpoint():
|
||||||
|
item = {
|
||||||
|
"id": "op-31", "operationId": "op-31", "ownerLogin": "timmy",
|
||||||
|
"status": "queued", "kind": "update-reply-read", "notificationId": 31,
|
||||||
|
"body": "Acknowledged", "replyConfirmed": True,
|
||||||
|
}
|
||||||
|
script = f"""
|
||||||
|
const createSync=require({json.dumps(str(SYNC))});
|
||||||
|
let queued={json.dumps(item)};const calls=[];
|
||||||
|
const store={{
|
||||||
|
claimNext:async owner=>queued?(queued=null,{{...{json.dumps(item)}}}):null,
|
||||||
|
complete:async()=>calls.push('complete'),release:async()=>calls.push('release'),
|
||||||
|
fail:async()=>calls.push('fail'),countBlocked:async()=>0,
|
||||||
|
checkpoint:async()=>{{throw new Error('must not checkpoint again');}},
|
||||||
|
}};
|
||||||
|
const fetchJson=async(url,options={{}})=>{{calls.push([url,options.method]);return url==='api/v1/background-identity'?{{login:'timmy'}}:{{ok:true}};}};
|
||||||
|
createSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["calls"] == [
|
||||||
|
["api/v1/background-identity", None],
|
||||||
|
["api/v1/notifications/31/read", "PATCH"],
|
||||||
|
"complete",
|
||||||
|
]
|
||||||
|
assert output["result"]["confirmed"] == [{"ok": True}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_reply_read_delivery_is_recoverable_from_update_drafts():
|
||||||
|
record = {"version": 2, "items": [{
|
||||||
|
"id": "op-44", "operationId": "op-44", "ownerLogin": "timmy",
|
||||||
|
"status": "attention", "kind": "update-reply-read", "notificationId": 44,
|
||||||
|
"body": "Handled", "replyConfirmed": True, "error": "Read failed",
|
||||||
|
}]}
|
||||||
|
script = f"""
|
||||||
|
const createDraftInbox=require({json.dumps(str(DRAFTS))});
|
||||||
|
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({json.dumps(record)})]]);
|
||||||
|
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i],getItem:k=>values.get(k)||null,
|
||||||
|
setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||||
|
process.stdout.write(JSON.stringify(item));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["outbox_kind"] == "update-reply-read"
|
||||||
|
assert output["title"] == "Update #44"
|
||||||
|
assert output["route"] == {"kind": "update", "notification_id": 44}
|
||||||
|
assert output["status"] == "attention"
|
||||||
Loading…
Reference in New Issue
Block a user