import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import FlasherPlugin from '@flasher/flasher/flasher-plugin' import type { Envelope, Theme } from '@flasher/flasher/types' // Mock SCSS import vi.mock('@flasher/flasher/themes/index.scss', () => ({})) const createMockTheme = (customRender?: (envelope: Envelope) => string): Theme => ({ render: customRender || ((envelope: Envelope) => `
${envelope.title} ${envelope.message}
`), }) const createEnvelope = (overrides: Partial = {}): Envelope => ({ type: 'success', message: 'Test message', title: 'Test title', options: {}, metadata: { plugin: 'theme.test' }, ...overrides, }) describe('FlasherPlugin', () => { let plugin: FlasherPlugin beforeEach(() => { plugin = new FlasherPlugin(createMockTheme()) vi.useFakeTimers() // Mock requestAnimationFrame to execute callback immediately vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(performance.now()) return 0 }) }) afterEach(() => { vi.useRealTimers() }) describe('constructor', () => { it('should throw error when theme is missing', () => { expect(() => new FlasherPlugin(null as unknown as Theme)).toThrow('Theme is required') }) it('should throw error when theme has no render function', () => { expect(() => new FlasherPlugin({} as Theme)).toThrow('Theme must have a render function') }) it('should throw error when theme render is not a function', () => { expect(() => new FlasherPlugin({ render: 'not a function' } as unknown as Theme)).toThrow('Theme must have a render function') }) it('should create plugin with valid theme', () => { const p = new FlasherPlugin(createMockTheme()) expect(p).toBeInstanceOf(FlasherPlugin) }) }) describe('renderOptions', () => { it('should merge options with defaults', () => { plugin.renderOptions({ timeout: 5000, position: 'bottom-left' }) plugin.renderEnvelopes([createEnvelope()]) const container = document.querySelector('.fl-wrapper') expect(container?.getAttribute('data-position')).toBe('bottom-left') }) it('should do nothing with null/undefined options', () => { plugin.renderOptions(null as unknown as Record) plugin.renderOptions(undefined as unknown as Record) // Should not throw }) }) describe('renderEnvelopes', () => { it('should do nothing with empty envelopes', () => { plugin.renderEnvelopes([]) expect(document.querySelector('.fl-wrapper')).toBeNull() }) it('should do nothing with null/undefined envelopes', () => { plugin.renderEnvelopes(null as unknown as Envelope[]) plugin.renderEnvelopes(undefined as unknown as Envelope[]) // Should not throw }) it('should create container with default position', () => { plugin.renderEnvelopes([createEnvelope()]) const container = document.querySelector('.fl-wrapper') expect(container).toBeTruthy() expect(container?.getAttribute('data-position')).toBe('top-right') }) it('should create notification inside container', () => { plugin.renderEnvelopes([createEnvelope()]) const notification = document.querySelector('.fl-container') expect(notification).toBeTruthy() }) it('should add fl-show class after animation frame', () => { plugin.renderEnvelopes([createEnvelope()]) const notification = document.querySelector('.fl-container') // With mocked requestAnimationFrame, fl-show is added immediately expect(notification?.classList.contains('fl-show')).toBe(true) }) it('should reuse existing container for same position', () => { plugin.renderEnvelopes([createEnvelope()]) plugin.renderEnvelopes([createEnvelope()]) const containers = document.querySelectorAll('.fl-wrapper') expect(containers).toHaveLength(1) const notifications = document.querySelectorAll('.fl-container') expect(notifications).toHaveLength(2) }) it('should create separate containers for different positions', () => { plugin.renderEnvelopes([createEnvelope({ options: { position: 'top-left' } })]) plugin.renderEnvelopes([createEnvelope({ options: { position: 'bottom-right' } })]) const containers = document.querySelectorAll('.fl-wrapper') expect(containers).toHaveLength(2) }) it('should prepend notifications when direction is "top"', () => { plugin.renderOptions({ direction: 'top' }) plugin.renderEnvelopes([createEnvelope({ message: 'First' })]) plugin.renderEnvelopes([createEnvelope({ message: 'Second' })]) const container = document.querySelector('.fl-wrapper') const first = container?.firstElementChild expect(first?.querySelector('.fl-message')?.textContent).toBe('Second') }) it('should append notifications when direction is "bottom"', () => { plugin.renderOptions({ direction: 'bottom' }) plugin.renderEnvelopes([createEnvelope({ message: 'First' })]) plugin.renderEnvelopes([createEnvelope({ message: 'Second' })]) const container = document.querySelector('.fl-wrapper') const last = container?.lastElementChild expect(last?.querySelector('.fl-message')?.textContent).toBe('Second') }) it('should add fl-rtl class when rtl option is true', () => { plugin.renderOptions({ rtl: true }) plugin.renderEnvelopes([createEnvelope()]) const notification = document.querySelector('.fl-container') expect(notification?.classList.contains('fl-rtl')).toBe(true) }) it('should set Turbo temporary attribute on container', () => { plugin.renderEnvelopes([createEnvelope()]) const container = document.querySelector('.fl-wrapper') expect(container?.hasAttribute('data-turbo-temporary')).toBe(true) }) }) describe('timeout and timer', () => { it('should auto-remove notification after timeout', () => { plugin.renderOptions({ timeout: 5000 }) plugin.renderEnvelopes([createEnvelope()]) expect(document.querySelector('.fl-container')).toBeTruthy() vi.advanceTimersByTime(5000) const notification = document.querySelector('.fl-container') expect(notification?.classList.contains('fl-show')).toBe(false) }) it('should use type-specific timeout when global timeout is null', () => { plugin.renderOptions({ timeout: null, timeouts: { success: 3000 }, }) plugin.renderEnvelopes([createEnvelope({ type: 'success' })]) // fl-show is added immediately with mocked requestAnimationFrame const notification = document.querySelector('.fl-container') as HTMLElement expect(notification).toBeTruthy() expect(notification.classList.contains('fl-show')).toBe(true) // Advance most of the way (with buffer for timer precision) vi.advanceTimersByTime(2800) expect(notification.classList.contains('fl-show')).toBe(true) // Advance past the timeout vi.advanceTimersByTime(300) expect(notification.classList.contains('fl-show')).toBe(false) }) it('should respect envelope-specific timeout over global', () => { plugin.renderOptions({ timeout: 10000 }) plugin.renderEnvelopes([createEnvelope({ options: { timeout: 2000 } })]) // fl-show is added immediately with mocked requestAnimationFrame const notification = document.querySelector('.fl-container') as HTMLElement expect(notification).toBeTruthy() expect(notification.classList.contains('fl-show')).toBe(true) // Should still be showing before timeout vi.advanceTimersByTime(1800) expect(notification.classList.contains('fl-show')).toBe(true) // Should be removed after timeout vi.advanceTimersByTime(300) expect(notification.classList.contains('fl-show')).toBe(false) }) it('should create sticky notification when timeout is false', () => { plugin.renderEnvelopes([createEnvelope({ options: { timeout: false } })]) const notification = document.querySelector('.fl-container') expect(notification?.classList.contains('fl-sticky')).toBe(true) // Should not auto-remove vi.advanceTimersByTime(60000) expect(document.querySelector('.fl-container')).toBeTruthy() }) it('should create sticky notification when timeout is 0', () => { plugin.renderEnvelopes([createEnvelope({ options: { timeout: 0 } })]) const notification = document.querySelector('.fl-container') expect(notification?.classList.contains('fl-sticky')).toBe(true) }) it('should create sticky notification when timeout is negative', () => { plugin.renderEnvelopes([createEnvelope({ options: { timeout: -1 } })]) const notification = document.querySelector('.fl-container') expect(notification?.classList.contains('fl-sticky')).toBe(true) }) it('should update progress bar during countdown', () => { plugin.renderOptions({ timeout: 1000, fps: 10 }) plugin.renderEnvelopes([createEnvelope()]) vi.advanceTimersByTime(100) // First tick const progressBar = document.querySelector('.fl-progress') as HTMLElement expect(progressBar).toBeTruthy() // After 500ms, should be around 50% vi.advanceTimersByTime(400) const width = Number.parseFloat(progressBar.style.width) expect(width).toBeLessThan(60) expect(width).toBeGreaterThan(40) }) it('should create 100% progress bar for sticky notifications', () => { plugin.renderEnvelopes([createEnvelope({ options: { timeout: false } })]) vi.runAllTimers() const progressBar = document.querySelector('.fl-progress.fl-sticky-progress') as HTMLElement expect(progressBar?.style.width).toBe('100%') }) }) describe('close button', () => { it('should remove notification when close button is clicked', () => { plugin.renderEnvelopes([createEnvelope({ options: { timeout: false } })]) const closeButton = document.querySelector('.fl-close') as HTMLElement closeButton.click() const notification = document.querySelector('.fl-container') expect(notification?.classList.contains('fl-show')).toBe(false) }) it('should stop event propagation on close click', () => { plugin.renderEnvelopes([createEnvelope({ options: { timeout: false } })]) const notification = document.querySelector('.fl-container') as HTMLElement const notificationClickHandler = vi.fn() notification.addEventListener('click', notificationClickHandler) const closeButton = document.querySelector('.fl-close') as HTMLElement closeButton.click() expect(notificationClickHandler).not.toHaveBeenCalled() }) }) describe('hover pause', () => { it('should pause timer on mouse over', () => { plugin.renderOptions({ timeout: 5000 }) plugin.renderEnvelopes([createEnvelope()]) vi.advanceTimersByTime(2000) // Advance halfway const notification = document.querySelector('.fl-container') as HTMLElement notification.dispatchEvent(new MouseEvent('mouseover')) vi.advanceTimersByTime(10000) // Advance a lot more // Should still be showing because timer was paused expect(notification.classList.contains('fl-show')).toBe(true) }) it('should resume timer on mouse out', () => { plugin.renderOptions({ timeout: 5000 }) plugin.renderEnvelopes([createEnvelope()]) // Run requestAnimationFrame to add fl-show vi.advanceTimersByTime(0) const notification = document.querySelector('.fl-container') as HTMLElement vi.advanceTimersByTime(2000) // 2s elapsed notification.dispatchEvent(new MouseEvent('mouseover')) vi.advanceTimersByTime(5000) // Paused, no change notification.dispatchEvent(new MouseEvent('mouseout')) vi.advanceTimersByTime(3500) // Remaining time + buffer expect(notification.classList.contains('fl-show')).toBe(false) }) }) describe('HTML escaping', () => { it('should escape HTML when escapeHtml option is true', () => { plugin.renderOptions({ escapeHtml: true }) plugin.renderEnvelopes([createEnvelope({ message: '', title: 'Bold', })]) const message = document.querySelector('.fl-message') const title = document.querySelector('.fl-title') expect(message?.innerHTML).toContain('<script>') expect(title?.innerHTML).toContain('<b>') }) it('should not escape HTML when escapeHtml is false (default)', () => { plugin.renderEnvelopes([createEnvelope({ message: 'Bold message', })]) const message = document.querySelector('.fl-message') expect(message?.innerHTML).toContain('Bold message') }) it('should escape special characters correctly', () => { plugin.renderOptions({ escapeHtml: true }) plugin.renderEnvelopes([createEnvelope({ message: '& < > " \' ` = /', })]) const message = document.querySelector('.fl-message') // When innerHTML is serialized, browser only escapes & < > minimally // The key test is that < and > are escaped (prevents XSS) expect(message?.innerHTML).toContain('&') expect(message?.innerHTML).toContain('<') expect(message?.innerHTML).toContain('>') // Text content should have all original characters expect(message?.textContent).toBe('& < > " \' ` = /') }) it('should handle null/undefined message gracefully when escaping', () => { plugin.renderOptions({ escapeHtml: true }) // This tests the escapeHtml method's null handling plugin.renderEnvelopes([createEnvelope({ message: null as unknown as string, })]) // Should not throw }) it('should respect per-envelope escapeHtml option', () => { plugin.renderOptions({ escapeHtml: false }) // Global false plugin.renderEnvelopes([createEnvelope({ message: 'Bold', options: { escapeHtml: true }, // Per-envelope true })]) const message = document.querySelector('.fl-message') expect(message?.innerHTML).toContain('<b>') }) }) describe('custom styles', () => { it('should apply custom style properties to container', () => { plugin.renderOptions({ style: { zIndex: '9999', marginTop: '20px', }, }) plugin.renderEnvelopes([createEnvelope()]) const container = document.querySelector('.fl-wrapper') as HTMLElement expect(container.style.getPropertyValue('z-index')).toBe('9999') expect(container.style.getPropertyValue('margin-top')).toBe('20px') }) }) describe('container cleanup', () => { it('should remove empty container after last notification is removed', () => { plugin.renderOptions({ timeout: 1000 }) plugin.renderEnvelopes([createEnvelope()]) vi.advanceTimersByTime(1000) const notification = document.querySelector('.fl-container') as HTMLElement // Trigger transition end notification?.ontransitionend?.({} as TransitionEvent) expect(document.querySelector('.fl-wrapper')).toBeNull() }) it('should keep container when other notifications remain', () => { plugin.renderOptions({ timeout: false }) plugin.renderEnvelopes([createEnvelope({ message: 'First' })]) plugin.renderEnvelopes([createEnvelope({ message: 'Second' })]) // Remove first notification const closeButtons = document.querySelectorAll('.fl-close') ;(closeButtons[0] as HTMLElement).click() const notification = document.querySelectorAll('.fl-container')[0] as HTMLElement notification?.ontransitionend?.({} as TransitionEvent) expect(document.querySelector('.fl-wrapper')).toBeTruthy() expect(document.querySelectorAll('.fl-container')).toHaveLength(1) }) }) describe('DOM ready handling', () => { it('should defer rendering if DOM is loading', () => { // Mock document.readyState Object.defineProperty(document, 'readyState', { value: 'loading', writable: true, }) const addEventListenerSpy = vi.spyOn(document, 'addEventListener') plugin.renderEnvelopes([createEnvelope()]) expect(addEventListenerSpy).toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function)) // Reset Object.defineProperty(document, 'readyState', { value: 'complete', writable: true, }) }) }) describe('error handling', () => { it('should log error and continue when envelope rendering fails', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const badTheme: Theme = { render: () => { throw new Error('Render error') }, } const badPlugin = new FlasherPlugin(badTheme) badPlugin.renderEnvelopes([createEnvelope()]) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Error rendering envelope'), expect.any(Error), expect.any(Object), ) }) }) })