HERMES-GIELIE/tests/test_pwa_notification_controls.py
nesquena-hermes d473f6c5a6 fix(notifications): notify on completion if tab was hidden during the stream (#4416)
A background tab's SSE is throttled by Chromium, so the stream 'done' event
arrives late (after the user returns, document.hidden=false) and the
response-complete notification was suppressed by the live visibility check.
Track per-stream whether the tab was hidden at ANY point during the stream
(_STREAM_WAS_HIDDEN, set at attach, OR'd by one idempotent visibilitychange
listener, read+cleared at done) and pass forceHidden accordingly.
sendBrowserNotification bypasses ONLY the live visibility gate on forceHidden,
still honoring the user's notifications-enabled setting — so a watched stream
stays silent (matches Slack/Discord/Gmail/Claude) but a backgrounded one
notifies. Correct fix for the #4416 root cause; supersedes #4421's always-fire
approach (which would also fire when the tab was never hidden).

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-06-19 23:48:42 +00:00

117 lines
6 KiB
Python

"""Regression coverage for PWA-backed browser notifications (#3196)."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
SW_JS = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
CHANGELOG = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
def test_browser_notifications_use_service_worker_when_available():
assert "function _showPwaNotification" in MESSAGES_JS
assert "navigator.serviceWorker.ready" in MESSAGES_JS
assert "reg.showNotification" in MESSAGES_JS
assert "new Notification" in MESSAGES_JS
assert "function sendBrowserNotification" in MESSAGES_JS
def test_notification_payload_uses_completion_session_when_provided():
assert "function _notificationOptions" in MESSAGES_JS
assert "const sid=(options&&options.sid)||(S&&S.session&&S.session.session_id);" in MESSAGES_JS
assert "_sessionUrlForSid(sid)" in MESSAGES_JS
assert "data:{url}" in MESSAGES_JS
assert "tag:sid?`hermes-${sid}`" in MESSAGES_JS
assert "sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished',{forceHidden:_wasEverHidden,sid:activeSid})" in MESSAGES_JS
assert "sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid})" in MESSAGES_JS
assert "sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid})" in MESSAGES_JS
def test_completion_notification_fires_when_tab_was_hidden_during_stream():
"""#4416: a throttled background-tab SSE delivers `done` late (after the user
returns, document.hidden=false), which silently dropped the completion
notification. The done handler now passes forceHidden based on whether the
tab was hidden at ANY point during the stream, and sendBrowserNotification
bypasses ONLY the live visibility gate (not the user's enabled setting) on
forceHidden — so a backgrounded stream notifies, a watched one stays silent."""
# The per-stream hidden tracker exists and is wired at attach + done.
assert "_STREAM_WAS_HIDDEN" in MESSAGES_JS
assert "function _bindStreamHiddenTracker" in MESSAGES_JS
assert "const _wasEverHidden=!!_STREAM_WAS_HIDDEN[activeSid];" in MESSAGES_JS
assert "delete _STREAM_WAS_HIDDEN[activeSid];" in MESSAGES_JS
# sendBrowserNotification honors forceHidden but still respects the
# notifications-enabled setting (forceHidden is NOT the test-button force).
assert "const forceHidden=!!(options&&options.forceHidden);" in MESSAGES_JS
assert "if(!force&&!window._notificationsEnabled) return;" in MESSAGES_JS
assert "if(!force&&!forceHidden&&!document.hidden) return;" in MESSAGES_JS
def test_service_worker_handles_notification_clicks_without_hijacking_other_sessions():
assert "notificationclick" in SW_JS
assert "event.notification.close()" in SW_JS
assert "clients.matchAll" in SW_JS
assert "clients.openWindow" in SW_JS
# Match the open tab on pathname, not the full href (query/hash differ).
assert "samePath(client.url)" in SW_JS
assert "new URL(clientUrl).pathname === targetPath" in SW_JS
assert "targetClient.focus()" in SW_JS
exact_idx = SW_JS.index("targetClient.focus()")
open_idx = SW_JS.index("self.clients.openWindow(targetUrl)")
navigate_idx = SW_JS.index("focusableClient.navigate(targetUrl)")
assert exact_idx < open_idx < navigate_idx
def test_settings_expose_permission_and_test_controls():
assert "notificationPermissionStatus" in INDEX_HTML
assert 'id="notificationPermissionButtonWrap"' in INDEX_HTML
assert 'id="notificationPermissionButton"' in INDEX_HTML
assert "requestNotificationPermission()" in INDEX_HTML
assert "sendBrowserNotification('Hermes test'" in INDEX_HTML
assert "{force:true}" in INDEX_HTML
assert "function updateNotificationPermissionStatus" in PANELS_JS
assert "const btn=$('notificationPermissionButton');" in PANELS_JS
assert "const btnWrap=$('notificationPermissionButtonWrap');" in PANELS_JS
assert "btn.disabled=granted;" in PANELS_JS
assert "btn.title=granted?'':label;" in PANELS_JS
assert "if(btnWrap) btnWrap.title=label;" in PANELS_JS
assert "notifications_permission_status" in PANELS_JS
assert "btn.setAttribute('aria-label', label);" in PANELS_JS
assert "btn.setAttribute('aria-disabled', granted?'true':'false');" in PANELS_JS
assert "btn.setAttribute('aria-disabled','true');" in PANELS_JS
def test_granted_permission_branch_is_not_silent():
fn = MESSAGES_JS[
MESSAGES_JS.index("function requestNotificationPermission(){") :
MESSAGES_JS.index("function sendBrowserNotification(", MESSAGES_JS.index("function requestNotificationPermission(){"))
]
assert "if(Notification.permission==='granted'){" in fn
granted_branch = fn[
fn.index("if(Notification.permission==='granted'){") :
fn.index("if(Notification.permission==='denied'){")
]
assert "updateNotificationPermissionStatus()" in granted_branch
assert "showToast(t('notifications_enabled_toast'),3000)" in granted_branch
assert "return Promise.resolve('granted');" in granted_branch
def test_notification_i18n_and_changelog_entries_exist():
for key in [
"notifications_enable_btn",
"notifications_test_btn",
"notifications_permission_status",
"notifications_enabled_toast",
"notifications_denied",
"notifications_unsupported",
]:
assert key in I18N_JS
assert "PWA notifications now use the service worker" in CHANGELOG
assert "#3196" in CHANGELOG
entry = next(
line for line in CHANGELOG.splitlines()
if "Notification permission controls now reflect the real browser state" in line
)
assert entry.count("#4118") == 1