Compare commits
No commits in common. "7bdfe624954f09c7ac89ba4f9c331484d652b701" and "a7afd856349ab2a5e1dbdd698c926eec9e480652" have entirely different histories.
7bdfe62495
...
a7afd85634
10
README.md
10
README.md
|
|
@ -141,15 +141,7 @@ and registered with Background Sync, so a supporting installed browser can deliv
|
||||||
new issues, issue comments, pull-request comments, and unread-update replies after
|
new issues, issue comments, pull-request comments, and unread-update replies after
|
||||||
every dashboard client has closed. The worker verifies the current Gitea login, shares
|
every dashboard client has closed. The worker verifies the current Gitea login, shares
|
||||||
an atomic delivery claim with the foreground path, and preserves the original
|
an atomic delivery claim with the foreground path, and preserves the original
|
||||||
idempotency key. Installed browsers can explicitly enable **Notify me when queued
|
idempotency key. Browsers without
|
||||||
work finishes**; permission is requested only from that user gesture and the choice
|
|
||||||
is stored for the confirmed account in the private background outbox database.
|
|
||||||
Successful deliveries produce privacy-safe receipts that open the created issue or
|
|
||||||
source conversation, while permanent validation failures open Drafts for recovery.
|
|
||||||
Notification text never includes issue titles, comment bodies, or validation details.
|
|
||||||
The preference is off by default, unsupported or denied browsers retain foreground
|
|
||||||
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
|
|
||||||
Browsers without
|
|
||||||
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
|
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
|
||||||
queued and needs-attention messages with explicit send/discard controls; reopening the
|
queued and needs-attention messages with explicit send/discard controls; reopening the
|
||||||
dashboard reconciles worker completions and permanent failures into the visible outbox.
|
dashboard reconciles worker completions and permanent failures into the visible outbox.
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||||
const existing = await records.getAll();
|
const existing = await records.getAll();
|
||||||
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
|
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
|
||||||
for (const current of existing) {
|
for (const current of existing) {
|
||||||
if (current.recordType === 'receipt-preference') continue;
|
|
||||||
const currentLane = current.outboxLane || 'issue';
|
const currentLane = current.outboxLane || 'issue';
|
||||||
if (currentLane !== outboxLane) continue;
|
if (currentLane !== outboxLane) continue;
|
||||||
const replacement = incoming.get(current.id);
|
const replacement = incoming.get(current.id);
|
||||||
|
|
@ -113,27 +112,6 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function preferenceId(ownerLogin) {
|
|
||||||
return 'receipt-preference:' + String(ownerLogin || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setReceiptPreference(ownerLogin, enabled) {
|
|
||||||
const login = String(ownerLogin || '').trim();
|
|
||||||
if (!login) return;
|
|
||||||
return transact(async records => {
|
|
||||||
const id = preferenceId(login);
|
|
||||||
if (!enabled) return records.delete(id);
|
|
||||||
return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getReceiptPreference(ownerLogin) {
|
|
||||||
const id = preferenceId(ownerLogin);
|
|
||||||
return transact(async records => Boolean(
|
|
||||||
(await records.getAll()).find(item => item.id === id)?.enabled
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reconcile,
|
reconcile,
|
||||||
upsert,
|
upsert,
|
||||||
|
|
@ -142,32 +120,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||||
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
||||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||||
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
|
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
|
||||||
snapshot: () => transact(async records =>
|
snapshot: () => transact(records => records.getAll()),
|
||||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
|
||||||
countBlocked: ownerLogin => transact(async records =>
|
countBlocked: ownerLogin => transact(async records =>
|
||||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference' &&
|
(await records.getAll()).filter(item => item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||||
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
|
||||||
setReceiptPreference,
|
|
||||||
getReceiptPreference,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||||
function receiptFor(item, status, delivered = {}) {
|
|
||||||
if (status === 'attention') {
|
|
||||||
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
|
|
||||||
}
|
|
||||||
if (item.kind === 'update-reply') {
|
|
||||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
|
||||||
}
|
|
||||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
|
||||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
|
||||||
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
|
||||||
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
|
||||||
}
|
|
||||||
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function deliveryRequest(item) {
|
function deliveryRequest(item) {
|
||||||
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);
|
||||||
|
|
@ -220,13 +179,12 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||||
try {
|
try {
|
||||||
const delivered = await fetchJson(request.url, request.options);
|
const delivered = await fetchJson(request.url, request.options);
|
||||||
await store.complete(item.id);
|
await store.complete(item.id);
|
||||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
return item.kind ? { message: delivered } : { issue: delivered };
|
||||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = Number(error?.status || 0);
|
const status = Number(error?.status || 0);
|
||||||
if (status >= 400 && status < 500) {
|
if (status >= 400 && status < 500) {
|
||||||
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
||||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
return { attention: true, error };
|
||||||
}
|
}
|
||||||
await store.release(item.id);
|
await store.release(item.id);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -247,9 +205,8 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||||
});
|
});
|
||||||
const login = String(identity?.login || '').trim();
|
const login = String(identity?.login || '').trim();
|
||||||
const confirmed = [];
|
const confirmed = [];
|
||||||
const receipts = [];
|
|
||||||
let attention = 0;
|
let attention = 0;
|
||||||
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
|
if (!login) return { confirmed, blocked: 0, attention };
|
||||||
while (true) {
|
while (true) {
|
||||||
const item = await store.claimNext(login);
|
const item = await store.claimNext(login);
|
||||||
if (!item) break;
|
if (!item) break;
|
||||||
|
|
@ -257,18 +214,15 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||||
if (result.issue) confirmed.push(result.issue);
|
if (result.issue) confirmed.push(result.issue);
|
||||||
if (result.message) confirmed.push(result.message);
|
if (result.message) confirmed.push(result.message);
|
||||||
if (result.attention) attention += 1;
|
if (result.attention) attention += 1;
|
||||||
if (result.receipt) receipts.push(result.receipt);
|
|
||||||
}
|
}
|
||||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||||
return { confirmed, blocked, attention, login, receipts };
|
return { confirmed, blocked, attention };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
flush, send,
|
flush, send,
|
||||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||||
snapshot: () => store.snapshot(),
|
snapshot: () => store.snapshot(),
|
||||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
|
||||||
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -331,10 +331,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
</label>
|
</label>
|
||||||
<div class="offline-work-controls">
|
<div class="offline-work-controls">
|
||||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
|
||||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||||
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-work-list" id="my-work-list"></div>
|
<div class="my-work-list" id="my-work-list"></div>
|
||||||
|
|
@ -2412,10 +2410,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||||||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||||
if (activeFlushLogin) {
|
if (activeFlushLogin) confirmedOwnerLogin = activeFlushLogin;
|
||||||
confirmedOwnerLogin = activeFlushLogin;
|
|
||||||
updateDeliveryReceiptControls();
|
|
||||||
}
|
|
||||||
snapshot.context.notifications = lastNotifications;
|
snapshot.context.notifications = lastNotifications;
|
||||||
renderContextSnapshot(snapshot.context);
|
renderContextSnapshot(snapshot.context);
|
||||||
if (contextFreshness?.stale) markMyWorkStale();
|
if (contextFreshness?.stale) markMyWorkStale();
|
||||||
|
|
@ -3362,33 +3357,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const offlineStatus = qs('#offline-status');
|
const offlineStatus = qs('#offline-status');
|
||||||
const keepWorkOffline = qs('#keep-work-offline');
|
const keepWorkOffline = qs('#keep-work-offline');
|
||||||
const offlineWorkStatus = qs('#offline-work-status');
|
const offlineWorkStatus = qs('#offline-work-status');
|
||||||
const deliveryReceipts = qs('#delivery-receipts');
|
|
||||||
const deliveryReceiptStatus = qs('#delivery-receipt-status');
|
|
||||||
function updateOfflineWorkControls(message) {
|
function updateOfflineWorkControls(message) {
|
||||||
keepWorkOffline.checked = offlineWorkStore.enabled();
|
keepWorkOffline.checked = offlineWorkStore.enabled();
|
||||||
const saved = offlineWorkStore.load();
|
const saved = offlineWorkStore.load();
|
||||||
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
||||||
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
|
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
|
||||||
}
|
}
|
||||||
async function updateDeliveryReceiptControls(message) {
|
|
||||||
const supported = Boolean(backgroundIssueSync && 'Notification' in window && 'serviceWorker' in navigator);
|
|
||||||
deliveryReceipts.disabled = !supported || !confirmedOwnerLogin;
|
|
||||||
if (!supported) {
|
|
||||||
deliveryReceipts.checked = false;
|
|
||||||
deliveryReceiptStatus.textContent = 'Delivery notifications are unavailable in this browser.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirmedOwnerLogin) {
|
|
||||||
deliveryReceipts.checked = false;
|
|
||||||
deliveryReceiptStatus.textContent = 'Waiting for your signed-in account.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deliveryReceipts.checked = Notification.permission === 'granted' &&
|
|
||||||
await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin);
|
|
||||||
deliveryReceiptStatus.textContent = message || (deliveryReceipts.checked
|
|
||||||
? 'Background delivery receipts enabled for @' + confirmedOwnerLogin + '.'
|
|
||||||
: Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Off by default.');
|
|
||||||
}
|
|
||||||
function setOfflineWorkMode(value) {
|
function setOfflineWorkMode(value) {
|
||||||
offlineWorkMode = value;
|
offlineWorkMode = value;
|
||||||
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
|
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
|
||||||
|
|
@ -3440,24 +3414,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||||||
});
|
});
|
||||||
deliveryReceipts.addEventListener('change', async () => {
|
|
||||||
let enabled = deliveryReceipts.checked;
|
|
||||||
if (enabled && Notification.permission !== 'granted') {
|
|
||||||
enabled = await Notification.requestPermission() === 'granted';
|
|
||||||
}
|
|
||||||
deliveryReceipts.checked = enabled;
|
|
||||||
if (backgroundIssueSync && confirmedOwnerLogin) {
|
|
||||||
await backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled);
|
|
||||||
}
|
|
||||||
await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' :
|
|
||||||
(Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.'));
|
|
||||||
});
|
|
||||||
qs('#clear-offline-work').addEventListener('click', () => {
|
qs('#clear-offline-work').addEventListener('click', () => {
|
||||||
offlineWorkStore.clear();
|
offlineWorkStore.clear();
|
||||||
updateOfflineWorkControls('Offline work data cleared.');
|
updateOfflineWorkControls('Offline work data cleared.');
|
||||||
});
|
});
|
||||||
updateOfflineWorkControls();
|
updateOfflineWorkControls();
|
||||||
updateDeliveryReceiptControls();
|
|
||||||
if (!navigator.onLine) showOfflineStatus();
|
if (!navigator.onLine) showOfflineStatus();
|
||||||
window.addEventListener('offline', showOfflineStatus);
|
window.addEventListener('offline', showOfflineStatus);
|
||||||
window.addEventListener('online', reconnectLiveData);
|
window.addEventListener('online', reconnectLiveData);
|
||||||
|
|
@ -3532,14 +3493,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
updateWorkPaginationControls();
|
updateWorkPaginationControls();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
function openDeliveryReceiptRoute() {
|
|
||||||
if (window.location.hash !== '#/my-work/drafts') return;
|
|
||||||
qs('[data-work-filter="draft"]').click();
|
|
||||||
qs('#my-work').scrollIntoView({block:'start'});
|
|
||||||
qs('#my-work').focus();
|
|
||||||
}
|
|
||||||
window.addEventListener('hashchange', openDeliveryReceiptRoute);
|
|
||||||
openDeliveryReceiptRoute();
|
|
||||||
qs('#work-milestone-filter').addEventListener('change', event => {
|
qs('#work-milestone-filter').addEventListener('change', event => {
|
||||||
selectedWorkMilestone = event.target.value;
|
selectedWorkMilestone = event.target.value;
|
||||||
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
|
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
|
||||||
|
|
|
||||||
|
|
@ -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-v17';
|
const CACHE = 'stackchain-dashboard-shell-v16';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
BASE,
|
BASE,
|
||||||
|
|
@ -64,23 +64,6 @@ const issueSync = self.__issueSync || createBackgroundIssueSync({
|
||||||
store: createIssueSyncStore(), fetchJson, base: BASE,
|
store: createIssueSyncStore(), fetchJson, base: BASE,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function flushAndNotify() {
|
|
||||||
const result = await issueSync.flush();
|
|
||||||
if (!result?.login || !result.receipts?.length ||
|
|
||||||
!await issueSync.getReceiptPreference?.(result.login)) return;
|
|
||||||
for (const receipt of result.receipts) {
|
|
||||||
const needsAttention = receipt.status === 'attention';
|
|
||||||
const title = needsAttention
|
|
||||||
? 'Queued work needs attention'
|
|
||||||
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
|
|
||||||
await self.registration.showNotification(title, {
|
|
||||||
body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
|
||||||
tag: 'stackchain-delivery-' + receipt.id,
|
|
||||||
data: { route: receipt.route },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||||
});
|
});
|
||||||
|
|
@ -93,21 +76,7 @@ self.addEventListener('activate', event => {
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('sync', event => {
|
self.addEventListener('sync', event => {
|
||||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(issueSync.flush());
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', event => {
|
|
||||||
event.notification.close();
|
|
||||||
const route = String(event.notification.data?.route || '');
|
|
||||||
if (!route.startsWith('#/my-work/')) return;
|
|
||||||
const target = new URL(BASE + route, self.location.origin).href;
|
|
||||||
event.waitUntil((async () => {
|
|
||||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
||||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
|
||||||
if (!client) return self.clients.openWindow(target);
|
|
||||||
if (client.navigate) await client.navigate(target);
|
|
||||||
return client.focus();
|
|
||||||
})());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', event => {
|
self.addEventListener('fetch', event => {
|
||||||
|
|
|
||||||
|
|
@ -64,59 +64,6 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
|
|
||||||
script = f"""
|
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
||||||
const queued = [
|
|
||||||
{{id:'capture-1',operationId:'capture-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Secret title',body:'Secret body',labelIds:[]}},
|
|
||||||
{{id:'reply-1',operationId:'reply-1',ownerLogin:'timmy',status:'queued',kind:'pull-comment',repository:'stackchain/web',number:8,body:'Secret reply'}},
|
|
||||||
{{id:'bad-1',operationId:'bad-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Bad',body:'Secret failure',labelIds:[]}},
|
|
||||||
];
|
|
||||||
const store = {{
|
|
||||||
claimNext: async () => queued.shift() || null,
|
|
||||||
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
|
|
||||||
countBlocked: async () => 0,
|
|
||||||
}};
|
|
||||||
let issueMutations = 0;
|
|
||||||
const fetchJson = async (url) => {{
|
|
||||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
|
||||||
if (url.endsWith('/issues') && issueMutations++ === 0) return {{number:44,repository:'stackchain/api',title:'Secret title'}};
|
|
||||||
if (url.includes('/pulls/8/comments')) return {{id:91,body:'Secret reply'}};
|
|
||||||
const error = new Error('Sensitive validation detail'); error.status = 422; throw error;
|
|
||||||
}};
|
|
||||||
(async () => {{
|
|
||||||
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
|
||||||
process.stdout.write(JSON.stringify(result));
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
|
|
||||||
assert output["login"] == "timmy"
|
|
||||||
assert output["receipts"] == [
|
|
||||||
{
|
|
||||||
"id": "capture-1",
|
|
||||||
"status": "confirmed",
|
|
||||||
"kind": "issue",
|
|
||||||
"route": "#/my-work/issue/stackchain/api/44",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "reply-1",
|
|
||||||
"status": "confirmed",
|
|
||||||
"kind": "message",
|
|
||||||
"route": "#/my-work/pull/stackchain/web/8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bad-1",
|
|
||||||
"status": "attention",
|
|
||||||
"kind": "issue",
|
|
||||||
"route": "#/my-work/drafts",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
serialized = json.dumps(output["receipts"])
|
|
||||||
assert "Secret" not in serialized
|
|
||||||
assert "Sensitive" not in serialized
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("item", "expected_url"),
|
("item", "expected_url"),
|
||||||
[
|
[
|
||||||
|
|
@ -194,47 +141,6 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
|
|
||||||
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.setReceiptPreference('timmy', true);
|
|
||||||
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
|
|
||||||
const result={{
|
|
||||||
timmy:await store.getReceiptPreference('timmy'),
|
|
||||||
alexander:await store.getReceiptPreference('alexander'),
|
|
||||||
snapshot:await store.snapshot(),
|
|
||||||
blocked:await store.countBlocked('alexander'),
|
|
||||||
}};
|
|
||||||
await store.setReceiptPreference('timmy', false);
|
|
||||||
result.disabled=await store.getReceiptPreference('timmy');
|
|
||||||
process.stdout.write(JSON.stringify(result));
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
|
|
||||||
assert output == {
|
|
||||||
"timmy": True,
|
|
||||||
"alexander": False,
|
|
||||||
"snapshot": [
|
|
||||||
{
|
|
||||||
"id": "issue",
|
|
||||||
"ownerLogin": "timmy",
|
|
||||||
"status": "queued",
|
|
||||||
"outboxLane": "issue",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"blocked": 1,
|
|
||||||
"disabled": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_closed_app_sync_leaves_another_accounts_issue_queued():
|
def test_closed_app_sync_leaves_another_accounts_issue_queued():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||||
|
|
@ -486,16 +392,3 @@ async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
|
||||||
assert "issueOutbox.reconcileBackground(records);" in html
|
assert "issueOutbox.reconcileBackground(records);" in html
|
||||||
assert "authoredOutbox.reconcileBackground(records);" in html
|
assert "authoredOutbox.reconcileBackground(records);" in html
|
||||||
assert "if ('indexedDB' in window)" in html
|
assert "if ('indexedDB' in window)" in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in():
|
|
||||||
html = await dashboard()
|
|
||||||
|
|
||||||
assert 'id="delivery-receipts" type="checkbox"' in html
|
|
||||||
assert "deliveryReceipts.addEventListener('change', async () => {" in html
|
|
||||||
assert "await Notification.requestPermission()" in html
|
|
||||||
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
|
|
||||||
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
|
|
||||||
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
|
|
||||||
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const vm = require('vm');
|
const vm = require('vm');
|
||||||
const listeners = {{}};
|
const listeners = {{}};
|
||||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||||
const cache = {{
|
const cache = {{
|
||||||
addAll: async urls => {{ state.added = urls; }},
|
addAll: async urls => {{ state.added = urls; }},
|
||||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||||
|
|
@ -26,16 +26,8 @@ const context = {{
|
||||||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||||
skipWaiting: async () => {{ state.skipped = true; }},
|
skipWaiting: async () => {{ state.skipped = true; }},
|
||||||
clients: {{
|
clients: {{ claim: async () => {{ state.claimed = true; }} }},
|
||||||
claim: async () => {{ state.claimed = true; }},
|
__issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; }} }},
|
||||||
matchAll: async () => state.clientList || [],
|
|
||||||
openWindow: async url => {{ state.opened.push(url); }},
|
|
||||||
}},
|
|
||||||
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
|
||||||
__issueSync: {{
|
|
||||||
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
|
|
||||||
getReceiptPreference: async login => state.receiptLogin === login,
|
|
||||||
}},
|
|
||||||
}},
|
}},
|
||||||
importScripts: () => {{}},
|
importScripts: () => {{}},
|
||||||
caches: {{
|
caches: {{
|
||||||
|
|
@ -68,14 +60,6 @@ async function dispatchSync(tag) {{
|
||||||
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
||||||
if (pending) await pending;
|
if (pending) await pending;
|
||||||
}}
|
}}
|
||||||
async function dispatchNotificationClick(route) {{
|
|
||||||
let pending;
|
|
||||||
listeners.notificationclick({{
|
|
||||||
notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}},
|
|
||||||
waitUntil: promise => {{ pending = promise; }},
|
|
||||||
}});
|
|
||||||
if (pending) await pending;
|
|
||||||
}}
|
|
||||||
(async () => {{
|
(async () => {{
|
||||||
{scenario}
|
{scenario}
|
||||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
|
@ -86,10 +70,10 @@ async function dispatchNotificationClick(route) {{
|
||||||
return json.loads(completed.stdout)
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
def test_background_delivery_receipts_ship_in_a_new_shell_cache():
|
def test_operator_session_boundary_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v17" in source
|
assert "stackchain-dashboard-shell-v16" in source
|
||||||
|
|
||||||
|
|
||||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||||
|
|
@ -104,46 +88,6 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
|
||||||
assert result["backgroundFlushes"] == 1
|
assert result["backgroundFlushes"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
|
|
||||||
result = run_worker_scenario(
|
|
||||||
"""
|
|
||||||
state.receiptLogin = 'timmy';
|
|
||||||
state.flushResult = {login:'timmy', receipts:[
|
|
||||||
{id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'},
|
|
||||||
{id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'},
|
|
||||||
]};
|
|
||||||
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
|
|
||||||
await dispatchSync('stackchain-issue-outbox-v1');
|
|
||||||
await dispatchNotificationClick('#/my-work/issue/stackchain/api/44');
|
|
||||||
process.stdout.write(JSON.stringify(state));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["notifications"] == [
|
|
||||||
{
|
|
||||||
"title": "Queued issue created",
|
|
||||||
"options": {
|
|
||||||
"body": "Tap to open it in Stackchain.",
|
|
||||||
"tag": "stackchain-delivery-capture-1",
|
|
||||||
"data": {"route": "#/my-work/issue/stackchain/api/44"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Queued work needs attention",
|
|
||||||
"options": {
|
|
||||||
"body": "Tap to review it in Drafts.",
|
|
||||||
"tag": "stackchain-delivery-bad-1",
|
|
||||||
"data": {"route": "#/my-work/drafts"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
assert result["focused"] == [
|
|
||||||
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
|
|
||||||
]
|
|
||||||
assert result["opened"] == []
|
|
||||||
assert result["notificationClosed"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_background_mutations_obtain_session_bound_csrf_proof():
|
def test_background_mutations_obtain_session_bound_csrf_proof():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user