import { readFileSync } from 'node:fs'; import { JSDOM } from 'jsdom'; import { afterEach, describe, expect, it } from 'vitest'; const SOURCE = readFileSync(new URL('../src/web/public/notification-manager.js', import.meta.url), 'utf8'); type EventPreference = { enabled: boolean; browser: boolean; audio: boolean; push: boolean; }; type NotificationPreferences = { enabled: boolean; eventTypes: Record; _version: number; }; type Manager = { preferences: NotificationPreferences; notifications: unknown[]; getStorageKey: () => string; normalizePreferences: (preferences: Record) => NotificationPreferences; notify: (notification: Record) => void; }; const openWindows: JSDOM[] = []; function loadManager( saved?: Record, device: { deviceType?: string; handheld?: boolean } = {} ): { dom: JSDOM; manager: Manager } { const dom = new JSDOM( '
', { url: 'http://localhost/', runScripts: 'outside-only', } ); openWindows.push(dom); const win = dom.window as unknown as Window & typeof globalThis & { MobileDetection: { getDeviceType: () => string; isHandheldDevice?: () => boolean; }; STUCK_THRESHOLD_DEFAULT_MS: number; GROUPING_TIMEOUT_MS: number; NOTIFICATION_LIST_CAP: number; }; win.MobileDetection = { getDeviceType: () => device.deviceType ?? 'desktop', ...(typeof device.handheld === 'boolean' ? { isHandheldDevice: () => device.handheld === true } : {}), }; win.STUCK_THRESHOLD_DEFAULT_MS = 600_000; win.GROUPING_TIMEOUT_MS = 5_000; win.NOTIFICATION_LIST_CAP = 100; win.requestAnimationFrame = ((callback: FrameRequestCallback) => { callback(0); return 1; }) as typeof requestAnimationFrame; if (saved) { win.localStorage.setItem('codeman-notification-prefs', JSON.stringify(saved)); } win.eval(` ${SOURCE} window.__testNotificationManager = NotificationManager; `); const NotificationManager = ( win as unknown as { __testNotificationManager: new (app: { sessions: Map }) => Manager; } ).__testNotificationManager; const manager = new NotificationManager({ sessions: new Map() }) as Manager; return { dom, manager }; } afterEach(() => { for (const dom of openWindows.splice(0)) dom.window.close(); }); describe('notification noise defaults', () => { it('keeps response-complete and team lifecycle drawer entries opt-in', () => { const { manager } = loadManager(); expect(manager.preferences.eventTypes.stop.enabled).toBe(false); for (const category of ['hook-stop', 'hook-teammate-idle', 'hook-task-completed']) { manager.notify({ urgency: 'info', category, sessionId: 'session-1', sessionName: 'session', title: category, message: category, }); } expect(manager.notifications).toHaveLength(0); }); it('migrates the old drawer-only Stop default but preserves explicit delivery', () => { const quietV4 = { enabled: true, eventTypes: { stop: { enabled: true, browser: false, audio: false, push: false }, }, _version: 4, }; const { manager: quietManager } = loadManager(quietV4); expect(quietManager.preferences.eventTypes.stop.enabled).toBe(false); expect(quietManager.preferences._version).toBe(5); const browserV4 = { enabled: true, eventTypes: { stop: { enabled: true, browser: true, audio: false, push: false }, }, _version: 4, }; const { manager: browserManager } = loadManager(browserV4); expect(browserManager.preferences.eventTypes.stop.enabled).toBe(true); }); it('normalizes server-hydrated v4 preferences through the same quiet migration', () => { const { manager } = loadManager(); manager.preferences = manager.normalizePreferences({ enabled: true, eventTypes: { stop: { enabled: true, browser: false, audio: false, push: false }, }, _version: 4, }); expect(manager.preferences.eventTypes.stop.enabled).toBe(false); expect(manager.preferences._version).toBe(5); }); it('keeps mobile notification defaults and storage on an unfolded handheld', () => { const { manager } = loadManager(undefined, { deviceType: 'desktop', handheld: true, }); expect(manager.preferences.enabled).toBe(false); expect(manager.getStorageKey()).toBe('codeman-notification-prefs-mobile'); }); });