154 lines
5.6 KiB
Python
154 lines
5.6 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};
|
|
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'],
|
|
inspectPrivateDatabases:async()=>inventory,
|
|
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 · 0 private work records · 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 · 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": "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": "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": "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,
|
|
}
|