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=''; 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 controller=createDeviceStorage({ summary, detail, clearCachesButton, clearAllButton, localStorage, caches, privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'], storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})}, 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": "1 private browser item · 2 private work stores · 1 cached app copy", } 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": "1 private browser item · 2 private work stores · 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})); """) 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.", } 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, }