import json import subprocess from pathlib import Path MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js" INDEX = MODULE.parent / "index.html" DASHBOARD = MODULE.parent / "dashboard.js" def run_scenario(script: str) -> dict: harness = r""" const createPushNotifications = require(__MODULE__); const state = { prompts:0, requests:[], subscriptions:[], text:'' }; const control = { checked:false, disabled:false, addEventListener:(_name, callback) => state.change = callback, }; const deadlineControl = { checked:false, disabled:false, addEventListener:(_name, callback) => state.deadlineChange = callback, }; const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback}; const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback}; const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }}; const startDayControl = { checked:false, disabled:false, addEventListener:(_name, callback) => state.startDayChange = callback, }; const startDayHour = {value:'9', disabled:false}; const startDayStatus = {set textContent(value) { state.startDayText = value; }, get textContent() { return state.startDayText; }}; const followingControl = { checked:false, disabled:false, addEventListener:(_name, callback) => state.followingChange = callback, }; const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }}; const quietControl = {checked:false, disabled:false, addEventListener:(_name, callback) => state.quietChange = callback}; const quietStart = {value:'22:00', disabled:false, addEventListener:(_name, callback) => state.quietStartChange = callback}; const quietEnd = {value:'07:00', disabled:false, addEventListener:(_name, callback) => state.quietEndChange = callback}; const quietStatus = {set textContent(value) { state.quietText = value; }, get textContent() { return state.quietText; }}; const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }}; const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback}; const deadlineSnooze = {hidden:true}; const deadlineSnoozeStatus = {set textContent(value) { state.snoozeText = value; }, get textContent() { return state.snoozeText; }}; const deadlineSnoozeReview = {disabled:false, addEventListener:(_name, callback) => state.reviewSnooze = callback}; const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }}; const registration = {pushManager:{ getSubscription: async () => state.current || null, subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; }, }}; const feature = createPushNotifications({ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, startDayControl, startDayStatus, startDayHour, followingControl, followingStatus, quietControl, quietStart, quietEnd, quietStatus, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines:() => { state.reviewed = true; }, notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }}, serviceWorker: {ready:Promise.resolve(registration)}, fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); if (state.failResume && options.method === 'DELETE' && url.endsWith('/deadlines/snooze')) throw new Error('offline'); return state.server || {available:true,subscribed:false,public_key:'AQID'}; }, }); (async () => { __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); }); """.replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script) completed = subprocess.run( ["node", "-e", harness], capture_output=True, check=True, text=True ) return json.loads(completed.stdout) def test_initialization_reports_availability_without_prompting_for_permission(): result = run_scenario("await feature.init(); process.stdout.write(JSON.stringify(state));") assert result["prompts"] == 0 assert result["requests"] == [["api/v1/push-subscription", "GET", ""]] assert result["text"] == "New update notifications are off for this device." def test_user_gesture_subscribes_device_and_reports_enabled_state(): result = run_scenario(""" await feature.init(); control.checked = true; await state.change(); process.stdout.write(JSON.stringify(state)); """) assert result["prompts"] == 1 assert result["subscriptions"][0]["userVisibleOnly"] is True assert result["requests"][-1][0:2] == ["api/v1/push-subscription", "PUT"] assert json.loads(result["requests"][-1][2])["endpoint"] == "https://push.example/device" assert result["text"] == "New update notifications enabled for this device." def test_following_alert_toggle_is_opt_in_and_does_not_change_other_channels(): result = run_scenario(""" state.server = {available:true,subscribed:true,following_enabled:false,deadline_enabled:true,start_day_enabled:true,public_key:'AQID'}; state.current = existing; await feature.init(); followingControl.checked = true; await state.followingChange(); process.stdout.write(JSON.stringify({requests:state.requests, following:followingControl.checked, deadline:deadlineControl.checked, startDay:startDayControl.checked, text:state.followingText})); """) assert result["following"] is True assert result["deadline"] is True assert result["startDay"] is True assert result["requests"][-1][0:2] == ["api/v1/push-subscription/following", "PUT"] assert json.loads(result["requests"][-1][2]) == {"enabled": True} assert result["text"] == "Following change alerts enabled for this device." def test_device_settings_render_and_wire_the_following_alert_preference(): index = INDEX.read_text() dashboard = DASHBOARD.read_text() assert 'for="push-following"' in index assert 'id="push-following" type="checkbox"' in index assert 'id="push-following-status" role="status" aria-live="polite"' in index assert "followingControl:qs('#push-following')" in dashboard assert "followingStatus:qs('#push-following-status')" in dashboard def test_quiet_hours_are_restored_and_saved_as_one_local_schedule(): result = run_scenario(""" state.server = {available:true,subscribed:true,quiet_hours_enabled:true,quiet_hours_start:'21:30',quiet_hours_end:'06:45',quiet_hours_timezone:'America/New_York',public_key:'AQID'}; state.current = existing; await feature.init(); quietStart.value = '22:15'; quietEnd.value = '07:30'; await state.quietStartChange(); process.stdout.write(JSON.stringify({requests:state.requests, checked:quietControl.checked, start:quietStart.value, end:quietEnd.value, text:state.quietText})); """) assert result["checked"] is True assert result["requests"][-1][0:2] == ["api/v1/push-subscription/quiet-hours", "PUT"] body = json.loads(result["requests"][-1][2]) assert body["enabled"] is True assert body["start"] == "22:15" assert body["end"] == "07:30" assert isinstance(body["timezone"], str) and body["timezone"] assert result["text"] == "Routine alerts paused from 22:15 to 07:30 local time." def test_device_settings_render_and_wire_mobile_quiet_hours(): index = INDEX.read_text() dashboard = DASHBOARD.read_text() assert 'id="push-quiet-hours" type="checkbox"' in index assert 'id="push-quiet-start" type="time"' in index assert 'id="push-quiet-end" type="time"' in index assert 'id="push-quiet-status" role="status" aria-live="polite"' in index module = MODULE.read_text() assert "querySelector('#push-quiet-hours')" in module assert "querySelector('#push-quiet-start')" in module assert "querySelector('#push-quiet-end')" in module def test_degraded_device_can_run_a_test_notification_and_show_recovery(): result = run_scenario(""" state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}}; state.current = existing; await feature.init(); const degraded = {text:state.text,hidden:testControl.hidden}; state.server = {delivery_state:'healthy',delivered:true}; await state.testDelivery(); process.stdout.write(JSON.stringify({degraded,text:state.text,hidden:testControl.hidden,disabled:testControl.disabled,requests:state.requests})); """) assert result["degraded"] == { "text": "Update notifications need attention after 3 failed deliveries. Send a test notification.", "hidden": False, } assert result["requests"][-1][0:2] == ["api/v1/push-subscription/test", "POST"] assert result["text"] == "Test delivered. Update notifications are working on this device." assert result["hidden"] is False assert result["disabled"] is False def test_deadline_opt_in_reuses_subscription_and_sends_local_timezone_without_second_prompt(): result = run_scenario(""" state.current = existing; state.server = {available:true,subscribed:true,deadline_enabled:false,public_key:'AQID'}; await feature.init(); deadlineControl.checked = true; await state.deadlineChange(); process.stdout.write(JSON.stringify(state)); """) assert result["prompts"] == 0 assert result["subscriptions"] == [] assert result["requests"][-1][0:2] == ["api/v1/push-subscription/deadlines", "PUT"] body = json.loads(result["requests"][-1][2]) assert body["enabled"] is True assert body["reminder_hour"] == 9 assert isinstance(body["timezone"], str) and body["timezone"] assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 2 days." def test_start_day_opt_in_reuses_subscription_and_persists_local_hour(): result = run_scenario(""" state.current = existing; state.server = {available:true,subscribed:true,start_day_enabled:false,start_day_reminder_hour:9,public_key:'AQID'}; await feature.init(); startDayHour.value = '8'; startDayControl.checked = true; await state.startDayChange(); process.stdout.write(JSON.stringify(state)); """) assert result["prompts"] == 0 assert result["requests"][-1][0:2] == ["api/v1/push-subscription/start-day", "PUT"] body = json.loads(result["requests"][-1][2]) assert body["enabled"] is True assert body["reminder_hour"] == 8 assert isinstance(body["timezone"], str) and body["timezone"] assert result["startDayText"] == "Start-day reminder enabled for 08:00 local time." def test_deadline_setup_subscribes_once_and_persists_selected_local_hour(): result = run_scenario(""" state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'}; await feature.init(); deadlineHour.value = '8'; await feature.enableDeadline(); process.stdout.write(JSON.stringify(state)); """) assert result["prompts"] == 1 assert len(result["subscriptions"]) == 1 assert [request[0] for request in result["requests"][-2:]] == [ "api/v1/push-subscription", "api/v1/push-subscription/deadlines", ] body = json.loads(result["requests"][-1][2]) assert body["enabled"] is True assert body["reminder_hour"] == 8 assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time, next 2 days." def test_deadline_setup_persists_and_restores_confirmed_horizon(): saved = run_scenario(""" state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,public_key:'AQID'}; state.current = existing; await feature.init(); deadlineDays.value = '7'; await feature.changeDeadline(); process.stdout.write(JSON.stringify(state)); """) assert json.loads(saved["requests"][-1][2])["reminder_days"] == 7 assert saved["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 7 days." restored = run_scenario(""" state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,reminder_days:0,public_key:'AQID'}; state.current = existing; await feature.init(); process.stdout.write(JSON.stringify({days:deadlineDays.value, readiness:feature.deadlineReadiness()})); """) assert restored == { "days": "0", "readiness": { "state": "complete", "detail": "Deadline reminders enabled for 17:00 local time, due today.", }, } def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete(): restored = run_scenario(""" state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,public_key:'AQID'}; state.current = existing; await feature.init(); process.stdout.write(JSON.stringify({hour:deadlineHour.value, checked:deadlineControl.checked, readiness:feature.deadlineReadiness()})); """) assert restored == { "hour": "17", "checked": True, "readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time, next 2 days."}, } denied = run_scenario(""" state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'}; state.permission = 'denied'; await feature.init(); deadlineHour.value = '8'; await feature.enableDeadline(); process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:feature.deadlineReadiness(), requests:state.requests})); """) assert denied["checked"] is False assert denied["readiness"]["state"] == "blocked" assert denied["readiness"]["actionLabel"] == "Check again" assert "blocked" in denied["readiness"]["detail"].lower() assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"] def test_blocked_update_setup_can_recheck_without_reprompting_or_mutating(): result = run_scenario(""" state.permission = 'denied'; await feature.init(); control.checked = true; await state.change(); const blocked = feature.notificationReadiness(); await feature.recoverPermission(); process.stdout.write(JSON.stringify({blocked,after:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests})); """) assert result["blocked"] == { "state": "blocked", "detail": "Notifications are blocked. Allow them in browser settings, then check again.", "actionLabel": "Check again", } assert result["after"] == result["blocked"] assert result["prompts"] == 1 assert result["subscriptions"] == 0 assert result["requests"] == [["api/v1/push-subscription", "GET", ""]] def test_previously_blocked_permission_starts_a_resumable_update_intent(): result = run_scenario(""" state.permission = 'denied'; state.notification.permission = 'denied'; await feature.init(); await feature.recoverPermission('updates'); state.notification.permission = 'granted'; const readyToRecover = feature.notificationReadiness(); await feature.recoverPermission('updates'); process.stdout.write(JSON.stringify({readyToRecover,readiness:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests})); """) assert result["readyToRecover"]["actionLabel"] == "Check again" assert result["readiness"]["state"] == "complete" assert result["prompts"] == 0 assert result["subscriptions"] == 1 assert [request[0:2] for request in result["requests"]] == [ ["api/v1/push-subscription", "GET"], ["api/v1/push-subscription", "PUT"], ] def test_permission_recovery_resumes_pending_update_subscription_once(): result = run_scenario(""" state.permission = 'denied'; await feature.init(); control.checked = true; await state.change(); state.notification.permission = 'granted'; const readyToRecover = feature.notificationReadiness(); await feature.recoverPermission(); await feature.recoverPermission(); process.stdout.write(JSON.stringify({readyToRecover,readiness:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests})); """) assert result["readyToRecover"] == { "state": "blocked", "detail": "Notification permission changed. Check again to finish setup.", "actionLabel": "Check again", } assert result["readiness"]["state"] == "complete" assert result["prompts"] == 1 assert result["subscriptions"] == 1 assert [request[0:2] for request in result["requests"]] == [ ["api/v1/push-subscription", "GET"], ["api/v1/push-subscription", "PUT"], ] def test_permission_recovery_resumes_pending_deadline_choices_once(): result = run_scenario(""" state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,reminder_days:2,public_key:'AQID'}; state.permission = 'denied'; await feature.init(); deadlineHour.value = '17'; deadlineDays.value = '7'; await feature.enableDeadline(); const blocked = feature.deadlineReadiness(); state.notification.permission = 'granted'; const readyToRecover = feature.deadlineReadiness(); await feature.recoverPermission(); await feature.recoverPermission(); process.stdout.write(JSON.stringify({blocked,readyToRecover,readiness:feature.deadlineReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests})); """) assert result["blocked"]["state"] == "blocked" assert result["blocked"]["actionLabel"] == "Check again" assert result["readyToRecover"] == { "state": "blocked", "detail": "Notification permission changed. Check again to finish setup.", "actionLabel": "Check again", } assert result["readiness"] == { "state": "complete", "detail": "Deadline reminders enabled for 17:00 local time, next 7 days.", } assert result["prompts"] == 1 assert result["subscriptions"] == 1 assert [request[0:2] for request in result["requests"]] == [ ["api/v1/push-subscription", "GET"], ["api/v1/push-subscription", "PUT"], ["api/v1/push-subscription/deadlines", "PUT"], ] assert json.loads(result["requests"][-1][2])["reminder_hour"] == 17 assert json.loads(result["requests"][-1][2])["reminder_days"] == 7 def test_active_deadline_snooze_is_visible_in_agenda_with_a_local_resume_time(): result = run_scenario(""" state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,snoozed_until:1765003600,public_key:'AQID'}; state.current = existing; await feature.init(); process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,text:state.snoozeText})); """) assert result["hidden"] is False assert result["text"].startswith("Deadline reminders snoozed until ") def test_review_now_resumes_this_device_and_opens_the_deadline_review_flow(): result = run_scenario(""" state.server = {available:true,subscribed:true,deadline_enabled:true,snoozed_until:1765003600,public_key:'AQID'}; state.current = existing; await feature.init(); await state.reviewSnooze(); process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,disabled:deadlineSnoozeReview.disabled,reviewed:state.reviewed,requests:state.requests})); """) assert result["requests"][-1][0:2] == [ "api/v1/push-subscription/deadlines/snooze", "DELETE", ] assert result["hidden"] is True assert result["disabled"] is False assert result["reviewed"] is True def test_failed_resume_keeps_the_snooze_visible_and_retryable(): result = run_scenario(""" state.server = {available:true,subscribed:true,deadline_enabled:true,snoozed_until:1765003600,public_key:'AQID'}; state.current = existing; await feature.init(); state.failResume = true; await state.reviewSnooze(); process.stdout.write(JSON.stringify({hidden:deadlineSnooze.hidden,disabled:deadlineSnoozeReview.disabled,text:state.snoozeText,reviewed:state.reviewed})); """) assert result == { "hidden": False, "disabled": False, "text": "Could not resume deadline reminders. Check your connection and try again.", } def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller(): root = MODULE.parents[1] html = (root / "frontend" / "index.html").read_text() dashboard = (root / "frontend" / "dashboard.js").read_text() worker = (root / "frontend" / "service-worker.js").read_text() css = (root / "frontend" / "dashboard.css").read_text() requirements = (root / "requirements.txt").read_text() readme = (root / "README.md").read_text() assert 'id="push-updates"' in html assert 'id="push-update-status"' in html assert 'id="push-test"' in html assert 'id="push-deadlines"' in html assert 'id="push-deadline-hour"' in html assert 'id="push-deadline-days"' in html assert 'id="device-setup-deadline-days"' in html assert 'id="push-deadline-status"' in html assert '' in html assert "createPushNotifications({" in dashboard assert "testControl:qs('#push-test')" in dashboard assert "BASE + 'static/push-notifications.js'" in worker assert ".push-update-control" in css and "min-height:44px" in css assert "pywebpush==" in requirements assert "STACKCHAIN_VAPID_PUBLIC_KEY" in readme assert "STACKCHAIN_VAPID_PRIVATE_KEY" in readme def test_mobile_agenda_mounts_a_touch_safe_snooze_recovery_flow(): root = MODULE.parents[1] html = (root / "frontend" / "index.html").read_text() dashboard = (root / "frontend" / "dashboard.js").read_text() css = (root / "frontend" / "dashboard.css").read_text() assert 'id="deadline-snooze"' in html assert 'id="deadline-snooze-status"' in html assert 'id="review-snoozed-deadlines"' in html assert "deadlineSnooze:qs('#deadline-snooze')" in dashboard assert "onReviewDeadlines:" in dashboard assert "window.location.hash = '#/my-work/agenda'" in dashboard assert "qs('#protect-today')" in dashboard assert ".deadline-snooze" in css assert ".deadline-snooze button { min-height:44px;" in css assert "overflow-wrap:anywhere" in css