From 1248647aa4df427edd94b236f46e15a06f280a6b Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 13:36:20 +0000 Subject: [PATCH] feat: recover blocked notification setup (Closes #1132) --- frontend/dashboard.js | 10 ++ frontend/mobile-device-setup.js | 19 ++-- frontend/push-notifications.js | 65 +++++++++++- frontend/service-worker.js | 2 +- tests/test_comment_next.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 25 ++++- tests/test_mobile_insights.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_push_frontend.py | 114 +++++++++++++++++++++- tests/test_service_worker.py | 28 +++--- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 16 files changed, 239 insertions(+), 42 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 9af4374..8aab6a9 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -7990,9 +7990,15 @@ enableOffline:() => setOfflineWorkEnabled(true), storageProtectionReadiness:() => deviceStorage.persistenceReadiness(), protectStorage:() => deviceStorage.requestPersistence(), + notificationReadiness:() => pushController?.notificationReadiness() + || {state:'unavailable', detail:'Update notifications are unavailable.'}, enablePush:async () => { const controller = await pushControllerReady; if (!controller) return; + if (controller.notificationReadiness().state === 'blocked') { + await controller.recoverPermission('updates'); + return; + } qs('#push-updates').checked = true; await controller.change(); }, @@ -8003,6 +8009,10 @@ if (!controller) return; qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value; qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value; + if (controller.deadlineReadiness().state === 'blocked') { + await controller.recoverPermission('deadline'); + return; + } await controller.enableDeadline(); }, }); diff --git a/frontend/mobile-device-setup.js b/frontend/mobile-device-setup.js index 69d7079..6b8d33e 100644 --- a/frontend/mobile-device-setup.js +++ b/frontend/mobile-device-setup.js @@ -5,11 +5,11 @@ const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until'; const promptDismissMs = 7 * 24 * 60 * 60 * 1000; const steps = [ - ['install', options.installButton, options.installStatus, options.install], - ['offline', options.offlineButton, options.offlineStatus, options.enableOffline], - ['protection', options.protectionButton, options.protectionStatus, options.protectStorage], - ['push', options.pushButton, options.pushStatus, options.enablePush], - ['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline], + ['install', options.installButton, options.installStatus, options.install, 'Install'], + ['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'], + ['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'], + ['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'], + ['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'], ]; let trigger = options.launcher; let ownsDetour = false; @@ -35,9 +35,10 @@ const readiness = await options.getReadiness(); let available = 0; let complete = 0; - steps.forEach(([name, button, status]) => { + steps.forEach(([name, button, status, _action, defaultActionLabel]) => { const step = readiness[name]; status.textContent = step.detail; + button.textContent = step.actionLabel || defaultActionLabel; button.hidden = step.state === 'complete' || step.state === 'unavailable'; button.disabled = step.state === 'pending'; if (step.state !== 'unavailable') available += 1; @@ -130,11 +131,7 @@ function mountMobileDeviceSetup(options) { ? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'} : {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'}, protection:options.storageProtectionReadiness(), - push:qs('#push-updates').disabled - ? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'} - : qs('#push-updates').checked - ? {state:'complete', detail:'New update notifications are enabled.'} - : {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'}, + push:options.notificationReadiness(), deadline:options.deadlineReadiness(), }), install:() => options.installApp.install(), diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js index 765b83a..dbeb24c 100644 --- a/frontend/push-notifications.js +++ b/frontend/push-notifications.js @@ -7,6 +7,34 @@ notification, serviceWorker, fetchJson, }) { let configuration = null; + let pendingIntent = null; + let recoveryPromise = null; + + function blockedReadiness() { + return { + state:'blocked', + detail:'Notifications are blocked. Allow them in browser settings, then check again.', + actionLabel:'Check again', + }; + } + + function pendingRecoveryReadiness() { + return notification.permission === 'granted' + ? {state:'blocked', detail:'Notification permission changed. Check again to finish setup.', actionLabel:'Check again'} + : blockedReadiness(); + } + + function notificationReadiness() { + if (!configuration?.available || control?.disabled) { + return {state:'unavailable', detail:status?.textContent || 'Update notifications are unavailable.'}; + } + if (configuration.subscribed) { + return {state:'complete', detail:'New update notifications are enabled.'}; + } + if (pendingIntent === 'updates') return pendingRecoveryReadiness(); + if (notification.permission === 'denied') return blockedReadiness(); + return {state:'incomplete', detail:status?.textContent || 'New update notifications are off.'}; + } function renderDeliveryHealth() { const health = Object.values(configuration?.delivery_health || {}); @@ -77,6 +105,7 @@ if (!configuration?.available || deadlineControl?.disabled) { return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'}; } + if (pendingIntent === 'deadline') return pendingRecoveryReadiness(); return configuration.deadline_enabled ? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)} : {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'}; @@ -98,15 +127,20 @@ control.checked = false; if (testControl) testControl.hidden = true; if (deadlineControl) deadlineControl.checked = false; + configuration.subscribed = false; + configuration.deadline_enabled = false; + pendingIntent = null; status.textContent = 'New update notifications are off for this device.'; if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.'; } async function ensureSubscription() { - const permission = await notification.requestPermission(); + const permission = notification.permission === 'granted' + ? 'granted' + : await notification.requestPermission(); if (permission !== 'granted') { control.checked = false; - status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.'; + status.textContent = blockedReadiness().detail; return null; } const registration = await serviceWorker.ready; @@ -130,7 +164,10 @@ } async function enable() { - await ensureSubscription(); + pendingIntent = 'updates'; + const subscription = await ensureSubscription(); + if (subscription) pendingIntent = null; + return subscription; } async function change() { @@ -153,6 +190,7 @@ try { const registration = await serviceWorker.ready; let subscription = await registration.pushManager.getSubscription(); + if (deadlineControl.checked) pendingIntent = 'deadline'; if (deadlineControl.checked && !subscription) subscription = await ensureSubscription(); if (deadlineControl.checked && !subscription) { deadlineControl.checked = false; @@ -171,6 +209,7 @@ configuration.reminder_hour = reminderHour; configuration.reminder_days = reminderDays; configuration.timezone = timezone; + pendingIntent = null; deadlineStatus.textContent = deadlineControl.checked ? enabledDeadlineText(reminderHour, reminderDays) : 'Deadline reminders are off for this device.'; @@ -191,6 +230,24 @@ return changeDeadline(); } + async function recoverPermission(intent = null) { + if (!pendingIntent && (intent === 'updates' || intent === 'deadline')) pendingIntent = intent; + if (!pendingIntent || notification.permission !== 'granted') return false; + if (recoveryPromise) return recoveryPromise; + recoveryPromise = (async () => { + if (pendingIntent === 'deadline') { + deadlineControl.checked = true; + return changeDeadline(); + } + return Boolean(await enable()); + })(); + try { + return await recoveryPromise; + } finally { + recoveryPromise = null; + } + } + async function init() { if (!control || !notification || !serviceWorker) return; control.addEventListener('change', change); @@ -215,5 +272,5 @@ renderDeadlineSnooze(); } - return {init, change, changeDeadline, enableDeadline, deadlineReadiness}; + return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 8fa8085..5297c7d 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v122'; +const CACHE = 'stackchain-dashboard-shell-v123'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 8a0f220..3e00e33 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda 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 worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v122" in worker + assert "stackchain-dashboard-shell-v123" in worker diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index b66b83b..1aff82b 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 1eba9af..65f6f95 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,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-v122" in worker + assert "stackchain-dashboard-shell-v123" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 5f1dd5b..9546762 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,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])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v122" in worker + assert "stackchain-dashboard-shell-v123" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index c1e9320..8d8b344 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -258,6 +258,26 @@ process.stdout.write(JSON.stringify({ } +def test_blocked_notification_step_becomes_a_check_again_action(): + result = run_scenario(""" +readiness.push = { + state:'blocked', + detail:'Notifications are blocked. Allow them in browser settings, then check again.', + actionLabel:'Check again', +}; +await launcher.dispatch('click', {currentTarget:launcher}); +await pushButton.dispatch('click'); +process.stdout.write(JSON.stringify({label:pushButton.textContent,detail:pushStatus.textContent,calls:state.pushCalls,hidden:pushButton.hidden})); +""") + + assert result == { + "label": "Check again", + "detail": "Notifications are blocked. Allow them in browser settings, then check again.", + "calls": 1, + "hidden": False, + } + + def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): root = MODULE.parents[1] html = (root / "frontend" / "index.html").read_text() @@ -293,9 +313,12 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "protectStorage:() => deviceStorage.requestPersistence()" in dashboard assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module assert "promptStorage:localStorage" in dashboard + assert "notificationReadiness:() => pushController?.notificationReadiness()" in dashboard + assert "controller.recoverPermission('updates')" in dashboard + assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v122" in worker + assert "stackchain-dashboard-shell-v123" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index 2c67747..5ff5d13 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): worker = (CONTROLLER.parent / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v122" in worker + assert "stackchain-dashboard-shell-v123" in worker assert "BASE + 'static/mobile-insights.js'" in worker diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index dc00d49..54e9f39 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v122" in service_worker + assert "stackchain-dashboard-shell-v123" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 7e8aecf..cc54b18 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_push_frontend.py b/tests/test_push_frontend.py index bfef1f0..907465d 100644 --- a/tests/test_push_frontend.py +++ b/tests/test_push_frontend.py @@ -35,7 +35,7 @@ const feature = createPushNotifications({ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines:() => { state.reviewed = true; }, - notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }}, + notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }}, serviceWorker: {ready:Promise.resolve(registration)}, fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); if (state.failResume && options.method === 'DELETE' && url.endsWith('/deadlines/snooze')) throw new Error('offline'); return state.server || {available:true,subscribed:false,public_key:'AQID'}; }, }); @@ -180,11 +180,121 @@ await feature.enableDeadline(); process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:feature.deadlineReadiness(), requests:state.requests})); """) assert denied["checked"] is False - assert denied["readiness"]["state"] == "incomplete" + assert denied["readiness"]["state"] == "blocked" + assert denied["readiness"]["actionLabel"] == "Check again" assert "blocked" in denied["readiness"]["detail"].lower() assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"] +def test_blocked_update_setup_can_recheck_without_reprompting_or_mutating(): + result = run_scenario(""" +state.permission = 'denied'; +await feature.init(); +control.checked = true; +await state.change(); +const blocked = feature.notificationReadiness(); +await feature.recoverPermission(); +process.stdout.write(JSON.stringify({blocked,after:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests})); +""") + + assert result["blocked"] == { + "state": "blocked", + "detail": "Notifications are blocked. Allow them in browser settings, then check again.", + "actionLabel": "Check again", + } + assert result["after"] == result["blocked"] + assert result["prompts"] == 1 + assert result["subscriptions"] == 0 + assert result["requests"] == [["api/v1/push-subscription", "GET", ""]] + + +def test_previously_blocked_permission_starts_a_resumable_update_intent(): + result = run_scenario(""" +state.permission = 'denied'; +state.notification.permission = 'denied'; +await feature.init(); +await feature.recoverPermission('updates'); +state.notification.permission = 'granted'; +const readyToRecover = feature.notificationReadiness(); +await feature.recoverPermission('updates'); +process.stdout.write(JSON.stringify({readyToRecover,readiness:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests})); +""") + + assert result["readyToRecover"]["actionLabel"] == "Check again" + assert result["readiness"]["state"] == "complete" + assert result["prompts"] == 0 + assert result["subscriptions"] == 1 + assert [request[0:2] for request in result["requests"]] == [ + ["api/v1/push-subscription", "GET"], + ["api/v1/push-subscription", "PUT"], + ] + + +def test_permission_recovery_resumes_pending_update_subscription_once(): + result = run_scenario(""" +state.permission = 'denied'; +await feature.init(); +control.checked = true; +await state.change(); +state.notification.permission = 'granted'; +const readyToRecover = feature.notificationReadiness(); +await feature.recoverPermission(); +await feature.recoverPermission(); +process.stdout.write(JSON.stringify({readyToRecover,readiness:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests})); +""") + + assert result["readyToRecover"] == { + "state": "blocked", + "detail": "Notification permission changed. Check again to finish setup.", + "actionLabel": "Check again", + } + assert result["readiness"]["state"] == "complete" + assert result["prompts"] == 1 + assert result["subscriptions"] == 1 + assert [request[0:2] for request in result["requests"]] == [ + ["api/v1/push-subscription", "GET"], + ["api/v1/push-subscription", "PUT"], + ] + + +def test_permission_recovery_resumes_pending_deadline_choices_once(): + result = run_scenario(""" +state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,reminder_days:2,public_key:'AQID'}; +state.permission = 'denied'; +await feature.init(); +deadlineHour.value = '17'; +deadlineDays.value = '7'; +await feature.enableDeadline(); +const blocked = feature.deadlineReadiness(); +state.notification.permission = 'granted'; +const readyToRecover = feature.deadlineReadiness(); +await feature.recoverPermission(); +await feature.recoverPermission(); +process.stdout.write(JSON.stringify({blocked,readyToRecover,readiness:feature.deadlineReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests})); +""") + + assert result["blocked"]["state"] == "blocked" + assert result["blocked"]["actionLabel"] == "Check again" + assert result["readyToRecover"] == { + "state": "blocked", + "detail": "Notification permission changed. Check again to finish setup.", + "actionLabel": "Check again", + } + assert result["readiness"] == { + "state": "complete", + "detail": "Deadline reminders enabled for 17:00 local time, next 7 days.", + } + assert result["prompts"] == 1 + assert result["subscriptions"] == 1 + assert [request[0:2] for request in result["requests"]] == [ + ["api/v1/push-subscription", "GET"], + ["api/v1/push-subscription", "PUT"], + ["api/v1/push-subscription/deadlines", "PUT"], + ] + assert json.loads(result["requests"][-1][2])["reminder_hour"] == 17 + assert json.loads(result["requests"][-1][2])["reminder_days"] == 7 + + def test_active_deadline_snooze_is_visible_in_agenda_with_a_local_resume_time(): result = run_scenario(""" state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,snoozed_until:1765003600,public_key:'AQID'}; diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index d7d6285..3f05924 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -168,13 +168,13 @@ async function dispatchPush(payload) {{ def test_offline_activation_migration_rolls_the_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -183,7 +183,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -192,7 +192,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -200,14 +200,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -215,7 +215,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -223,7 +223,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -233,14 +233,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -249,21 +249,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" 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-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/update-ownership.js'" in source @@ -1137,7 +1137,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(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 0f278be..51453ee 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -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(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v122';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v123';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 1b6b716..464bd8a 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -191,7 +191,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v122" in source + assert "stackchain-dashboard-shell-v123" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0