feat: protect offline work from storage eviction (Closes #1110)
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 committed 2026-08-19 03:36:48 +00:00
1 parent 9a471b7903
commit dc4f457b21
18 files changed
+231 -42

No files matched your search

+1 -1
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 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
+109 -6
View File
@@ -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",
}
+1 -1
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():
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
+1 -1
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 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
+1 -1
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]))
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():
+38 -8
View File
@@ -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
+1 -1
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():
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
+1 -1
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 "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
+1 -1
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():
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
+14 -14
View File
@@ -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
+1 -1
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():
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
+1 -1
View File
@@ -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