Merge pull request 'Complete private outbox purge before device sign-out' (#294) from timmy/293-complete-device-outbox-purge into main
This commit is contained in:
commit
54029d2668
|
|
@ -8,7 +8,14 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||||
request.result.createObjectStore('issues', { keyPath: 'id' });
|
request.result.createObjectStore('issues', { keyPath: 'id' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
db.onversionchange = () => {
|
||||||
|
db.close();
|
||||||
|
databasePromise = undefined;
|
||||||
|
};
|
||||||
|
resolve(db);
|
||||||
|
};
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
return databasePromise;
|
return databasePromise;
|
||||||
|
|
@ -17,7 +24,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
return async work => {
|
const transact = async work => {
|
||||||
const db = await database();
|
const db = await database();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = db.transaction('issues', 'readwrite');
|
const transaction = db.transaction('issues', 'readwrite');
|
||||||
|
|
@ -38,6 +45,13 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
transact.close = async () => {
|
||||||
|
if (!databasePromise) return;
|
||||||
|
const db = await databasePromise;
|
||||||
|
db.close();
|
||||||
|
databasePromise = undefined;
|
||||||
|
};
|
||||||
|
return transact;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
|
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
|
||||||
|
|
@ -149,10 +163,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||||
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||||
setReceiptPreference,
|
setReceiptPreference,
|
||||||
getReceiptPreference,
|
getReceiptPreference,
|
||||||
|
close: () => transact.close?.(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||||
|
let purgeRequested = false;
|
||||||
|
let activeFlush = null;
|
||||||
function receiptFor(item, status, delivered = {}) {
|
function receiptFor(item, status, delivered = {}) {
|
||||||
if (status === 'attention') {
|
if (status === 'attention') {
|
||||||
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
|
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
|
||||||
|
|
@ -238,6 +255,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send(item, currentLogin) {
|
async function send(item, currentLogin) {
|
||||||
|
if (purgeRequested) return { blocked: true };
|
||||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||||
await store.upsert(item);
|
await store.upsert(item);
|
||||||
const claimed = await store.claim(item.id, currentLogin);
|
const claimed = await store.claim(item.id, currentLogin);
|
||||||
|
|
@ -245,7 +263,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||||
return deliver(claimed);
|
return deliver(claimed);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flush() {
|
async function runFlush() {
|
||||||
const identity = await fetchJson(base + 'api/v1/background-identity', {
|
const identity = await fetchJson(base + 'api/v1/background-identity', {
|
||||||
headers: { Accept: 'application/json' }, cache: 'no-store',
|
headers: { Accept: 'application/json' }, cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|
@ -267,8 +285,21 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||||
return { confirmed, blocked, attention, login, receipts };
|
return { confirmed, blocked, attention, login, receipts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] });
|
||||||
|
if (activeFlush) return activeFlush;
|
||||||
|
activeFlush = runFlush().finally(() => { activeFlush = null; });
|
||||||
|
return activeFlush;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purge() {
|
||||||
|
purgeRequested = true;
|
||||||
|
if (activeFlush) await activeFlush.catch(() => {});
|
||||||
|
await store.close?.();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
flush, send,
|
flush, send, purge,
|
||||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||||
snapshot: () => store.snapshot(),
|
snapshot: () => store.snapshot(),
|
||||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v24';
|
const CACHE = 'stackchain-dashboard-shell-v25';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
BASE,
|
BASE,
|
||||||
|
|
@ -100,6 +100,14 @@ self.addEventListener('sync', event => {
|
||||||
|
|
||||||
self.addEventListener('message', event => {
|
self.addEventListener('message', event => {
|
||||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
||||||
|
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||||
|
try {
|
||||||
|
await issueSync.purge();
|
||||||
|
event.ports?.[0]?.postMessage({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||||
|
}
|
||||||
|
})());
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', event => {
|
self.addEventListener('notificationclick', event => {
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,14 @@
|
||||||
indexedDB: root.indexedDB,
|
indexedDB: root.indexedDB,
|
||||||
caches: root.caches,
|
caches: root.caches,
|
||||||
serviceWorker: root.navigator?.serviceWorker,
|
serviceWorker: root.navigator?.serviceWorker,
|
||||||
|
MessageChannel: root.MessageChannel,
|
||||||
location: root.location,
|
location: root.location,
|
||||||
confirmAction: message => root.confirm(message),
|
confirmAction: message => root.confirm(message),
|
||||||
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||||||
|
onClearError: error => {
|
||||||
|
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
|
||||||
|
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
root.fetch = boundary.fetch;
|
root.fetch = boundary.fetch;
|
||||||
const attach = () => {
|
const attach = () => {
|
||||||
|
|
@ -30,8 +35,9 @@
|
||||||
root.stackchainSession = boundary;
|
root.stackchainSession = boundary;
|
||||||
}
|
}
|
||||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location, confirmAction,
|
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
|
||||||
onExpired = () => {},
|
onExpired = () => {},
|
||||||
|
onClearError = () => {},
|
||||||
}) {
|
}) {
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
||||||
|
|
@ -73,11 +79,44 @@
|
||||||
} catch (_error) { /* Cookie invalidation still protects server data. */ }
|
} catch (_error) { /* Cookie invalidation still protects server data. */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function stopWorkerOutbox() {
|
||||||
|
const registration = await serviceWorker?.ready;
|
||||||
|
if (!registration?.active || !MessageChannel) return;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
|
||||||
|
channel.port1.onmessage = event => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (event.data?.ok) resolve();
|
||||||
|
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
|
||||||
|
};
|
||||||
|
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePrivateOutbox() {
|
||||||
|
if (!indexedDB) return Promise.resolve();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let request;
|
||||||
|
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||||
|
catch (error) { reject(error); return; }
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||||
|
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function clearPrivateDeviceData() {
|
async function clearPrivateDeviceData() {
|
||||||
removeDashboardStorage(localStorage);
|
removeDashboardStorage(localStorage);
|
||||||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||||
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
|
try {
|
||||||
catch (_error) { /* Continue clearing other dashboard state. */ }
|
await stopWorkerOutbox();
|
||||||
|
await deletePrivateOutbox();
|
||||||
|
} catch (_error) {
|
||||||
|
const error = new Error('Could not clear private queued work from this device.');
|
||||||
|
onClearError(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const keys = await caches?.keys?.() || [];
|
const keys = await caches?.keys?.() || [];
|
||||||
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
|
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,39 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
||||||
|
script = f"""
|
||||||
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||||
|
const state={{opens:0,closes:0}};
|
||||||
|
const indexedDB={{lastDb:null,open:()=>{{
|
||||||
|
state.opens += 1;
|
||||||
|
const request={{result:null,onsuccess:null,onerror:null,onupgradeneeded:null}};
|
||||||
|
const db={{
|
||||||
|
objectStoreNames:{{contains:()=>true}},
|
||||||
|
close:()=>{{state.closes += 1;}},
|
||||||
|
transaction:()=>{{
|
||||||
|
const tx={{oncomplete:null,onerror:null,onabort:null,error:null,objectStore:()=>({{
|
||||||
|
getAll:()=>{{const get={{onsuccess:null,onerror:null,result:[]}};queueMicrotask(()=>get.onsuccess?.());return get;}},
|
||||||
|
}})}};
|
||||||
|
setTimeout(()=>tx.oncomplete?.(),0);
|
||||||
|
return tx;
|
||||||
|
}},
|
||||||
|
}};
|
||||||
|
indexedDB.lastDb=db;request.result=db;queueMicrotask(()=>request.onsuccess?.());return request;
|
||||||
|
}}}};
|
||||||
|
(async()=>{{
|
||||||
|
const store=createBackgroundIssueSync.createIssueSyncStore({{indexedDB}});
|
||||||
|
await store.snapshot();
|
||||||
|
indexedDB.lastDb.onversionchange?.();
|
||||||
|
await store.snapshot();
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output == {"opens": 2, "closes": 1}
|
||||||
|
|
||||||
|
|
||||||
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
|
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
|
||||||
def run_session_scenario(scenario: str) -> dict:
|
def run_session_scenario(scenario: str) -> dict:
|
||||||
harness = f"""
|
harness = f"""
|
||||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [], confirmations: [] }};
|
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [] }};
|
||||||
const storage = {{
|
const storage = {{
|
||||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||||
get length() {{ return this.values.size; }},
|
get length() {{ return this.values.size; }},
|
||||||
|
|
@ -31,10 +31,24 @@ const boundary = createSessionBoundary({{
|
||||||
}},
|
}},
|
||||||
localStorage: storage,
|
localStorage: storage,
|
||||||
sessionStorage: storage,
|
sessionStorage: storage,
|
||||||
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
|
indexedDB: {{ deleteDatabase: name => {{
|
||||||
|
state.deletedDatabases.push(name);
|
||||||
|
const request = {{ onsuccess: null, onerror: null, onblocked: null, error: null }};
|
||||||
|
setTimeout(() => {{
|
||||||
|
if (state.failDeletion) {{ request.error = new Error('database blocked'); request.onerror?.(); }}
|
||||||
|
else {{ state.deletionCompleted = true; request.onsuccess?.(); }}
|
||||||
|
}}, 20);
|
||||||
|
return request;
|
||||||
|
}} }},
|
||||||
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
|
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
|
||||||
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
|
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: (message, ports = []) => {{ state.workerMessages.push(message); ports[0]?.postMessage({{ok:true}}); }} }} }}) }},
|
||||||
location: {{ assign: value => {{ state.assigned = value; }} }},
|
MessageChannel: class {{ constructor() {{
|
||||||
|
const first = {{ onmessage: null, postMessage: data => queueMicrotask(() => second.onmessage?.({{data}})) }};
|
||||||
|
const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }};
|
||||||
|
this.port1 = first; this.port2 = second;
|
||||||
|
}} }},
|
||||||
|
location: {{ assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }} }},
|
||||||
|
onClearError: error => state.clearErrors.push(error.message),
|
||||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||||
}});
|
}});
|
||||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
|
@ -72,6 +86,21 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||||
assert result["assigned"] == "/dashboard/login"
|
assert result["assigned"] == "/dashboard/login"
|
||||||
|
assert result["assignedAfterDeletion"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_out_stays_on_page_and_reports_failed_private_outbox_deletion():
|
||||||
|
result = run_session_scenario(
|
||||||
|
"""
|
||||||
|
state.failDeletion = true;
|
||||||
|
try { await boundary.signOut(); } catch (error) { state.signOutError = error.message; }
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["assigned"] == ""
|
||||||
|
assert result["clearErrors"] == ["Could not clear private queued work from this device."]
|
||||||
|
assert result["signOutError"] == "Could not clear private queued work from this device."
|
||||||
|
|
||||||
|
|
||||||
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():
|
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const vm = require('vm');
|
const vm = require('vm');
|
||||||
const listeners = {{}};
|
const listeners = {{}};
|
||||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||||
const cache = {{
|
const cache = {{
|
||||||
addAll: async urls => {{ state.added = urls; }},
|
addAll: async urls => {{ state.added = urls; }},
|
||||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||||
|
|
@ -34,6 +34,7 @@ const context = {{
|
||||||
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
||||||
__issueSync: {{
|
__issueSync: {{
|
||||||
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
|
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
|
||||||
|
purge: async () => {{ state.outboxPurges += 1; }},
|
||||||
getReceiptPreference: async login => state.receiptLogin === login,
|
getReceiptPreference: async login => state.receiptLogin === login,
|
||||||
}},
|
}},
|
||||||
}},
|
}},
|
||||||
|
|
@ -68,9 +69,9 @@ async function dispatchSync(tag) {{
|
||||||
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
||||||
if (pending) await pending;
|
if (pending) await pending;
|
||||||
}}
|
}}
|
||||||
async function dispatchMessage(data) {{
|
async function dispatchMessage(data, ports = []) {{
|
||||||
let pending;
|
let pending;
|
||||||
listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }});
|
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
||||||
if (pending) await pending;
|
if (pending) await pending;
|
||||||
}}
|
}}
|
||||||
async function dispatchNotificationClick(route) {{
|
async function dispatchNotificationClick(route) {{
|
||||||
|
|
@ -94,7 +95,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
def test_mobile_install_flow_ships_in_a_new_shell_cache():
|
def test_mobile_install_flow_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v24" in source
|
assert "stackchain-dashboard-shell-v25" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -121,6 +122,19 @@ def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||||
assert result["backgroundFlushes"] == 1
|
assert result["backgroundFlushes"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
const replies = [];
|
||||||
|
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage: value => replies.push(value)}]);
|
||||||
|
process.stdout.write(JSON.stringify({state,replies}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["state"]["outboxPurges"] == 1
|
||||||
|
assert result["replies"] == [{"ok": True}]
|
||||||
|
|
||||||
|
|
||||||
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
|
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user