diff --git a/README.md b/README.md index b860904..238fea7 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,11 @@ is assembled in source order into one content-addressed JavaScript response. Das HTML and the offline worker reference that exact fingerprint, while the runtime receives immutable caching and HTML/worker responses remain revalidated. The stylesheet and fingerprinted runtime are same-origin assets included atomically in the offline PWA shell. +Device Setup also checks whether the browser has granted persistent storage without prompting. +Choose **Protect offline work** to request protection from automatic storage-pressure eviction; +Stackchain reports **Protected**, **Best effort**, **Denied**, or an unavailable/retryable state +truthfully. Offline work continues when protection is unavailable or denied, but the browser may +remove best-effort data, so persistence does not replace backups or device security. Use **Sign out & clear this device** on shared devices; it clears Stackchain's offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without removing unrelated forge preferences. Rotate either dashboard secret by replacing diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d39531b..0aa5c81 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -7976,12 +7976,16 @@ isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent), }); installApp.start(); + const deviceStorage = createDeviceStorage.mount(document); + await deviceStorage.start(); deviceSetup = createMobileDeviceSetup.mount({ document, installApp, promptStorage:localStorage, timerView, offlineAvailable:() => offlineStorageReady, offlineEnabled:() => offlineWorkStore.enabled(), enableOffline:() => setOfflineWorkEnabled(true), + storageProtectionReadiness:() => deviceStorage.persistenceReadiness(), + protectStorage:() => deviceStorage.requestPersistence(), enablePush:async () => { const controller = await pushControllerReady; if (!controller) return; @@ -7998,7 +8002,6 @@ await controller.enableDeadline(); }, }); - await createDeviceStorage.mount(document).start(); await deviceSetup.start(); return deviceSetup; } diff --git a/frontend/device-storage.js b/frontend/device-storage.js index ada651b..f7ac9c0 100644 --- a/frontend/device-storage.js +++ b/frontend/device-storage.js @@ -21,6 +21,7 @@ let privateRecordCount = 0; let inventoryUnavailable = false; let fullClearArmed = false; + let persistence = { state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable' }; const ownedCaches = async () => (await options.caches?.keys?.() || []) .filter(name => name.startsWith('stackchain-dashboard-')); @@ -37,10 +38,48 @@ return Math.round((Number(value) || 0) / (1024 * 1024)); } + async function inspectPersistence() { + if (typeof options.storageManager?.persisted !== 'function' || typeof options.storageManager?.persist !== 'function') { + persistence = {state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable'}; + return persistence; + } + try { + const protectedStorage = await options.storageManager.persisted(); + persistence = protectedStorage + ? {state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.', label:'Protected'} + : {state:'incomplete', detail:'Offline work uses best-effort browser storage and may be removed under storage pressure.', label:'Best effort'}; + } catch (_error) { + persistence = {state:'incomplete', detail:'Storage protection could not be checked. Retry to protect offline work.', label:'Check failed'}; + } + return persistence; + } + + function persistenceReadiness() { + return {state:persistence.state, detail:persistence.detail}; + } + + async function requestPersistence() { + if (typeof options.storageManager?.persist !== 'function') return persistenceReadiness(); + let granted = false; + try { granted = await options.storageManager.persist(); } + catch (_error) { + persistence = {state:'incomplete', detail:'Storage protection request failed. Offline work still uses best-effort storage; retry when ready.', label:'Request failed'}; + options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Request failed '); + return persistenceReadiness(); + } + await refresh(); + if (!granted && persistence.state !== 'complete') { + persistence = {state:'incomplete', detail:'The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.', label:'Denied'}; + options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Denied '); + } + return persistenceReadiness(); + } + async function refresh() { let estimate = null; try { estimate = await options.storageManager?.estimate?.(); } catch (_error) { /* Storage estimates are optional. */ } + await inspectPersistence(); options.summary.textContent = estimate?.quota ? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.` : 'Browser storage usage is unavailable.'; @@ -52,7 +91,7 @@ privateRecordCount = inventory.recordCount; inventoryUnavailable = inventory.unavailable; const cacheCount = (await ownedCaches()).length; - options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · ` + options.detail.textContent = `Storage protection: ${persistence.label} · ${itemCount} private browser item${itemCount === 1 ? '' : 's'} · ` + `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · ` + `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`; return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount }; @@ -84,7 +123,7 @@ privateItemCount = 0; fullClearArmed = false; options.clearAllButton.textContent = 'Clear all private data'; - options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies'; + options.detail.textContent = `Storage protection: ${persistence.label} · 0 private browser items · 0 private work records · 0 cached app copies`; options.summary.textContent = 'All Stackchain private data was cleared from this device.'; } catch (error) { options.summary.textContent = `Private data was not fully cleared: ${error.message}`; @@ -99,5 +138,5 @@ return refresh(); } - return { refresh, start }; + return { refresh, start, persistenceReadiness, requestPersistence }; }); diff --git a/frontend/index.html b/frontend/index.html index 715c674e..70b8b74 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -112,6 +112,10 @@
Keep work available offline

+
  • +
    Protect offline work

    + +
  • Notify me about new updates

    diff --git a/frontend/mobile-device-setup.js b/frontend/mobile-device-setup.js index c641086..69d7079 100644 --- a/frontend/mobile-device-setup.js +++ b/frontend/mobile-device-setup.js @@ -7,6 +7,7 @@ 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], ]; @@ -108,8 +109,10 @@ function mountMobileDeviceSetup(options) { launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'), sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'), offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'), + protectionButton:qs('#device-setup-protection'), deadlineButton:qs('#device-setup-deadline'), installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'), + protectionStatus:qs('#device-setup-protection-status'), pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'), readyStatus:qs('#device-setup-ready-status'), promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'), @@ -126,6 +129,7 @@ function mountMobileDeviceSetup(options) { : options.offlineEnabled() ? {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 @@ -135,6 +139,7 @@ function mountMobileDeviceSetup(options) { }), install:() => options.installApp.install(), enableOffline:options.enableOffline, + protectStorage:options.protectStorage, enablePush:options.enablePush, enableDeadline:options.enableDeadline, }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 6e30a5c..f32013f 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-v121'; +const CACHE = 'stackchain-dashboard-shell-v122'; 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 a114934..8a0f220 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-v121" in worker + assert "stackchain-dashboard-shell-v122" in worker diff --git a/tests/test_device_storage.py b/tests/test_device_storage.py index 074f4f9..0f2a3b2 100644 --- a/tests/test_device_storage.py +++ b/tests/test_device_storage.py @@ -17,6 +17,7 @@ class Target { const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target(); let purgeCalls=0, purgeError=''; let inventory={recordCount:0, unavailable:false}; +let persistence={persisted:false, persistResult:false, persistedCalls:0, persistCalls:0, error:''}; const localStorage={ values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]), get length(){return this.values.size;}, @@ -27,11 +28,16 @@ const caches={ async keys(){return Array.from(this.values);}, async delete(key){return this.values.delete(key);}, }; +const storageManager={ + estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024}), + persisted:async()=>{persistence.persistedCalls++;if(persistence.error)throw new Error(persistence.error);return persistence.persisted;}, + persist:async()=>{persistence.persistCalls++;persistence.persisted=persistence.persistResult;return persistence.persistResult;}, +}; const controller=createDeviceStorage({ summary, detail, clearCachesButton, clearAllButton, localStorage, caches, privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'], inspectPrivateDatabases:async()=>inventory, - storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})}, + storageManager, clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);}, }); (async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);}); @@ -49,7 +55,104 @@ process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.t assert result == { "summary": "2 MB of 10 MB browser storage used.", - "detail": "1 private browser item · 0 private work records · 1 cached app copy", + "detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 1 cached app copy", + } + + +def test_refresh_checks_storage_protection_without_requesting_permission(): + result = run_scenario(""" +await controller.refresh(); +process.stdout.write(JSON.stringify({ + readiness:await controller.persistenceReadiness(), + persistedCalls:persistence.persistedCalls, + persistCalls:persistence.persistCalls, + detail:detail.textContent, +})); +""") + + assert result == { + "readiness": { + "state": "incomplete", + "detail": "Offline work uses best-effort browser storage and may be removed under storage pressure.", + }, + "persistedCalls": 1, + "persistCalls": 0, + "detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 1 cached app copy", + } + + +def test_explicit_request_confirms_storage_is_protected_before_claiming_success(): + result = run_scenario(""" +await controller.refresh(); +persistence.persistResult=true; +const readiness=await controller.requestPersistence(); +process.stdout.write(JSON.stringify({ + readiness, + persistedCalls:persistence.persistedCalls, + persistCalls:persistence.persistCalls, + detail:detail.textContent, +})); +""") + + assert result == { + "readiness": { + "state": "complete", + "detail": "Offline work is protected from automatic browser storage cleanup.", + }, + "persistedCalls": 2, + "persistCalls": 1, + "detail": "Storage protection: Protected · 1 private browser item · 0 private work records · 1 cached app copy", + } + + +def test_denied_request_remains_retryable_and_does_not_claim_protection(): + result = run_scenario(""" +await controller.refresh(); +const readiness=await controller.requestPersistence(); +process.stdout.write(JSON.stringify({readiness,persistCalls:persistence.persistCalls})); +""") + + assert result == { + "readiness": { + "state": "incomplete", + "detail": "The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.", + }, + "persistCalls": 1, + } + + +def test_denied_request_keeps_setup_and_storage_panel_in_the_same_state_when_recheck_fails(): + result = run_scenario(""" +await controller.refresh(); +storageManager.persist=async()=>{persistence.persistCalls++;persistence.error='check blocked';return false;}; +const readiness=await controller.requestPersistence(); +process.stdout.write(JSON.stringify({readiness,detail:detail.textContent})); +""") + + assert result == { + "readiness": { + "state": "incomplete", + "detail": "The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.", + }, + "detail": "Storage protection: Denied · 1 private browser item · 0 private work records · 1 cached app copy", + } + + +def test_failed_request_is_retryable_and_updates_storage_status_without_data_loss(): + result = run_scenario(""" +await controller.refresh(); +storageManager.persist=async()=>{persistence.persistCalls++;throw new Error('blocked');}; +const readiness=await controller.requestPersistence(); +process.stdout.write(JSON.stringify({readiness,detail:detail.textContent,persistCalls:persistence.persistCalls})); +""") + + assert result == { + "readiness": { + "state": "incomplete", + "detail": "Storage protection request failed. Offline work still uses best-effort storage; retry when ready.", + }, + "detail": "Storage protection: Request failed · 1 private browser item · 0 private work records · 1 cached app copy", + "persistCalls": 1, } @@ -69,7 +172,7 @@ process.stdout.write(JSON.stringify({ "caches": ["other-app"], "storage": ["stackchain.draft.1", "other.preference"], "status": "Cached app copies cleared. Private work was kept.", - "detail": "1 private browser item · 0 private work records · 0 cached app copies", + "detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 0 cached app copies", } @@ -90,7 +193,7 @@ process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.tex }, "calls": 1, "status": "All Stackchain private data was cleared from this device.", - "detail": "0 private browser items · 0 private work records · 0 cached app copies", + "detail": "Storage protection: Best effort · 0 private browser items · 0 private work records · 0 cached app copies", } @@ -112,7 +215,7 @@ process.stdout.write(JSON.stringify({ "calls": 0, "label": "Confirm: clear private work", "status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.", - "detail": "0 private browser items · 3 private work records · 1 cached app copy", + "detail": "Storage protection: Best effort · 0 private browser items · 3 private work records · 1 cached app copy", } @@ -132,7 +235,7 @@ process.stdout.write(JSON.stringify({ assert result == { "calls": 0, "label": "Confirm: clear private work", - "detail": "0 private browser items · private work status unknown · 1 cached app copy", + "detail": "Storage protection: Best effort · 0 private browser items · private work status unknown · 1 cached app copy", } diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index bb1292c..b66b83b 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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 279709a..1eba9af 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-v121" in worker + assert "stackchain-dashboard-shell-v122" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 17038e0..5f1dd5b 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-v121" in worker + assert "stackchain-dashboard-shell-v122" 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 74e474a..c1e9320 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -24,10 +24,12 @@ const closeButton = new FakeTarget(); const sheet = new FakeTarget(); sheet.hidden = true; const installButton = new FakeTarget(); const offlineButton = new FakeTarget(); +const protectionButton = new FakeTarget(); const pushButton = new FakeTarget(); const deadlineButton = new FakeTarget(); const installStatus = new FakeTarget(); const offlineStatus = new FakeTarget(); +const protectionStatus = new FakeTarget(); const pushStatus = new FakeTarget(); const deadlineStatus = new FakeTarget(); const readyStatus = new FakeTarget(); @@ -46,12 +48,13 @@ const promptStorage = { let readiness = { install:{state:'complete', detail:'Stackchain is installed.'}, offline:{state:'incomplete', detail:'Offline work is off.'}, + protection:{state:'incomplete', detail:'Offline work uses best-effort browser storage.'}, push:{state:'unavailable', detail:'Notifications are unavailable.'}, deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'}, }; const setup = createMobileDeviceSetup({ - launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton, - installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget, + launcher, closeButton, sheet, installButton, offlineButton, protectionButton, pushButton, deadlineButton, + installStatus, offlineStatus, protectionStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget, promptCard, promptSummary, promptLauncher, promptDismiss, returnButton, isMobile:() => true, timerView:{ @@ -62,6 +65,7 @@ const setup = createMobileDeviceSetup({ getReadiness:() => { state.events.push('readiness'); return readiness; }, install:async () => { state.installCalls += 1; }, enableOffline:async () => { state.offlineCalls += 1; }, + protectStorage:async () => { state.protectionCalls = (state.protectionCalls || 0) + 1; }, enablePush:async () => { state.pushCalls += 1; }, enableDeadline:async () => { state.deadlineCalls += 1; }, }); @@ -90,11 +94,33 @@ process.stdout.write(JSON.stringify({ "install": "Stackchain is installed.", "offline": "Offline work is off.", "push": "Notifications are unavailable.", - "summary": "1 of 2 available steps ready.", + "summary": "1 of 3 available steps ready.", "calls": [0, 0, 0], } +def test_storage_protection_runs_only_from_its_explicit_setup_action(): + result = run_scenario(""" +await launcher.dispatch('click', {currentTarget:launcher}); +const callsOnOpen=state.protectionCalls || 0; +readiness.protection={state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.'}; +await protectionButton.dispatch('click'); +process.stdout.write(JSON.stringify({ + callsOnOpen, + callsAfterAction:state.protectionCalls, + detail:protectionStatus.textContent, + hidden:protectionButton.hidden, +})); +""") + + assert result == { + "callsOnOpen": 0, + "callsAfterAction": 1, + "detail": "Offline work is protected from automatic browser storage cleanup.", + "hidden": True, + } + + def test_incomplete_device_is_discoverable_without_triggering_setup_actions(): result = run_scenario(""" process.stdout.write(JSON.stringify({ @@ -106,7 +132,7 @@ process.stdout.write(JSON.stringify({ assert result == { "hidden": False, - "summary": "1 of 2 steps complete", + "summary": "1 of 3 steps complete", "calls": [0, 0, 0], } @@ -146,6 +172,7 @@ process.stdout.write(JSON.stringify({hiddenAfterDismiss, delay:dismissedUntil - def test_setup_action_rechecks_real_state_before_marking_step_ready(): result = run_scenario(""" await launcher.dispatch('click', {currentTarget:launcher}); +readiness.protection = {state:'complete', detail:'Offline work is protected.'}; readiness.offline = {state:'complete', detail:'Offline work is saved.'}; await offlineButton.dispatch('click'); process.stdout.write(JSON.stringify({ @@ -227,7 +254,7 @@ process.stdout.write(JSON.stringify({ "calls": 1, "detail": "Deadline reminders enabled for 08:00 local time.", "hidden": True, - "summary": "3 of 4 available steps ready.", + "summary": "3 of 5 available steps ready.", } @@ -248,7 +275,8 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert 'id="device-setup-today-detour"' in html assert 'id="return-from-device-setup"' in html assert 'aria-labelledby="device-setup-heading"' in html - assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline")) + assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "protection", "push", "deadline")) + assert 'id="device-setup-protection-status"' in html assert 'id="device-storage-summary"' in html assert 'id="device-storage-detail"' in html assert 'id="clear-device-caches"' in html @@ -260,12 +288,14 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "createMobileDeviceSetup.mount({" in dashboard assert "timerView," in dashboard assert "isMobile:options.isMobile || (() => innerWidth <= 600)" in MODULE.read_text() - assert "createDeviceStorage.mount(document)" in dashboard + assert "const deviceStorage = createDeviceStorage.mount(document)" in dashboard + assert "storageProtectionReadiness:() => deviceStorage.persistenceReadiness()" in dashboard + assert "protectStorage:() => deviceStorage.requestPersistence()" in dashboard assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v121" in worker + assert "stackchain-dashboard-shell-v122" 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 a049c6a..2c67747 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-v121" in worker + assert "stackchain-dashboard-shell-v122" 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 f527684..dc00d49 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-v121" in service_worker + assert "stackchain-dashboard-shell-v122" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index bbd9772..7e8aecf 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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_service_worker.py b/tests/test_service_worker.py index 358b2f4..72f732b 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-v121" in source + assert "stackchain-dashboard-shell-v122" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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-v121" in source + assert "stackchain-dashboard-shell-v122" 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 22f0897..0f278be 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-v121';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v122';" 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 530804f..1b6b716 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-v121" in source + assert "stackchain-dashboard-shell-v122" in source assert "BASE + 'static/today-sync.js'" in source