feat: protect offline work from storage eviction (Closes #1110)
All checks were successful
CI / lint (pull_request) Successful in 2m47s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m52s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-19 03:36:48 +00:00
parent 9a471b7903
commit dc4f457b21
18 changed files with 231 additions and 42 deletions

View File

@ -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 HTML and the offline worker reference that exact fingerprint, while the runtime receives
immutable caching and HTML/worker responses remain revalidated. The stylesheet and immutable caching and HTML/worker responses remain revalidated. The stylesheet and
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell. 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 Use **Sign out & clear this device** on shared devices; it clears Stackchain's
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
removing unrelated forge preferences. Rotate either dashboard secret by replacing removing unrelated forge preferences. Rotate either dashboard secret by replacing

View File

@ -7976,12 +7976,16 @@
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent), isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
}); });
installApp.start(); installApp.start();
const deviceStorage = createDeviceStorage.mount(document);
await deviceStorage.start();
deviceSetup = createMobileDeviceSetup.mount({ deviceSetup = createMobileDeviceSetup.mount({
document, installApp, promptStorage:localStorage, document, installApp, promptStorage:localStorage,
timerView, timerView,
offlineAvailable:() => offlineStorageReady, offlineAvailable:() => offlineStorageReady,
offlineEnabled:() => offlineWorkStore.enabled(), offlineEnabled:() => offlineWorkStore.enabled(),
enableOffline:() => setOfflineWorkEnabled(true), enableOffline:() => setOfflineWorkEnabled(true),
storageProtectionReadiness:() => deviceStorage.persistenceReadiness(),
protectStorage:() => deviceStorage.requestPersistence(),
enablePush:async () => { enablePush:async () => {
const controller = await pushControllerReady; const controller = await pushControllerReady;
if (!controller) return; if (!controller) return;
@ -7998,7 +8002,6 @@
await controller.enableDeadline(); await controller.enableDeadline();
}, },
}); });
await createDeviceStorage.mount(document).start();
await deviceSetup.start(); await deviceSetup.start();
return deviceSetup; return deviceSetup;
} }

View File

@ -21,6 +21,7 @@
let privateRecordCount = 0; let privateRecordCount = 0;
let inventoryUnavailable = false; let inventoryUnavailable = false;
let fullClearArmed = 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?.() || []) const ownedCaches = async () => (await options.caches?.keys?.() || [])
.filter(name => name.startsWith('stackchain-dashboard-')); .filter(name => name.startsWith('stackchain-dashboard-'));
@ -37,10 +38,48 @@
return Math.round((Number(value) || 0) / (1024 * 1024)); 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() { async function refresh() {
let estimate = null; let estimate = null;
try { estimate = await options.storageManager?.estimate?.(); } try { estimate = await options.storageManager?.estimate?.(); }
catch (_error) { /* Storage estimates are optional. */ } catch (_error) { /* Storage estimates are optional. */ }
await inspectPersistence();
options.summary.textContent = estimate?.quota options.summary.textContent = estimate?.quota
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.` ? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
: 'Browser storage usage is unavailable.'; : 'Browser storage usage is unavailable.';
@ -52,7 +91,7 @@
privateRecordCount = inventory.recordCount; privateRecordCount = inventory.recordCount;
inventoryUnavailable = inventory.unavailable; inventoryUnavailable = inventory.unavailable;
const cacheCount = (await ownedCaches()).length; 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'}`} · ` + `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`; + `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount }; return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
@ -84,7 +123,7 @@
privateItemCount = 0; privateItemCount = 0;
fullClearArmed = false; fullClearArmed = false;
options.clearAllButton.textContent = 'Clear all private data'; 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.'; options.summary.textContent = 'All Stackchain private data was cleared from this device.';
} catch (error) { } catch (error) {
options.summary.textContent = `Private data was not fully cleared: ${error.message}`; options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
@ -99,5 +138,5 @@
return refresh(); return refresh();
} }
return { refresh, start }; return { refresh, start, persistenceReadiness, requestPersistence };
}); });

View File

@ -112,6 +112,10 @@
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div> <div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button> <button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
</li> </li>
<li class="device-setup-step">
<div><strong>Protect offline work</strong><p class="small" id="device-setup-protection-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-protection" type="button">Protect</button>
</li>
<li class="device-setup-step"> <li class="device-setup-step">
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div> <div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button> <button class="device-setup-action" id="device-setup-push" type="button">Enable</button>

View File

@ -7,6 +7,7 @@
const steps = [ const steps = [
['install', options.installButton, options.installStatus, options.install], ['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline], ['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['protection', options.protectionButton, options.protectionStatus, options.protectStorage],
['push', options.pushButton, options.pushStatus, options.enablePush], ['push', options.pushButton, options.pushStatus, options.enablePush],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline], ['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
]; ];
@ -108,8 +109,10 @@ function mountMobileDeviceSetup(options) {
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'), launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'), sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'), offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
protectionButton:qs('#device-setup-protection'),
deadlineButton:qs('#device-setup-deadline'), deadlineButton:qs('#device-setup-deadline'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'), 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'), pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
readyStatus:qs('#device-setup-ready-status'), readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'), promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
@ -126,6 +129,7 @@ function mountMobileDeviceSetup(options) {
: options.offlineEnabled() : options.offlineEnabled()
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'} ? {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.'}, : {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
protection:options.storageProtectionReadiness(),
push:qs('#push-updates').disabled push:qs('#push-updates').disabled
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'} ? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
: qs('#push-updates').checked : qs('#push-updates').checked
@ -135,6 +139,7 @@ function mountMobileDeviceSetup(options) {
}), }),
install:() => options.installApp.install(), install:() => options.installApp.install(),
enableOffline:options.enableOffline, enableOffline:options.enableOffline,
protectStorage:options.protectStorage,
enablePush:options.enablePush, enablePush:options.enablePush,
enableDeadline:options.enableDeadline, enableDeadline:options.enableDeadline,
}); });

View File

@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.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 OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;

View File

@ -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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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

View File

@ -17,6 +17,7 @@ class Target {
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target(); const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
let purgeCalls=0, purgeError=''; let purgeCalls=0, purgeError='';
let inventory={recordCount:0, unavailable:false}; let inventory={recordCount:0, unavailable:false};
let persistence={persisted:false, persistResult:false, persistedCalls:0, persistCalls:0, error:''};
const localStorage={ const localStorage={
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]), values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
get length(){return this.values.size;}, get length(){return this.values.size;},
@ -27,11 +28,16 @@ const caches={
async keys(){return Array.from(this.values);}, async keys(){return Array.from(this.values);},
async delete(key){return this.values.delete(key);}, 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({ const controller=createDeviceStorage({
summary, detail, clearCachesButton, clearAllButton, localStorage, caches, summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'], privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
inspectPrivateDatabases:async()=>inventory, inspectPrivateDatabases:async()=>inventory,
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})}, storageManager,
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);}, clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
}); });
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);}); (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 == { assert result == {
"summary": "2 MB of 10 MB browser storage used.", "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"], "caches": ["other-app"],
"storage": ["stackchain.draft.1", "other.preference"], "storage": ["stackchain.draft.1", "other.preference"],
"status": "Cached app copies cleared. Private work was kept.", "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, "calls": 1,
"status": "All Stackchain private data was cleared from this device.", "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, "calls": 0,
"label": "Confirm: clear private work", "label": "Confirm: clear private work",
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.", "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 == { assert result == {
"calls": 0, "calls": 0,
"label": "Confirm: clear private work", "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",
} }

View File

@ -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(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/later-sync.js'" in source

View File

@ -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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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

View File

@ -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])) 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 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(): def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -24,10 +24,12 @@ const closeButton = new FakeTarget();
const sheet = new FakeTarget(); sheet.hidden = true; const sheet = new FakeTarget(); sheet.hidden = true;
const installButton = new FakeTarget(); const installButton = new FakeTarget();
const offlineButton = new FakeTarget(); const offlineButton = new FakeTarget();
const protectionButton = new FakeTarget();
const pushButton = new FakeTarget(); const pushButton = new FakeTarget();
const deadlineButton = new FakeTarget(); const deadlineButton = new FakeTarget();
const installStatus = new FakeTarget(); const installStatus = new FakeTarget();
const offlineStatus = new FakeTarget(); const offlineStatus = new FakeTarget();
const protectionStatus = new FakeTarget();
const pushStatus = new FakeTarget(); const pushStatus = new FakeTarget();
const deadlineStatus = new FakeTarget(); const deadlineStatus = new FakeTarget();
const readyStatus = new FakeTarget(); const readyStatus = new FakeTarget();
@ -46,12 +48,13 @@ const promptStorage = {
let readiness = { let readiness = {
install:{state:'complete', detail:'Stackchain is installed.'}, install:{state:'complete', detail:'Stackchain is installed.'},
offline:{state:'incomplete', detail:'Offline work is off.'}, 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.'}, push:{state:'unavailable', detail:'Notifications are unavailable.'},
deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'}, deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'},
}; };
const setup = createMobileDeviceSetup({ const setup = createMobileDeviceSetup({
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton, launcher, closeButton, sheet, installButton, offlineButton, protectionButton, pushButton, deadlineButton,
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget, installStatus, offlineStatus, protectionStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
promptCard, promptSummary, promptLauncher, promptDismiss, promptCard, promptSummary, promptLauncher, promptDismiss,
returnButton, isMobile:() => true, returnButton, isMobile:() => true,
timerView:{ timerView:{
@ -62,6 +65,7 @@ const setup = createMobileDeviceSetup({
getReadiness:() => { state.events.push('readiness'); return readiness; }, getReadiness:() => { state.events.push('readiness'); return readiness; },
install:async () => { state.installCalls += 1; }, install:async () => { state.installCalls += 1; },
enableOffline:async () => { state.offlineCalls += 1; }, enableOffline:async () => { state.offlineCalls += 1; },
protectStorage:async () => { state.protectionCalls = (state.protectionCalls || 0) + 1; },
enablePush:async () => { state.pushCalls += 1; }, enablePush:async () => { state.pushCalls += 1; },
enableDeadline:async () => { state.deadlineCalls += 1; }, enableDeadline:async () => { state.deadlineCalls += 1; },
}); });
@ -90,11 +94,33 @@ process.stdout.write(JSON.stringify({
"install": "Stackchain is installed.", "install": "Stackchain is installed.",
"offline": "Offline work is off.", "offline": "Offline work is off.",
"push": "Notifications are unavailable.", "push": "Notifications are unavailable.",
"summary": "1 of 2 available steps ready.", "summary": "1 of 3 available steps ready.",
"calls": [0, 0, 0], "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(): def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
result = run_scenario(""" result = run_scenario("""
process.stdout.write(JSON.stringify({ process.stdout.write(JSON.stringify({
@ -106,7 +132,7 @@ process.stdout.write(JSON.stringify({
assert result == { assert result == {
"hidden": False, "hidden": False,
"summary": "1 of 2 steps complete", "summary": "1 of 3 steps complete",
"calls": [0, 0, 0], "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(): def test_setup_action_rechecks_real_state_before_marking_step_ready():
result = run_scenario(""" result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher}); await launcher.dispatch('click', {currentTarget:launcher});
readiness.protection = {state:'complete', detail:'Offline work is protected.'};
readiness.offline = {state:'complete', detail:'Offline work is saved.'}; readiness.offline = {state:'complete', detail:'Offline work is saved.'};
await offlineButton.dispatch('click'); await offlineButton.dispatch('click');
process.stdout.write(JSON.stringify({ process.stdout.write(JSON.stringify({
@ -227,7 +254,7 @@ process.stdout.write(JSON.stringify({
"calls": 1, "calls": 1,
"detail": "Deadline reminders enabled for 08:00 local time.", "detail": "Deadline reminders enabled for 08:00 local time.",
"hidden": True, "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="device-setup-today-detour"' in html
assert 'id="return-from-device-setup"' in html assert 'id="return-from-device-setup"' in html
assert 'aria-labelledby="device-setup-heading"' 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-summary"' in html
assert 'id="device-storage-detail"' in html assert 'id="device-storage-detail"' in html
assert 'id="clear-device-caches"' 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 "createMobileDeviceSetup.mount({" in dashboard
assert "timerView," in dashboard assert "timerView," in dashboard
assert "isMobile:options.isMobile || (() => innerWidth <= 600)" in MODULE.read_text() 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 "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
assert "promptStorage:localStorage" in dashboard assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker 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-setup-panel" in css
assert ".device-readiness-card" in css assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css assert "overflow-x:hidden" in css

View File

@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell(): def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text() 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 assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -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 ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker 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 @pytest.mark.anyio

View File

@ -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(): def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text() 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.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -168,13 +168,13 @@ async function dispatchPush(payload) {{
def test_offline_activation_migration_rolls_the_shell_cache(): def test_offline_activation_migration_rolls_the_shell_cache():
source = WORKER.read_text() 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(): def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" 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(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text() 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/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.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(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text() 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-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.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(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text() 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/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically(): def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text() 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/today-completion.js'" in source
assert "BASE + 'static/dashboard.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(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.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(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text() 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/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.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(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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 assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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(): def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/queue-today.js'" in source

View File

@ -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(): def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text() 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 assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -191,7 +191,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() 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 assert "BASE + 'static/today-sync.js'" in source