perf: adapt live polling to server freshness (#465)
This commit is contained in:
parent
7639a42d7b
commit
72453b76db
|
|
@ -84,6 +84,11 @@ not changed by this session boundary. Installed-app
|
||||||
navigations are also deadline-bounded: after four seconds without a network response,
|
navigations are also deadline-bounded: after four seconds without a network response,
|
||||||
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
|
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
|
||||||
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
|
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
|
||||||
|
Live dashboard refreshes use the server's per-section freshness windows: healthy sections
|
||||||
|
refresh at their earliest deadline while a fully degraded snapshot waits for its reported
|
||||||
|
cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures
|
||||||
|
back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit
|
||||||
|
refresh still run immediately; a successful response resets transport backoff.
|
||||||
Closed-app
|
Closed-app
|
||||||
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
||||||
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ function buildLiveRevisionQuery(revisions = {}) {
|
||||||
return params.toString();
|
return params.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retryAfterMs(value) {
|
||||||
|
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
|
||||||
|
const seconds = Number(value);
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||||
|
return Math.min(seconds * 1000, 300000);
|
||||||
|
}
|
||||||
|
|
||||||
function createContextPoller({
|
function createContextPoller({
|
||||||
fetchContext,
|
fetchContext,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
|
|
@ -21,25 +28,51 @@ function createContextPoller({
|
||||||
clearDeadlineTimer = clearTimeout,
|
clearDeadlineTimer = clearTimeout,
|
||||||
intervalMs = 8000,
|
intervalMs = 8000,
|
||||||
timeoutMs = 12000,
|
timeoutMs = 12000,
|
||||||
|
maxBackoffMs = 60000,
|
||||||
}) {
|
}) {
|
||||||
let activeRequest = null;
|
let activeRequest = null;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let revisions = {};
|
let revisions = {};
|
||||||
let retainedSnapshot = null;
|
let retainedSnapshot = null;
|
||||||
|
let nextDelayMs = intervalMs;
|
||||||
|
let failureStreak = 0;
|
||||||
|
|
||||||
|
function snapshotDelay(snapshot) {
|
||||||
|
const freshness = snapshot && snapshot.freshness;
|
||||||
|
const sections = freshness && freshness.sections;
|
||||||
|
const sectionValues = sections && Object.values(sections);
|
||||||
|
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
|
||||||
|
const retrySeconds = Number(freshness.retry_in_seconds);
|
||||||
|
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
|
||||||
|
}
|
||||||
|
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
|
||||||
|
const healthyDeadlines = (sectionValues || [])
|
||||||
|
.filter(section => !section.degraded)
|
||||||
|
.map(section => freshForSeconds - Number(section.age_seconds))
|
||||||
|
.filter(seconds => Number.isFinite(seconds));
|
||||||
|
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
|
||||||
|
const earliestDeadline = Math.min(...healthyDeadlines);
|
||||||
|
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
|
||||||
|
return intervalMs;
|
||||||
|
}
|
||||||
|
return Math.max(0, earliestDeadline) * 1000;
|
||||||
|
}
|
||||||
|
return intervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
function cancelTimer() {
|
function cancelTimer() {
|
||||||
if (timer !== null) clearTimer(timer);
|
if (timer !== null) clearTimer(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedule() {
|
function schedule(delayMs = intervalMs) {
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
if (stopped || isHidden()) return;
|
if (stopped || isHidden()) return;
|
||||||
timer = setTimer(() => {
|
timer = setTimer(() => {
|
||||||
timer = null;
|
timer = null;
|
||||||
refresh();
|
refresh();
|
||||||
}, intervalMs);
|
}, delayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abortError() {
|
function abortError() {
|
||||||
|
|
@ -91,6 +124,8 @@ function createContextPoller({
|
||||||
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
|
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
|
||||||
.then((snapshot) => {
|
.then((snapshot) => {
|
||||||
if (activeRequest !== requestState) return retainedSnapshot;
|
if (activeRequest !== requestState) return retainedSnapshot;
|
||||||
|
failureStreak = 0;
|
||||||
|
nextDelayMs = snapshotDelay(snapshot);
|
||||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||||
);
|
);
|
||||||
|
|
@ -100,7 +135,14 @@ function createContextPoller({
|
||||||
return retainedSnapshot;
|
return retainedSnapshot;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (activeRequest === requestState && !stopped) onError(error);
|
if (activeRequest === requestState && !stopped) {
|
||||||
|
const retryAfterMs = Number(error && error.retryAfterMs);
|
||||||
|
failureStreak += 1;
|
||||||
|
nextDelayMs = Number.isFinite(retryAfterMs) && retryAfterMs > 0
|
||||||
|
? retryAfterMs
|
||||||
|
: Math.min(intervalMs * (2 ** (failureStreak - 1)), maxBackoffMs);
|
||||||
|
onError(error);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -108,7 +150,7 @@ function createContextPoller({
|
||||||
clearDeadlineTimer(requestState.deadline);
|
clearDeadlineTimer(requestState.deadline);
|
||||||
if (activeRequest !== requestState) return;
|
if (activeRequest !== requestState) return;
|
||||||
activeRequest = null;
|
activeRequest = null;
|
||||||
schedule();
|
schedule(nextDelayMs);
|
||||||
});
|
});
|
||||||
return requestState.promise;
|
return requestState.promise;
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +178,7 @@ function createContextPoller({
|
||||||
}
|
}
|
||||||
|
|
||||||
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
|
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
|
||||||
|
createContextPoller.retryAfterMs = retryAfterMs;
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = createContextPoller;
|
module.exports = createContextPoller;
|
||||||
|
|
|
||||||
|
|
@ -376,7 +376,11 @@
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
if (!res.ok) {
|
||||||
|
const error = new Error('HTTP ' + res.status);
|
||||||
|
error.retryAfterMs = createContextPoller.retryAfterMs(res.headers.get('Retry-After'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-v79';
|
const CACHE = 'stackchain-dashboard-shell-v80';
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -281,4 +281,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read()
|
||||||
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-v79" in worker
|
assert "stackchain-dashboard-shell-v80" in worker
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
|
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
|
||||||
|
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||||
|
|
||||||
|
|
||||||
def run_node(script: str) -> dict:
|
def run_node(script: str) -> dict:
|
||||||
|
|
@ -116,6 +117,185 @@ const poller = createContextPoller({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():
|
||||||
|
script = f"""
|
||||||
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
const delays = [];
|
||||||
|
const poller = createContextPoller({{
|
||||||
|
fetchContext: () => Promise.resolve({{
|
||||||
|
context: {{}}, events: [], notifications: [],
|
||||||
|
freshness: {{
|
||||||
|
fresh_for_seconds: 8,
|
||||||
|
retry_in_seconds: 60,
|
||||||
|
sections: {{
|
||||||
|
context: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||||
|
events: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||||
|
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}}),
|
||||||
|
onSnapshot: () => {{}},
|
||||||
|
onError: error => {{ throw error; }},
|
||||||
|
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||||
|
clearTimer: () => {{}},
|
||||||
|
intervalMs: 8000,
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await poller.start();
|
||||||
|
process.stdout.write(JSON.stringify({{ delays }}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {"delays": [60000]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_poller_uses_earliest_healthy_section_freshness_deadline():
|
||||||
|
script = f"""
|
||||||
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
const delays = [];
|
||||||
|
const poller = createContextPoller({{
|
||||||
|
fetchContext: () => Promise.resolve({{
|
||||||
|
context: {{}}, events: [], notifications: [],
|
||||||
|
freshness: {{
|
||||||
|
fresh_for_seconds: 8,
|
||||||
|
retry_in_seconds: 60,
|
||||||
|
sections: {{
|
||||||
|
context: {{ degraded: false, age_seconds: 2 }},
|
||||||
|
events: {{ degraded: false, age_seconds: 5 }},
|
||||||
|
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}}),
|
||||||
|
onSnapshot: () => {{}},
|
||||||
|
onError: error => {{ throw error; }},
|
||||||
|
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||||
|
clearTimer: () => {{}},
|
||||||
|
intervalMs: 8000,
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await poller.start();
|
||||||
|
process.stdout.write(JSON.stringify({{ delays }}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {"delays": [3000]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_poller_does_not_spin_while_stale_sections_are_revalidating():
|
||||||
|
script = f"""
|
||||||
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
const delays = [];
|
||||||
|
const poller = createContextPoller({{
|
||||||
|
fetchContext: () => Promise.resolve({{
|
||||||
|
context: {{}}, events: [], notifications: [],
|
||||||
|
freshness: {{
|
||||||
|
fresh_for_seconds: 8,
|
||||||
|
sections: {{
|
||||||
|
context: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
||||||
|
events: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
||||||
|
notifications: {{ degraded: false, revalidating: true, age_seconds: 12 }},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}}),
|
||||||
|
onSnapshot: () => {{}},
|
||||||
|
onError: error => {{ throw error; }},
|
||||||
|
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||||
|
clearTimer: () => {{}},
|
||||||
|
intervalMs: 8000,
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await poller.start();
|
||||||
|
process.stdout.write(JSON.stringify({{ delays }}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {"delays": [8000]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_poller_honors_http_retry_after_delay():
|
||||||
|
script = f"""
|
||||||
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
const delays = [];
|
||||||
|
const errors = [];
|
||||||
|
const unavailable = new Error('HTTP 503');
|
||||||
|
unavailable.retryAfterMs = 45000;
|
||||||
|
const poller = createContextPoller({{
|
||||||
|
fetchContext: () => Promise.reject(unavailable),
|
||||||
|
onSnapshot: () => {{}},
|
||||||
|
onError: error => errors.push(error.message),
|
||||||
|
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||||
|
clearTimer: () => {{}},
|
||||||
|
intervalMs: 8000,
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await poller.start();
|
||||||
|
process.stdout.write(JSON.stringify({{ delays, errors }}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {"delays": [45000], "errors": ["HTTP 503"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_after_parser_accepts_bounded_delta_seconds():
|
||||||
|
script = f"""
|
||||||
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
valid: createContextPoller.retryAfterMs('45'),
|
||||||
|
zero: createContextPoller.retryAfterMs('0'),
|
||||||
|
invalid: createContextPoller.retryAfterMs('later'),
|
||||||
|
excessive: createContextPoller.retryAfterMs('999999'),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {
|
||||||
|
"valid": 45000,
|
||||||
|
"zero": None,
|
||||||
|
"invalid": None,
|
||||||
|
"excessive": 300000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_poller_backs_off_transport_failures_and_resets_after_success():
|
||||||
|
script = f"""
|
||||||
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
const delays = [];
|
||||||
|
let calls = 0;
|
||||||
|
const poller = createContextPoller({{
|
||||||
|
fetchContext: () => {{
|
||||||
|
calls += 1;
|
||||||
|
if (calls < 6) return Promise.reject(new TypeError('network unavailable'));
|
||||||
|
return Promise.resolve({{ context: {{}}, events: [], notifications: [] }});
|
||||||
|
}},
|
||||||
|
onSnapshot: () => {{}},
|
||||||
|
onError: () => {{}},
|
||||||
|
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
|
||||||
|
clearTimer: () => {{}},
|
||||||
|
intervalMs: 8000,
|
||||||
|
maxBackoffMs: 60000,
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await poller.refresh();
|
||||||
|
await poller.refresh();
|
||||||
|
await poller.refresh();
|
||||||
|
await poller.refresh();
|
||||||
|
await poller.refresh();
|
||||||
|
await poller.refresh();
|
||||||
|
process.stdout.write(JSON.stringify({{ delays }}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {
|
||||||
|
"delays": [8000, 16000, 32000, 60000, 60000, 8000]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_fetch_passes_retry_after_to_the_poller_error():
|
||||||
|
source = DASHBOARD.read_text()
|
||||||
|
|
||||||
|
assert "createContextPoller.retryAfterMs(res.headers.get('Retry-After'))" in source
|
||||||
|
assert "error.retryAfterMs" in source
|
||||||
|
|
||||||
|
|
||||||
def test_live_revision_query_preserves_bounded_opaque_tokens():
|
def test_live_revision_query_preserves_bounded_opaque_tokens():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||||
|
|
|
||||||
|
|
@ -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():
|
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-v79" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/later-sync.js'" 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 { 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-v79" in worker
|
assert "stackchain-dashboard-shell-v80" in worker
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,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-v79" in worker
|
assert "stackchain-dashboard-shell-v80" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -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():
|
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-v79" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
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-v79" in source
|
assert "stackchain-dashboard-shell-v80" 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
|
||||||
|
|
@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
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-v79" in source
|
assert "stackchain-dashboard-shell-v80" 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-v79" in source
|
assert "stackchain-dashboard-shell-v80" 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
|
||||||
|
|
||||||
|
|
@ -146,7 +146,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-v79" in source
|
assert "stackchain-dashboard-shell-v80" 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
|
||||||
|
|
||||||
|
|
@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
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-v79" in source
|
assert "stackchain-dashboard-shell-v80" 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-v79" in source
|
assert "stackchain-dashboard-shell-v80" 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
|
||||||
|
|
@ -170,21 +170,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-v79" in source
|
assert "stackchain-dashboard-shell-v80" 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-v79" in source
|
assert "stackchain-dashboard-shell-v80" 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-v79" in source
|
assert "stackchain-dashboard-shell-v80" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -385,7 +385,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-v79" in source
|
assert "stackchain-dashboard-shell-v80" 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-v79';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v80';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" 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():
|
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-v79" in source
|
assert "stackchain-dashboard-shell-v80" 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