fix: guard IndexedDB private work cleanup (Closes #1000)
CI / lint (pull_request) Successful in 3m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m9s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-17 04:39:04 +00:00
1 parent 8148545295
commit a2c91e0296
10 files changed
+195 -9

No files matched your search

@@ -84,7 +84,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
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")
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
for selector in ("#clear-device-caches", "#clear-private-device-data"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
+48 -3
View File
@@ -16,6 +16,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};
const localStorage={
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
get length(){return this.values.size;},
@@ -29,6 +30,7 @@ const caches={
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);},
});
@@ -47,7 +49,7 @@ 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 · 2 private work stores · 1 cached app copy",
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
}
@@ -67,7 +69,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 · 2 private work stores · 0 cached app copies",
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
}
@@ -77,7 +79,7 @@ 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}));
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent,detail:detail.textContent}));
""")
assert result == {
@@ -88,6 +90,49 @@ 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",
}
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",
}
+79
View File
@@ -0,0 +1,79 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "private-data-inventory.js"
def run_inventory(databases: dict[str, list[int]], registered: list[str]) -> dict:
script = r"""
const createInspector = require(__MODULE__);
const databases = __DATABASES__;
const indexedDB = {
async databases() { return Object.keys(databases).map(name => ({name})); },
open(name) {
const request = {};
queueMicrotask(() => {
const stores = databases[name];
const db = {
objectStoreNames: stores.map((_records, index) => `store-${index}`),
transaction(names) {
return {
objectStore(storeName) {
return {
count() {
const countRequest = {};
const index = Number(storeName.split('-')[1]);
queueMicrotask(() => {
countRequest.result = stores[index];
countRequest.onsuccess?.();
});
return countRequest;
},
};
},
};
},
close() {},
};
request.result = db;
request.onsuccess?.();
});
return request;
},
};
createInspector(indexedDB)(__REGISTERED__)
.then(result => process.stdout.write(JSON.stringify(result)))
.catch(error => { console.error(error); process.exit(1); });
"""
script = (
script.replace("__MODULE__", json.dumps(str(MODULE)))
.replace("__DATABASES__", json.dumps(databases))
.replace("__REGISTERED__", json.dumps(registered))
)
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_inventory_counts_records_without_creating_absent_registered_databases():
result = run_inventory(
{"private-a": [2, 1], "other-app": [99]},
["private-a", "private-empty", "private-missing"],
)
assert result == {"recordCount": 3, "unavailable": False}
def test_inventory_is_conservatively_unavailable_when_enumeration_is_unsupported():
script = f"""
const createInspector = require({json.dumps(str(MODULE))});
createInspector({{}})(['private-a'])
.then(result => process.stdout.write(JSON.stringify(result)))
.catch(error => {{ console.error(error.message); process.exit(1); }});
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {"recordCount": 0, "unavailable": True}
+9
View File
@@ -44,3 +44,12 @@ def test_every_private_data_purge_context_consumes_the_shared_registry():
html = (FRONTEND / "index.html").read_text()
assert html.index('static/private-data-registry.js') < html.index('static/session.js')
def test_private_inventory_loads_after_registry_and_before_storage_controller():
html = (FRONTEND / "index.html").read_text()
registry = html.index('static/private-data-registry.js')
inventory = html.index('static/private-data-inventory.js')
controller = html.index('static/device-storage.js')
assert registry < inventory < controller
+1
View File
@@ -1055,6 +1055,7 @@ 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-data-inventory.js",
"/dashboard/static/private-device-data.js",
"/dashboard/static/device-storage.js",
"/dashboard/static/mobile-device-setup.js",