mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 05:12:33 +00:00
fix(notifications): request OS permission at startup and fix socketService listener leak (#998)
This commit is contained in:
Generated
+27
-1
@@ -4,11 +4,12 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "OpenHuman"
|
||||
version = "0.53.3"
|
||||
version = "0.53.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
"cef",
|
||||
"chrono",
|
||||
"directories",
|
||||
@@ -21,6 +22,8 @@ dependencies = [
|
||||
"notify-rust",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"objc2-user-notifications",
|
||||
"parking_lot",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
@@ -2825,6 +2828,16 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
@@ -2946,6 +2959,19 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-user-notifications"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-location",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-web-kit"
|
||||
version = "0.3.2"
|
||||
|
||||
@@ -113,6 +113,9 @@ objc2-app-kit = "0.3.2"
|
||||
mac-notification-sys = "0.6"
|
||||
# iMessage scanner reads ~/Library/Messages/chat.db read-only on macOS.
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
objc2-user-notifications = "0.3.2"
|
||||
block2 = "0.6.2"
|
||||
objc2-foundation = "0.3.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
notify-rust = { version = "4", default-features = false, features = ["dbus"] }
|
||||
|
||||
@@ -637,8 +637,13 @@ fn show_native_notification(
|
||||
tag: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
let permission_state = app
|
||||
.notification()
|
||||
.permission_state()
|
||||
.map(|s| format!("{s:?}"))
|
||||
.unwrap_or_else(|e| format!("err({e})"));
|
||||
log::debug!(
|
||||
"[notify] show_native_notification title_chars={} body_chars={} tag={:?}",
|
||||
"[notify] show_native_notification title_chars={} body_chars={} tag={:?} permission={permission_state}",
|
||||
title.len(),
|
||||
body.len(),
|
||||
tag
|
||||
@@ -647,11 +652,102 @@ fn show_native_notification(
|
||||
if !body.is_empty() {
|
||||
builder = builder.body(&body);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
builder = builder.sound("default");
|
||||
}
|
||||
builder
|
||||
.show()
|
||||
.map_err(|e| format!("notification show failed: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_notification_permission_state_inner() -> Result<String, String> {
|
||||
use std::ptr::NonNull;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2_user_notifications::{
|
||||
UNAuthorizationStatus, UNNotificationSettings, UNUserNotificationCenter,
|
||||
};
|
||||
|
||||
let center = UNUserNotificationCenter::currentNotificationCenter();
|
||||
let (tx, rx) = mpsc::channel::<String>();
|
||||
let completion = RcBlock::new(move |settings: NonNull<UNNotificationSettings>| {
|
||||
let status = unsafe { settings.as_ref().authorizationStatus() };
|
||||
let state = if status == UNAuthorizationStatus::Authorized {
|
||||
"granted"
|
||||
} else if status == UNAuthorizationStatus::Denied {
|
||||
"denied"
|
||||
} else if status == UNAuthorizationStatus::NotDetermined {
|
||||
"not_determined"
|
||||
} else if status == UNAuthorizationStatus::Provisional {
|
||||
"provisional"
|
||||
} else if status == UNAuthorizationStatus::Ephemeral {
|
||||
"ephemeral"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
let _ = tx.send(state.to_string());
|
||||
});
|
||||
center.getNotificationSettingsWithCompletionHandler(&completion);
|
||||
rx.recv_timeout(std::time::Duration::from_secs(2))
|
||||
.map_err(|_| "timed out waiting for macOS notification settings".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_notification_permission_request_inner() -> Result<String, String> {
|
||||
use block2::RcBlock;
|
||||
use objc2::runtime::Bool;
|
||||
use objc2_foundation::NSError;
|
||||
use objc2_user_notifications::{UNAuthorizationOptions, UNUserNotificationCenter};
|
||||
use std::sync::mpsc;
|
||||
|
||||
let center = UNUserNotificationCenter::currentNotificationCenter();
|
||||
let (tx, rx) = mpsc::channel::<bool>();
|
||||
let options = UNAuthorizationOptions::Alert
|
||||
| UNAuthorizationOptions::Badge
|
||||
| UNAuthorizationOptions::Sound;
|
||||
let completion = RcBlock::new(move |granted: Bool, _error: *mut NSError| {
|
||||
let _ = tx.send(granted.as_bool());
|
||||
});
|
||||
center.requestAuthorizationWithOptions_completionHandler(options, &completion);
|
||||
let granted = rx
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(|_| "timed out waiting for macOS permission prompt result".to_string())?;
|
||||
if granted {
|
||||
Ok("granted".to_string())
|
||||
} else {
|
||||
// If the user denies or notifications are disabled for the app,
|
||||
// macOS reports `false` here.
|
||||
Ok("denied".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn notification_permission_state() -> Result<String, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return macos_notification_permission_state_inner();
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
Ok("granted".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn notification_permission_request() -> Result<String, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return macos_notification_permission_request_inner();
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
Ok("granted".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main_window(app: &AppHandle<AppRuntime>) -> Result<(), String> {
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
@@ -1383,6 +1479,8 @@ pub fn run() {
|
||||
gmail::gmail_trash,
|
||||
gmail::gmail_add_label,
|
||||
gmail::gmail_find_linkedin_profile_url,
|
||||
notification_permission_state,
|
||||
notification_permission_request,
|
||||
activate_main_window,
|
||||
show_native_notification
|
||||
])
|
||||
|
||||
+1
-1
Submodule app/src-tauri/vendor/tauri-plugin-notification updated: 7db785afbe...36c4004f3d
@@ -10,10 +10,14 @@
|
||||
*
|
||||
* Mounted once at AppShell root.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
|
||||
import { showNativeNotification } from '../lib/nativeNotifications/tauriBridge';
|
||||
import {
|
||||
ensureNotificationPermission,
|
||||
showNativeNotification,
|
||||
} from '../lib/nativeNotifications/tauriBridge';
|
||||
import { purgeWebviewAccount } from '../services/webviewAccountService';
|
||||
import { addAccount, removeAccount } from '../store/accountsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
@@ -181,21 +185,53 @@ function renderBody(path: string, close: () => void) {
|
||||
// ── Notifications ────────────────────────────────────────────────────────
|
||||
|
||||
const NotificationsBody = ({ close }: { close: () => void }) => {
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'queued' | 'sent' | 'error'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const TEST_NOTIFICATION_DELAY_MS = 3500;
|
||||
const notificationTimeoutRef = useRef<number | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
if (notificationTimeoutRef.current !== null) {
|
||||
window.clearTimeout(notificationTimeoutRef.current);
|
||||
notificationTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAllow = async () => {
|
||||
setStatus('sending');
|
||||
setError(null);
|
||||
try {
|
||||
// First send triggers the OS permission prompt on macOS / Windows.
|
||||
// Once granted the notification appears and subsequent calls
|
||||
// succeed silently.
|
||||
const granted = await ensureNotificationPermission();
|
||||
if (coreIsTauri() && !granted) {
|
||||
setStatus('error');
|
||||
setError(
|
||||
'Notification permission was denied. Please enable notifications for OpenHuman in System Settings → Notifications.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Delay the test ping so users can move focus away from OpenHuman.
|
||||
// macOS commonly suppresses banner popouts while the app is foreground.
|
||||
setStatus('queued');
|
||||
await new Promise<void>(resolve => {
|
||||
notificationTimeoutRef.current = window.setTimeout(() => {
|
||||
notificationTimeoutRef.current = null;
|
||||
resolve();
|
||||
}, TEST_NOTIFICATION_DELAY_MS);
|
||||
});
|
||||
if (cancelledRef.current) {
|
||||
return;
|
||||
}
|
||||
await showNativeNotification({
|
||||
title: 'OpenHuman is good to go',
|
||||
body: 'You will get pings here when something needs your attention.',
|
||||
tag: 'welcome-notification-test',
|
||||
});
|
||||
if (cancelledRef.current) {
|
||||
return;
|
||||
}
|
||||
setStatus('sent');
|
||||
} catch (e) {
|
||||
setStatus('error');
|
||||
@@ -213,10 +249,19 @@ const NotificationsBody = ({ close }: { close: () => void }) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAllow()}
|
||||
disabled={status === 'sending'}
|
||||
disabled={status === 'sending' || status === 'queued'}
|
||||
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60">
|
||||
{status === 'sending' ? 'Asking your OS…' : 'Send test notification'}
|
||||
{status === 'sending'
|
||||
? 'Asking your OS…'
|
||||
: status === 'queued'
|
||||
? 'Sending test in 3.5s… switch apps now'
|
||||
: 'Send test notification'}
|
||||
</button>
|
||||
{status === 'queued' && (
|
||||
<p className="text-xs text-stone-600">
|
||||
Timer started. Switch to another app for a moment so the banner can pop out.
|
||||
</p>
|
||||
)}
|
||||
{status === 'sent' && (
|
||||
<p className="text-xs text-sage-700">
|
||||
Sent. If you saw a pop-up in the corner, you're all set. If your OS asked for permission,
|
||||
|
||||
@@ -50,12 +50,21 @@ function scoreBadgeClass(score: number): string {
|
||||
interface Props {
|
||||
notification: IntegrationNotification;
|
||||
onMarkRead: (id: string) => void;
|
||||
onNavigate?: (id: string) => void;
|
||||
onDismiss?: (id: string) => void;
|
||||
}
|
||||
|
||||
const NotificationCard = ({ notification: n, onMarkRead, onDismiss }: Props) => {
|
||||
const NotificationCard = ({ notification: n, onMarkRead, onNavigate, onDismiss }: Props) => {
|
||||
const isUnread = n.status === 'unread';
|
||||
|
||||
const handleBodyClick = () => {
|
||||
if (onNavigate) {
|
||||
onNavigate(n.id);
|
||||
} else if (isUnread) {
|
||||
onMarkRead(n.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full p-3 border-b border-stone-100 hover:bg-stone-50 transition-colors duration-150 ${
|
||||
@@ -70,9 +79,7 @@ const NotificationCard = ({ notification: n, onMarkRead, onDismiss }: Props) =>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isUnread) onMarkRead(n.id);
|
||||
}}
|
||||
onClick={handleBodyClick}
|
||||
className="flex-1 min-w-0 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 rounded-sm">
|
||||
{/* Header row: provider badge + timestamp */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { resolveIntegrationRoute } from '../../lib/notificationRouter';
|
||||
import {
|
||||
dismissNotification,
|
||||
fetchNotifications,
|
||||
markNotificationActed,
|
||||
markNotificationRead,
|
||||
} from '../../services/notificationService';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import {
|
||||
dismissIntegrationNotification,
|
||||
markIntegrationActed,
|
||||
markIntegrationRead,
|
||||
setIntegrationError,
|
||||
setIntegrationLoading,
|
||||
@@ -21,6 +25,7 @@ import NotificationCard from './NotificationCard';
|
||||
|
||||
const NotificationCenter = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
integrationItems: items,
|
||||
integrationLoading: loading,
|
||||
@@ -73,6 +78,20 @@ const NotificationCenter = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Navigate to the resolved route for the notification and mark it as acted. */
|
||||
const handleNavigate = async (id: string) => {
|
||||
const n = items.find(i => i.id === id);
|
||||
if (!n) return;
|
||||
const route = resolveIntegrationRoute(n);
|
||||
dispatch(markIntegrationActed(id));
|
||||
navigate(route);
|
||||
try {
|
||||
await markNotificationActed(id);
|
||||
} catch {
|
||||
// Optimistic update already applied; failure is non-critical.
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async (id: string) => {
|
||||
dispatch(dismissIntegrationNotification(id));
|
||||
try {
|
||||
@@ -191,6 +210,9 @@ const NotificationCenter = () => {
|
||||
onMarkRead={id => {
|
||||
void handleMarkRead(id);
|
||||
}}
|
||||
onNavigate={id => {
|
||||
void handleNavigate(id);
|
||||
}}
|
||||
onDismiss={id => {
|
||||
void handleDismiss(id);
|
||||
}}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { startNativeNotificationsService, stopNativeNotificationsService } from './service';
|
||||
export { showNativeNotification } from './tauriBridge';
|
||||
export { ensureNotificationPermission, showNativeNotification } from './tauriBridge';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type NotificationItem,
|
||||
notificationReceived,
|
||||
} from '../../store/notificationSlice';
|
||||
import { showNativeNotification } from './tauriBridge';
|
||||
import { ensureNotificationPermission, showNativeNotification } from './tauriBridge';
|
||||
|
||||
const log = debug('native-notifications');
|
||||
|
||||
@@ -52,18 +52,27 @@ function dispatchAndMaybeBanner(
|
||||
timestampOverride?: number
|
||||
): void {
|
||||
const prefs = store.getState().notifications.preferences;
|
||||
log(
|
||||
'[dispatch] category=%s id=%s enabled=%s focused=%s',
|
||||
category,
|
||||
item.id,
|
||||
prefs[category],
|
||||
windowIsFocused()
|
||||
);
|
||||
if (!prefs[category]) {
|
||||
log('category %s disabled, skipping', category);
|
||||
return;
|
||||
}
|
||||
const timestamp = timestampOverride && timestampOverride > 0 ? timestampOverride : Date.now();
|
||||
const full: NotificationItem = { ...item, category, timestamp, read: false };
|
||||
log('[dispatch] enqueue id=%s title=%s', full.id, full.title);
|
||||
store.dispatch(notificationReceived(full));
|
||||
// Only fire OS-level banner when the user isn't already looking at the
|
||||
// window — otherwise the in-app center is enough and a native toast is
|
||||
// redundant noise.
|
||||
if (!windowIsFocused()) {
|
||||
void showNativeNotification({ title: full.title, body: full.body, tag: full.id });
|
||||
log('[dispatch] window unfocused, firing native banner id=%s', full.id);
|
||||
void showNativeNotification({ title: full.title, body: full.body });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +91,15 @@ export function startNativeNotificationsService(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
// Request OS notification permission early so native banners can fire.
|
||||
// Fire-and-forget — permission state is logged for diagnostics.
|
||||
void ensureNotificationPermission().then(granted => {
|
||||
log('notification permission ensured: granted=%s', granted);
|
||||
});
|
||||
|
||||
chatDoneListener = (...args: unknown[]) => {
|
||||
const p = (args[0] ?? {}) as ChatDonePayload;
|
||||
log('[socket] chat_done');
|
||||
dispatchAndMaybeBanner('agents', {
|
||||
id: `chat_done:${p.thread_id ?? 'unknown'}:${p.request_id ?? Date.now()}`,
|
||||
title: 'Agent reply ready',
|
||||
@@ -94,6 +110,7 @@ export function startNativeNotificationsService(): void {
|
||||
|
||||
chatErrorListener = (...args: unknown[]) => {
|
||||
const p = (args[0] ?? {}) as ChatErrorPayload;
|
||||
log('[socket] chat_error');
|
||||
dispatchAndMaybeBanner('system', {
|
||||
id: `chat_error:${p.thread_id ?? 'unknown'}:${p.request_id ?? Date.now()}`,
|
||||
title: 'Agent error',
|
||||
@@ -107,7 +124,11 @@ export function startNativeNotificationsService(): void {
|
||||
// bus. See src/openhuman/notifications/bus.rs.
|
||||
coreNotificationListener = (...args: unknown[]) => {
|
||||
const p = (args[0] ?? {}) as CoreNotificationPayload;
|
||||
if (!p.id || !p.title) return;
|
||||
log('[socket] core_notification id=%s category=%s', p.id, p.category);
|
||||
if (!p.id || !p.title) {
|
||||
log('[socket] core_notification missing id/title dropped');
|
||||
return;
|
||||
}
|
||||
const serverTs = p.timestamp_ms && p.timestamp_ms > 0 ? p.timestamp_ms : Date.now();
|
||||
dispatchAndMaybeBanner(
|
||||
p.category,
|
||||
@@ -123,6 +144,7 @@ export function startNativeNotificationsService(): void {
|
||||
|
||||
disconnectListener = (...args: unknown[]) => {
|
||||
const reason = typeof args[0] === 'string' ? args[0] : 'unknown';
|
||||
log('[socket] disconnect reason=%s', reason);
|
||||
dispatchAndMaybeBanner('system', {
|
||||
id: `socket_disconnect:${Date.now()}`,
|
||||
title: 'Connection lost',
|
||||
|
||||
@@ -7,7 +7,38 @@ const errLog = debug('native-notifications:bridge:error');
|
||||
export interface ShowNativeNotificationArgs {
|
||||
title: string;
|
||||
body: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request OS notification permission if not already granted.
|
||||
* Returns true if permission is (or was just) granted, false otherwise.
|
||||
* No-op (returns false) when running outside Tauri.
|
||||
*/
|
||||
export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
if (!isTauri()) {
|
||||
log('not running in tauri, skipping permission request');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const grantedRaw = await invoke<boolean | null>('plugin:notification|is_permission_granted');
|
||||
const granted = grantedRaw === true;
|
||||
log('notification permission check (plugin): granted=%s raw=%o', granted, grantedRaw);
|
||||
if (granted) return true;
|
||||
|
||||
const requestResult = await invoke<string>('plugin:notification|request_permission');
|
||||
const requestState = String(requestResult ?? 'unknown').toLowerCase();
|
||||
const nowGranted = requestState === 'granted' || requestState === 'provisional';
|
||||
log('notification permission request result=%s granted=%s', requestState, nowGranted);
|
||||
if (nowGranted) return true;
|
||||
|
||||
// Re-check once after request because some platforms may not return
|
||||
// a definitive granted state from request_permission directly.
|
||||
const pluginGranted = await invoke<boolean | null>('plugin:notification|is_permission_granted');
|
||||
return pluginGranted === true;
|
||||
} catch (err) {
|
||||
errLog('ensureNotificationPermission failed: %O', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,12 +51,10 @@ export async function showNativeNotification(args: ShowNativeNotificationArgs):
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke('show_native_notification', {
|
||||
title: args.title,
|
||||
body: args.body,
|
||||
tag: args.tag ?? null,
|
||||
await invoke('plugin:notification|notify', {
|
||||
options: { title: args.title, body: args.body, sound: 'default' },
|
||||
});
|
||||
} catch (err) {
|
||||
errLog('show_native_notification failed: %O', err);
|
||||
errLog('plugin:notification|notify failed: %O', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { NotificationItem } from '../store/notificationSlice';
|
||||
import type { IntegrationNotification } from '../types/notifications';
|
||||
import { resolveIntegrationRoute, resolveSystemRoute } from './notificationRouter';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeIntegration = (
|
||||
overrides: Partial<IntegrationNotification> = {}
|
||||
): IntegrationNotification => ({
|
||||
id: 'i-1',
|
||||
provider: 'slack',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
raw_payload: {},
|
||||
status: 'unread',
|
||||
received_at: '2026-04-29T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSystem = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||
id: 's-1',
|
||||
category: 'messages',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
timestamp: 1,
|
||||
read: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// resolveIntegrationRoute
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveIntegrationRoute', () => {
|
||||
it('returns explicit deep_link when present', () => {
|
||||
const n = makeIntegration({ deep_link: '/chat?account=abc' });
|
||||
expect(resolveIntegrationRoute(n)).toBe('/chat?account=abc');
|
||||
});
|
||||
|
||||
it.each(['gmail', 'slack', 'whatsapp', 'telegram', 'discord', 'linkedin'])(
|
||||
'routes %s provider to /chat',
|
||||
provider => {
|
||||
expect(resolveIntegrationRoute(makeIntegration({ provider }))).toBe('/chat');
|
||||
}
|
||||
);
|
||||
|
||||
it('falls back to /notifications for unknown providers', () => {
|
||||
expect(resolveIntegrationRoute(makeIntegration({ provider: 'unknown-app' }))).toBe(
|
||||
'/notifications'
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers deep_link over provider default', () => {
|
||||
const n = makeIntegration({ provider: 'slack', deep_link: '/skills' });
|
||||
expect(resolveIntegrationRoute(n)).toBe('/skills');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// resolveSystemRoute
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveSystemRoute', () => {
|
||||
it('returns explicit deepLink when present', () => {
|
||||
const item = makeSystem({ deepLink: '/skills' });
|
||||
expect(resolveSystemRoute(item)).toBe('/skills');
|
||||
});
|
||||
|
||||
it('routes messages category to /chat', () => {
|
||||
expect(resolveSystemRoute(makeSystem({ category: 'messages' }))).toBe('/chat');
|
||||
});
|
||||
|
||||
it('routes agents category to /chat', () => {
|
||||
expect(resolveSystemRoute(makeSystem({ category: 'agents' }))).toBe('/chat');
|
||||
});
|
||||
|
||||
it('routes skills category to /skills', () => {
|
||||
expect(resolveSystemRoute(makeSystem({ category: 'skills' }))).toBe('/skills');
|
||||
});
|
||||
|
||||
it('routes system category to /home', () => {
|
||||
expect(resolveSystemRoute(makeSystem({ category: 'system' }))).toBe('/home');
|
||||
});
|
||||
|
||||
it('prefers deepLink over category default', () => {
|
||||
const item = makeSystem({ category: 'messages', deepLink: '/notifications' });
|
||||
expect(resolveSystemRoute(item)).toBe('/notifications');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import type { NotificationItem } from '../store/notificationSlice';
|
||||
import type { IntegrationNotification } from '../types/notifications';
|
||||
|
||||
const log = debug('notifications:router');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Known in-app hash routes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ROUTES = {
|
||||
chat: '/chat',
|
||||
skills: '/skills',
|
||||
home: '/home',
|
||||
notifications: '/notifications',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Providers whose notifications belong in the unified chat / accounts view.
|
||||
* Add new provider slugs here as integrations are added.
|
||||
*/
|
||||
const MESSAGE_PROVIDERS = new Set([
|
||||
'gmail',
|
||||
'slack',
|
||||
'whatsapp',
|
||||
'telegram',
|
||||
'discord',
|
||||
'linkedin',
|
||||
]);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Route resolvers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a hash-router path for an integration (provider) notification.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Explicit `deep_link` set by the core triage pipeline.
|
||||
* 2. Provider default — message providers → /chat.
|
||||
* 3. `/notifications` fallback.
|
||||
*/
|
||||
export function resolveIntegrationRoute(n: IntegrationNotification): string {
|
||||
if (n.deep_link) {
|
||||
log('[notification-router] integration id=%s explicit deep_link=%s', n.id, n.deep_link);
|
||||
return n.deep_link;
|
||||
}
|
||||
|
||||
if (MESSAGE_PROVIDERS.has(n.provider)) {
|
||||
log('[notification-router] integration id=%s provider=%s → /chat', n.id, n.provider);
|
||||
return ROUTES.chat;
|
||||
}
|
||||
|
||||
log(
|
||||
'[notification-router] integration id=%s provider=%s → /notifications (fallback)',
|
||||
n.id,
|
||||
n.provider
|
||||
);
|
||||
return ROUTES.notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a hash-router path for a system-event (`NotificationItem`) notification.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Explicit `deepLink` stored on the item.
|
||||
* 2. Category default: messages/agents → /chat; skills → /skills; system → /home.
|
||||
* 3. `/notifications` fallback.
|
||||
*/
|
||||
export function resolveSystemRoute(item: NotificationItem): string {
|
||||
if (item.deepLink) {
|
||||
log('[notification-router] system id=%s explicit deepLink=%s', item.id, item.deepLink);
|
||||
return item.deepLink;
|
||||
}
|
||||
|
||||
switch (item.category) {
|
||||
case 'messages':
|
||||
log('[notification-router] system id=%s category=messages → /chat', item.id);
|
||||
return ROUTES.chat;
|
||||
case 'agents':
|
||||
log('[notification-router] system id=%s category=agents → /chat', item.id);
|
||||
return ROUTES.chat;
|
||||
case 'skills':
|
||||
log('[notification-router] system id=%s category=skills → /skills', item.id);
|
||||
return ROUTES.skills;
|
||||
case 'system':
|
||||
log('[notification-router] system id=%s category=system → /home', item.id);
|
||||
return ROUTES.home;
|
||||
default:
|
||||
log(
|
||||
'[notification-router] system id=%s category=%s → /notifications (fallback)',
|
||||
item.id,
|
||||
item.category
|
||||
);
|
||||
return ROUTES.notifications;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import NotificationCenter from '../components/notifications/NotificationCenter';
|
||||
import { resolveSystemRoute } from '../lib/notificationRouter';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import {
|
||||
clearAll,
|
||||
@@ -38,18 +39,22 @@ const Notifications = () => {
|
||||
|
||||
const handleClick = (item: NotificationItem) => {
|
||||
if (!item.read) dispatch(markRead({ id: item.id }));
|
||||
if (item.deepLink) navigate(item.deepLink);
|
||||
navigate(resolveSystemRoute(item));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 pt-6 space-y-4">
|
||||
{/* Integration notifications — from connected accounts, scored by local AI */}
|
||||
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden min-h-[200px]">
|
||||
<div
|
||||
data-testid="integration-notifications-section"
|
||||
className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden min-h-[200px]">
|
||||
<NotificationCenter />
|
||||
</div>
|
||||
|
||||
{/* Core-bridge notifications — system events */}
|
||||
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
|
||||
<div
|
||||
data-testid="system-events-section"
|
||||
className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-stone-900">System Events</h1>
|
||||
@@ -80,7 +85,7 @@ const Notifications = () => {
|
||||
) : (
|
||||
<ul className="divide-y divide-stone-100">
|
||||
{items.map(item => (
|
||||
<li key={item.id}>
|
||||
<li key={item.id} data-testid="notification-item">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClick(item)}
|
||||
|
||||
@@ -117,6 +117,12 @@ class SocketService {
|
||||
private token: string | null = null;
|
||||
private mcpTransport: SocketIOMCPTransportImpl | null = null;
|
||||
private pendingListeners: Array<{ event: string; callback: (...args: unknown[]) => void }> = [];
|
||||
// Maps original caller callbacks → wrapped callbacks so off() can locate the
|
||||
// exact function references that were registered with socket.io, scoped by event.
|
||||
private listenerMap = new Map<
|
||||
string,
|
||||
Map<(...args: unknown[]) => void, Set<(...args: unknown[]) => void>>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Connect to the socket server with authentication.
|
||||
@@ -268,15 +274,17 @@ class SocketService {
|
||||
* Disconnect from the socket server
|
||||
*/
|
||||
disconnect(): void {
|
||||
const uid = getSocketUserId();
|
||||
if (this.socket) {
|
||||
const uid = getSocketUserId();
|
||||
socketLog('Disconnecting', { userId: uid });
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.token = null;
|
||||
this.mcpTransport = null;
|
||||
store.dispatch(resetForUser({ userId: uid }));
|
||||
}
|
||||
this.token = null;
|
||||
this.mcpTransport = null;
|
||||
this.listenerMap.clear();
|
||||
this.pendingListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,6 +328,13 @@ class SocketService {
|
||||
socketLog('Received event', { event, argsCount: args.length, hasData: args.length > 0 });
|
||||
callback(...args);
|
||||
};
|
||||
// Track original→wrapped per event so the same callback can be used for
|
||||
// multiple events without collisions.
|
||||
const byEvent = this.listenerMap.get(event) ?? new Map();
|
||||
const wrappedSet = byEvent.get(callback) ?? new Set();
|
||||
wrappedSet.add(wrappedCallback);
|
||||
byEvent.set(callback, wrappedSet);
|
||||
this.listenerMap.set(event, byEvent);
|
||||
if (this.socket) {
|
||||
this.socket.on(event, wrappedCallback);
|
||||
} else {
|
||||
@@ -332,12 +347,31 @@ class SocketService {
|
||||
* Remove an event listener
|
||||
*/
|
||||
off(event: string, callback?: (...args: unknown[]) => void): void {
|
||||
if (this.socket) {
|
||||
if (callback) {
|
||||
this.socket.off(event, callback);
|
||||
} else {
|
||||
this.socket.off(event);
|
||||
if (callback) {
|
||||
const byEvent = this.listenerMap.get(event);
|
||||
const wrappedSet = byEvent?.get(callback);
|
||||
const wrappedCallbacks =
|
||||
wrappedSet && wrappedSet.size > 0 ? Array.from(wrappedSet) : [callback];
|
||||
const hadWrapped = !!wrappedSet && wrappedSet.size > 0;
|
||||
byEvent?.delete(callback);
|
||||
if (byEvent && byEvent.size === 0) {
|
||||
this.listenerMap.delete(event);
|
||||
}
|
||||
socketLog('Removing listener', { event, hadWrappedVersion: hadWrapped });
|
||||
for (const wrapped of wrappedCallbacks) {
|
||||
if (this.socket) {
|
||||
this.socket.off(event, wrapped);
|
||||
}
|
||||
// Also remove from the pending queue in case the socket isn't up yet.
|
||||
this.pendingListeners = this.pendingListeners.filter(
|
||||
p => !(p.event === event && p.callback === wrapped)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
socketLog('Removing all listeners for event', { event });
|
||||
this.socket?.off(event);
|
||||
this.pendingListeners = this.pendingListeners.filter(p => p.event !== event);
|
||||
this.listenerMap.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,8 +385,25 @@ class SocketService {
|
||||
argsCount: args.length,
|
||||
hasData: args.length > 0,
|
||||
});
|
||||
callback(...args);
|
||||
try {
|
||||
callback(...args);
|
||||
} finally {
|
||||
const byEvent = this.listenerMap.get(event);
|
||||
const wrappedSet = byEvent?.get(callback);
|
||||
wrappedSet?.delete(wrappedCallback);
|
||||
if (wrappedSet && wrappedSet.size === 0) {
|
||||
byEvent?.delete(callback);
|
||||
}
|
||||
if (byEvent && byEvent.size === 0) {
|
||||
this.listenerMap.delete(event);
|
||||
}
|
||||
}
|
||||
};
|
||||
const byEvent = this.listenerMap.get(event) ?? new Map();
|
||||
const wrappedSet = byEvent.get(callback) ?? new Set();
|
||||
wrappedSet.add(wrappedCallback);
|
||||
byEvent.set(callback, wrappedSet);
|
||||
this.listenerMap.set(event, byEvent);
|
||||
if (this.socket) {
|
||||
this.socket.once(event, wrappedCallback);
|
||||
} else {
|
||||
|
||||
@@ -27,6 +27,13 @@ describe('notificationSlice', () => {
|
||||
expect(s2.items.map(i => i.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('notificationReceived dedupes by id', () => {
|
||||
let s = reducer(undefined, notificationReceived(makeItem({ id: 'dup', title: 'first' })));
|
||||
s = reducer(s, notificationReceived(makeItem({ id: 'dup', title: 'updated' })));
|
||||
expect(s.items).toHaveLength(1);
|
||||
expect(s.items[0].title).toBe('updated');
|
||||
});
|
||||
|
||||
it('drops item when its category preference is off', () => {
|
||||
let s = reducer(undefined, setPreference({ category: 'messages', enabled: false }));
|
||||
s = reducer(s, notificationReceived(makeItem({ id: 'a', category: 'messages' })));
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { IntegrationNotification } from '../../types/notifications';
|
||||
import notificationReducer, {
|
||||
addIntegrationNotification,
|
||||
dismissIntegrationNotification,
|
||||
markIntegrationActed,
|
||||
markIntegrationRead,
|
||||
setIntegrationNotifications,
|
||||
} from '../notificationSlice';
|
||||
@@ -80,6 +81,41 @@ describe('notificationSlice — integration notifications', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('markIntegrationActed', () => {
|
||||
it('sets status to acted and decrements unread count for an unread notification', () => {
|
||||
const n = makeNotification({ id: 'n-1', status: 'unread' });
|
||||
const loaded = notificationReducer(
|
||||
initialState,
|
||||
setIntegrationNotifications({ items: [n], unread_count: 1 })
|
||||
);
|
||||
const state = notificationReducer(loaded, markIntegrationActed('n-1'));
|
||||
expect(state.integrationItems[0].status).toBe('acted');
|
||||
expect(state.integrationUnreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it('sets status to acted without changing unread count for a read notification', () => {
|
||||
const n = makeNotification({ id: 'n-1', status: 'read' });
|
||||
const loaded = notificationReducer(
|
||||
initialState,
|
||||
setIntegrationNotifications({ items: [n], unread_count: 0 })
|
||||
);
|
||||
const state = notificationReducer(loaded, markIntegrationActed('n-1'));
|
||||
expect(state.integrationItems[0].status).toBe('acted');
|
||||
expect(state.integrationUnreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it('is a no-op for unknown id', () => {
|
||||
const n = makeNotification({ id: 'n-1', status: 'unread' });
|
||||
const loaded = notificationReducer(
|
||||
initialState,
|
||||
setIntegrationNotifications({ items: [n], unread_count: 1 })
|
||||
);
|
||||
const state = notificationReducer(loaded, markIntegrationActed('does-not-exist'));
|
||||
expect(state.integrationUnreadCount).toBe(1);
|
||||
expect(state.integrationItems[0].status).toBe('unread');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addIntegrationNotification', () => {
|
||||
it('prepends a new unread notification and increments integrationUnreadCount', () => {
|
||||
const n1 = makeNotification({ id: 'n-1' });
|
||||
|
||||
@@ -51,6 +51,13 @@ const notificationSlice = createSlice({
|
||||
notificationReceived(state, action: PayloadAction<NotificationItem>) {
|
||||
const item = action.payload;
|
||||
if (!state.preferences[item.category]) return;
|
||||
const existingIndex = state.items.findIndex(i => i.id === item.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Replace existing entry in place to avoid duplicate rows when
|
||||
// socket reconnects or upstream replays the same event id.
|
||||
state.items[existingIndex] = item;
|
||||
return;
|
||||
}
|
||||
state.items.unshift(item);
|
||||
if (state.items.length > MAX_ITEMS) {
|
||||
state.items.length = MAX_ITEMS;
|
||||
@@ -95,6 +102,16 @@ const notificationSlice = createSlice({
|
||||
state.integrationUnreadCount = Math.max(0, state.integrationUnreadCount - 1);
|
||||
}
|
||||
},
|
||||
markIntegrationActed(state, action: PayloadAction<string>) {
|
||||
const n = state.integrationItems.find(i => i.id === action.payload);
|
||||
if (n) {
|
||||
const wasUnread = n.status === 'unread';
|
||||
n.status = 'acted';
|
||||
if (wasUnread) {
|
||||
state.integrationUnreadCount = Math.max(0, state.integrationUnreadCount - 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissIntegrationNotification(state, action: PayloadAction<string>) {
|
||||
const n = state.integrationItems.find(i => i.id === action.payload);
|
||||
if (n) {
|
||||
@@ -133,6 +150,7 @@ export const {
|
||||
setIntegrationError,
|
||||
setIntegrationNotifications,
|
||||
markIntegrationRead,
|
||||
markIntegrationActed,
|
||||
dismissIntegrationNotification,
|
||||
addIntegrationNotification,
|
||||
} = notificationSlice.actions;
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface IntegrationNotification {
|
||||
received_at: string;
|
||||
/** ISO 8601 timestamp — undefined until triage completes */
|
||||
scored_at?: string;
|
||||
/** Optional in-app hash route (e.g. "/chat") set by the core triage pipeline. */
|
||||
deep_link?: string;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
|
||||
@@ -111,6 +111,7 @@ const HASH_TO_SIDEBAR_LABEL = {
|
||||
'/skills': 'Skills',
|
||||
'/home': 'Home',
|
||||
'/conversations': 'Conversations',
|
||||
'/notifications': 'Alerts',
|
||||
'/settings': 'Settings',
|
||||
'/intelligence': 'Intelligence',
|
||||
};
|
||||
@@ -277,6 +278,10 @@ export async function navigateToConversations() {
|
||||
await navigateViaHash('/conversations');
|
||||
}
|
||||
|
||||
export async function navigateToNotifications() {
|
||||
await navigateViaHash('/notifications');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onboarding walkthrough
|
||||
// Current flow: Welcome → Local AI → Screen & Accessibility → Tools → Skills (5 steps, indices 0–4).
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// @ts-nocheck
|
||||
import { browser, expect } from '@wdio/globals';
|
||||
|
||||
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
dumpAccessibilityTree,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { supportsExecuteScript } from '../helpers/platform';
|
||||
import { completeOnboardingIfVisible, navigateViaHash } from '../helpers/shared-flows';
|
||||
import { startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
function stepLog(message: string, context?: unknown): void {
|
||||
const stamp = new Date().toISOString();
|
||||
if (context === undefined) {
|
||||
console.log(`[NotificationsE2E][${stamp}] ${message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[NotificationsE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2));
|
||||
}
|
||||
|
||||
function getUnreadCount(stats: Record<string, unknown>): number {
|
||||
const candidates = ['unread_count', 'unread', 'total_unread'];
|
||||
for (const key of candidates) {
|
||||
const value = stats[key];
|
||||
if (typeof value === 'number') return value;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function waitForNotificationsSections(timeout = 10_000): Promise<void> {
|
||||
await browser.waitUntil(
|
||||
async () =>
|
||||
(await browser.execute(() => {
|
||||
const integration = document.querySelector(
|
||||
'[data-testid="integration-notifications-section"]'
|
||||
);
|
||||
const system = document.querySelector('[data-testid="system-events-section"]');
|
||||
return integration !== null && system !== null;
|
||||
})) === true,
|
||||
{ timeout, timeoutMsg: 'Notifications sections did not render in time' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the core ping/about RPC until it responds or the deadline expires.
|
||||
* Fails fast if the sidecar is not reachable within the timeout.
|
||||
*/
|
||||
async function waitForCoreSidecar(timeout = 30_000): Promise<void> {
|
||||
let lastErr: unknown;
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
const result = await callOpenhumanRpc('openhuman.about_info', {});
|
||||
if (result.ok) {
|
||||
stepLog('core sidecar ready', { result: result.result });
|
||||
return true;
|
||||
}
|
||||
lastErr = result.error;
|
||||
return false;
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
interval: 1_000,
|
||||
timeoutMsg: `Core sidecar not ready after ${timeout}ms: ${String(lastErr)}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
describe('Notifications', () => {
|
||||
before(async () => {
|
||||
await startMockServer();
|
||||
await waitForApp();
|
||||
|
||||
await triggerAuthDeepLinkBypass('e2e-notifications-user');
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
await completeOnboardingIfVisible('[NotificationsE2E]');
|
||||
|
||||
// Fail fast if core sidecar is not up.
|
||||
await waitForCoreSidecar(30_000);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
it('notification_ingest creates a new notification via core RPC', async () => {
|
||||
const result = await callOpenhumanRpc('openhuman.notification_ingest', {
|
||||
id: 'e2e-notif-001',
|
||||
category: 'system',
|
||||
title: 'E2E Test Notification',
|
||||
body: 'Created by the notifications E2E spec',
|
||||
timestamp_ms: Date.now(),
|
||||
});
|
||||
stepLog('notification_ingest result', { ok: result.ok, result: result.result });
|
||||
expect(result.ok).toBe(true);
|
||||
const payload = result.result?.result ?? {};
|
||||
expect(payload.skipped).not.toBe(true);
|
||||
expect(payload.id).toBe('e2e-notif-001');
|
||||
});
|
||||
|
||||
it('notification_list returns the ingested notification', async () => {
|
||||
const result = await callOpenhumanRpc('openhuman.notification_list', { limit: 20 });
|
||||
stepLog('notification_list result', { ok: result.ok, result: result.result });
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const items: unknown[] =
|
||||
result.result?.result?.notifications ?? result.result?.result?.items ?? [];
|
||||
const found = items.some(
|
||||
(n: unknown) =>
|
||||
typeof n === 'object' &&
|
||||
n !== null &&
|
||||
(n as Record<string, unknown>)['id'] === 'e2e-notif-001'
|
||||
);
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
|
||||
it('notification_mark_read transitions notification status', async () => {
|
||||
const before = await callOpenhumanRpc('openhuman.notification_stats', {});
|
||||
expect(before.ok).toBe(true);
|
||||
const beforeStats = before.result?.result ?? {};
|
||||
const initialUnread = getUnreadCount(beforeStats);
|
||||
|
||||
const result = await callOpenhumanRpc('openhuman.notification_mark_read', {
|
||||
id: 'e2e-notif-001',
|
||||
});
|
||||
stepLog('notification_mark_read result', { ok: result.ok, result: result.result });
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const after = await callOpenhumanRpc('openhuman.notification_stats', {});
|
||||
expect(after.ok).toBe(true);
|
||||
const afterStats = after.result?.result ?? {};
|
||||
const finalUnread = getUnreadCount(afterStats);
|
||||
if (initialUnread > 0) {
|
||||
expect(finalUnread).toBeLessThan(initialUnread);
|
||||
} else {
|
||||
expect(finalUnread).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('notification_stats returns aggregate statistics', async () => {
|
||||
const result = await callOpenhumanRpc('openhuman.notification_stats', {});
|
||||
stepLog('notification_stats result', { ok: result.ok, result: result.result });
|
||||
expect(result.ok).toBe(true);
|
||||
const stats = result.result?.result ?? {};
|
||||
// Stats must have at least a numeric total or unread count.
|
||||
const hasNumericField = Object.values(stats).some(v => typeof v === 'number');
|
||||
expect(hasNumericField).toBe(true);
|
||||
});
|
||||
|
||||
it('Notifications page renders integration notifications', async () => {
|
||||
if (!supportsExecuteScript()) {
|
||||
stepLog('skipping UI test — supportsExecuteScript() is false (Appium Mac2)');
|
||||
return;
|
||||
}
|
||||
|
||||
await navigateViaHash('/notifications');
|
||||
await waitForNotificationsSections(10_000);
|
||||
|
||||
const currentHash = await browser.execute(() => window.location.hash);
|
||||
stepLog('Notifications route hash', { currentHash });
|
||||
expect(String(currentHash)).toContain('/notifications');
|
||||
|
||||
// The integration notifications section wraps NotificationCenter.
|
||||
const sectionVisible = await browser.execute(() => {
|
||||
const el = document.querySelector('[data-testid="integration-notifications-section"]');
|
||||
return el !== null;
|
||||
});
|
||||
|
||||
if (!sectionVisible) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
stepLog('integration-notifications-section not found', { tree: tree.slice(0, 4000) });
|
||||
}
|
||||
expect(sectionVisible).toBe(true);
|
||||
await waitForText('E2E Test Notification', 8_000);
|
||||
await waitForText('Created by the notifications E2E spec', 8_000);
|
||||
});
|
||||
|
||||
it('Notifications page shows System Events section', async () => {
|
||||
if (!supportsExecuteScript()) {
|
||||
stepLog('skipping UI test — supportsExecuteScript() is false (Appium Mac2)');
|
||||
return;
|
||||
}
|
||||
|
||||
await navigateViaHash('/notifications');
|
||||
await waitForNotificationsSections(10_000);
|
||||
|
||||
const sectionVisible = await browser.execute(() => {
|
||||
const el = document.querySelector('[data-testid="system-events-section"]');
|
||||
return el !== null;
|
||||
});
|
||||
|
||||
if (!sectionVisible) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
stepLog('system-events-section not found', { tree: tree.slice(0, 4000) });
|
||||
}
|
||||
expect(sectionVisible).toBe(true);
|
||||
|
||||
// The heading text should also be present.
|
||||
await waitForText('System Events', 8_000);
|
||||
await waitForText('All caught up', 8_000);
|
||||
});
|
||||
|
||||
it('native notification permission command returns a valid state', async () => {
|
||||
if (!supportsExecuteScript()) {
|
||||
stepLog('skipping tauri command test — supportsExecuteScript() is false (Appium Mac2)');
|
||||
return;
|
||||
}
|
||||
|
||||
// E2E command-wiring validation intentionally exercises the low-level
|
||||
// invoke bridge from the webview context.
|
||||
const state = await browser.execute(async () => {
|
||||
const invoker = (window as unknown as { __TAURI_INTERNALS__?: { invoke?: Function } })
|
||||
.__TAURI_INTERNALS__?.invoke;
|
||||
if (typeof invoker !== 'function') {
|
||||
throw new Error('window.__TAURI_INTERNALS__.invoke is not available');
|
||||
}
|
||||
return await invoker('notification_permission_state');
|
||||
});
|
||||
|
||||
stepLog('notification_permission_state result', { state });
|
||||
const allowedStates = [
|
||||
'granted',
|
||||
'denied',
|
||||
'not_determined',
|
||||
'provisional',
|
||||
'ephemeral',
|
||||
'unknown',
|
||||
];
|
||||
expect(allowedStates.includes(String(state))).toBe(true);
|
||||
});
|
||||
|
||||
it('native notification plugin command is callable from webview', async () => {
|
||||
if (!supportsExecuteScript()) {
|
||||
stepLog('skipping tauri command test — supportsExecuteScript() is false (Appium Mac2)');
|
||||
return;
|
||||
}
|
||||
|
||||
// E2E command-wiring validation intentionally exercises the low-level
|
||||
// invoke bridge from the webview context.
|
||||
const result = await browser.execute(async () => {
|
||||
const invoker = (window as unknown as { __TAURI_INTERNALS__?: { invoke?: Function } })
|
||||
.__TAURI_INTERNALS__?.invoke;
|
||||
if (typeof invoker !== 'function') {
|
||||
throw new Error('window.__TAURI_INTERNALS__.invoke is not available');
|
||||
}
|
||||
await invoker('plugin:notification|notify', {
|
||||
options: {
|
||||
title: 'OpenHuman E2E notification',
|
||||
body: 'Verifies the plugin command is wired and callable.',
|
||||
},
|
||||
});
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
stepLog('plugin:notification|notify execute result', { result });
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
});
|
||||
@@ -174,6 +174,29 @@ Deep links require a `.app` bundle. Use `pnpm tauri build --debug --bundles app`
|
||||
|
||||
The first Docker build compiles Rust + tauri-driver from source. Subsequent runs use cached layers. Cargo registry and git sources are cached via Docker volumes.
|
||||
|
||||
## Spec: Notifications
|
||||
|
||||
**File**: `app/test/e2e/specs/notifications.spec.ts`
|
||||
|
||||
Tests notification RPC methods via the live core sidecar and the Notifications UI page:
|
||||
|
||||
- `notification_ingest` — creates a new notification via core RPC
|
||||
- `notification_list` — verifies the ingested notification is returned
|
||||
- `notification_mark_read` — marks a notification as read
|
||||
- `notification_stats` — checks aggregate statistics shape
|
||||
- UI: Notifications page renders the integration notifications section (`[data-testid="integration-notifications-section"]`)
|
||||
- UI: Notifications page shows the System Events section (`[data-testid="system-events-section"]`)
|
||||
|
||||
**Run**:
|
||||
|
||||
```bash
|
||||
bash app/scripts/e2e-run-spec.sh test/e2e/specs/notifications.spec.ts notifications
|
||||
```
|
||||
|
||||
**Platform note**: RPC tests (`notification_ingest`, `notification_list`, `notification_mark_read`, `notification_stats`) run on both Linux (tauri-driver) and macOS (Appium Mac2). UI assertions (Notifications page sections) require Linux / tauri-driver because `browser.execute()` is unavailable on Mac2 — those tests auto-skip when `supportsExecuteScript()` returns `false`.
|
||||
|
||||
---
|
||||
|
||||
## Agent-observable artifact flow
|
||||
|
||||
For a canonical, inspectable run that drops screenshots, page-source dumps, and mock request logs on disk:
|
||||
|
||||
Reference in New Issue
Block a user