feat: protect offline work from storage eviction (Closes #1110)
This commit is contained in:
parent
9a471b7903
commit
dc4f457b21
|
|
@ -453,6 +453,11 @@ is assembled in source order into one content-addressed JavaScript response. Das
|
||||||
HTML and the offline worker reference that exact fingerprint, while the runtime receives
|
HTML and the offline worker reference that exact fingerprint, while the runtime receives
|
||||||
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
|
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
|
||||||
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
|
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
|
||||||
|
Device Setup also checks whether the browser has granted persistent storage without prompting.
|
||||||
|
Choose **Protect offline work** to request protection from automatic storage-pressure eviction;
|
||||||
|
Stackchain reports **Protected**, **Best effort**, **Denied**, or an unavailable/retryable state
|
||||||
|
truthfully. Offline work continues when protection is unavailable or denied, but the browser may
|
||||||
|
remove best-effort data, so persistence does not replace backups or device security.
|
||||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||||
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
||||||
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
||||||
|
|
|
||||||
|
|
@ -7976,12 +7976,16 @@
|
||||||
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
|
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
|
||||||
});
|
});
|
||||||
installApp.start();
|
installApp.start();
|
||||||
|
const deviceStorage = createDeviceStorage.mount(document);
|
||||||
|
await deviceStorage.start();
|
||||||
deviceSetup = createMobileDeviceSetup.mount({
|
deviceSetup = createMobileDeviceSetup.mount({
|
||||||
document, installApp, promptStorage:localStorage,
|
document, installApp, promptStorage:localStorage,
|
||||||
timerView,
|
timerView,
|
||||||
offlineAvailable:() => offlineStorageReady,
|
offlineAvailable:() => offlineStorageReady,
|
||||||
offlineEnabled:() => offlineWorkStore.enabled(),
|
offlineEnabled:() => offlineWorkStore.enabled(),
|
||||||
enableOffline:() => setOfflineWorkEnabled(true),
|
enableOffline:() => setOfflineWorkEnabled(true),
|
||||||
|
storageProtectionReadiness:() => deviceStorage.persistenceReadiness(),
|
||||||
|
protectStorage:() => deviceStorage.requestPersistence(),
|
||||||
enablePush:async () => {
|
enablePush:async () => {
|
||||||
const controller = await pushControllerReady;
|
const controller = await pushControllerReady;
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
|
|
@ -7998,7 +8002,6 @@
|
||||||
await controller.enableDeadline();
|
await controller.enableDeadline();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await createDeviceStorage.mount(document).start();
|
|
||||||
await deviceSetup.start();
|
await deviceSetup.start();
|
||||||
return deviceSetup;
|
return deviceSetup;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
let privateRecordCount = 0;
|
let privateRecordCount = 0;
|
||||||
let inventoryUnavailable = false;
|
let inventoryUnavailable = false;
|
||||||
let fullClearArmed = false;
|
let fullClearArmed = false;
|
||||||
|
let persistence = { state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable' };
|
||||||
const ownedCaches = async () => (await options.caches?.keys?.() || [])
|
const ownedCaches = async () => (await options.caches?.keys?.() || [])
|
||||||
.filter(name => name.startsWith('stackchain-dashboard-'));
|
.filter(name => name.startsWith('stackchain-dashboard-'));
|
||||||
|
|
||||||
|
|
@ -37,10 +38,48 @@
|
||||||
return Math.round((Number(value) || 0) / (1024 * 1024));
|
return Math.round((Number(value) || 0) / (1024 * 1024));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function inspectPersistence() {
|
||||||
|
if (typeof options.storageManager?.persisted !== 'function' || typeof options.storageManager?.persist !== 'function') {
|
||||||
|
persistence = {state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable'};
|
||||||
|
return persistence;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const protectedStorage = await options.storageManager.persisted();
|
||||||
|
persistence = protectedStorage
|
||||||
|
? {state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.', label:'Protected'}
|
||||||
|
: {state:'incomplete', detail:'Offline work uses best-effort browser storage and may be removed under storage pressure.', label:'Best effort'};
|
||||||
|
} catch (_error) {
|
||||||
|
persistence = {state:'incomplete', detail:'Storage protection could not be checked. Retry to protect offline work.', label:'Check failed'};
|
||||||
|
}
|
||||||
|
return persistence;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistenceReadiness() {
|
||||||
|
return {state:persistence.state, detail:persistence.detail};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPersistence() {
|
||||||
|
if (typeof options.storageManager?.persist !== 'function') return persistenceReadiness();
|
||||||
|
let granted = false;
|
||||||
|
try { granted = await options.storageManager.persist(); }
|
||||||
|
catch (_error) {
|
||||||
|
persistence = {state:'incomplete', detail:'Storage protection request failed. Offline work still uses best-effort storage; retry when ready.', label:'Request failed'};
|
||||||
|
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Request failed ');
|
||||||
|
return persistenceReadiness();
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
if (!granted && persistence.state !== 'complete') {
|
||||||
|
persistence = {state:'incomplete', detail:'The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.', label:'Denied'};
|
||||||
|
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Denied ');
|
||||||
|
}
|
||||||
|
return persistenceReadiness();
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
let estimate = null;
|
let estimate = null;
|
||||||
try { estimate = await options.storageManager?.estimate?.(); }
|
try { estimate = await options.storageManager?.estimate?.(); }
|
||||||
catch (_error) { /* Storage estimates are optional. */ }
|
catch (_error) { /* Storage estimates are optional. */ }
|
||||||
|
await inspectPersistence();
|
||||||
options.summary.textContent = estimate?.quota
|
options.summary.textContent = estimate?.quota
|
||||||
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
|
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
|
||||||
: 'Browser storage usage is unavailable.';
|
: 'Browser storage usage is unavailable.';
|
||||||
|
|
@ -52,7 +91,7 @@
|
||||||
privateRecordCount = inventory.recordCount;
|
privateRecordCount = inventory.recordCount;
|
||||||
inventoryUnavailable = inventory.unavailable;
|
inventoryUnavailable = inventory.unavailable;
|
||||||
const cacheCount = (await ownedCaches()).length;
|
const cacheCount = (await ownedCaches()).length;
|
||||||
options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
options.detail.textContent = `Storage protection: ${persistence.label} · ${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
||||||
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
|
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
|
||||||
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
|
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
|
||||||
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
|
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
|
||||||
|
|
@ -84,7 +123,7 @@
|
||||||
privateItemCount = 0;
|
privateItemCount = 0;
|
||||||
fullClearArmed = false;
|
fullClearArmed = false;
|
||||||
options.clearAllButton.textContent = 'Clear all private data';
|
options.clearAllButton.textContent = 'Clear all private data';
|
||||||
options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies';
|
options.detail.textContent = `Storage protection: ${persistence.label} · 0 private browser items · 0 private work records · 0 cached app copies`;
|
||||||
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
|
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
|
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
|
||||||
|
|
@ -99,5 +138,5 @@
|
||||||
return refresh();
|
return refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { refresh, start };
|
return { refresh, start, persistenceReadiness, requestPersistence };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,10 @@
|
||||||
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
|
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
|
||||||
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
|
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="device-setup-step">
|
||||||
|
<div><strong>Protect offline work</strong><p class="small" id="device-setup-protection-status" role="status"></p></div>
|
||||||
|
<button class="device-setup-action" id="device-setup-protection" type="button">Protect</button>
|
||||||
|
</li>
|
||||||
<li class="device-setup-step">
|
<li class="device-setup-step">
|
||||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
const steps = [
|
const steps = [
|
||||||
['install', options.installButton, options.installStatus, options.install],
|
['install', options.installButton, options.installStatus, options.install],
|
||||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||||
|
['protection', options.protectionButton, options.protectionStatus, options.protectStorage],
|
||||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||||
];
|
];
|
||||||
|
|
@ -108,8 +109,10 @@ function mountMobileDeviceSetup(options) {
|
||||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||||
|
protectionButton:qs('#device-setup-protection'),
|
||||||
deadlineButton:qs('#device-setup-deadline'),
|
deadlineButton:qs('#device-setup-deadline'),
|
||||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||||
|
protectionStatus:qs('#device-setup-protection-status'),
|
||||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||||
readyStatus:qs('#device-setup-ready-status'),
|
readyStatus:qs('#device-setup-ready-status'),
|
||||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||||
|
|
@ -126,6 +129,7 @@ function mountMobileDeviceSetup(options) {
|
||||||
: options.offlineEnabled()
|
: options.offlineEnabled()
|
||||||
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
||||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||||
|
protection:options.storageProtectionReadiness(),
|
||||||
push:qs('#push-updates').disabled
|
push:qs('#push-updates').disabled
|
||||||
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
||||||
: qs('#push-updates').checked
|
: qs('#push-updates').checked
|
||||||
|
|
@ -135,6 +139,7 @@ function mountMobileDeviceSetup(options) {
|
||||||
}),
|
}),
|
||||||
install:() => options.installApp.install(),
|
install:() => options.installApp.install(),
|
||||||
enableOffline:options.enableOffline,
|
enableOffline:options.enableOffline,
|
||||||
|
protectStorage:options.protectStorage,
|
||||||
enablePush:options.enablePush,
|
enablePush:options.enablePush,
|
||||||
enableDeadline:options.enableDeadline,
|
enableDeadline:options.enableDeadline,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/private-data-registry.js');
|
importScripts(BASE + 'static/private-data-registry.js');
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v121';
|
const CACHE = 'stackchain-dashboard-shell-v122';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
|
||||||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
assert "stackchain-dashboard-shell-v121" in worker
|
assert "stackchain-dashboard-shell-v122" in worker
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class Target {
|
||||||
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
|
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
|
||||||
let purgeCalls=0, purgeError='';
|
let purgeCalls=0, purgeError='';
|
||||||
let inventory={recordCount:0, unavailable:false};
|
let inventory={recordCount:0, unavailable:false};
|
||||||
|
let persistence={persisted:false, persistResult:false, persistedCalls:0, persistCalls:0, error:''};
|
||||||
const localStorage={
|
const localStorage={
|
||||||
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
|
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
|
||||||
get length(){return this.values.size;},
|
get length(){return this.values.size;},
|
||||||
|
|
@ -27,11 +28,16 @@ const caches={
|
||||||
async keys(){return Array.from(this.values);},
|
async keys(){return Array.from(this.values);},
|
||||||
async delete(key){return this.values.delete(key);},
|
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({
|
const controller=createDeviceStorage({
|
||||||
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
|
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
|
||||||
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
|
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
|
||||||
inspectPrivateDatabases:async()=>inventory,
|
inspectPrivateDatabases:async()=>inventory,
|
||||||
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
|
storageManager,
|
||||||
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
|
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
|
||||||
});
|
});
|
||||||
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
|
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
|
||||||
|
|
@ -49,7 +55,104 @@ process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.t
|
||||||
|
|
||||||
assert result == {
|
assert result == {
|
||||||
"summary": "2 MB of 10 MB browser storage used.",
|
"summary": "2 MB of 10 MB browser storage used.",
|
||||||
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
|
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -69,7 +172,7 @@ process.stdout.write(JSON.stringify({
|
||||||
"caches": ["other-app"],
|
"caches": ["other-app"],
|
||||||
"storage": ["stackchain.draft.1", "other.preference"],
|
"storage": ["stackchain.draft.1", "other.preference"],
|
||||||
"status": "Cached app copies cleared. Private work was kept.",
|
"status": "Cached app copies cleared. Private work was kept.",
|
||||||
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
|
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 0 cached app copies",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -90,7 +193,7 @@ process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.tex
|
||||||
},
|
},
|
||||||
"calls": 1,
|
"calls": 1,
|
||||||
"status": "All Stackchain private data was cleared from this device.",
|
"status": "All Stackchain private data was cleared from this device.",
|
||||||
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
|
"detail": "Storage protection: Best effort · 0 private browser items · 0 private work records · 0 cached app copies",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -112,7 +215,7 @@ process.stdout.write(JSON.stringify({
|
||||||
"calls": 0,
|
"calls": 0,
|
||||||
"label": "Confirm: clear private work",
|
"label": "Confirm: clear private work",
|
||||||
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
|
"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",
|
"detail": "Storage protection: Best effort · 0 private browser items · 3 private work records · 1 cached app copy",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -132,7 +235,7 @@ process.stdout.write(JSON.stringify({
|
||||||
assert result == {
|
assert result == {
|
||||||
"calls": 0,
|
"calls": 0,
|
||||||
"label": "Confirm: clear private work",
|
"label": "Confirm: clear private work",
|
||||||
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
|
"detail": "Storage protection: Best effort · 0 private browser items · private work status unknown · 1 cached app copy",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v121" in worker
|
assert "stackchain-dashboard-shell-v122" in worker
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v121" in worker
|
assert "stackchain-dashboard-shell-v122" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,12 @@ const closeButton = new FakeTarget();
|
||||||
const sheet = new FakeTarget(); sheet.hidden = true;
|
const sheet = new FakeTarget(); sheet.hidden = true;
|
||||||
const installButton = new FakeTarget();
|
const installButton = new FakeTarget();
|
||||||
const offlineButton = new FakeTarget();
|
const offlineButton = new FakeTarget();
|
||||||
|
const protectionButton = new FakeTarget();
|
||||||
const pushButton = new FakeTarget();
|
const pushButton = new FakeTarget();
|
||||||
const deadlineButton = new FakeTarget();
|
const deadlineButton = new FakeTarget();
|
||||||
const installStatus = new FakeTarget();
|
const installStatus = new FakeTarget();
|
||||||
const offlineStatus = new FakeTarget();
|
const offlineStatus = new FakeTarget();
|
||||||
|
const protectionStatus = new FakeTarget();
|
||||||
const pushStatus = new FakeTarget();
|
const pushStatus = new FakeTarget();
|
||||||
const deadlineStatus = new FakeTarget();
|
const deadlineStatus = new FakeTarget();
|
||||||
const readyStatus = new FakeTarget();
|
const readyStatus = new FakeTarget();
|
||||||
|
|
@ -46,12 +48,13 @@ const promptStorage = {
|
||||||
let readiness = {
|
let readiness = {
|
||||||
install:{state:'complete', detail:'Stackchain is installed.'},
|
install:{state:'complete', detail:'Stackchain is installed.'},
|
||||||
offline:{state:'incomplete', detail:'Offline work is off.'},
|
offline:{state:'incomplete', detail:'Offline work is off.'},
|
||||||
|
protection:{state:'incomplete', detail:'Offline work uses best-effort browser storage.'},
|
||||||
push:{state:'unavailable', detail:'Notifications are unavailable.'},
|
push:{state:'unavailable', detail:'Notifications are unavailable.'},
|
||||||
deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'},
|
deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'},
|
||||||
};
|
};
|
||||||
const setup = createMobileDeviceSetup({
|
const setup = createMobileDeviceSetup({
|
||||||
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
|
launcher, closeButton, sheet, installButton, offlineButton, protectionButton, pushButton, deadlineButton,
|
||||||
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
installStatus, offlineStatus, protectionStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
||||||
promptCard, promptSummary, promptLauncher, promptDismiss,
|
promptCard, promptSummary, promptLauncher, promptDismiss,
|
||||||
returnButton, isMobile:() => true,
|
returnButton, isMobile:() => true,
|
||||||
timerView:{
|
timerView:{
|
||||||
|
|
@ -62,6 +65,7 @@ const setup = createMobileDeviceSetup({
|
||||||
getReadiness:() => { state.events.push('readiness'); return readiness; },
|
getReadiness:() => { state.events.push('readiness'); return readiness; },
|
||||||
install:async () => { state.installCalls += 1; },
|
install:async () => { state.installCalls += 1; },
|
||||||
enableOffline:async () => { state.offlineCalls += 1; },
|
enableOffline:async () => { state.offlineCalls += 1; },
|
||||||
|
protectStorage:async () => { state.protectionCalls = (state.protectionCalls || 0) + 1; },
|
||||||
enablePush:async () => { state.pushCalls += 1; },
|
enablePush:async () => { state.pushCalls += 1; },
|
||||||
enableDeadline:async () => { state.deadlineCalls += 1; },
|
enableDeadline:async () => { state.deadlineCalls += 1; },
|
||||||
});
|
});
|
||||||
|
|
@ -90,11 +94,33 @@ process.stdout.write(JSON.stringify({
|
||||||
"install": "Stackchain is installed.",
|
"install": "Stackchain is installed.",
|
||||||
"offline": "Offline work is off.",
|
"offline": "Offline work is off.",
|
||||||
"push": "Notifications are unavailable.",
|
"push": "Notifications are unavailable.",
|
||||||
"summary": "1 of 2 available steps ready.",
|
"summary": "1 of 3 available steps ready.",
|
||||||
"calls": [0, 0, 0],
|
"calls": [0, 0, 0],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_protection_runs_only_from_its_explicit_setup_action():
|
||||||
|
result = run_scenario("""
|
||||||
|
await launcher.dispatch('click', {currentTarget:launcher});
|
||||||
|
const callsOnOpen=state.protectionCalls || 0;
|
||||||
|
readiness.protection={state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.'};
|
||||||
|
await protectionButton.dispatch('click');
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
callsOnOpen,
|
||||||
|
callsAfterAction:state.protectionCalls,
|
||||||
|
detail:protectionStatus.textContent,
|
||||||
|
hidden:protectionButton.hidden,
|
||||||
|
}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"callsOnOpen": 0,
|
||||||
|
"callsAfterAction": 1,
|
||||||
|
"detail": "Offline work is protected from automatic browser storage cleanup.",
|
||||||
|
"hidden": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
|
def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
|
||||||
result = run_scenario("""
|
result = run_scenario("""
|
||||||
process.stdout.write(JSON.stringify({
|
process.stdout.write(JSON.stringify({
|
||||||
|
|
@ -106,7 +132,7 @@ process.stdout.write(JSON.stringify({
|
||||||
|
|
||||||
assert result == {
|
assert result == {
|
||||||
"hidden": False,
|
"hidden": False,
|
||||||
"summary": "1 of 2 steps complete",
|
"summary": "1 of 3 steps complete",
|
||||||
"calls": [0, 0, 0],
|
"calls": [0, 0, 0],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,6 +172,7 @@ process.stdout.write(JSON.stringify({hiddenAfterDismiss, delay:dismissedUntil -
|
||||||
def test_setup_action_rechecks_real_state_before_marking_step_ready():
|
def test_setup_action_rechecks_real_state_before_marking_step_ready():
|
||||||
result = run_scenario("""
|
result = run_scenario("""
|
||||||
await launcher.dispatch('click', {currentTarget:launcher});
|
await launcher.dispatch('click', {currentTarget:launcher});
|
||||||
|
readiness.protection = {state:'complete', detail:'Offline work is protected.'};
|
||||||
readiness.offline = {state:'complete', detail:'Offline work is saved.'};
|
readiness.offline = {state:'complete', detail:'Offline work is saved.'};
|
||||||
await offlineButton.dispatch('click');
|
await offlineButton.dispatch('click');
|
||||||
process.stdout.write(JSON.stringify({
|
process.stdout.write(JSON.stringify({
|
||||||
|
|
@ -227,7 +254,7 @@ process.stdout.write(JSON.stringify({
|
||||||
"calls": 1,
|
"calls": 1,
|
||||||
"detail": "Deadline reminders enabled for 08:00 local time.",
|
"detail": "Deadline reminders enabled for 08:00 local time.",
|
||||||
"hidden": True,
|
"hidden": True,
|
||||||
"summary": "3 of 4 available steps ready.",
|
"summary": "3 of 5 available steps ready.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -248,7 +275,8 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert 'id="device-setup-today-detour"' in html
|
assert 'id="device-setup-today-detour"' in html
|
||||||
assert 'id="return-from-device-setup"' in html
|
assert 'id="return-from-device-setup"' in html
|
||||||
assert 'aria-labelledby="device-setup-heading"' 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 all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "protection", "push", "deadline"))
|
||||||
|
assert 'id="device-setup-protection-status"' in html
|
||||||
assert 'id="device-storage-summary"' in html
|
assert 'id="device-storage-summary"' in html
|
||||||
assert 'id="device-storage-detail"' in html
|
assert 'id="device-storage-detail"' in html
|
||||||
assert 'id="clear-device-caches"' in html
|
assert 'id="clear-device-caches"' in html
|
||||||
|
|
@ -260,12 +288,14 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
assert "createMobileDeviceSetup.mount({" in dashboard
|
||||||
assert "timerView," in dashboard
|
assert "timerView," in dashboard
|
||||||
assert "isMobile:options.isMobile || (() => innerWidth <= 600)" in MODULE.read_text()
|
assert "isMobile:options.isMobile || (() => innerWidth <= 600)" in MODULE.read_text()
|
||||||
assert "createDeviceStorage.mount(document)" in dashboard
|
assert "const deviceStorage = createDeviceStorage.mount(document)" in dashboard
|
||||||
|
assert "storageProtectionReadiness:() => deviceStorage.persistenceReadiness()" in dashboard
|
||||||
|
assert "protectStorage:() => deviceStorage.requestPersistence()" in dashboard
|
||||||
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
|
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
|
||||||
assert "promptStorage:localStorage" in dashboard
|
assert "promptStorage:localStorage" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||||
assert "stackchain-dashboard-shell-v121" in worker
|
assert "stackchain-dashboard-shell-v122" in worker
|
||||||
assert ".device-setup-panel" in css
|
assert ".device-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in worker
|
assert "stackchain-dashboard-shell-v122" in worker
|
||||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||||
assert "stackchain-dashboard-shell-v121" in service_worker
|
assert "stackchain-dashboard-shell-v122" in service_worker
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -168,13 +168,13 @@ async function dispatchPush(payload) {{
|
||||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
|
|
||||||
|
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -183,7 +183,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/authored-outbox.js'" in source
|
assert "BASE + 'static/authored-outbox.js'" in source
|
||||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||||
|
|
@ -192,7 +192,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||||
assert "BASE + 'static/issue-attachment.js'" in source
|
assert "BASE + 'static/issue-attachment.js'" in source
|
||||||
|
|
||||||
|
|
@ -200,14 +200,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -215,7 +215,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -223,7 +223,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/issue-sheet.js'" in source
|
assert "BASE + 'static/issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
@ -233,14 +233,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -249,21 +249,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1137,7 +1137,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||||
def test_readiness_runtime_is_available_in_offline_shell():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
service_worker = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "const CACHE = 'stackchain-dashboard-shell-v121';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v122';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v121" in source
|
assert "stackchain-dashboard-shell-v122" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user