Merge pull request 'Notify operators when background queued work finishes' (#261) from timmy/260-background-delivery-receipts into main
This commit is contained in:
commit
7bdfe62495
10
README.md
10
README.md
|
|
@ -141,7 +141,15 @@ and registered with Background Sync, so a supporting installed browser can deliv
|
|||
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
|
||||
an atomic delivery claim with the foreground path, and preserves the original
|
||||
idempotency key. Browsers without
|
||||
idempotency key. Installed browsers can explicitly enable **Notify me when queued
|
||||
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
|
||||
queued and needs-attention messages with explicit send/discard controls; reopening the
|
||||
dashboard reconciles worker completions and permanent failures into the visible outbox.
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
|||
const existing = await records.getAll();
|
||||
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
|
||||
for (const current of existing) {
|
||||
if (current.recordType === 'receipt-preference') continue;
|
||||
const currentLane = current.outboxLane || 'issue';
|
||||
if (currentLane !== outboxLane) continue;
|
||||
const replacement = incoming.get(current.id);
|
||||
|
|
@ -112,6 +113,27 @@ 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 {
|
||||
reconcile,
|
||||
upsert,
|
||||
|
|
@ -120,13 +142,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
|||
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
|
||||
snapshot: () => transact(records => records.getAll()),
|
||||
snapshot: () => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
||||
countBlocked: ownerLogin => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference' &&
|
||||
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||
setReceiptPreference,
|
||||
getReceiptPreference,
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
|
|
@ -179,12 +220,13 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
|||
try {
|
||||
const delivered = await fetchJson(request.url, request.options);
|
||||
await store.complete(item.id);
|
||||
return item.kind ? { message: delivered } : { issue: delivered };
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
||||
return { attention: true, error };
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
}
|
||||
await store.release(item.id);
|
||||
throw error;
|
||||
|
|
@ -205,8 +247,9 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
|||
});
|
||||
const login = String(identity?.login || '').trim();
|
||||
const confirmed = [];
|
||||
const receipts = [];
|
||||
let attention = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention };
|
||||
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
|
||||
while (true) {
|
||||
const item = await store.claimNext(login);
|
||||
if (!item) break;
|
||||
|
|
@ -214,15 +257,18 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
|||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.message) confirmed.push(result.message);
|
||||
if (result.attention) attention += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
return { confirmed, blocked, attention };
|
||||
return { confirmed, blocked, attention, login, receipts };
|
||||
}
|
||||
|
||||
return {
|
||||
flush, send,
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -331,8 +331,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
</label>
|
||||
<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="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>
|
||||
<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 class="my-work-list" id="my-work-list"></div>
|
||||
|
|
@ -2410,7 +2412,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) confirmedOwnerLogin = activeFlushLogin;
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
updateDeliveryReceiptControls();
|
||||
}
|
||||
snapshot.context.notifications = lastNotifications;
|
||||
renderContextSnapshot(snapshot.context);
|
||||
if (contextFreshness?.stale) markMyWorkStale();
|
||||
|
|
@ -3357,12 +3362,33 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
const offlineStatus = qs('#offline-status');
|
||||
const keepWorkOffline = qs('#keep-work-offline');
|
||||
const offlineWorkStatus = qs('#offline-work-status');
|
||||
const deliveryReceipts = qs('#delivery-receipts');
|
||||
const deliveryReceiptStatus = qs('#delivery-receipt-status');
|
||||
function updateOfflineWorkControls(message) {
|
||||
keepWorkOffline.checked = offlineWorkStore.enabled();
|
||||
const saved = offlineWorkStore.load();
|
||||
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.'));
|
||||
}
|
||||
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) {
|
||||
offlineWorkMode = value;
|
||||
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
|
||||
|
|
@ -3414,11 +3440,24 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
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', () => {
|
||||
offlineWorkStore.clear();
|
||||
updateOfflineWorkControls('Offline work data cleared.');
|
||||
});
|
||||
updateOfflineWorkControls();
|
||||
updateDeliveryReceiptControls();
|
||||
if (!navigator.onLine) showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectLiveData);
|
||||
|
|
@ -3493,6 +3532,14 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
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 => {
|
||||
selectedWorkMilestone = event.target.value;
|
||||
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v16';
|
||||
const CACHE = 'stackchain-dashboard-shell-v17';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
|
|
@ -64,6 +64,23 @@ const issueSync = self.__issueSync || createBackgroundIssueSync({
|
|||
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 => {
|
||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
|
@ -76,7 +93,21 @@ self.addEventListener('activate', event => {
|
|||
});
|
||||
|
||||
self.addEventListener('sync', event => {
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(issueSync.flush());
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
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 => {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,59 @@ 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(
|
||||
("item", "expected_url"),
|
||||
[
|
||||
|
|
@ -141,6 +194,47 @@ 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():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
|
@ -392,3 +486,16 @@ async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
|
|||
assert "issueOutbox.reconcileBackground(records);" in html
|
||||
assert "authoredOutbox.reconcileBackground(records);" 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 vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||
|
|
@ -26,8 +26,16 @@ const context = {{
|
|||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{ claim: async () => {{ state.claimed = true; }} }},
|
||||
__issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; }} }},
|
||||
clients: {{
|
||||
claim: async () => {{ state.claimed = true; }},
|
||||
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: () => {{}},
|
||||
caches: {{
|
||||
|
|
@ -60,6 +68,14 @@ async function dispatchSync(tag) {{
|
|||
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
||||
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 () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
|
|
@ -70,10 +86,10 @@ async function dispatchSync(tag) {{
|
|||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_operator_session_boundary_ships_in_a_new_shell_cache():
|
||||
def test_background_delivery_receipts_ship_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v16" in source
|
||||
assert "stackchain-dashboard-shell-v17" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
|
|
@ -88,6 +104,46 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
|
|||
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():
|
||||
source = WORKER.read_text()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user