diff --git a/README.md b/README.md index 463f1c7..2739222 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,12 @@ another worker replays a confirmed result instead of posting duplicate content. Today queue is warmed automatically after a healthy authenticated refresh and as soon as it is added. The readiness indicator reports saved, pending, and retryable items; unchanged `updated_at` revisions make no detail request, transient failures retain the prior copy, -and pull-request diffs remain online-only. +and pull-request diffs remain online-only. Saved unread-update conversations remain +triageable offline: **Queue read & next** writes an account-bound, notification-ID- +deduplicated acknowledgement to the durable background delivery system, removes the +update from the local queue immediately, and opens the next saved conversation. A cold +offline reload suppresses acknowledgements still waiting to sync; reconnect uses the +authenticated notification-read endpoint and keeps transient failures queued. Closed-app delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 14c89e3..98e8841 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -232,6 +232,9 @@ function createBackgroundIssueSync({ if (status === 'attention') { return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' }; } + if (item.kind === 'notification-read') { + return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' }; + } if (item.kind === 'update-reply') { return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) }; } @@ -244,6 +247,12 @@ function createBackgroundIssueSync({ } function deliveryRequest(item) { + if (item.kind === 'notification-read') { + return { + url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', + options: { method: 'PATCH', headers: { Accept: 'application/json' } }, + }; + } if (item.kind === 'update-reply') { return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item); } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 7ef3ee5..2824ce1 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -208,10 +208,16 @@ backgroundSync: backgroundIssueSync, getOwnerLogin: () => confirmedOwnerLogin, }); + const notificationReadOutbox = createNotificationReadOutbox({ + storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, + backgroundSync: backgroundIssueSync, + getOwnerLogin: () => confirmedOwnerLogin, + }); if (backgroundIssueSync) { backgroundIssueSync.snapshot().then(records => { issueOutbox.reconcileBackground(records); authoredOutbox.reconcileBackground(records); + notificationReadOutbox.reconcileBackground(records); }).catch(() => { /* The foreground localStorage outboxes remain available. */ }); } const shareParams = new URLSearchParams(location.search); @@ -439,6 +445,8 @@ load: fetchNotificationDetail, loadConversation: fetchNotificationConversation, markRead: markNotificationRead, + queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId), + loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item), onOpen: item => { selectedUpdate = item; qs('#update-sheet').classList.add('open'); @@ -521,7 +529,7 @@ notificationReader.open(item, savedDetail).then(opened => { if (opened && selectedUpdate === item) { qs('#update-sheet-status').textContent = 'Offline update · saved ' + fmt(savedDetail.saved_at) + - ' · replies queue for sync. Reconnect to mark read, take ownership, defer, or load older messages.'; + ' · replies and read acknowledgements queue for sync. Reconnect to take ownership, defer, or load older messages.'; } }); } else { @@ -679,7 +687,8 @@ } function setOfflineUpdateControls(offline) { - qs('#mark-update-read-next').disabled = offline; + qs('#mark-update-read-next').disabled = false; + qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next'; qs('#update-ownership-action').disabled = offline; qs('#load-older-update-comments').disabled = offline; qs('#update-sheet .detail-defer').inert = offline; @@ -1714,6 +1723,15 @@ applyAuthoredOutboxResult(await authoredOutbox.flush(activeFlushLogin)); } + async function flushNotificationReadOutbox() { + if (!navigator.onLine || !activeFlushLogin || !notificationReadOutbox.list().length) return; + const result = await notificationReadOutbox.flush(activeFlushLogin); + if (result.confirmed?.length) { + qs('#my-work-action-status').textContent = result.confirmed.length + + ' queued update' + (result.confirmed.length === 1 ? '' : 's') + ' marked read.'; + } + } + function canQueueMessage(error) { const status = Number(error?.status || 0); return !status || status >= 500; @@ -1965,6 +1983,7 @@ } flushIssueOutbox(); flushAuthoredOutbox(); + flushNotificationReadOutbox(); } else if (!snapshot.context) handleContextError(new Error('Context section unavailable')); if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events); if (eventsFreshness?.revalidating) { @@ -3083,7 +3102,8 @@ confirmedOwnerLogin = String(saved.user?.login || '').trim(); planningOwnerLogin = confirmedOwnerLogin; updatePlanningAvailability(); - lastNotifications = saved.notifications || []; + saved.notifications = notificationReadOutbox.suppress(saved.notifications || []); + lastNotifications = saved.notifications; notificationPagination = saved.notification_pagination || { page:1, total:lastNotifications.length, has_more:false }; workPagination = saved.work_pagination || {}; lastContextSnapshot = saved; diff --git a/frontend/index.html b/frontend/index.html index 6e38b91..01b7429 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -533,6 +533,7 @@ + diff --git a/frontend/my-work.js b/frontend/my-work.js index 52124dd..683683b 100644 --- a/frontend/my-work.js +++ b/frontend/my-work.js @@ -280,6 +280,8 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) { function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose, + queueRead = null, + loadSaved = () => null, loadConversation = null, onConversation = () => {}, createPager = typeof createConversationPager === 'function' ? createConversationPager : null, @@ -344,20 +346,21 @@ function createNotificationReader({ } }, async markReadAndNext(items) { - if (!selected || marking || offlineHydrated) return false; + if (!selected || marking || (offlineHydrated && !queueRead)) return false; const current = selected; + const queueing = offlineHydrated; marking = true; - onStatus('Marking update read…'); + onStatus(queueing ? 'Queueing update read…' : 'Marking update read…'); try { - await markRead(current.notification_id); + if (queueing) await queueRead(current.notification_id); + else await markRead(current.notification_id); 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) - ); - if (next) await open(next); + 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(); @@ -365,7 +368,7 @@ function createNotificationReader({ } return { items: updated, next: next || null }; } catch (_error) { - onStatus('Could not mark update read. Retry.'); + onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.'); return false; } finally { marking = false; diff --git a/frontend/notification-read-outbox.js b/frontend/notification-read-outbox.js new file mode 100644 index 0000000..cfc8ff5 --- /dev/null +++ b/frontend/notification-read-outbox.js @@ -0,0 +1,125 @@ +function createNotificationReadOutbox({ + storage, fetchJson, backgroundSync, coordinator, getOwnerLogin = () => '', now = () => Date.now(), maxItems = 100, +}) { + const storageKey = 'stackchain.notification-read-outbox.v1'; + + function read() { + try { + const record = JSON.parse(storage?.getItem(storageKey) || 'null'); + if (record?.version !== 1 || !Array.isArray(record.items)) return []; + return record.items.filter(item => item?.kind === 'notification-read' && + Number.isInteger(item.notificationId) && item.notificationId > 0 && item.ownerLogin); + } catch (_error) { return []; } + } + + function write(items, mirror = true) { + storage?.setItem(storageKey, JSON.stringify({ version: 1, items })); + coordinator?.notify('notification-read'); + if (mirror && backgroundSync?.reconcile) { + Promise.resolve(backgroundSync.reconcile(items, 'notification-read')) + .then(() => items.length ? backgroundSync.requestSync?.() : undefined) + .catch(() => { /* Foreground reconnect remains available. */ }); + } + } + + function itemId(ownerLogin, notificationId) { + return 'notification-read:' + ownerLogin + ':' + notificationId; + } + + async function enqueueDurably(notificationId) { + notificationId = Number(notificationId); + const ownerLogin = String(getOwnerLogin() || '').trim(); + if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing this update.'); + if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid update.'); + const items = read(); + const id = itemId(ownerLogin, notificationId); + let item = items.find(candidate => candidate.id === id); + if (!item) { + if (items.length >= maxItems) throw new Error('Update acknowledgement queue is full. Reconnect before clearing more updates.'); + item = { + id, kind: 'notification-read', notificationId, ownerLogin, + status: 'queued', queuedAt: Number(now()), + }; + items.push(item); + write(items, false); + } + if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) { + return { item: { ...item }, background: false, durability: 'foreground-only' }; + } + try { + await backgroundSync.reconcile(read(), 'notification-read'); + await backgroundSync.requestSync(); + return { item: { ...item }, background: true, durability: 'background' }; + } catch (error) { + return { item: { ...item }, background: false, durability: 'foreground-only', error }; + } + } + + function suppress(items, login = getOwnerLogin()) { + const pending = new Set(read().filter(item => item.ownerLogin === String(login || '').trim()) + .map(item => item.notificationId)); + return (items || []).filter(item => !pending.has(Number(item?.notification_id ?? item?.id))); + } + + async function send(item, currentLogin) { + if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; + if (backgroundSync?.send) return backgroundSync.send(item, currentLogin); + await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', { + method: 'PATCH', headers: { Accept: 'application/json' }, + }); + return { confirmed: true }; + } + + async function flush(currentLogin) { + currentLogin = String(currentLogin || '').trim(); + const confirmed = []; + let items = read(); + for (const item of items) { + if (item.ownerLogin !== currentLogin || item.status === 'attention') continue; + try { + const result = await send(item, currentLogin); + if (result?.blocked || result?.busy) continue; + if (result?.attention) { + items = items.map(candidate => candidate.id === item.id ? { + ...candidate, status: 'attention', error: String(result.error?.message || 'Update needs attention').slice(0, 240), + } : candidate); + write(items); + continue; + } + confirmed.push(item.notificationId); + items = items.filter(candidate => candidate.id !== item.id); + write(items); + } catch (error) { + const status = Number(error?.status || 0); + if (status >= 400 && status < 500) { + items = items.map(candidate => candidate.id === item.id ? { + ...candidate, status: 'attention', error: String(error?.message || 'Update needs attention').slice(0, 240), + } : candidate); + write(items); + continue; + } + break; + } + } + return { confirmed, remaining: read() }; + } + + function reconcileBackground(records) { + const states = new Map((records || []).filter(item => item?.kind === 'notification-read') + .map(item => [item.id, item])); + const items = read().flatMap(item => { + const state = states.get(item.id); + if (state?.status === 'sent') return []; + if (state?.status === 'attention') return [{ + ...item, status: 'attention', error: String(state.error || 'Update needs attention').slice(0, 240), + }]; + return [item]; + }); + write(items); + return items; + } + + return { enqueueDurably, flush, suppress, reconcileBackground, list: () => read().map(item => ({ ...item })) }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createNotificationReadOutbox; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 183d208..a1e96a4 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v41'; +const CACHE = 'stackchain-dashboard-shell-v42'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, @@ -19,6 +19,7 @@ const SHELL = [ BASE + 'static/outbox-coordinator.js', BASE + 'static/issue-outbox.js', BASE + 'static/authored-outbox.js', + BASE + 'static/notification-read-outbox.js', BASE + 'static/offline-work.js', BASE + 'static/offline-today.js', BASE + 'static/my-work.js', diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 51f4990..fe8b9c3 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v41" in worker + assert "stackchain-dashboard-shell-v42" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 5ce45c7..c6ec534 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v41" in worker + assert "stackchain-dashboard-shell-v42" in worker diff --git a/tests/test_my_work.py b/tests/test_my_work.py index ba48d20..5c48212 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -2406,6 +2406,51 @@ const reader = build.createNotificationReader({{ } +def test_notification_reader_queues_saved_update_read_offline_and_opens_next_saved_update(): + script = f""" +const build = require({json.dumps(str(MY_WORK))}); +const items = [ + {{kind:'update', notification_id:42, has_update:true, title:'First'}}, + {{kind:'update', notification_id:43, has_update:true, title:'Second'}}, +]; +const saved = new Map([ + [42, {{id:42, title:'Saved first', conversation:{{comments:[],page:1,total:0}}}}], + [43, {{id:43, title:'Saved second', conversation:{{comments:[],page:1,total:0}}}}], +]); +const events = []; +const reader = build.createNotificationReader({{ + load: async () => {{ throw new Error('network must not run'); }}, + markRead: async () => {{ throw new Error('network must not run'); }}, + queueRead: async id => events.push(['queued', id]), + loadSaved: item => saved.get(item.notification_id), + onOpen: item => events.push(['open', item.notification_id]), + onDetail: detail => events.push(['detail', detail.id]), + onItems: next => events.push(['items', next.map(item => item.notification_id)]), + onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']), +}}); +(async () => {{ + await reader.open(items[0], saved.get(42)); + events.length = 0; + const result = await reader.markReadAndNext(items); + process.stdout.write(JSON.stringify({{result, events}})); +}})(); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert output["result"]["next"]["notification_id"] == 43 + assert output["events"] == [ + ["status", "Queueing update read…"], + ["queued", 42], + ["items", [43]], + ["open", 43], + ["status", "Loading update…"], + ["detail", 43], + ["status", "Update ready."], + ] + + def test_notification_reader_appends_a_confirmed_reply_exactly_once(): script = f""" const build = require({json.dumps(str(MY_WORK))}); diff --git a/tests/test_notification_read_outbox.py b/tests/test_notification_read_outbox.py new file mode 100644 index 0000000..cee3e86 --- /dev/null +++ b/tests/test_notification_read_outbox.py @@ -0,0 +1,135 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +ROOT = Path(__file__).resolve().parents[1] +OUTBOX = ROOT / "frontend" / "notification-read-outbox.js" +BACKGROUND_SYNC = ROOT / "frontend" / "background-issue-sync.js" +WORKER = ROOT / "frontend" / "service-worker.js" + + +def run_node(script): + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +def test_offline_read_admission_is_account_bound_deduplicated_and_suppresses_saved_updates(): + script = f""" +const createOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{ + getItem:key => values.has(key) ? values.get(key) : null, + setItem:(key,value) => values.set(key,value), +}}; +const reconciled = []; +const backgroundSync = {{ + reconcile: async (items, lane) => reconciled.push([lane, items]), + requestSync: async () => true, +}}; +const outbox = createOutbox({{ + storage, backgroundSync, getOwnerLogin:() => 'timmy', now:() => 1234, +}}); +(async () => {{ + const first = await outbox.enqueueDurably(42); + const duplicate = await outbox.enqueueDurably(42); + const visible = outbox.suppress([ + {{notification_id:42, title:'Queued'}}, {{notification_id:43, title:'Visible'}}, + ]); + process.stdout.write(JSON.stringify({{ + first, duplicate, items:outbox.list(), visible, reconciled, + }})); +}})(); +""" + output = run_node(script) + + assert output["first"]["item"]["notificationId"] == 42 + assert output["duplicate"]["item"]["id"] == output["first"]["item"]["id"] + assert len(output["items"]) == 1 + assert output["items"][0]["ownerLogin"] == "timmy" + assert [item["notification_id"] for item in output["visible"]] == [43] + assert output["reconciled"][-1][0] == "notification-read" + assert len(output["reconciled"][-1][1]) == 1 + + +def test_foreground_flush_patches_each_unique_read_and_keeps_transient_failures_queued(): + script = f""" +const createOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:key => values.get(key) || null,setItem:(key,value) => values.set(key,value)}}; +const calls = []; +const outbox = createOutbox({{ + storage, getOwnerLogin:() => 'timmy', + fetchJson: async (url, options) => {{ + calls.push([url, options.method]); + if (url.includes('/43/')) {{ const error = new Error('offline'); error.status = 503; throw error; }} + return {{ok:true}}; + }}, +}}); +(async () => {{ + await outbox.enqueueDurably(42); + await outbox.enqueueDurably(43); + const result = await outbox.flush('timmy'); + process.stdout.write(JSON.stringify({{calls, result, items:outbox.list()}})); +}})(); +""" + output = run_node(script) + + assert output["calls"] == [ + ["api/v1/notifications/42/read", "PATCH"], + ["api/v1/notifications/43/read", "PATCH"], + ] + assert output["result"]["confirmed"] == [42] + assert [item["notificationId"] for item in output["items"]] == [43] + assert output["items"][0]["status"] == "queued" + + +def test_background_delivery_uses_patch_and_reports_read_receipt(): + script = f""" +const createSync = require({json.dumps(str(BACKGROUND_SYNC))}); +const requests = []; +const store = {{ + upsert:async () => {{}}, claim:async () => ({{ + id:'notification-read:timmy:42', kind:'notification-read', notificationId:42, + ownerLogin:'timmy', status:'sending', + }}), complete:async () => {{}}, release:async () => {{}}, fail:async () => {{}}, +}}; +const sync = createSync({{ + store, + fetchJson:async (url, options) => {{ requests.push([url, options.method, options.body || null]); return {{ok:true}}; }}, +}}); +(async () => {{ + const result = await sync.send({{ + id:'notification-read:timmy:42', kind:'notification-read', notificationId:42, + ownerLogin:'timmy', status:'queued', + }}, 'timmy'); + process.stdout.write(JSON.stringify({{requests, result}})); +}})(); +""" + output = run_node(script) + + assert output["requests"] == [["api/v1/notifications/42/read", "PATCH", None]] + assert output["result"]["receipt"] == { + "id": "notification-read:timmy:42", + "status": "confirmed", + "kind": "notification-read", + "route": "#/my-work/updates", + } + + +@pytest.mark.anyio +async def test_dashboard_wires_offline_queue_read_next_through_precached_background_outbox(): + html = await dashboard() + worker = WORKER.read_text() + + assert '' in html + assert "const notificationReadOutbox = createNotificationReadOutbox({" in html + assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html + assert "notificationReadOutbox.suppress(saved.notifications || [])" in html + assert "notificationReadOutbox.flush(activeFlushLogin)" in html + assert "Queue read & next" in html + assert "BASE + 'static/notification-read-outbox.js'" in worker diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py index 402f551..13b29a8 100644 --- a/tests/test_offline_work.py +++ b/tests/test_offline_work.py @@ -205,7 +205,7 @@ async def test_saved_today_details_open_offline_without_enabling_server_state_ac @pytest.mark.anyio -async def test_saved_unread_update_opens_offline_with_reply_only_controls(): +async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_controls(): html = await dashboard() assert "notificationReader.open(item, savedDetail)" in html @@ -213,8 +213,9 @@ async def test_saved_unread_update_opens_offline_with_reply_only_controls(): assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html assert "Offline update · saved " in html assert "setOfflineUpdateControls(true)" in html - assert "qs('#mark-update-read-next').disabled = offline;" in html + assert "qs('#mark-update-read-next').disabled = false;" in html + assert "offline ? 'Queue read & next' : 'Mark read & next'" in html assert "qs('#update-ownership-action').disabled = offline;" in html assert "qs('#load-older-update-comments').disabled = offline;" in html assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html - assert "Reconnect to mark read, take ownership, defer, or load older messages." in html + assert "replies and read acknowledgements queue for sync" in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 2d71bb4..16576a3 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v41" in source + assert "stackchain-dashboard-shell-v42" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache(): def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v41" in source + assert "stackchain-dashboard-shell-v42" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v41" in source + assert "stackchain-dashboard-shell-v42" in source assert "BASE + 'static/update-ownership.js'" in source @@ -303,6 +303,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/outbox-coordinator.js", "/dashboard/static/issue-outbox.js", "/dashboard/static/authored-outbox.js", + "/dashboard/static/notification-read-outbox.js", "/dashboard/static/offline-work.js", "/dashboard/static/offline-today.js", "/dashboard/static/my-work.js",