feat: manage private device storage (Closes #998)
CI / lint (pull_request) Successful in 2m51s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-17 03:37:21 +00:00
1 parent f30143b5ea
commit 70c8ea6584
12 files changed
+256 -5

No files matched your search

@@ -80,6 +80,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
expect(page.locator("#my-work")).to_be_visible()
expect(dock).to_be_visible()
page.locator("#app-menu-toggle").click()
page.locator("#open-device-setup").click()
expect(page.locator("#device-setup-sheet")).to_be_visible()
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
expect(page.locator("#device-storage-detail")).to_contain_text("private work stores")
for selector in ("#clear-device-caches", "#clear-private-device-data"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#close-device-setup").click()
expect(page.locator("#device-setup-sheet")).to_be_hidden()
assert len(workspace_requests) == 1, workspace_requests
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == []
+108
View File
@@ -0,0 +1,108 @@
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,
}
+9
View File
@@ -197,6 +197,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
storage_module = (root / "frontend" / "device-storage.js").read_text()
worker = (root / "frontend" / "service-worker.js").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
@@ -208,9 +209,17 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert 'id="device-setup-sheet"' 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 'id="device-storage-summary"' in html
assert 'id="device-storage-detail"' in html
assert 'id="clear-device-caches"' in html
assert 'id="clear-private-device-data"' in html
assert '<script src="static/private-device-data.js"></script>' in html
assert '<script src="static/device-storage.js"></script>' in html
assert 'id="device-setup-deadline-hour"' in html
assert '<script src="static/mobile-device-setup.js"></script>' in html
assert "createMobileDeviceSetup.mount({" in dashboard
assert "createDeviceStorage.mount(document)" 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
+4 -3
View File
@@ -9,12 +9,12 @@ PRIVATE_DATA_JS = Path(__file__).resolve().parents[1] / "frontend" / "private-de
def test_private_device_data_purger_waits_for_owned_outbox_and_cache_deletion():
harness = f"""
const createPrivateDeviceDataPurger = require({json.dumps(str(PRIVATE_DATA_JS))});
const state = {{removed:[], databases:[], caches:[], workerMessages:[], complete:false}};
const state = {{removed:[], databases:[], caches:[], workerMessages:[], order:[], complete:false}};
const storage = {{
values:new Map([['stackchain.draft','private'],['other.preference','keep']]),
get length(){{return this.values.size;}},
key(index){{return Array.from(this.values.keys())[index] || null;}},
removeItem(key){{state.removed.push(key);this.values.delete(key);}},
removeItem(key){{state.order.push('storage');state.removed.push(key);this.values.delete(key);}},
}};
const clear = createPrivateDeviceDataPurger({{
localStorage:storage, sessionStorage:storage,
@@ -25,7 +25,7 @@ const clear = createPrivateDeviceDataPurger({{
return request;
}}}},
caches:{{keys:async()=>['stackchain-dashboard-shell-v37','other-app'],delete:async key=>state.caches.push(key)}},
serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}},
serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.order.push('worker');state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}},
MessageChannel:class{{constructor(){{
const first={{onmessage:null,postMessage:data=>queueMicrotask(()=>second.onmessage?.({{data}}))}};
const second={{onmessage:null,postMessage:data=>queueMicrotask(()=>first.onmessage?.({{data}}))}};
@@ -53,4 +53,5 @@ const clear = createPrivateDeviceDataPurger({{
]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert state["order"][0] == "worker"
assert state["complete"] is True
+2
View File
@@ -1055,6 +1055,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-insights.js",
"/dashboard/static/mobile-app-shortcuts.js",
"/dashboard/static/install-app.js",
"/dashboard/static/private-device-data.js",
"/dashboard/static/device-storage.js",
"/dashboard/static/mobile-device-setup.js",
"/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-composer-viewport.js",