Move offline work details from localStorage to IndexedDB #526
12
README.md
12
README.md
|
|
@ -386,8 +386,12 @@ newest 20 comments. Requested-review records also retain the head SHA, CI state,
|
|||
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
|
||||
per file. A previously opened unread update is retained by notification
|
||||
identity with allowlisted subject context and its newest 20 conversation messages.
|
||||
This account-bound cache is limited to ten records across all detail kinds; credentials,
|
||||
repository catalogs, events, raw patches, and complete API responses are excluded.
|
||||
The snapshot and each account-bound detail are committed as separate IndexedDB records,
|
||||
so large review previews do not consume the small synchronous localStorage quota or require
|
||||
rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1
|
||||
localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains
|
||||
limited to ten records across all detail kinds; credentials, repository catalogs, events, raw
|
||||
patches, and complete API responses are excluded.
|
||||
A cold offline launch labels the saved time. Cached Today details open in the existing
|
||||
phone sheet, where comments can enter the account-bound durable outbox. For issue and
|
||||
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
|
||||
|
|
@ -401,8 +405,8 @@ queued operation and its matching SHA-scoped draft and progress.
|
|||
Cached unread updates use the same phone conversation sheet and replies enter the
|
||||
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
|
||||
Cards without a saved detail explain that reconnection is required. **Clear offline
|
||||
work data** deletes both stores, and opting out or seven-day expiry deletes them
|
||||
automatically.
|
||||
work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the
|
||||
IndexedDB records; seven-day expiry removes stale records automatically.
|
||||
|
||||
API responses and mutations are never cached by the service worker. New issue captures,
|
||||
issue comments, pull-request comments, and unread-update replies use bounded local
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
(function(){
|
||||
(async function(){
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
const cardPlanning = createCardPlanning(document);
|
||||
|
|
@ -399,7 +399,8 @@
|
|||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
||||
await offlineWorkStore.ready();
|
||||
function renderOfflineTodayStatus(status) {
|
||||
const container = qs('#offline-today-readiness');
|
||||
const label = qs('#offline-today-status');
|
||||
|
|
@ -656,7 +657,7 @@
|
|||
setOfflineUpdateControls(false);
|
||||
qs('#keep-update-unread').focus();
|
||||
},
|
||||
onDetail: detail => {
|
||||
onDetail: async detail => {
|
||||
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
|
||||
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
|
||||
qs('#update-subject-state').textContent = detail.state || '';
|
||||
|
|
@ -667,7 +668,7 @@
|
|||
} else {
|
||||
updateOwnership.open(detail, selectedUpdate);
|
||||
if (offlineWorkStore.enabled() && confirmedOwnerLogin && selectedUpdate) {
|
||||
offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
|
||||
await offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail);
|
||||
}
|
||||
}
|
||||
qs('#retry-update-load').hidden = true;
|
||||
|
|
@ -1549,11 +1550,11 @@
|
|||
setClock();
|
||||
}
|
||||
|
||||
function handleContextError(e) {
|
||||
async function handleContextError(e) {
|
||||
console.error('context failed', e);
|
||||
liveMode = false;
|
||||
activeFlushLogin = '';
|
||||
if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;
|
||||
if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return;
|
||||
const timeoutStatus = e.name === 'TimeoutError' ?
|
||||
'Update delayed · showing last snapshot' : 'Update failed · showing last snapshot';
|
||||
setStatus(hasContextSnapshot ? timeoutStatus : 'Unavailable');
|
||||
|
|
@ -2268,7 +2269,7 @@
|
|||
qs('#issue-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
|
||||
' · comments queue for sync';
|
||||
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
|
||||
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
|
||||
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
|
||||
}
|
||||
} catch (error) {
|
||||
if (selectedIssue !== item) return;
|
||||
|
|
@ -2445,7 +2446,7 @@
|
|||
qs('#pull-sheet-status').textContent = 'Offline copy · saved ' + fmt(detail.saved_at) +
|
||||
' · comments queue for sync';
|
||||
} else if (offlineWorkStore.enabled() && confirmedOwnerLogin && todayWork.contains(item)) {
|
||||
offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
|
||||
await offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail);
|
||||
}
|
||||
} catch (error) {
|
||||
if (selectedPull !== item) return;
|
||||
|
|
@ -3117,7 +3118,7 @@
|
|||
qs('#gitea-events-status').textContent = message;
|
||||
}
|
||||
|
||||
function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) {
|
||||
async function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) {
|
||||
setOfflineWorkMode(false);
|
||||
offlineStatus.hidden = true;
|
||||
const contextFreshness = snapshot.freshness?.sections?.context;
|
||||
|
|
@ -3171,12 +3172,13 @@
|
|||
if (contextFreshness?.stale) markMyWorkStale();
|
||||
else if (!notificationsFresh) markNotificationsStale();
|
||||
if (!contextFreshness?.stale && notificationsFresh) {
|
||||
offlineWorkStore.save({
|
||||
const admitted = await offlineWorkStore.save({
|
||||
...snapshot.context,
|
||||
notifications: snapshot.notifications,
|
||||
notification_pagination: snapshot.notification_pagination,
|
||||
});
|
||||
updateOfflineWorkControls();
|
||||
await updateOfflineWorkControls(admitted === false ?
|
||||
'Offline saving unavailable · retry after reconnect.' : undefined);
|
||||
warmTodayOffline();
|
||||
}
|
||||
flushIssueOutbox();
|
||||
|
|
@ -4690,8 +4692,8 @@
|
|||
const contextPoller = createContextPoller({
|
||||
fetchContext: fetchLiveSnapshot,
|
||||
onSnapshot: renderLiveSnapshot,
|
||||
onError: error => {
|
||||
handleContextError(error);
|
||||
onError: async error => {
|
||||
await handleContextError(error);
|
||||
setEventStreamStatus('Update failed · showing last activity');
|
||||
},
|
||||
isHidden: () => document.hidden,
|
||||
|
|
@ -4704,9 +4706,9 @@
|
|||
const offlineWorkStatus = qs('#offline-work-status');
|
||||
const deliveryReceipts = qs('#delivery-receipts');
|
||||
const deliveryReceiptStatus = qs('#delivery-receipt-status');
|
||||
function updateOfflineWorkControls(message) {
|
||||
async function updateOfflineWorkControls(message) {
|
||||
keepWorkOffline.checked = offlineWorkStore.enabled();
|
||||
const saved = offlineWorkStore.load();
|
||||
const saved = await offlineWorkStore.load();
|
||||
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
||||
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
|
||||
}
|
||||
|
|
@ -4738,8 +4740,8 @@
|
|||
.forEach(button => { button.disabled = true; });
|
||||
}
|
||||
}
|
||||
function hydrateOfflineWork(mode = 'offline') {
|
||||
const saved = offlineWorkStore.load();
|
||||
async function hydrateOfflineWork(mode = 'offline') {
|
||||
const saved = await offlineWorkStore.load();
|
||||
if (!saved) return false;
|
||||
const outage = mode === 'outage';
|
||||
confirmedOwnerLogin = String(saved.user?.login || '').trim();
|
||||
|
|
@ -4768,11 +4770,11 @@
|
|||
offlineStatus.hidden = false;
|
||||
return true;
|
||||
}
|
||||
function showOfflineStatus() {
|
||||
async function showOfflineStatus() {
|
||||
activeFlushLogin = '';
|
||||
offlineStatus.hidden = false;
|
||||
setStatus('Offline');
|
||||
if (!hasContextSnapshot) hydrateOfflineWork();
|
||||
if (!hasContextSnapshot) await hydrateOfflineWork();
|
||||
}
|
||||
function reconnectLiveData() {
|
||||
offlineStatus.hidden = true;
|
||||
|
|
@ -4782,13 +4784,18 @@
|
|||
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
|
||||
});
|
||||
}
|
||||
keepWorkOffline.addEventListener('change', () => {
|
||||
keepWorkOffline.addEventListener('change', async () => {
|
||||
offlineWorkStore.setEnabled(keepWorkOffline.checked);
|
||||
if (keepWorkOffline.checked && liveMode && lastContextSnapshot) {
|
||||
offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
|
||||
const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
|
||||
notification_pagination:notificationPagination });
|
||||
if (admitted === false) {
|
||||
await updateOfflineWorkControls('Offline saving unavailable · retry after reconnect.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||||
if (!keepWorkOffline.checked) await offlineWorkStore.clear();
|
||||
await updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||||
if (keepWorkOffline.checked) warmTodayOffline();
|
||||
else offlineToday.cancel();
|
||||
});
|
||||
|
|
@ -4804,18 +4811,18 @@
|
|||
await updateDeliveryReceiptControls(enabled ? 'Background delivery receipts enabled.' :
|
||||
(Notification.permission === 'denied' ? 'Notifications are blocked in browser settings.' : 'Background delivery receipts disabled.'));
|
||||
});
|
||||
qs('#clear-offline-work').addEventListener('click', () => {
|
||||
offlineWorkStore.clear();
|
||||
qs('#clear-offline-work').addEventListener('click', async () => {
|
||||
await offlineWorkStore.clear();
|
||||
offlineToday.cancel();
|
||||
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
|
||||
updateOfflineWorkControls('Offline work data cleared.');
|
||||
await updateOfflineWorkControls('Offline work data cleared.');
|
||||
});
|
||||
qs('#retry-offline-today').addEventListener('click', () =>
|
||||
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
|
||||
);
|
||||
updateOfflineWorkControls();
|
||||
await updateOfflineWorkControls();
|
||||
updateDeliveryReceiptControls();
|
||||
if (!navigator.onLine) showOfflineStatus();
|
||||
if (!navigator.onLine) await showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectLiveData);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,14 +31,18 @@
|
|||
}
|
||||
|
||||
const previousFailed = failed;
|
||||
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
|
||||
const savedDetails = new Map();
|
||||
await Promise.all(items.map(async item => {
|
||||
savedDetails.set(itemKey(item), await loadSavedDetail(login, item));
|
||||
}));
|
||||
const readyKeys = new Set(items.filter(item => savedDetails.get(itemKey(item))).map(itemKey));
|
||||
const snapshot = pending => ({
|
||||
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
|
||||
});
|
||||
const candidates = items.filter(item => {
|
||||
const key = itemKey(item);
|
||||
if (onlyFailed && !previousFailed.has(key)) return false;
|
||||
const saved = loadSavedDetail(login, item);
|
||||
const saved = savedDetails.get(key);
|
||||
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
|
||||
});
|
||||
failed = new Set();
|
||||
|
|
@ -51,8 +55,9 @@
|
|||
try {
|
||||
const detail = await loadDetail(item);
|
||||
if (runGeneration !== generation) return;
|
||||
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
|
||||
if (saved !== false) readyKeys.add(itemKey(item));
|
||||
const saved = await saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
|
||||
if (saved === false) throw new Error('Offline detail was not durably admitted.');
|
||||
readyKeys.add(itemKey(item));
|
||||
} catch (_error) {
|
||||
if (runGeneration === generation) failed.add(itemKey(item));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,54 @@
|
|||
];
|
||||
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
|
||||
|
||||
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-offline-work-v2') {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise;
|
||||
function database() {
|
||||
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains('work')) {
|
||||
request.result.createObjectStore('work', { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
db.onversionchange = () => { db.close(); databasePromise = undefined; };
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => reject(request.error || new Error('Offline work database failed to open.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
const requested = request => new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return async work => {
|
||||
const db = await database();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('work', 'readwrite');
|
||||
const store = transaction.objectStore('work');
|
||||
let result;
|
||||
let workError;
|
||||
transaction.oncomplete = () => workError ? undefined : resolve(result);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(workError || transaction.error || new Error('Offline work transaction aborted.'));
|
||||
Promise.resolve(work({
|
||||
get: key => requested(store.get(key)),
|
||||
getAll: () => requested(store.getAll()),
|
||||
put: value => requested(store.put(value)),
|
||||
delete: key => requested(store.delete(key)),
|
||||
clear: () => requested(store.clear()),
|
||||
})).then(value => { result = value; }).catch(error => {
|
||||
workError = error;
|
||||
try { transaction.abort(); } catch (_) { reject(error); }
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function pick(source, fields) {
|
||||
const output = {};
|
||||
fields.forEach(field => {
|
||||
|
|
@ -36,14 +84,94 @@
|
|||
}
|
||||
|
||||
function createOfflineWorkStore({
|
||||
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
|
||||
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
|
||||
maxDetails = 10,
|
||||
}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
let migrationPromise;
|
||||
let recordCache = null;
|
||||
function migrateLegacy() {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
if (!migrationPromise) migrationPromise = (async () => {
|
||||
let snapshot = null;
|
||||
let details = [];
|
||||
try {
|
||||
snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||
const parsedDetails = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
details = Array.isArray(parsedDetails) ? parsedDetails : [];
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!snapshot && !details.length) return true;
|
||||
await transact(async records => {
|
||||
if (snapshot && !(await records.get('snapshot'))) {
|
||||
await records.put({ ...snapshot, id:'snapshot' });
|
||||
}
|
||||
for (const record of details.slice(-Math.max(1, maxDetails))) {
|
||||
if (!record?.key || !record?.user_login || !record?.data) continue;
|
||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||
if (!(await records.get(id))) await records.put({ ...record, id });
|
||||
}
|
||||
});
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
return true;
|
||||
})().catch(() => false);
|
||||
return migrationPromise;
|
||||
}
|
||||
function ready() {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
if (recordCache) return Promise.resolve(true);
|
||||
return migrateLegacy().then(migrated => {
|
||||
if (!migrated) {
|
||||
recordCache = new Map();
|
||||
try {
|
||||
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||
if (snapshot) recordCache.set('snapshot', { ...snapshot, id:'snapshot' });
|
||||
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
if (Array.isArray(details)) details.forEach(record => {
|
||||
if (!record?.key || !record?.user_login || !record?.data) return;
|
||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||
recordCache.set(id, { ...record, id });
|
||||
});
|
||||
} catch (_) { recordCache.clear(); }
|
||||
return false;
|
||||
}
|
||||
return transact(async records => {
|
||||
const currentTime = now().getTime();
|
||||
const valid = [];
|
||||
for (const record of await records.getAll()) {
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const validShape = record?.id === 'snapshot' ?
|
||||
record.version === VERSION && record.user_login && record.data :
|
||||
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
|
||||
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
|
||||
if (record?.id) await records.delete(record.id);
|
||||
} else valid.push(record);
|
||||
}
|
||||
recordCache = new Map(valid.map(record => [record.id, record]));
|
||||
return true;
|
||||
});
|
||||
}).catch(() => false);
|
||||
}
|
||||
function enabled() {
|
||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (transact) {
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
} catch (_) { /* IndexedDB remains the source of truth. */ }
|
||||
migrationPromise = Promise.resolve(true);
|
||||
return transact(async records => {
|
||||
await records.clear();
|
||||
recordCache = new Map();
|
||||
return true;
|
||||
}).catch(() => false);
|
||||
}
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
|
|
@ -104,6 +232,22 @@
|
|||
saved_at: now().toISOString(),
|
||||
data,
|
||||
};
|
||||
if (transact) {
|
||||
record.id = 'detail:' + login + ':' + key;
|
||||
return ready().then(() => transact(async records => {
|
||||
const existing = (await records.getAll()).filter(candidate =>
|
||||
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
|
||||
);
|
||||
await records.put(record);
|
||||
const overflow = existing.concat(record).sort((a, b) =>
|
||||
String(a.saved_at).localeCompare(String(b.saved_at))
|
||||
).slice(0, -Math.max(1, maxDetails));
|
||||
for (const candidate of overflow) await records.delete(candidate.id);
|
||||
overflow.forEach(candidate => recordCache.delete(candidate.id));
|
||||
recordCache.set(record.id, record);
|
||||
return true;
|
||||
})).catch(() => false);
|
||||
}
|
||||
const records = readDetails().filter(candidate =>
|
||||
!(candidate?.key === key && candidate?.user_login === login)
|
||||
);
|
||||
|
|
@ -117,6 +261,19 @@
|
|||
const key = detailKey(item);
|
||||
login = String(login || '').trim();
|
||||
if (!login || !key) return null;
|
||||
if (transact) {
|
||||
const id = 'detail:' + login + ':' + key;
|
||||
if (!recordCache) return ready().then(() => loadDetail(login, item));
|
||||
const record = recordCache.get(id);
|
||||
if (!record) return null;
|
||||
const savedAt = Date.parse(record.saved_at || '');
|
||||
if (!record.data || !Number.isFinite(savedAt) || now().getTime() - savedAt > maxAgeMs) {
|
||||
recordCache.delete(id);
|
||||
transact(async records => { await records.delete(id); }).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
const records = readDetails();
|
||||
const currentTime = now().getTime();
|
||||
const valid = records.filter(record => {
|
||||
|
|
@ -142,6 +299,7 @@
|
|||
function save(snapshot) {
|
||||
if (!enabled() || !snapshot?.user?.login) return false;
|
||||
const record = {
|
||||
id: 'snapshot',
|
||||
version: VERSION,
|
||||
user_login: String(snapshot.user.login),
|
||||
saved_at: now().toISOString(),
|
||||
|
|
@ -154,12 +312,36 @@
|
|||
notification_pagination: snapshot.notification_pagination || {},
|
||||
},
|
||||
};
|
||||
if (transact) {
|
||||
return ready().then(() => transact(async records => {
|
||||
await records.put(record);
|
||||
recordCache.set(record.id, record);
|
||||
return true;
|
||||
})).catch(() => false);
|
||||
}
|
||||
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function load(expectedLogin) {
|
||||
if (transact) {
|
||||
if (!recordCache) return ready().then(() => load(expectedLogin));
|
||||
const record = recordCache.get('snapshot');
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
|
||||
!Number.isFinite(savedAt);
|
||||
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
|
||||
if (invalid || expired) {
|
||||
if (record) {
|
||||
recordCache.delete('snapshot');
|
||||
transact(async records => { await records.delete('snapshot'); }).catch(() => {});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (expectedLogin && record.user_login !== expectedLogin) return null;
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
let record;
|
||||
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
|
||||
catch (_) { clear(); return null; }
|
||||
|
|
@ -175,7 +357,7 @@
|
|||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
|
||||
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
|
||||
return { enabled, setEnabled, ready, save, load, saveDetail, loadDetail, clear };
|
||||
}
|
||||
|
||||
return createOfflineWorkStore;
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@
|
|||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
|
|
@ -52,7 +52,8 @@
|
|||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v85';
|
||||
const CACHE = 'stackchain-dashboard-shell-v86';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
|
|
|||
|
|
@ -572,11 +572,11 @@
|
|||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
|
|
@ -589,7 +589,8 @@
|
|||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||
try {
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
|
|
|
|||
|
|
@ -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 button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v85" in worker
|
||||
assert "stackchain-dashboard-shell-v86" in worker
|
||||
|
|
|
|||
|
|
@ -346,7 +346,9 @@ process.stdout.write(JSON.stringify(state));
|
|||
)
|
||||
|
||||
assert result["deletedAtIdle"] == []
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
"/dashboard/login?reason=session-idle",
|
||||
|
|
@ -369,7 +371,9 @@ process.stdout.write(JSON.stringify(state));
|
|||
|
||||
assert result["valid"] is False
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
|
@ -497,7 +501,9 @@ process.stdout.write(JSON.stringify(state));
|
|||
"/dashboard/login?reason=session-expired"
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["expiredAfterDeletion"] is True
|
||||
|
|
@ -517,7 +523,9 @@ process.stdout.write(JSON.stringify(state));
|
|||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
|
|
@ -610,7 +618,9 @@ process.stdout.write(JSON.stringify(state));
|
|||
assert request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert "stackchain.private" in result["removed"]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
assert result["assignedAfterDeletion"] is True
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
|||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v85';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v86';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -137,4 +137,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 pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v85" in worker
|
||||
assert "stackchain-dashboard-shell-v86" in worker
|
||||
|
|
|
|||
|
|
@ -41,7 +41,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]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v85" in worker
|
||||
assert "stackchain-dashboard-shell-v86" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
|
|
|||
|
|
@ -56,6 +56,33 @@ process.stdout.write(JSON.stringify({peak, loaded, saved, status}));
|
|||
assert result["status"] == {"total": 5, "ready": 5, "failed": 0, "pending": 0}
|
||||
|
||||
|
||||
def test_readiness_waits_for_durable_async_admission_and_reports_rejection():
|
||||
result = run_scenario("""
|
||||
const events = [];
|
||||
const warmer = createOfflineToday({
|
||||
loadDetail: async () => ({title:'Fetched detail'}),
|
||||
loadSavedDetail: async () => null,
|
||||
saveDetail: async () => {
|
||||
events.push('admission-started');
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
events.push('admission-rejected');
|
||||
return false;
|
||||
},
|
||||
onStatus: status => events.push('ready:' + status.ready + '/failed:' + status.failed),
|
||||
});
|
||||
const status = await warmer.warm('timmy', [{
|
||||
kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'v1',
|
||||
}]);
|
||||
process.stdout.write(JSON.stringify({events, status, failed:warmer.failedKeys()}));
|
||||
""")
|
||||
|
||||
assert result["status"] == {"total": 1, "ready": 0, "failed": 1, "pending": 0}
|
||||
assert result["failed"] == ["issue:stackchain/dashboard:1"]
|
||||
assert result["events"].index("admission-rejected") < result["events"].index(
|
||||
"ready:0/failed:1"
|
||||
)
|
||||
|
||||
|
||||
def test_refreshes_only_changed_revisions_and_keeps_prior_copy_on_failure():
|
||||
result = run_scenario("""
|
||||
const items = [
|
||||
|
|
|
|||
|
|
@ -27,6 +27,136 @@ const storage = {{
|
|||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def run_async_scenario(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createOfflineWorkStore = require({json.dumps(str(OFFLINE_WORK))});
|
||||
const values = new Map();
|
||||
const records = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const transaction = async work => work({{
|
||||
get: async key => records.get(key),
|
||||
getAll: async () => [...records.values()],
|
||||
put: async value => records.set(value.id, structuredClone(value)),
|
||||
delete: async key => records.delete(key),
|
||||
clear: async () => records.clear(),
|
||||
}});
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_indexeddb_commit_persists_snapshot_and_details_without_localstorage_payloads():
|
||||
result = run_async_scenario("""
|
||||
const store = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
store.setEnabled(true);
|
||||
const snapshotSaved = await store.save({
|
||||
user:{login:'timmy', full_name:'Timmy'}, issues:[{number:8, title:'Large offline review'}],
|
||||
pull_requests:[], notifications:[],
|
||||
});
|
||||
const item = {kind:'pull', repository:'stackchain/dashboard', number:8, is_review:true};
|
||||
const detailSaved = await store.saveDetail('timmy', item, {
|
||||
title:'Large offline review', head_sha:'abc123',
|
||||
files:[{filename:'large.diff', diff_lines:['+private review line']}],
|
||||
});
|
||||
const restarted = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:01:00Z')});
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshotSaved, detailSaved,
|
||||
loaded:await restarted.load('timmy'),
|
||||
detail:await restarted.loadDetail('timmy', item),
|
||||
localValues:[...values.values()].join(' '),
|
||||
recordIds:[...records.keys()].sort(),
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["snapshotSaved"] is True
|
||||
assert result["detailSaved"] is True
|
||||
assert result["loaded"]["user"]["login"] == "timmy"
|
||||
assert result["detail"]["head_sha"] == "abc123"
|
||||
assert "Large offline review" not in result["localValues"]
|
||||
assert "private review line" not in result["localValues"]
|
||||
assert result["recordIds"] == ["detail:timmy:pull:stackchain/dashboard:8", "snapshot"]
|
||||
|
||||
|
||||
def test_legacy_localstorage_payloads_migrate_only_after_indexeddb_commit():
|
||||
result = run_async_scenario("""
|
||||
values.set('stackchain.offline-work.enabled.v1', 'true');
|
||||
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
|
||||
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
||||
data:{user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]},
|
||||
}));
|
||||
values.set('stackchain.offline-work.details.v1', JSON.stringify([{
|
||||
key:'issue:stackchain/dashboard:9', user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
||||
data:{title:'Migrated issue'},
|
||||
}]));
|
||||
const store = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:01:00Z')});
|
||||
const snapshot = await store.load('timmy');
|
||||
const detail = await store.loadDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:9});
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshot, detail,
|
||||
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1'),
|
||||
legacyDetails:values.has('stackchain.offline-work.details.v1'),
|
||||
recordIds:[...records.keys()].sort(),
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["snapshot"]["user"]["login"] == "timmy"
|
||||
assert result["detail"]["title"] == "Migrated issue"
|
||||
assert result["legacySnapshot"] is False
|
||||
assert result["legacyDetails"] is False
|
||||
assert result["recordIds"] == ["detail:timmy:issue:stackchain/dashboard:9", "snapshot"]
|
||||
|
||||
|
||||
def test_failed_migration_keeps_legacy_snapshot_readable_and_untouched():
|
||||
result = run_async_scenario("""
|
||||
values.set('stackchain.offline-work.enabled.v1', 'true');
|
||||
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
|
||||
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
||||
data:{user:{login:'timmy'}, issues:[{number:7, title:'Keep me'}], pull_requests:[], notifications:[]},
|
||||
}));
|
||||
let attempts = 0;
|
||||
const flakyTransaction = async work => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('quota admission failed');
|
||||
return transaction(work);
|
||||
};
|
||||
const store = createOfflineWorkStore({storage, transaction:flakyTransaction, now:() => new Date('2026-08-07T12:01:00Z')});
|
||||
const snapshot = await store.load('timmy');
|
||||
process.stdout.write(JSON.stringify({
|
||||
snapshot, attempts,
|
||||
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1'),
|
||||
recordIds:[...records.keys()],
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["snapshot"]["issues"][0]["title"] == "Keep me"
|
||||
assert result["legacySnapshot"] is True
|
||||
assert result["recordIds"] == []
|
||||
|
||||
|
||||
def test_ready_atomically_prunes_all_expired_indexeddb_records():
|
||||
result = run_async_scenario("""
|
||||
const writer = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:00Z'), maxAgeMs:1000});
|
||||
writer.setEnabled(true);
|
||||
await writer.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
|
||||
await writer.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:1}, {title:'One'});
|
||||
await writer.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:2}, {title:'Two'});
|
||||
const expired = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:02Z'), maxAgeMs:1000});
|
||||
await expired.ready();
|
||||
process.stdout.write(JSON.stringify({recordIds:[...records.keys()]}));
|
||||
""")
|
||||
|
||||
assert result["recordIds"] == []
|
||||
|
||||
|
||||
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
|
|
@ -213,11 +343,23 @@ async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydr
|
|||
assert '.offline-work-controls button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_uses_async_indexeddb_offline_store_end_to_end():
|
||||
html = await dashboard()
|
||||
|
||||
assert "createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB })" in html
|
||||
assert "async function hydrateOfflineWork" in html
|
||||
assert "const saved = await offlineWorkStore.load();" in html
|
||||
assert "await offlineWorkStore.save({" in html
|
||||
assert "await offlineWorkStore.clear();" in html
|
||||
assert "Offline saving unavailable · retry after reconnect." in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot():
|
||||
html = await dashboard()
|
||||
|
||||
assert "if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;" in html
|
||||
assert "if (!hasContextSnapshot && await hydrateOfflineWork('outage')) return;" in html
|
||||
assert "Outage · saved " in html
|
||||
assert "Server unavailable · showing private My Work saved " in html
|
||||
assert "Live details and actions will return automatically." in html
|
||||
|
|
|
|||
|
|
@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
|||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
|
|
@ -43,7 +43,10 @@ const clear = createPrivateDeviceDataPurger({{
|
|||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["remaining"] == ["other.preference"]
|
||||
assert state["databases"] == ["stackchain-background-outbox-v1"]
|
||||
assert state["databases"] == [
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert state["complete"] is True
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ async function dispatchNotificationClick(route) {{
|
|||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -134,14 +134,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
|||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -157,14 +157,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -173,21 +173,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
|||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -397,7 +397,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():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" 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():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v85';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v86';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v85" in source
|
||||
assert "stackchain-dashboard-shell-v86" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user