Recover foreground dashboard actions from stalled requests #398
|
|
@ -61,7 +61,12 @@ triageable offline: **Queue read & next** writes an account-bound, notification-
|
||||||
deduplicated acknowledgement to the durable background delivery system, removes the
|
deduplicated acknowledgement to the durable background delivery system, removes the
|
||||||
update from the local queue immediately, and opens the next saved conversation. A cold
|
update from the local queue immediately, and opens the next saved conversation. A cold
|
||||||
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
|
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
|
||||||
authenticated notification-read endpoint and keeps transient failures queued. Installed-app
|
authenticated notification-read endpoint and keeps transient failures queued. Every foreground
|
||||||
|
same-origin dashboard API request also has a 15-second browser deadline, including fresh-
|
||||||
|
authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's
|
||||||
|
fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server
|
||||||
|
outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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-v60';
|
const CACHE = 'stackchain-dashboard-shell-v61';
|
||||||
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;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@
|
||||||
promptAuthorization = () => null,
|
promptAuthorization = () => null,
|
||||||
onExpired = () => {},
|
onExpired = () => {},
|
||||||
onClearError = () => {},
|
onClearError = () => {},
|
||||||
|
requestTimeoutMs = 15000,
|
||||||
}) {
|
}) {
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||||
let expirationStarted = false;
|
let expirationStarted = false;
|
||||||
|
|
@ -122,6 +123,56 @@
|
||||||
catch (_error) { return false; }
|
catch (_error) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDashboardApi(input) {
|
||||||
|
try {
|
||||||
|
const url = new URL(String(input?.url || input), origin);
|
||||||
|
return url.origin === origin && url.pathname.startsWith(base + 'api/v1/');
|
||||||
|
} catch (_error) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithDeadline(input, options, method, phase = 'request') {
|
||||||
|
if (!isDashboardApi(input)) return fetchImpl(input, options);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const callerSignal = options.signal || input?.signal;
|
||||||
|
let timeout;
|
||||||
|
let rejectCancellation;
|
||||||
|
const cancellation = new Promise((_resolve, reject) => { rejectCancellation = reject; });
|
||||||
|
const cancelFromCaller = () => {
|
||||||
|
const error = callerSignal.reason || new DOMException('The request was aborted.', 'AbortError');
|
||||||
|
error.source = 'caller';
|
||||||
|
controller.abort(error);
|
||||||
|
rejectCancellation(error);
|
||||||
|
};
|
||||||
|
if (callerSignal?.aborted) cancelFromCaller();
|
||||||
|
else callerSignal?.addEventListener?.('abort', cancelFromCaller, { once: true });
|
||||||
|
const deadline = new Promise((_resolve, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
const error = new Error(
|
||||||
|
SAFE_METHODS.has(method)
|
||||||
|
? 'Request timed out. Try again.'
|
||||||
|
: 'Request timed out. Refresh to verify the outcome before retrying.'
|
||||||
|
);
|
||||||
|
error.name = 'TimeoutError';
|
||||||
|
error.method = method;
|
||||||
|
error.outcome = 'unknown';
|
||||||
|
error.safeToRetry = SAFE_METHODS.has(method);
|
||||||
|
error.phase = phase;
|
||||||
|
controller.abort(error);
|
||||||
|
reject(error);
|
||||||
|
}, requestTimeoutMs);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
Promise.resolve().then(() => fetchImpl(input, { ...options, signal: controller.signal })),
|
||||||
|
deadline,
|
||||||
|
cancellation,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
callerSignal?.removeEventListener?.('abort', cancelFromCaller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function sessionFetch(input, options = {}, allowStepUp = true) {
|
async function sessionFetch(input, options = {}, allowStepUp = true) {
|
||||||
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
||||||
const requestOptions = { ...options };
|
const requestOptions = { ...options };
|
||||||
|
|
@ -131,7 +182,7 @@
|
||||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||||
requestOptions.headers = headers;
|
requestOptions.headers = headers;
|
||||||
}
|
}
|
||||||
const response = await fetchImpl(input, requestOptions);
|
const response = await fetchWithDeadline(input, requestOptions, method);
|
||||||
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
|
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
|
||||||
const payload = await response.clone().json().catch(() => ({}));
|
const payload = await response.clone().json().catch(() => ({}));
|
||||||
const detail = payload?.detail || {};
|
const detail = payload?.detail || {};
|
||||||
|
|
@ -147,7 +198,7 @@
|
||||||
});
|
});
|
||||||
const csrf = csrfToken();
|
const csrf = csrfToken();
|
||||||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||||||
const authorization = await fetchImpl(base + 'api/v1/fresh-authorization', {
|
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authorizationHeaders,
|
headers: authorizationHeaders,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -155,7 +206,7 @@
|
||||||
action: detail.action,
|
action: detail.action,
|
||||||
target: detail.target,
|
target: detail.target,
|
||||||
}),
|
}),
|
||||||
});
|
}, 'POST', 'fresh-authorization');
|
||||||
if (!authorization.ok) return authorization;
|
if (!authorization.ok) return authorization;
|
||||||
const grant = await authorization.json().catch(() => ({}));
|
const grant = await authorization.json().catch(() => ({}));
|
||||||
if (!grant.grant) return response;
|
if (!grant.grant) return response;
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,160 @@ const boundary = createSessionBoundary({{
|
||||||
return json.loads(result.stdout)
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_origin_api_fetch_times_out_even_when_fetch_ignores_abort():
|
||||||
|
script = f"""
|
||||||
|
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||||
|
let aborted = false;
|
||||||
|
const boundary = createSessionBoundary({{
|
||||||
|
cookie: () => '',
|
||||||
|
origin: 'https://forge.example',
|
||||||
|
base: '/dashboard/',
|
||||||
|
requestTimeoutMs: 10,
|
||||||
|
fetchImpl: (_url, options = {{}}) => new Promise(resolve => {{
|
||||||
|
options.signal?.addEventListener('abort', () => {{ aborted = true; }});
|
||||||
|
setTimeout(() => resolve(new Response('{{}}', {{status:200}})), 80);
|
||||||
|
}}),
|
||||||
|
location: {{ replace: () => {{}} }},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const started = Date.now();
|
||||||
|
try {{ await boundary.fetch('/dashboard/api/v1/context'); }}
|
||||||
|
catch (error) {{
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
name:error.name, message:error.message, method:error.method,
|
||||||
|
outcome:error.outcome, aborted, elapsed:Date.now()-started,
|
||||||
|
}}));
|
||||||
|
}}
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output["name"] == "TimeoutError"
|
||||||
|
assert output["message"] == "Request timed out. Try again."
|
||||||
|
assert output["method"] == "GET"
|
||||||
|
assert output["outcome"] == "unknown"
|
||||||
|
assert output["aborted"] is True
|
||||||
|
assert output["elapsed"] < 70
|
||||||
|
|
||||||
|
|
||||||
|
def test_caller_abort_cancels_same_origin_api_fetch_without_waiting_for_deadline():
|
||||||
|
script = f"""
|
||||||
|
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||||
|
const caller = new AbortController();
|
||||||
|
let requestAborted = false;
|
||||||
|
const boundary = createSessionBoundary({{
|
||||||
|
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
|
||||||
|
fetchImpl: (_url, options = {{}}) => new Promise(() => {{
|
||||||
|
options.signal.addEventListener('abort', () => {{ requestAborted = true; }});
|
||||||
|
}}),
|
||||||
|
location: {{ replace: () => {{}} }},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const started = Date.now();
|
||||||
|
const pending = boundary.fetch('/dashboard/api/v1/context', {{signal:caller.signal}});
|
||||||
|
setTimeout(() => caller.abort(new DOMException('Superseded', 'AbortError')), 10);
|
||||||
|
try {{ await pending; }} catch (error) {{
|
||||||
|
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source, requestAborted, elapsed:Date.now()-started}}));
|
||||||
|
}}
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output["name"] == "AbortError"
|
||||||
|
assert output["message"] == "Superseded"
|
||||||
|
assert output["source"] == "caller"
|
||||||
|
assert output["requestAborted"] is True
|
||||||
|
assert output["elapsed"] < 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_object_abort_signal_is_composed_with_the_api_deadline():
|
||||||
|
script = f"""
|
||||||
|
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||||
|
const caller = new AbortController();
|
||||||
|
const boundary = createSessionBoundary({{
|
||||||
|
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
|
||||||
|
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const request = new Request('https://forge.example/dashboard/api/v1/context', {{signal:caller.signal}});
|
||||||
|
const pending = boundary.fetch(request);
|
||||||
|
setTimeout(() => caller.abort(new DOMException('Request superseded', 'AbortError')), 5);
|
||||||
|
try {{ await pending; }} catch (error) {{
|
||||||
|
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source}}));
|
||||||
|
}}
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"name": "AbortError",
|
||||||
|
"message": "Request superseded",
|
||||||
|
"source": "caller",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutation_timeout_warns_that_the_server_outcome_may_be_ambiguous():
|
||||||
|
script = f"""
|
||||||
|
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||||
|
const boundary = createSessionBoundary({{
|
||||||
|
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
|
||||||
|
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/issues/9', {{method:'PATCH'}}); }}
|
||||||
|
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, message:error.message, method:error.method, safeToRetry:error.safeToRetry}})); }}
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output == {
|
||||||
|
"name": "TimeoutError",
|
||||||
|
"message": "Request timed out. Refresh to verify the outcome before retrying.",
|
||||||
|
"method": "PATCH",
|
||||||
|
"safeToRetry": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_authorization_request_uses_the_same_deadline_boundary():
|
||||||
|
script = f"""
|
||||||
|
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||||
|
let calls = 0;
|
||||||
|
const boundary = createSessionBoundary({{
|
||||||
|
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
|
||||||
|
fetchImpl: () => {{
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) return Promise.resolve(new Response(JSON.stringify({{detail:{{code:'step_up_required', action:'merge_pull', target:'stackchain/app#9'}}}}), {{status:428, headers:{{'Content-Type':'application/json'}}}}));
|
||||||
|
return new Promise(() => {{}});
|
||||||
|
}},
|
||||||
|
promptAuthorization: async () => 'operator-token', location: {{replace:() => {{}}}},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/pulls/9/merge', {{method:'POST'}}); }}
|
||||||
|
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, phase:error.phase, calls}})); }}
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"name": "TimeoutError",
|
||||||
|
"phase": "fresh-authorization",
|
||||||
|
"calls": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_mutating_same_origin_fetch_receives_csrf_proof():
|
def test_mutating_same_origin_fetch_receives_csrf_proof():
|
||||||
result = run_session_scenario(
|
result = run_session_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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-v60" in source
|
assert "stackchain-dashboard-shell-v61" 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-v60" in worker
|
assert "stackchain-dashboard-shell-v61" in worker
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,4 @@ 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-v60" in worker
|
assert "stackchain-dashboard-shell-v61" in worker
|
||||||
|
|
|
||||||
|
|
@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||||
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-v60" in source
|
assert "stackchain-dashboard-shell-v61" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,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-v60" in source
|
assert "stackchain-dashboard-shell-v61" 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
|
||||||
|
|
@ -117,7 +117,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
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-v60" in source
|
assert "stackchain-dashboard-shell-v61" 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
|
||||||
|
|
||||||
|
|
@ -125,14 +125,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-v60" in source
|
assert "stackchain-dashboard-shell-v61" 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-v60" in source
|
assert "stackchain-dashboard-shell-v61" 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
|
||||||
|
|
@ -141,21 +141,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-v60" in source
|
assert "stackchain-dashboard-shell-v61" 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-v60" in source
|
assert "stackchain-dashboard-shell-v61" 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-v60" in source
|
assert "stackchain-dashboard-shell-v61" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||||
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-v60" in source
|
assert "stackchain-dashboard-shell-v61" 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