961 lines
34 KiB
Python
961 lines
34 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
WORKER = Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js"
|
|
|
|
|
|
def run_worker_scenario(scenario: str) -> dict:
|
|
harness = f"""
|
|
const fs = require('fs');
|
|
const vm = require('vm');
|
|
const listeners = {{}};
|
|
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
|
const storedResponses = new Map();
|
|
storedResponses.set(
|
|
'https://forge.example/dashboard/__offline-session-lease',
|
|
new Response(JSON.stringify({{
|
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
idle_expires_at: Math.floor(Date.now() / 1000) + 900,
|
|
}})),
|
|
);
|
|
const cache = {{
|
|
addAll: async urls => {{ state.added = urls; }},
|
|
match: async request => {{
|
|
const key = String(request.url || request);
|
|
if (storedResponses.has(key)) return storedResponses.get(key).clone();
|
|
return state.cachedBody === null ? null : new Response(state.cachedBody);
|
|
}},
|
|
put: async (request, response) => {{
|
|
const key = String(request.url || request);
|
|
state.puts.push(key);
|
|
storedResponses.set(key, response.clone());
|
|
}},
|
|
}};
|
|
const context = {{
|
|
URL, Request, Response, Headers, AbortController,
|
|
setTimeout, clearTimeout, console,
|
|
self: {{
|
|
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
|
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
|
|
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
|
|
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
|
skipWaiting: async () => {{ state.skipped = true; }},
|
|
clients: {{
|
|
claim: async () => {{ state.claimed = true; }},
|
|
matchAll: async () => state.clientList || [],
|
|
openWindow: async url => {{ state.opened.push(url); }},
|
|
}},
|
|
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
|
__issueSync: {{
|
|
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
|
|
purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }},
|
|
resume: async () => {{ state.backgroundResumes += 1; state.outboxLifecycle.push('resume'); }},
|
|
getReceiptPreference: async login => state.receiptLogin === login,
|
|
}},
|
|
}},
|
|
importScripts: () => {{}},
|
|
caches: {{
|
|
open: async () => cache,
|
|
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
|
|
delete: async key => {{ state.deleted.push(key); return true; }},
|
|
match: async request => cache.match(request),
|
|
}},
|
|
fetch: async (request, options = {{}}) => {{
|
|
state.fetches.push(String(request.url || request));
|
|
if (state.failFetch) throw new Error('offline');
|
|
if (state.stallFetch) return new Promise((resolve, reject) => {{
|
|
options.signal?.addEventListener('abort', () => {{
|
|
state.fetchAborted = true;
|
|
reject(new Error('aborted'));
|
|
}}, {{ once: true }});
|
|
}});
|
|
if (state.lateFetch) return new Promise(resolve => {{
|
|
options.signal?.addEventListener('abort', () => {{ state.fetchAborted = true; }}, {{ once: true }});
|
|
setTimeout(() => resolve(new Response('late network')), 50);
|
|
}});
|
|
const response = new Response('network', {{ status: state.fetchStatus }});
|
|
Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }});
|
|
return response;
|
|
}},
|
|
}};
|
|
vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
|
|
async function dispatch(name, request) {{
|
|
let pending;
|
|
let response;
|
|
listeners[name]({{
|
|
request,
|
|
waitUntil: promise => {{ pending = promise; }},
|
|
respondWith: promise => {{ response = promise; }},
|
|
}});
|
|
if (pending) await pending;
|
|
return response ? await response : null;
|
|
}}
|
|
async function dispatchSync(tag) {{
|
|
let pending;
|
|
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
|
if (pending) await pending;
|
|
}}
|
|
async function dispatchMessage(data, ports = []) {{
|
|
let pending;
|
|
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
|
if (pending) await pending;
|
|
}}
|
|
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null) {{
|
|
let pending;
|
|
listeners.notificationclick({{
|
|
action,
|
|
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}},
|
|
waitUntil: promise => {{ pending = promise; }},
|
|
}});
|
|
if (pending) await pending;
|
|
}}
|
|
async function dispatchPush(payload) {{
|
|
let pending;
|
|
listeners.push({{
|
|
data: {{json: () => payload}},
|
|
waitUntil: promise => {{ pending = promise; }},
|
|
}});
|
|
if (pending) await pending;
|
|
}}
|
|
(async () => {{
|
|
{scenario}
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
completed = subprocess.run(
|
|
["node", "-e", harness], capture_output=True, check=True, text=True
|
|
)
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v90" in source
|
|
assert "BASE + 'static/my-work.js'" in source
|
|
assert "BASE + 'static/dashboard.js'" in source
|
|
assert "BASE + 'static/dashboard.css'" in source
|
|
|
|
|
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v90" in source
|
|
assert "BASE + 'static/dashboard.js'" in source
|
|
|
|
|
|
def test_offline_review_next_ships_today_completion_atomically():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v90" in source
|
|
assert "BASE + 'static/today-completion.js'" in source
|
|
assert "BASE + 'static/dashboard.js'" in source
|
|
|
|
|
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v90" in source
|
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
|
assert "BASE + 'static/dashboard.js'" in source
|
|
|
|
|
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v90" in source
|
|
assert "BASE + 'static/later-picker.js'" in source
|
|
|
|
|
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v90" in source
|
|
assert "BASE + 'static/dashboard.css'" in source
|
|
assert "BASE + 'static/dashboard.js'" in source
|
|
assert "BASE + 'static/install-app.js'" in source
|
|
|
|
|
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v90" in source
|
|
assert "BASE + 'static/today-sync.js'" in source
|
|
|
|
|
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v90" in source
|
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
|
|
|
|
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v90" in source
|
|
assert "BASE + 'static/update-ownership.js'" in source
|
|
|
|
|
|
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
|
result = run_worker_scenario(
|
|
"""
|
|
await dispatchSync('stackchain-issue-outbox-v1');
|
|
await dispatchSync('another-app-sync');
|
|
process.stdout.write(JSON.stringify(state));
|
|
"""
|
|
)
|
|
|
|
assert result["backgroundFlushes"] == 1
|
|
|
|
|
|
def test_background_delivery_preserves_structured_uncertain_error():
|
|
result = run_worker_scenario(
|
|
"""
|
|
context.fetch=async()=>new Response(JSON.stringify({detail:{code:'delivery_uncertain',message:'Verify it was not posted before retrying.'}}),{status:422,headers:{'Content-Type':'application/json'}});
|
|
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
|
|
.then(()=>({ok:true}),error=>({status:error.status,code:error.code,message:error.message}));
|
|
process.stdout.write(JSON.stringify(outcome));
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"status": 422,
|
|
"code": "delivery_uncertain",
|
|
"message": "Verify it was not posted before retrying.",
|
|
}
|
|
|
|
|
|
def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_clients():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.clientMessages=[];
|
|
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
|
|
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_revoked'}),{status:401,headers:{'Content-Type':'application/json'}});
|
|
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
|
|
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
|
|
process.stdout.write(JSON.stringify({state,outcome}));
|
|
"""
|
|
)
|
|
|
|
assert result["outcome"] == {"status": 401, "code": "session_revoked"}
|
|
assert result["state"]["outboxPurges"] == 1
|
|
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
|
assert result["state"]["clientMessages"] == [
|
|
{"type": "stackchain-session-revoked"}
|
|
]
|
|
|
|
|
|
def test_idle_background_session_preserves_outbox_and_notifies_dashboard_clients():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.clientMessages=[];
|
|
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
|
|
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_idle'}),{status:401,headers:{'Content-Type':'application/json'}});
|
|
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
|
|
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
|
|
process.stdout.write(JSON.stringify({state,outcome}));
|
|
"""
|
|
)
|
|
|
|
assert result["outcome"] == {"status": 401, "code": "session_idle"}
|
|
assert result["state"]["outboxPurges"] == 0
|
|
assert result["state"]["deleted"] == []
|
|
assert result["state"]["clientMessages"] == [
|
|
{"type": "stackchain-session-idle"}
|
|
]
|
|
|
|
|
|
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_authenticated_resume_rearms_a_previously_purged_worker_before_flushing():
|
|
result = run_worker_scenario(
|
|
"""
|
|
const replies = [];
|
|
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:value=>replies.push(value)}]);
|
|
await dispatchMessage({type:'stackchain-resume-outbox'});
|
|
process.stdout.write(JSON.stringify({state,replies}));
|
|
"""
|
|
)
|
|
|
|
assert result["replies"] == [{"ok": True}]
|
|
assert result["state"]["outboxLifecycle"] == ["purge", "resume", "flush"]
|
|
assert result["state"]["backgroundResumes"] == 1
|
|
|
|
|
|
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
|
|
result = run_worker_scenario(
|
|
"""
|
|
let mutations=0;
|
|
context.fetch=(request, options={})=>{
|
|
if(String(request).includes('/api/v1/session')){
|
|
return new Promise((resolve,reject)=>options.signal?.addEventListener('abort',()=>reject(new Error('aborted')), {once:true}));
|
|
}
|
|
mutations+=1;
|
|
return Promise.resolve(new Response('{}',{status:200,headers:{'Content-Type':'application/json'}}));
|
|
};
|
|
const controller=new AbortController();
|
|
const request=context.self.__testWithSessionCsrf(() =>
|
|
context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST',signal:controller.signal})
|
|
).then(()=> 'completed', error=>error.message);
|
|
controller.abort();
|
|
const outcome=await Promise.race([request,new Promise(resolve=>setTimeout(()=>resolve('blocked'),40))]);
|
|
process.stdout.write(JSON.stringify({outcome,mutations}));
|
|
process.exit(0);
|
|
"""
|
|
)
|
|
|
|
assert result == {"outcome": "aborted", "mutations": 0}
|
|
|
|
|
|
def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
|
result = run_worker_scenario(
|
|
"""
|
|
const replies = [];
|
|
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage: value => replies.push(value)}]);
|
|
process.stdout.write(JSON.stringify({state,replies}));
|
|
"""
|
|
)
|
|
|
|
assert result["state"]["outboxPurges"] == 1
|
|
assert result["replies"] == [{"ok": True}]
|
|
|
|
|
|
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.receiptLogin = 'timmy';
|
|
state.flushResult = {login:'timmy', receipts:[
|
|
{id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'},
|
|
{id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'},
|
|
{id:'review-1',status:'authorization',kind:'message',route:'#/my-work/drafts'},
|
|
]};
|
|
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
|
|
await dispatchSync('stackchain-issue-outbox-v1');
|
|
await dispatchNotificationClick('#/my-work/issue/stackchain/api/44');
|
|
process.stdout.write(JSON.stringify(state));
|
|
"""
|
|
)
|
|
|
|
assert result["notifications"] == [
|
|
{
|
|
"title": "Queued issue created",
|
|
"options": {
|
|
"body": "Tap to open it in Stackchain.",
|
|
"tag": "stackchain-delivery-capture-1",
|
|
"data": {"route": "#/my-work/issue/stackchain/api/44"},
|
|
},
|
|
},
|
|
{
|
|
"title": "Queued work needs attention",
|
|
"options": {
|
|
"body": "Tap to review it in Drafts.",
|
|
"tag": "stackchain-delivery-bad-1",
|
|
"data": {"route": "#/my-work/drafts"},
|
|
},
|
|
},
|
|
{
|
|
"title": "Queued review needs authorization",
|
|
"options": {
|
|
"body": "Tap to authorize it in the Delivery center.",
|
|
"tag": "stackchain-delivery-review-1",
|
|
"data": {"route": "#/my-work/drafts"},
|
|
},
|
|
},
|
|
]
|
|
assert result["focused"] == [
|
|
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
|
|
]
|
|
assert result["opened"] == []
|
|
assert result["notificationClosed"] is True
|
|
|
|
|
|
def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
|
result = run_worker_scenario(
|
|
"""
|
|
await dispatchPush({
|
|
title:'New work update', body:'Tap to review it in Stackchain.',
|
|
tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42,
|
|
repository:'must-not-render',
|
|
});
|
|
await dispatchNotificationClick('#/my-work/update/42');
|
|
process.stdout.write(JSON.stringify(state));
|
|
"""
|
|
)
|
|
|
|
assert result["notifications"] == [{
|
|
"title": "New work update",
|
|
"options": {
|
|
"body": "Tap to review it in Stackchain.",
|
|
"tag": "stackchain-update-42",
|
|
"actions": [
|
|
{"action": "mark-read", "title": "Mark read"},
|
|
{"action": "tomorrow", "title": "Tomorrow"},
|
|
],
|
|
"data": {
|
|
"route": "#/my-work/update/42",
|
|
"notificationId": 42,
|
|
},
|
|
},
|
|
}]
|
|
assert result["opened"] == [
|
|
"https://forge.example/dashboard/#/my-work/update/42"
|
|
]
|
|
assert "must-not-render" not in json.dumps(result["notifications"])
|
|
|
|
|
|
def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_copy():
|
|
result = run_worker_scenario(
|
|
"""
|
|
await dispatchPush({
|
|
title:'must-not-render', body:'private details must-not-render',
|
|
tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:7,
|
|
});
|
|
await dispatchNotificationClick('#/my-work/updates');
|
|
process.stdout.write(JSON.stringify(state));
|
|
"""
|
|
)
|
|
|
|
assert result["notifications"] == [{
|
|
"title": "7 new work updates",
|
|
"options": {
|
|
"body": "Tap to review them in Stackchain.",
|
|
"tag": "stackchain-update-digest",
|
|
"data": {"route": "#/my-work/updates"},
|
|
},
|
|
}]
|
|
assert result["opened"] == [
|
|
"https://forge.example/dashboard/#/my-work/updates"
|
|
]
|
|
assert "must-not-render" not in json.dumps(result["notifications"])
|
|
|
|
|
|
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
|
result = run_worker_scenario(
|
|
"""
|
|
const calls = [];
|
|
context.fetch = async (request, options = {}) => {
|
|
const url = String(request.url || request);
|
|
const headers = new Headers(options.headers || {});
|
|
calls.push({url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token')});
|
|
if (url.endsWith('/api/v1/session')) {
|
|
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
|
status:200, headers:{'Content-Type':'application/json'},
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({ok:true}), {
|
|
status:200, headers:{'Content-Type':'application/json'},
|
|
});
|
|
};
|
|
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
|
|
process.stdout.write(JSON.stringify({state,calls}));
|
|
"""
|
|
)
|
|
|
|
assert result["calls"] == [
|
|
{
|
|
"url": "https://forge.example/dashboard/api/v1/session",
|
|
"method": "GET",
|
|
"csrf": None,
|
|
},
|
|
{
|
|
"url": "https://forge.example/dashboard/api/v1/notifications/42/read",
|
|
"method": "PATCH",
|
|
"csrf": "session-proof",
|
|
},
|
|
]
|
|
assert result["state"]["notificationClosed"] is True
|
|
assert result["state"]["opened"] == []
|
|
assert result["state"]["focused"] == []
|
|
|
|
|
|
def test_push_tomorrow_action_defers_privately_without_opening_or_marking_read():
|
|
result = run_worker_scenario(
|
|
"""
|
|
const calls = [];
|
|
context.self.__STACKCHAIN_NOW = () => new Date('2026-08-11T18:30:00.000Z');
|
|
context.fetch = async (request, options = {}) => {
|
|
const url = String(request.url || request);
|
|
const headers = new Headers(options.headers || {});
|
|
calls.push({
|
|
url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token'),
|
|
body:options.body ? JSON.parse(options.body) : null,
|
|
});
|
|
if (url.endsWith('/api/v1/session')) {
|
|
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
|
status:200, headers:{'Content-Type':'application/json'},
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({id:42,status:'deferred'}), {
|
|
status:200, headers:{'Content-Type':'application/json'},
|
|
});
|
|
};
|
|
await dispatchNotificationClick('#/my-work/update/42', 'tomorrow', 42, 'stackchain-update-42');
|
|
process.stdout.write(JSON.stringify({state,calls}));
|
|
"""
|
|
)
|
|
|
|
assert result["calls"] == [
|
|
{
|
|
"url": "https://forge.example/dashboard/api/v1/session",
|
|
"method": "GET",
|
|
"csrf": None,
|
|
"body": None,
|
|
},
|
|
{
|
|
"url": "https://forge.example/dashboard/api/v1/notifications/42/later",
|
|
"method": "PATCH",
|
|
"csrf": "session-proof",
|
|
"body": {"wake_at": "2026-08-12T09:00:00.000Z"},
|
|
},
|
|
]
|
|
assert result["state"]["notificationClosed"] is True
|
|
assert result["state"]["opened"] == []
|
|
assert all(not call["url"].endswith("/read") for call in result["calls"])
|
|
|
|
|
|
def test_push_mark_read_failure_opens_existing_update_reader():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.clientList = [{
|
|
url:'https://forge.example/dashboard/',
|
|
navigate:async function(url){ this.url=url; },
|
|
focus:async function(){ state.focused.push(this.url); },
|
|
}];
|
|
context.fetch = async (request, options = {}) => {
|
|
const url = String(request.url || request);
|
|
if (url.endsWith('/api/v1/session')) {
|
|
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
|
status:200, headers:{'Content-Type':'application/json'},
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({error:'unavailable'}), {
|
|
status:503, headers:{'Content-Type':'application/json'},
|
|
});
|
|
};
|
|
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
|
|
process.stdout.write(JSON.stringify(state));
|
|
"""
|
|
)
|
|
|
|
assert result["focused"] == [
|
|
"https://forge.example/dashboard/#/my-work/update/42"
|
|
]
|
|
assert result["opened"] == []
|
|
assert result["notificationClosed"] is True
|
|
|
|
|
|
def test_push_mark_read_rejects_mismatched_notification_identity():
|
|
result = run_worker_scenario(
|
|
"""
|
|
const calls = [];
|
|
context.fetch = async (request, options = {}) => {
|
|
calls.push({url:String(request.url || request), method:String(options.method || 'GET')});
|
|
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
|
status:200, headers:{'Content-Type':'application/json'},
|
|
});
|
|
};
|
|
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 43);
|
|
process.stdout.write(JSON.stringify({state,calls}));
|
|
"""
|
|
)
|
|
|
|
assert result["calls"] == []
|
|
assert result["state"].get("notificationClosed") is not True
|
|
assert result["state"]["opened"] == []
|
|
|
|
|
|
def test_push_mark_read_timeout_aborts_request_and_opens_update_reader():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.clientList = [{
|
|
url:'https://forge.example/dashboard/',
|
|
navigate:async function(url){ this.url=url; },
|
|
focus:async function(){ state.focused.push(this.url); },
|
|
}];
|
|
context.fetch = async (request, options = {}) => {
|
|
const url = String(request.url || request);
|
|
if (url.endsWith('/api/v1/session')) {
|
|
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
|
status:200, headers:{'Content-Type':'application/json'},
|
|
});
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
options.signal?.addEventListener('abort', () => {
|
|
state.actionFetchAborted = true;
|
|
reject(new Error('aborted'));
|
|
}, {once:true});
|
|
});
|
|
};
|
|
dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
process.stdout.write(JSON.stringify(state));
|
|
process.exit(0);
|
|
"""
|
|
)
|
|
|
|
assert result["actionFetchAborted"] is True
|
|
assert result["focused"] == [
|
|
"https://forge.example/dashboard/#/my-work/update/42"
|
|
]
|
|
assert result["notificationClosed"] is True
|
|
|
|
|
|
def test_background_mutations_obtain_session_bound_csrf_proof():
|
|
source = WORKER.read_text()
|
|
|
|
assert "async function sessionCsrf(signal)" in source
|
|
assert "BASE + 'api/v1/session'" in source
|
|
assert "signal," in source
|
|
assert "headers.set('X-CSRF-Token', csrf)" in source
|
|
|
|
|
|
def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
|
source = WORKER.read_text()
|
|
|
|
assert "async function withSessionCsrf(work)" in source
|
|
assert "batchedCsrfController = new AbortController()" in source
|
|
assert "batchedCsrf = sessionCsrf(batchedCsrfController.signal)" in source
|
|
assert "batchedCsrfController.abort()" in source
|
|
assert "await (batchedCsrf || sessionCsrf(options.signal))" in source
|
|
assert "if (options.signal?.aborted)" in source
|
|
assert "batch: withSessionCsrf" in source
|
|
|
|
|
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v90" in source
|
|
assert "BASE + 'static/queue-today.js'" in source
|
|
|
|
|
|
def test_install_precaches_complete_subpath_scoped_app_shell():
|
|
result = run_worker_scenario(
|
|
"""
|
|
await dispatch('install');
|
|
process.stdout.write(JSON.stringify(state));
|
|
"""
|
|
)
|
|
|
|
assert result["skipped"] is True
|
|
assert result["added"][0] == "/dashboard/"
|
|
assert set(result["added"]) == {
|
|
"/dashboard/",
|
|
"/dashboard/manifest.webmanifest",
|
|
"/dashboard/static/dashboard.css",
|
|
"/dashboard/static/dashboard.js",
|
|
"/dashboard/static/icons/stackchain-192.png",
|
|
"/dashboard/static/icons/stackchain-512.png",
|
|
"/dashboard/static/session.js",
|
|
"/dashboard/static/feature-loader.js",
|
|
"/dashboard/static/markdown.js",
|
|
"/dashboard/static/commands.js",
|
|
"/dashboard/static/search-preview.js",
|
|
"/dashboard/static/widgets.js",
|
|
"/dashboard/static/drafts.js",
|
|
"/dashboard/static/unfiled-captures.js",
|
|
"/dashboard/static/outbox-coordinator.js",
|
|
"/dashboard/static/issue-outbox.js",
|
|
"/dashboard/static/authored-outbox.js",
|
|
"/dashboard/static/offline-issue-close.js",
|
|
"/dashboard/static/offline-issue-blocker.js",
|
|
"/dashboard/static/notification-read-outbox.js",
|
|
"/dashboard/static/offline-work.js",
|
|
"/dashboard/static/offline-today.js",
|
|
"/dashboard/static/my-work.js",
|
|
"/dashboard/static/card-planning.js",
|
|
"/dashboard/static/today-work.js",
|
|
"/dashboard/static/today-timer.js",
|
|
"/dashboard/static/today-recap.js",
|
|
"/dashboard/static/today-completion.js",
|
|
"/dashboard/static/today-readiness.js",
|
|
"/dashboard/static/comment-next.js",
|
|
"/dashboard/static/update-reply-read-next.js",
|
|
"/dashboard/static/plan-today.js",
|
|
"/dashboard/static/plan-today-preview.js",
|
|
"/dashboard/static/today-sync.js",
|
|
"/dashboard/static/update-ownership.js",
|
|
"/dashboard/static/later-work.js",
|
|
"/dashboard/static/later-sync.js",
|
|
"/dashboard/static/later-and-start.js",
|
|
"/dashboard/static/detail-defer.js",
|
|
"/dashboard/static/later-picker.js",
|
|
"/dashboard/static/pick-work.js",
|
|
"/dashboard/static/conversation.js",
|
|
"/dashboard/static/comment-actions.js",
|
|
"/dashboard/static/issue-attachment.js",
|
|
"/dashboard/static/issue-sheet.js",
|
|
"/dashboard/static/create-issue-sheet.js",
|
|
"/dashboard/static/create-and-start.js",
|
|
"/dashboard/static/assign-and-start.js",
|
|
"/dashboard/static/queue-today.js",
|
|
"/dashboard/static/pull-sheet.js",
|
|
"/dashboard/static/review-sheet.js",
|
|
"/dashboard/static/work-route.js",
|
|
"/dashboard/static/task-overlay-history.js",
|
|
"/dashboard/static/context-poller.js",
|
|
"/dashboard/static/mobile-task-dock.js",
|
|
"/dashboard/static/mobile-work-entry.js",
|
|
"/dashboard/static/mobile-launch.js",
|
|
"/dashboard/static/install-app.js",
|
|
"/dashboard/static/mobile-device-setup.js",
|
|
"/dashboard/static/mobile-search-viewport.js",
|
|
"/dashboard/static/mobile-composer-viewport.js",
|
|
"/dashboard/static/mention-composer.js",
|
|
"/dashboard/static/push-notifications.js",
|
|
"/dashboard/static/background-issue-sync.js",
|
|
}
|
|
|
|
|
|
def test_activate_deletes_only_stale_stackchain_caches():
|
|
result = run_worker_scenario(
|
|
"""
|
|
await dispatch('activate');
|
|
process.stdout.write(JSON.stringify(state));
|
|
"""
|
|
)
|
|
|
|
assert result["claimed"] is True
|
|
assert result["deleted"] == ["stackchain-dashboard-old"]
|
|
|
|
|
|
def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.failFetch = true;
|
|
state.cachedBody = 'cached dashboard';
|
|
const response = await dispatch('fetch', {
|
|
method: 'GET', mode: 'navigate',
|
|
url: 'https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com',
|
|
});
|
|
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
|
|
"""
|
|
)
|
|
|
|
assert result["body"] == "cached dashboard"
|
|
assert result["state"]["fetches"] == [
|
|
"https://forge.example/dashboard/?title=Shared&url=https%3A%2F%2Fexample.com"
|
|
]
|
|
|
|
|
|
def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell():
|
|
result = run_worker_scenario(
|
|
"""
|
|
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1, idleExpiresAt:1});
|
|
state.failFetch = true;
|
|
state.cachedBody = 'private cached dashboard';
|
|
const response = await dispatch('fetch', {
|
|
method:'GET', mode:'navigate', url:'https://forge.example/dashboard/',
|
|
});
|
|
process.stdout.write(JSON.stringify({
|
|
status: response.status,
|
|
body: await response.text(),
|
|
state,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert result["status"] == 401
|
|
assert result["body"] == "Your Stackchain session expired. Reconnect and sign in."
|
|
assert result["state"]["outboxPurges"] == 1
|
|
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
|
assert "private cached dashboard" not in result["body"]
|
|
|
|
|
|
def test_expired_idle_lease_locks_cached_shell_without_purging_recoverable_work():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.clientList = [{postMessage: message => { state.clientMessage = message; }}];
|
|
await dispatchMessage({
|
|
type:'stackchain-session-lease',
|
|
expiresAt:Math.floor(Date.now() / 1000) + 3600,
|
|
idleExpiresAt:1,
|
|
});
|
|
state.failFetch = true;
|
|
state.cachedBody = 'private cached dashboard';
|
|
const response = await dispatch('fetch', {
|
|
method:'GET', mode:'navigate', url:'https://forge.example/dashboard/',
|
|
});
|
|
process.stdout.write(JSON.stringify({
|
|
state, status:response.status, body:await response.text(),
|
|
cacheControl:response.headers.get('Cache-Control'),
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert result["status"] == 401
|
|
assert result["body"] == "Your Stackchain session is locked. Reconnect and sign in."
|
|
assert result["cacheControl"] == "no-store"
|
|
assert result["state"]["outboxPurges"] == 0
|
|
assert result["state"]["deleted"] == []
|
|
assert result["state"]["clientMessage"] == {"type": "stackchain-session-idle"}
|
|
assert "private cached dashboard" not in result["body"]
|
|
|
|
|
|
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.stallFetch = true;
|
|
state.cachedBody = 'cached dashboard';
|
|
const response = await Promise.race([
|
|
dispatch('fetch', {
|
|
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
|
}),
|
|
new Promise(resolve => setTimeout(() => resolve(null), 80)),
|
|
]);
|
|
process.stdout.write(JSON.stringify({
|
|
timedOut: response === null,
|
|
body: response ? await response.text() : null,
|
|
state,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert result["timedOut"] is False
|
|
assert result["body"] == "cached dashboard"
|
|
assert result["state"]["fetchAborted"] is True
|
|
assert result["state"]["puts"] == []
|
|
|
|
|
|
def test_stalled_navigation_without_cached_shell_returns_deterministic_504():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.stallFetch = true;
|
|
const response = await Promise.race([
|
|
dispatch('fetch', {
|
|
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
|
}),
|
|
new Promise(resolve => setTimeout(() => resolve(null), 80)),
|
|
]);
|
|
process.stdout.write(JSON.stringify({
|
|
status: response?.status || null,
|
|
body: response ? await response.text() : null,
|
|
contentType: response?.headers.get('Content-Type') || null,
|
|
state,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert result["status"] == 504
|
|
assert result["body"] == "Stackchain is offline and the dashboard is not cached yet. Reconnect and try again."
|
|
assert result["contentType"] == "text/plain; charset=utf-8"
|
|
assert result["state"]["fetchAborted"] is True
|
|
|
|
|
|
def test_navigation_deadline_wins_when_fetch_ignores_abort_and_prevents_late_cache_write():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.lateFetch = true;
|
|
state.cachedBody = 'cached dashboard';
|
|
const response = await Promise.race([
|
|
dispatch('fetch', {
|
|
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
|
}),
|
|
new Promise(resolve => setTimeout(() => resolve(null), 35)),
|
|
]);
|
|
const body = response ? await response.text() : null;
|
|
await new Promise(resolve => setTimeout(resolve, 60));
|
|
process.stdout.write(JSON.stringify({timedOut: response === null, body, state}));
|
|
"""
|
|
)
|
|
|
|
assert result["timedOut"] is False
|
|
assert result["body"] == "cached dashboard"
|
|
assert result["state"]["fetchAborted"] is True
|
|
assert result["state"]["puts"] == []
|
|
|
|
|
|
def test_redirected_login_navigation_does_not_replace_cached_dashboard_shell():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.fetchRedirected = true;
|
|
const response = await dispatch('fetch', {
|
|
method: 'GET', mode: 'navigate',
|
|
url: 'https://forge.example/dashboard/?title=Shared',
|
|
});
|
|
process.stdout.write(JSON.stringify({ status: response.status, state }));
|
|
"""
|
|
)
|
|
|
|
assert result["status"] == 200
|
|
assert result["state"]["puts"] == []
|
|
|
|
|
|
def test_cross_origin_navigation_is_not_intercepted_or_cached():
|
|
result = run_worker_scenario(
|
|
"""
|
|
const response = await dispatch('fetch', {
|
|
method: 'GET', mode: 'navigate', url: 'https://outside.example/page',
|
|
});
|
|
process.stdout.write(JSON.stringify({ intercepted: response !== null, state }));
|
|
"""
|
|
)
|
|
|
|
assert result["intercepted"] is False
|
|
assert result["state"]["fetches"] == []
|
|
assert result["state"]["puts"] == []
|
|
|
|
|
|
@pytest.mark.parametrize("status", [500, 502, 503, 504])
|
|
def test_server_outage_navigation_returns_working_cached_shell_without_replacing_it(status):
|
|
result = run_worker_scenario(
|
|
f"""
|
|
state.fetchStatus = {status};
|
|
state.cachedBody = 'cached dashboard';
|
|
const response = await dispatch('fetch', {{
|
|
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
|
}});
|
|
process.stdout.write(JSON.stringify({{ status: response.status, body: await response.text(), state }}));
|
|
"""
|
|
)
|
|
|
|
assert result["status"] == 200
|
|
assert result["body"] == "cached dashboard"
|
|
assert result["state"]["puts"] == []
|
|
|
|
|
|
def test_server_outage_without_cached_shell_preserves_network_error():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.fetchStatus = 503;
|
|
const response = await dispatch('fetch', {
|
|
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
|
});
|
|
process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
|
|
"""
|
|
)
|
|
|
|
assert result["status"] == 503
|
|
assert result["body"] == "network"
|
|
assert result["state"]["puts"] == []
|
|
|
|
|
|
def test_client_error_navigation_is_not_hidden_by_cached_shell():
|
|
result = run_worker_scenario(
|
|
"""
|
|
state.fetchStatus = 401;
|
|
state.cachedBody = 'cached dashboard';
|
|
const response = await dispatch('fetch', {
|
|
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
|
});
|
|
process.stdout.write(JSON.stringify({ status: response.status, body: await response.text(), state }));
|
|
"""
|
|
)
|
|
|
|
assert result["status"] == 401
|
|
assert result["body"] == "network"
|
|
assert result["state"]["puts"] == []
|