stackchain-dashboard/tests/test_device_storage.py
timmy dc4f457b21
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
feat: protect offline work from storage eviction (Closes #1110)
2026-08-19 03:36:48 +00:00

257 lines
9.7 KiB
Python

import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "device-storage.js"
def run_scenario(script: str) -> dict:
harness = r"""
const createDeviceStorage = require(__MODULE__);
class Target {
constructor() { this.listeners={}; this.textContent=''; this.hidden=false; this.disabled=false; this.dataset={}; }
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name) { for (const callback of this.listeners[name] || []) await callback(); }
}
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;},
key(index){return Array.from(this.values.keys())[index] || null;},
};
const caches={
values:new Set(['stackchain-dashboard-shell-a1','other-app']),
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,
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
});
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_inventory_reports_private_categories_and_quota_without_content():
result = run_scenario("""
await controller.refresh();
process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.textContent}));
""")
assert result == {
"summary": "2 MB of 10 MB browser storage used.",
"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,
}
def test_cache_only_cleanup_preserves_private_work_and_other_apps():
result = run_scenario("""
await controller.start();
await clearCachesButton.dispatch('click');
process.stdout.write(JSON.stringify({
caches:Array.from(caches.values),
storage:Array.from(localStorage.values.keys()),
status:summary.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"caches": ["other-app"],
"storage": ["stackchain.draft.1", "other.preference"],
"status": "Cached app copies cleared. Private work was kept.",
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 0 cached app copies",
}
def test_full_cleanup_requires_explicit_second_press_when_private_work_exists():
result = run_scenario("""
await controller.start();
await clearAllButton.dispatch('click');
const warning={calls:purgeCalls,label:clearAllButton.textContent,status:summary.textContent};
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent,detail:detail.textContent}));
""")
assert result == {
"warning": {
"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.",
},
"calls": 1,
"status": "All Stackchain private data was cleared from this device.",
"detail": "Storage protection: Best effort · 0 private browser items · 0 private work records · 0 cached app copies",
}
def test_indexeddb_only_work_requires_confirmation_before_purge():
result = run_scenario("""
localStorage.values.clear();
inventory={recordCount:3, unavailable:false};
await controller.start();
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:purgeCalls,
label:clearAllButton.textContent,
status:summary.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"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": "Storage protection: Best effort · 0 private browser items · 3 private work records · 1 cached app copy",
}
def test_unavailable_indexeddb_inventory_requires_confirmation_before_purge():
result = run_scenario("""
localStorage.values.clear();
inventory={recordCount:0, unavailable:true};
await controller.start();
await clearAllButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:purgeCalls,
label:clearAllButton.textContent,
detail:detail.textContent,
}));
""")
assert result == {
"calls": 0,
"label": "Confirm: clear private work",
"detail": "Storage protection: Best effort · 0 private browser items · private work status unknown · 1 cached app copy",
}
def test_full_cleanup_reports_blocked_deletion_without_claiming_success():
result = run_scenario("""
purgeError='IndexedDB deletion was blocked.';
await controller.start();
await clearAllButton.dispatch('click');
let escaped='';
try { await clearAllButton.dispatch('click'); } catch (error) { escaped=error.message; }
process.stdout.write(JSON.stringify({escaped,status:summary.textContent,disabled:clearAllButton.disabled}));
""")
assert result == {
"escaped": "",
"status": "Private data was not fully cleared: IndexedDB deletion was blocked.",
"disabled": False,
}