309 lines
10 KiB
Python
309 lines
10 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, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
|
const cache = {{
|
|
addAll: async urls => {{ state.added = urls; }},
|
|
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
|
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
|
|
}};
|
|
const context = {{
|
|
URL, Request, Response,
|
|
console,
|
|
self: {{
|
|
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
|
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; return state.flushResult; }},
|
|
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 => {{
|
|
state.fetches.push(String(request.url || request));
|
|
if (state.failFetch) throw new Error('offline');
|
|
return new Response('network', {{ status: state.fetchStatus }});
|
|
}},
|
|
}};
|
|
vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), 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) {{
|
|
let pending;
|
|
listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }});
|
|
if (pending) await pending;
|
|
}}
|
|
async function dispatchNotificationClick(route) {{
|
|
let pending;
|
|
listeners.notificationclick({{
|
|
notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}},
|
|
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_today_queue_ships_in_a_new_shell_cache():
|
|
source = WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v20" in source
|
|
assert "BASE + 'static/today-work.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_authenticated_page_message_resumes_queued_background_delivery():
|
|
result = run_worker_scenario(
|
|
"""
|
|
await dispatchMessage({type:'stackchain-resume-outbox'});
|
|
process.stdout.write(JSON.stringify(state));
|
|
"""
|
|
)
|
|
|
|
assert result["backgroundFlushes"] == 1
|
|
|
|
|
|
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
|
|
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'},
|
|
]};
|
|
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"},
|
|
},
|
|
},
|
|
]
|
|
assert result["focused"] == [
|
|
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
|
|
]
|
|
assert result["opened"] == []
|
|
assert result["notificationClosed"] is True
|
|
|
|
|
|
def test_background_mutations_obtain_session_bound_csrf_proof():
|
|
source = WORKER.read_text()
|
|
|
|
assert "async function sessionCsrf()" in source
|
|
assert "BASE + 'api/v1/session'" in source
|
|
assert "headers.set('X-CSRF-Token', csrf)" 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/icons/stackchain-192.png",
|
|
"/dashboard/static/icons/stackchain-512.png",
|
|
"/dashboard/static/session.js",
|
|
"/dashboard/static/markdown.js",
|
|
"/dashboard/static/commands.js",
|
|
"/dashboard/static/search-preview.js",
|
|
"/dashboard/static/widgets.js",
|
|
"/dashboard/static/drafts.js",
|
|
"/dashboard/static/outbox-coordinator.js",
|
|
"/dashboard/static/issue-outbox.js",
|
|
"/dashboard/static/authored-outbox.js",
|
|
"/dashboard/static/offline-work.js",
|
|
"/dashboard/static/my-work.js",
|
|
"/dashboard/static/today-work.js",
|
|
"/dashboard/static/later-work.js",
|
|
"/dashboard/static/detail-defer.js",
|
|
"/dashboard/static/pick-work.js",
|
|
"/dashboard/static/conversation.js",
|
|
"/dashboard/static/issue-sheet.js",
|
|
"/dashboard/static/create-issue-sheet.js",
|
|
"/dashboard/static/pull-sheet.js",
|
|
"/dashboard/static/review-sheet.js",
|
|
"/dashboard/static/work-route.js",
|
|
"/dashboard/static/context-poller.js",
|
|
"/dashboard/static/mobile-task-dock.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_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"] == []
|