feat: recover progressive identity after partial snapshots
Closes #1431
This commit is contained in:
parent
97a36ea22f
commit
26288d364c
|
|
@ -44,9 +44,14 @@ function createContextPoller({
|
||||||
const freshness = snapshot && snapshot.freshness;
|
const freshness = snapshot && snapshot.freshness;
|
||||||
const sections = freshness && freshness.sections;
|
const sections = freshness && freshness.sections;
|
||||||
const sectionValues = sections && Object.values(sections);
|
const sectionValues = sections && Object.values(sections);
|
||||||
|
const degradedRetrySeconds = (sectionValues || [])
|
||||||
|
.filter(section => section.degraded)
|
||||||
|
.map(section => Number(section.retry_in_seconds ?? freshness.retry_in_seconds))
|
||||||
|
.filter(seconds => Number.isFinite(seconds) && seconds > 0);
|
||||||
|
const failedSectionDelay = degradedRetrySeconds.length ?
|
||||||
|
Math.min(...degradedRetrySeconds) * 1000 : null;
|
||||||
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
|
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
|
||||||
const retrySeconds = Number(freshness.retry_in_seconds);
|
return failedSectionDelay || intervalMs;
|
||||||
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
|
|
||||||
}
|
}
|
||||||
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
|
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
|
||||||
const healthyDeadlines = (sectionValues || [])
|
const healthyDeadlines = (sectionValues || [])
|
||||||
|
|
@ -55,10 +60,10 @@ function createContextPoller({
|
||||||
.filter(seconds => Number.isFinite(seconds));
|
.filter(seconds => Number.isFinite(seconds));
|
||||||
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
|
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
|
||||||
const earliestDeadline = Math.min(...healthyDeadlines);
|
const earliestDeadline = Math.min(...healthyDeadlines);
|
||||||
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
|
const healthyDelay = earliestDeadline <= 0 &&
|
||||||
return intervalMs;
|
sectionValues.some(section => !section.degraded && section.revalidating) ?
|
||||||
}
|
intervalMs : Math.max(0, earliestDeadline) * 1000;
|
||||||
return Math.max(0, earliestDeadline) * 1000;
|
return failedSectionDelay === null ? healthyDelay : Math.min(healthyDelay, failedSectionDelay);
|
||||||
}
|
}
|
||||||
return intervalMs;
|
return intervalMs;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ function createProgressiveHumanGates(options = {}) {
|
||||||
});
|
});
|
||||||
const liveSnapshot = options.liveSnapshot || null;
|
const liveSnapshot = options.liveSnapshot || null;
|
||||||
const getIdentity = options.getIdentity || (async () => {
|
const getIdentity = options.getIdentity || (async () => {
|
||||||
const response = liveSnapshot ? await liveSnapshot.acquire() :
|
const response = liveSnapshot ? await liveSnapshot.acquire({requireIdentity:true}) :
|
||||||
await fetchJson('api/v1/live', {headers:{Accept:'application/json'}});
|
await fetchJson('api/v1/live', {headers:{Accept:'application/json'}});
|
||||||
const user = response?.context?.user || {};
|
const user = response?.context?.user || {};
|
||||||
const login = String(user.login || '').trim();
|
const login = String(user.login || '').trim();
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,18 @@ function createProgressiveLiveSnapshot({fetchSnapshot}) {
|
||||||
let value = null;
|
let value = null;
|
||||||
let flight = null;
|
let flight = null;
|
||||||
|
|
||||||
const acquire = () => {
|
const hasIdentity = snapshot => {
|
||||||
if (value) return Promise.resolve(value);
|
const user = snapshot?.context?.user || {};
|
||||||
if (flight) return flight;
|
return Boolean(String(user.login || '').trim());
|
||||||
|
};
|
||||||
|
const requireUsable = (snapshot, options) => {
|
||||||
|
if (!options?.requireIdentity || hasIdentity(snapshot)) return snapshot;
|
||||||
|
if (value === snapshot) value = null;
|
||||||
|
throw new Error('Authenticated account identity is unavailable.');
|
||||||
|
};
|
||||||
|
const acquire = (options = {}) => {
|
||||||
|
if (value) return Promise.resolve().then(() => requireUsable(value, options));
|
||||||
|
if (flight) return flight.then(snapshot => requireUsable(snapshot, options));
|
||||||
flight = Promise.resolve().then(() => fetchSnapshot()).then(snapshot => {
|
flight = Promise.resolve().then(() => fetchSnapshot()).then(snapshot => {
|
||||||
value = snapshot;
|
value = snapshot;
|
||||||
flight = null;
|
flight = null;
|
||||||
|
|
@ -13,7 +22,7 @@ function createProgressiveLiveSnapshot({fetchSnapshot}) {
|
||||||
flight = null;
|
flight = null;
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
return flight;
|
return flight.then(snapshot => requireUsable(snapshot, options));
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ function createProgressiveMyWork({
|
||||||
let poller = null;
|
let poller = null;
|
||||||
let detailTrigger = null;
|
let detailTrigger = null;
|
||||||
let openWork = null;
|
let openWork = null;
|
||||||
|
let contextUnavailable = false;
|
||||||
const deferredQueues = {
|
const deferredQueues = {
|
||||||
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
|
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||||
};
|
};
|
||||||
|
|
@ -89,7 +90,8 @@ function createProgressiveMyWork({
|
||||||
const render = () => {
|
const render = () => {
|
||||||
if (stopped || !list) return;
|
if (stopped || !list) return;
|
||||||
const visible = visibleItems();
|
const visible = visibleItems();
|
||||||
list.innerHTML = deferredQueues[active] ?
|
list.innerHTML = contextUnavailable && !items.length ?
|
||||||
|
'<div class="muted">Assigned work is reconnecting…</div>' : deferredQueues[active] ?
|
||||||
'<div class="muted">' + deferredQueues[active] + ' is still loading…</div>' : visible.length ? visible.map((item, index) => {
|
'<div class="muted">' + deferredQueues[active] + ' is still loading…</div>' : visible.length ? visible.map((item, index) => {
|
||||||
const title = escapeHtml(item.title || item.key || 'Untitled work');
|
const title = escapeHtml(item.title || item.key || 'Untitled work');
|
||||||
const context = escapeHtml(item.key || '');
|
const context = escapeHtml(item.key || '');
|
||||||
|
|
@ -128,6 +130,17 @@ function createProgressiveMyWork({
|
||||||
if (stopped) return false;
|
if (stopped) return false;
|
||||||
const transferable = snapshot && typeof snapshot === 'object' &&
|
const transferable = snapshot && typeof snapshot === 'object' &&
|
||||||
Object.prototype.hasOwnProperty.call(snapshot, 'context');
|
Object.prototype.hasOwnProperty.call(snapshot, 'context');
|
||||||
|
const contextDegraded = transferable && (
|
||||||
|
!snapshot.context || snapshot.freshness?.sections?.context?.degraded
|
||||||
|
);
|
||||||
|
if (contextDegraded) {
|
||||||
|
contextUnavailable = true;
|
||||||
|
render();
|
||||||
|
notify();
|
||||||
|
if (status) status.textContent = 'Assigned work is reconnecting…';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
contextUnavailable = false;
|
||||||
if (transferable) liveSnapshot = snapshot;
|
if (transferable) liveSnapshot = snapshot;
|
||||||
const context = snapshot?.context || snapshot || {};
|
const context = snapshot?.context || snapshot || {};
|
||||||
confirmedLogin = String(context.user?.login || '').trim();
|
confirmedLogin = String(context.user?.login || '').trim();
|
||||||
|
|
|
||||||
|
|
@ -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-v143';
|
const CACHE = 'stackchain-dashboard-shell-v144';
|
||||||
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-v143" in worker
|
assert "stackchain-dashboard-shell-v144" in worker
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,40 @@ def test_dashboard_adopts_progressive_snapshot_or_falls_back_to_immediate_load()
|
||||||
assert "if (!adoptedProgressiveSnapshot) await load();" in source
|
assert "if (!adoptedProgressiveSnapshot) await load();" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_poller_uses_failed_section_retry_before_healthy_freshness_deadline():
|
||||||
|
script = f"""
|
||||||
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
const delays = [];
|
||||||
|
const poller = createContextPoller({{
|
||||||
|
fetchContext: () => Promise.resolve({{
|
||||||
|
context: null, events: [], notifications: [],
|
||||||
|
freshness: {{
|
||||||
|
fresh_for_seconds: 8,
|
||||||
|
retry_in_seconds: 5,
|
||||||
|
sections: {{
|
||||||
|
context: {{ degraded: true, retry_in_seconds: 5, age_seconds: 12 }},
|
||||||
|
events: {{ degraded: false, age_seconds: 0 }},
|
||||||
|
notifications: {{ degraded: false, age_seconds: 0 }},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}}),
|
||||||
|
onSnapshot: () => {{}},
|
||||||
|
onError: error => {{ throw error; }},
|
||||||
|
setTimer: (_callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||||
|
clearTimer: () => {{}},
|
||||||
|
setDeadlineTimer: () => 1,
|
||||||
|
clearDeadlineTimer: () => {{}},
|
||||||
|
intervalMs: 8000,
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await poller.refresh();
|
||||||
|
process.stdout.write(JSON.stringify({{delays}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {"delays": [5000]}
|
||||||
|
|
||||||
|
|
||||||
def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():
|
def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
|
||||||
assert ".following-disposition-mode" in css
|
assert ".following-disposition-mode" in css
|
||||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||||
assert "stackchain-dashboard-shell-v143" in service_worker
|
assert "stackchain-dashboard-shell-v144" in service_worker
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,50 @@ const gates=createProgressiveHumanGates({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_progressive_human_gates_retries_partial_snapshot_identity_without_hydration():
|
||||||
|
script = f"""
|
||||||
|
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
|
||||||
|
const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))});
|
||||||
|
let liveCalls=0, gateCalls=0;
|
||||||
|
const responses=[
|
||||||
|
{{context:null,events:[],notifications:[]}},
|
||||||
|
{{context:{{user:{{id:7,login:'timmy'}}}},events:[],notifications:[]}},
|
||||||
|
];
|
||||||
|
const broker=createProgressiveLiveSnapshot({{fetchSnapshot:async()=>{{liveCalls+=1;return responses.shift();}}}});
|
||||||
|
const nodes={{
|
||||||
|
'#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:'',addEventListener(){{}}}},
|
||||||
|
'#human-gates-status':{{}}, '#human-gates':{{hidden:true}},
|
||||||
|
'#human-gate-detail':{{innerHTML:'',addEventListener(){{}},querySelectorAll:()=>[]}},
|
||||||
|
'#open-human-gates':{{addEventListener(){{}}}}, '#close-human-gates':{{addEventListener(){{}}}},
|
||||||
|
}};
|
||||||
|
const gates=createProgressiveHumanGates({{
|
||||||
|
document:{{querySelector:selector=>nodes[selector]||null}},
|
||||||
|
location:{{hash:'#/my-work/human-gates'}}, history:{{replaceState(){{}}}},
|
||||||
|
storage:{{getItem:()=>null,setItem(){{}}}}, isOnline:()=>true,
|
||||||
|
liveSnapshot:broker,
|
||||||
|
fetchJson:async()=>{{gateCalls+=1;return {{pending_count:1,items:[{{id:'g1',title:'Recovered',candidate_hash:'abc',revision:1,checks:[]}}]}};}},
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
let firstError='';
|
||||||
|
try{{await gates.start();}}catch(error){{firstError=error.message;}}
|
||||||
|
const recovered=await gates.start();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
firstError,recovered,liveCalls,gateCalls,started:gates.handoff().started,
|
||||||
|
}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"firstError": "Authenticated account identity is unavailable.",
|
||||||
|
"recovered": True,
|
||||||
|
"liveCalls": 2,
|
||||||
|
"gateCalls": 2,
|
||||||
|
"started": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_queue_loads_pending_count_uses_account_cache_and_renders_inbox_zero():
|
def test_queue_loads_pending_count_uses_account_cache_and_renders_inbox_zero():
|
||||||
output = run_node(r"""
|
output = run_node(r"""
|
||||||
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
|
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
|
||||||
|
|
@ -262,7 +306,7 @@ def test_human_gate_mobile_shell_and_deep_route_are_wired():
|
||||||
assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
|
assert "mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']})" in dashboard
|
||||||
assert "counts.gate = queueCounts.gate" in dashboard
|
assert "counts.gate = queueCounts.gate" in dashboard
|
||||||
assert "gate:preparationItems.gate || []" in dashboard
|
assert "gate:preparationItems.gate || []" in dashboard
|
||||||
assert "stackchain-dashboard-shell-v143" in WORKER.read_text()
|
assert "stackchain-dashboard-shell-v144" in WORKER.read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():
|
def test_deep_link_opens_human_gates_without_waiting_for_optional_workspace():
|
||||||
|
|
|
||||||
|
|
@ -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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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-v143" in worker
|
assert "stackchain-dashboard-shell-v144" 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-v143" in worker
|
assert "stackchain-dashboard-shell-v144" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "controller.recoverPermission('deadline')" in dashboard
|
assert "controller.recoverPermission('deadline')" 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-v143" in worker
|
assert "stackchain-dashboard-shell-v144" 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
|
||||||
|
|
|
||||||
|
|
@ -274,5 +274,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-v143" in worker
|
assert "stackchain-dashboard-shell-v144" in worker
|
||||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -469,7 +469,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-v143" in service_worker
|
assert "stackchain-dashboard-shell-v144" in service_worker
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
||||||
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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,50 @@ const broker=createProgressiveLiveSnapshot({{fetchSnapshot:()=>{{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_progressive_live_snapshot_retries_identityless_success_for_identity_consumers():
|
||||||
|
harness = f"""
|
||||||
|
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
|
||||||
|
let calls=0;
|
||||||
|
const responses=[
|
||||||
|
{{context:null,events:[],notifications:[{{id:1}}]}},
|
||||||
|
{{context:{{user:{{id:7,login:'timmy'}}}},events:[],notifications:[]}},
|
||||||
|
];
|
||||||
|
const broker=createProgressiveLiveSnapshot({{fetchSnapshot:async()=>{{
|
||||||
|
calls += 1;
|
||||||
|
return responses.shift();
|
||||||
|
}}}});
|
||||||
|
(async()=>{{
|
||||||
|
const partial=await broker.acquire();
|
||||||
|
const first=await Promise.allSettled([
|
||||||
|
broker.acquire({{requireIdentity:true}}),
|
||||||
|
broker.acquire({{requireIdentity:true}}),
|
||||||
|
]);
|
||||||
|
const retries=[
|
||||||
|
broker.acquire({{requireIdentity:true}}),
|
||||||
|
broker.acquire({{requireIdentity:true}}),
|
||||||
|
];
|
||||||
|
const recovered=await Promise.all(retries);
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
calls,
|
||||||
|
partialNotifications:partial.notifications.length,
|
||||||
|
first:first.map(result=>result.status),
|
||||||
|
same:recovered[0]===recovered[1],
|
||||||
|
identity:broker.identity(),
|
||||||
|
}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"calls": 2,
|
||||||
|
"partialNotifications": 1,
|
||||||
|
"first": ["rejected", "rejected"],
|
||||||
|
"same": True,
|
||||||
|
"identity": {"login": "timmy", "accountKey": "7:timmy"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_progressive_live_snapshot_retries_after_a_failed_shared_flight():
|
def test_progressive_live_snapshot_retries_after_a_failed_shared_flight():
|
||||||
harness = f"""
|
harness = f"""
|
||||||
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
|
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
|
||||||
|
|
@ -78,6 +122,43 @@ const broker=createProgressiveLiveSnapshot({{fetchSnapshot:async()=>{{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_progressive_my_work_reports_reconnecting_for_degraded_context_instead_of_inbox_zero():
|
||||||
|
harness = f"""
|
||||||
|
const fs=require('fs'); const vm=require('vm');
|
||||||
|
const list={{innerHTML:''}}; const status={{textContent:''}};
|
||||||
|
const document={{
|
||||||
|
hidden:false,
|
||||||
|
querySelector:s=>s==='#my-work-list'?list:s==='#my-work-status'?status:null,
|
||||||
|
querySelectorAll:()=>[],
|
||||||
|
}};
|
||||||
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
||||||
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
||||||
|
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{
|
||||||
|
context:null,events:[],notifications:[{{id:1}}],
|
||||||
|
freshness:{{retry_in_seconds:5,sections:{{
|
||||||
|
context:{{degraded:true,retry_in_seconds:5}},
|
||||||
|
events:{{degraded:false,age_seconds:1}},notifications:{{degraded:false,age_seconds:1}},
|
||||||
|
}}}},
|
||||||
|
}})}});
|
||||||
|
(async()=>{{
|
||||||
|
const started=await flow.start();
|
||||||
|
process.stdout.write(JSON.stringify({{started,status:status.textContent,html:list.innerHTML,counts:flow.counts()}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"started": False,
|
||||||
|
"status": "Assigned work is reconnecting…",
|
||||||
|
"html": '<div class="muted">Assigned work is reconnecting…</div>',
|
||||||
|
"counts": {"all": 0, "filed": 0, "authored": 0, "attention": 0,
|
||||||
|
"update": 0, "review": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_progressive_my_work_renders_and_filters_assigned_work_before_full_workspace():
|
def test_progressive_my_work_renders_and_filters_assigned_work_before_full_workspace():
|
||||||
harness = f"""
|
harness = f"""
|
||||||
const fs=require('fs'); const vm=require('vm');
|
const fs=require('fs'); const vm=require('vm');
|
||||||
|
|
|
||||||
|
|
@ -189,14 +189,14 @@ async function dispatchPush(payload) {{
|
||||||
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
|
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v143" in source
|
assert "stackchain-dashboard-shell-v144" in source
|
||||||
assert "BASE + 'static/progressive-live-snapshot.js'" in source
|
assert "BASE + 'static/progressive-live-snapshot.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v143" in source
|
assert "stackchain-dashboard-shell-v144" in source
|
||||||
assert "BASE + 'static/week-plan.js'" in source
|
assert "BASE + 'static/week-plan.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
||||||
|
|
@ -204,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
||||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v143" in source
|
assert "stackchain-dashboard-shell-v144" in source
|
||||||
|
|
||||||
|
|
||||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v143" in source
|
assert "stackchain-dashboard-shell-v144" in source
|
||||||
assert "BASE + 'static/week-plan.js'" in source
|
assert "BASE + 'static/week-plan.js'" 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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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
|
||||||
|
|
@ -226,7 +226,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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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
|
||||||
|
|
@ -235,7 +235,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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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
|
||||||
|
|
||||||
|
|
@ -243,14 +243,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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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
|
||||||
|
|
||||||
|
|
@ -258,7 +258,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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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
|
||||||
|
|
||||||
|
|
@ -266,7 +266,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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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
|
||||||
|
|
@ -276,14 +276,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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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
|
||||||
|
|
@ -292,21 +292,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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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-v143" in source
|
assert "stackchain-dashboard-shell-v144" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1361,7 +1361,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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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-v143';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v144';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
||||||
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-v143" in source
|
assert "stackchain-dashboard-shell-v144" 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