Compare commits
No commits in common. "741d768fc3558cc911c0d571ca616d91942e40e1" and "65d1f9f1945151cf5a2d40c58a1544477049d4f9" have entirely different histories.
741d768fc3
...
65d1f9f194
|
|
@ -152,10 +152,6 @@ work finishes**; permission is requested only from that user gesture and the cho
|
||||||
is stored for the confirmed account in the private background outbox database.
|
is stored for the confirmed account in the private background outbox database.
|
||||||
Successful deliveries produce privacy-safe receipts that open the created issue or
|
Successful deliveries produce privacy-safe receipts that open the created issue or
|
||||||
source conversation, while permanent validation failures open Drafts for recovery.
|
source conversation, while permanent validation failures open Drafts for recovery.
|
||||||
Authentication expiry during a worker mutation is recoverable: the worker releases the
|
|
||||||
claim without creating an Attention receipt, stops that drain, and keeps the original
|
|
||||||
idempotency key. Loading the authenticated dashboard after signing in asks the worker
|
|
||||||
to resume queued delivery automatically.
|
|
||||||
Notification text never includes issue titles, comment bodies, or validation details.
|
Notification text never includes issue titles, comment bodies, or validation details.
|
||||||
The preference is off by default, unsupported or denied browsers retain foreground
|
The preference is off by default, unsupported or denied browsers retain foreground
|
||||||
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
|
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
|
||||||
|
|
|
||||||
|
|
@ -224,10 +224,6 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = Number(error?.status || 0);
|
const status = Number(error?.status || 0);
|
||||||
if (status === 401) {
|
|
||||||
await store.release(item.id);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (status >= 400 && status < 500) {
|
if (status >= 400 && status < 500) {
|
||||||
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
||||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||||
|
|
|
||||||
|
|
@ -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-v18';
|
const CACHE = 'stackchain-dashboard-shell-v17';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
BASE,
|
BASE,
|
||||||
|
|
@ -96,10 +96,6 @@ self.addEventListener('sync', event => {
|
||||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('message', event => {
|
|
||||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', event => {
|
self.addEventListener('notificationclick', event => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const route = String(event.notification.data?.route || '');
|
const route = String(event.notification.data?.route || '');
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@
|
||||||
sessionStorage: root.sessionStorage,
|
sessionStorage: root.sessionStorage,
|
||||||
indexedDB: root.indexedDB,
|
indexedDB: root.indexedDB,
|
||||||
caches: root.caches,
|
caches: root.caches,
|
||||||
serviceWorker: root.navigator?.serviceWorker,
|
|
||||||
location: root.location,
|
location: root.location,
|
||||||
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||||||
});
|
});
|
||||||
|
|
@ -20,14 +19,13 @@
|
||||||
const attach = () => {
|
const attach = () => {
|
||||||
const button = root.document.getElementById('sign-out');
|
const button = root.document.getElementById('sign-out');
|
||||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||||
boundary.resumeQueuedWork();
|
|
||||||
};
|
};
|
||||||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||||
else attach();
|
else attach();
|
||||||
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,
|
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, location,
|
||||||
onExpired = () => {},
|
onExpired = () => {},
|
||||||
}) {
|
}) {
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
|
|
@ -90,12 +88,5 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resumeQueuedWork() {
|
return { fetch: sessionFetch, signOut, clearPrivateDeviceData };
|
||||||
try {
|
|
||||||
const registration = await serviceWorker?.ready;
|
|
||||||
registration?.active?.postMessage({ type: 'stackchain-resume-outbox' });
|
|
||||||
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork };
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -260,44 +260,6 @@ const fetchJson = async url => {{
|
||||||
assert output["result"]["blocked"] == 1
|
assert output["result"]["blocked"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_session_expiry_during_delivery_releases_claim_without_attention():
|
|
||||||
script = f"""
|
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
||||||
const queued = [
|
|
||||||
{{id:'capture-auth',operationId:'stable-key',ownerLogin:'timmy',repository:'o/r',title:'Keep me',body:'',labelIds:[]}},
|
|
||||||
{{id:'capture-later',operationId:'later-key',ownerLogin:'timmy',repository:'o/r',title:'Do not try yet',body:'',labelIds:[]}},
|
|
||||||
];
|
|
||||||
const state = {{released:[],failed:[],mutationKeys:[]}};
|
|
||||||
const store = {{
|
|
||||||
claimNext: async () => queued.shift() || null,
|
|
||||||
release: async id => state.released.push(id),
|
|
||||||
fail: async (id,message) => state.failed.push({{id,message}}),
|
|
||||||
countBlocked: async () => 0,
|
|
||||||
}};
|
|
||||||
const fetchJson = async (url, options={{}}) => {{
|
|
||||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
|
||||||
state.mutationKeys.push(options.headers['Idempotency-Key']);
|
|
||||||
const error = new Error('Authentication required'); error.status = 401; throw error;
|
|
||||||
}};
|
|
||||||
(async () => {{
|
|
||||||
let error = null;
|
|
||||||
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
|
|
||||||
catch (caught) {{ error = {{message:caught.message,status:caught.status}}; }}
|
|
||||||
process.stdout.write(JSON.stringify({{state,error}}));
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
|
|
||||||
assert output == {
|
|
||||||
"state": {
|
|
||||||
"released": ["capture-auth"],
|
|
||||||
"failed": [],
|
|
||||||
"mutationKeys": ["stable-key"],
|
|
||||||
},
|
|
||||||
"error": {"message": "Authentication required", "status": 401},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
|
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
|
||||||
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: [] }};
|
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '' }};
|
||||||
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; }},
|
||||||
|
|
@ -33,7 +33,6 @@ const boundary = createSessionBoundary({{
|
||||||
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); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
|
||||||
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) }} }}) }},
|
|
||||||
location: {{ assign: value => {{ state.assigned = value; }} }},
|
location: {{ assign: value => {{ state.assigned = value; }} }},
|
||||||
}});
|
}});
|
||||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
|
@ -73,17 +72,6 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["assigned"] == "/dashboard/login"
|
assert result["assigned"] == "/dashboard/login"
|
||||||
|
|
||||||
|
|
||||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
|
||||||
result = run_session_scenario(
|
|
||||||
"""
|
|
||||||
await boundary.resumeQueuedWork();
|
|
||||||
process.stdout.write(JSON.stringify(state));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["workerMessages"] == [{"type": "stackchain-resume-outbox"}]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,6 @@ 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) {{
|
|
||||||
let pending;
|
|
||||||
listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }});
|
|
||||||
if (pending) await pending;
|
|
||||||
}}
|
|
||||||
async function dispatchNotificationClick(route) {{
|
async function dispatchNotificationClick(route) {{
|
||||||
let pending;
|
let pending;
|
||||||
listeners.notificationclick({{
|
listeners.notificationclick({{
|
||||||
|
|
@ -91,10 +86,10 @@ async function dispatchNotificationClick(route) {{
|
||||||
return json.loads(completed.stdout)
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
def test_session_resume_flow_ships_in_a_new_shell_cache():
|
def test_background_delivery_receipts_ship_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v18" in source
|
assert "stackchain-dashboard-shell-v17" in source
|
||||||
|
|
||||||
|
|
||||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||||
|
|
@ -109,17 +104,6 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
|
||||||
assert result["backgroundFlushes"] == 1
|
assert result["backgroundFlushes"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_authenticated_page_message_resumes_queued_background_delivery():
|
|
||||||
result = run_worker_scenario(
|
|
||||||
"""
|
|
||||||
await dispatchMessage({type:'stackchain-resume-outbox'});
|
|
||||||
process.stdout.write(JSON.stringify(state));
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["backgroundFlushes"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
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