add vitest for JS/TS testing with comprehensive test coverage

This commit is contained in:
Younes ENNAJI
2026-02-25 15:52:21 +00:00
parent 62848e0fd1
commit d33de77835
21 changed files with 4641 additions and 67 deletions
+227
View File
@@ -0,0 +1,227 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Envelope } from '@flasher/flasher/types'
// Use vi.hoisted to define mocks that will be available during vi.mock hoisting
const { mockShow, mockNotyInstance, MockNoty } = vi.hoisted(() => {
const mockShow = vi.fn()
const mockNotyInstance = {
show: mockShow,
layoutDom: { dataset: {} as DOMStringMap },
}
const MockNoty = Object.assign(
vi.fn().mockImplementation(() => mockNotyInstance),
{ overrideDefaults: vi.fn() },
)
return { mockShow, mockNotyInstance, MockNoty }
})
vi.mock('noty', () => ({
default: MockNoty,
}))
// Import after mocks
import NotyPlugin from '@flasher/flasher-noty/noty'
const createEnvelope = (overrides: Partial<Envelope> = {}): Envelope => ({
type: 'success',
message: 'Test message',
title: 'Test title',
options: {},
metadata: { plugin: 'noty' },
...overrides,
})
describe('NotyPlugin', () => {
let plugin: NotyPlugin
beforeEach(() => {
vi.clearAllMocks()
// Restore mock implementations after clearing
MockNoty.mockImplementation(() => mockNotyInstance)
mockNotyInstance.layoutDom = { dataset: {} as DOMStringMap }
plugin = new NotyPlugin()
})
describe('renderEnvelopes', () => {
it('should do nothing with empty envelopes', () => {
plugin.renderEnvelopes([])
expect(MockNoty).not.toHaveBeenCalled()
})
it('should do nothing with null/undefined envelopes', () => {
plugin.renderEnvelopes(null as unknown as Envelope[])
plugin.renderEnvelopes(undefined as unknown as Envelope[])
expect(MockNoty).not.toHaveBeenCalled()
})
it('should create Noty instance with envelope data', () => {
plugin.renderEnvelopes([createEnvelope({
type: 'success',
message: 'Hello World',
})])
expect(MockNoty).toHaveBeenCalledWith(expect.objectContaining({
text: 'Hello World',
type: 'success',
}))
})
it('should call show() on Noty instance', () => {
plugin.renderEnvelopes([createEnvelope()])
expect(mockShow).toHaveBeenCalled()
})
it('should include default timeout option', () => {
plugin.renderEnvelopes([createEnvelope()])
expect(MockNoty).toHaveBeenCalledWith(expect.objectContaining({
timeout: 10000,
}))
})
it('should merge envelope options', () => {
plugin.renderEnvelopes([createEnvelope({
options: { timeout: 5000, layout: 'topRight' },
})])
expect(MockNoty).toHaveBeenCalledWith(expect.objectContaining({
timeout: 5000,
layout: 'topRight',
}))
})
it('should render multiple envelopes', () => {
plugin.renderEnvelopes([
createEnvelope({ message: 'First' }),
createEnvelope({ message: 'Second' }),
])
expect(MockNoty).toHaveBeenCalledTimes(2)
expect(mockShow).toHaveBeenCalledTimes(2)
})
it('should set Turbo compatibility on layoutDom', () => {
plugin.renderEnvelopes([createEnvelope()])
expect(mockNotyInstance.layoutDom.dataset.turboTemporary).toBe('')
})
it('should handle missing layoutDom gracefully', () => {
MockNoty.mockImplementationOnce(() => ({
show: mockShow,
layoutDom: null,
}))
// Should not throw
expect(() => plugin.renderEnvelopes([createEnvelope()])).not.toThrow()
})
it('should handle errors gracefully', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
MockNoty.mockImplementationOnce(() => {
throw new Error('Noty error')
})
plugin.renderEnvelopes([createEnvelope()])
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error rendering notification'),
expect.any(Error),
expect.any(Object),
)
})
})
describe('renderOptions', () => {
it('should do nothing with null/undefined options', () => {
plugin.renderOptions(null as unknown as Record<string, unknown>)
plugin.renderOptions(undefined as unknown as Record<string, unknown>)
expect(MockNoty.overrideDefaults).not.toHaveBeenCalled()
})
it('should call Noty.overrideDefaults with merged options', () => {
plugin.renderOptions({ timeout: 8000, layout: 'bottomLeft' })
expect(MockNoty.overrideDefaults).toHaveBeenCalledWith(expect.objectContaining({
timeout: 8000,
layout: 'bottomLeft',
}))
})
it('should preserve existing default options', () => {
plugin.renderOptions({ layout: 'topLeft' })
plugin.renderOptions({ theme: 'mint' })
// Get the last call arguments
const lastCall = MockNoty.overrideDefaults.mock.calls[MockNoty.overrideDefaults.mock.calls.length - 1][0]
expect(lastCall).toMatchObject({
timeout: 10000, // default
layout: 'topLeft', // from first call
theme: 'mint', // from second call
})
})
it('should use options in subsequent renderEnvelopes', () => {
plugin.renderOptions({ animation: { open: 'fadeIn', close: 'fadeOut' } })
plugin.renderEnvelopes([createEnvelope()])
expect(MockNoty).toHaveBeenCalledWith(expect.objectContaining({
animation: { open: 'fadeIn', close: 'fadeOut' },
}))
})
})
describe('convenience methods (inherited from AbstractPlugin)', () => {
it('success() should create success notification', () => {
plugin.success('Success message')
expect(MockNoty).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
})
it('error() should create error notification', () => {
plugin.error('Error message')
expect(MockNoty).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
it('info() should create info notification', () => {
plugin.info('Info message')
expect(MockNoty).toHaveBeenCalledWith(expect.objectContaining({
type: 'info',
}))
})
it('warning() should create warning notification', () => {
plugin.warning('Warning message')
expect(MockNoty).toHaveBeenCalledWith(expect.objectContaining({
type: 'warning',
}))
})
})
describe('notification types', () => {
it('should support all standard Noty types', () => {
const types = ['alert', 'success', 'error', 'warning', 'info']
types.forEach((type) => {
MockNoty.mockClear()
plugin.renderEnvelopes([createEnvelope({ type })])
expect(MockNoty).toHaveBeenCalledWith(expect.objectContaining({
type,
}))
})
})
})
})
+218
View File
@@ -0,0 +1,218 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Envelope } from '@flasher/flasher/types'
// Use vi.hoisted to define mocks that will be available during vi.mock hoisting
const { mockOpen, mockNotyfInstance, MockNotyf } = vi.hoisted(() => {
const mockOpen = vi.fn()
const mockNotyfInstance = {
open: mockOpen,
view: {
container: { dataset: {} as DOMStringMap },
a11yContainer: { dataset: {} as DOMStringMap },
},
}
const MockNotyf = vi.fn().mockImplementation(() => mockNotyfInstance)
return { mockOpen, mockNotyfInstance, MockNotyf }
})
vi.mock('notyf', () => ({
Notyf: MockNotyf,
}))
vi.mock('notyf/notyf.min.css', () => ({}))
// Import after mocks
import NotyfPlugin from '@flasher/flasher-notyf/notyf'
const createEnvelope = (overrides: Partial<Envelope> = {}): Envelope => ({
type: 'success',
message: 'Test message',
title: 'Test title',
options: {},
metadata: { plugin: 'notyf' },
...overrides,
})
describe('NotyfPlugin', () => {
let plugin: NotyfPlugin
beforeEach(() => {
vi.clearAllMocks()
// Restore mock implementations after clearing
MockNotyf.mockImplementation(() => mockNotyfInstance)
mockNotyfInstance.view.container.dataset = {} as DOMStringMap
mockNotyfInstance.view.a11yContainer.dataset = {} as DOMStringMap
plugin = new NotyfPlugin()
})
describe('renderEnvelopes', () => {
it('should initialize Notyf on first render', () => {
plugin.renderEnvelopes([createEnvelope()])
expect(MockNotyf).toHaveBeenCalled()
})
it('should call notyf.open with envelope data', () => {
plugin.renderEnvelopes([createEnvelope({
type: 'success',
message: 'Hello',
title: 'Title',
})])
expect(mockOpen).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'Hello',
title: 'Title',
}))
})
it('should merge envelope options', () => {
plugin.renderEnvelopes([createEnvelope({
options: { duration: 5000, dismissible: true },
})])
expect(mockOpen).toHaveBeenCalledWith(expect.objectContaining({
duration: 5000,
dismissible: true,
}))
})
it('should render multiple envelopes', () => {
plugin.renderEnvelopes([
createEnvelope({ message: 'First' }),
createEnvelope({ message: 'Second' }),
])
expect(mockOpen).toHaveBeenCalledTimes(2)
})
it('should set Turbo compatibility on containers', () => {
plugin.renderEnvelopes([createEnvelope()])
expect(mockNotyfInstance.view.container.dataset.turboTemporary).toBe('')
expect(mockNotyfInstance.view.a11yContainer.dataset.turboTemporary).toBe('')
})
it('should handle errors gracefully', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockOpen.mockImplementationOnce(() => {
throw new Error('Notyf error')
})
plugin.renderEnvelopes([createEnvelope()])
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error rendering notification'),
expect.any(Error),
expect.any(Object),
)
})
})
describe('renderOptions', () => {
it('should do nothing with null/undefined options', () => {
plugin.renderOptions(null as unknown as Record<string, unknown>)
plugin.renderOptions(undefined as unknown as Record<string, unknown>)
// Should not throw
})
it('should initialize Notyf with options', () => {
plugin.renderOptions({ duration: 8000, position: { x: 'left', y: 'bottom' } })
expect(MockNotyf).toHaveBeenCalledWith(expect.objectContaining({
duration: 8000,
position: { x: 'left', y: 'bottom' },
}))
})
it('should use default duration if not provided', () => {
plugin.renderOptions({ position: { x: 'center', y: 'top' } })
expect(MockNotyf).toHaveBeenCalledWith(expect.objectContaining({
duration: 10000,
}))
})
it('should add info type configuration', () => {
plugin.renderOptions({})
const callArgs = MockNotyf.mock.calls[0][0]
const infoType = callArgs.types.find((t: any) => t.type === 'info')
expect(infoType).toBeDefined()
expect(infoType.className).toBe('notyf__toast--info')
expect(infoType.background).toBe('#5784E5')
})
it('should add warning type configuration', () => {
plugin.renderOptions({})
const callArgs = MockNotyf.mock.calls[0][0]
const warningType = callArgs.types.find((t: any) => t.type === 'warning')
expect(warningType).toBeDefined()
expect(warningType.className).toBe('notyf__toast--warning')
expect(warningType.background).toBe('#E3A008')
})
it('should not duplicate types if already provided', () => {
plugin.renderOptions({
types: [
{ type: 'info', background: '#custom' },
],
})
const callArgs = MockNotyf.mock.calls[0][0]
const infoTypes = callArgs.types.filter((t: any) => t.type === 'info')
expect(infoTypes).toHaveLength(1)
expect(infoTypes[0].background).toBe('#custom')
})
})
describe('default initialization', () => {
it('should initialize with default options when rendering without prior options', () => {
plugin.renderEnvelopes([createEnvelope()])
expect(MockNotyf).toHaveBeenCalledWith(expect.objectContaining({
duration: 10000,
position: { x: 'right', y: 'top' },
dismissible: true,
}))
})
})
describe('convenience methods (inherited from AbstractPlugin)', () => {
it('success() should create success notification', () => {
plugin.success('Success message')
expect(mockOpen).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
})
it('error() should create error notification', () => {
plugin.error('Error message')
expect(mockOpen).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
it('info() should create info notification', () => {
plugin.info('Info message')
expect(mockOpen).toHaveBeenCalledWith(expect.objectContaining({
type: 'info',
}))
})
it('warning() should create warning notification', () => {
plugin.warning('Warning message')
expect(mockOpen).toHaveBeenCalledWith(expect.objectContaining({
type: 'warning',
}))
})
})
})
+271
View File
@@ -0,0 +1,271 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Envelope } from '@flasher/flasher/types'
// Use vi.hoisted to define mocks that will be available during vi.mock hoisting
const { mockFire, mockMixin, mockIsVisible, mockGetPopup, mockClose } = vi.hoisted(() => {
const mockFire = vi.fn().mockResolvedValue({ isConfirmed: true })
const mockMixin = vi.fn().mockReturnValue({ fire: mockFire })
const mockIsVisible = vi.fn().mockReturnValue(false)
const mockGetPopup = vi.fn().mockReturnValue({ style: { setProperty: vi.fn() } })
const mockClose = vi.fn()
return { mockFire, mockMixin, mockIsVisible, mockGetPopup, mockClose }
})
vi.mock('sweetalert2', () => ({
default: {
mixin: mockMixin,
isVisible: mockIsVisible,
getPopup: mockGetPopup,
close: mockClose,
},
}))
// Import after mocks
import SweetAlertPlugin from '@flasher/flasher-sweetalert/sweetalert'
const createEnvelope = (overrides: Partial<Envelope> = {}): Envelope => ({
type: 'success',
message: 'Test message',
title: 'Test title',
options: {},
metadata: { plugin: 'sweetalert' },
...overrides,
})
describe('SweetAlertPlugin', () => {
let plugin: SweetAlertPlugin
beforeEach(() => {
vi.clearAllMocks()
// Restore mock implementations after clearing
mockFire.mockResolvedValue({ isConfirmed: true })
mockMixin.mockReturnValue({ fire: mockFire })
mockIsVisible.mockReturnValue(false)
mockGetPopup.mockReturnValue({ style: { setProperty: vi.fn() } })
plugin = new SweetAlertPlugin()
})
describe('renderEnvelopes', () => {
it('should initialize SweetAlert on first render', async () => {
await plugin.renderEnvelopes([createEnvelope()])
expect(mockMixin).toHaveBeenCalled()
})
it('should call fire with envelope options', async () => {
await plugin.renderEnvelopes([createEnvelope({
type: 'success',
message: 'Hello World',
})])
expect(mockFire).toHaveBeenCalledWith(expect.objectContaining({
icon: 'success',
text: 'Hello World',
}))
})
it('should use envelope.type as icon by default', async () => {
await plugin.renderEnvelopes([createEnvelope({ type: 'error' })])
expect(mockFire).toHaveBeenCalledWith(expect.objectContaining({
icon: 'error',
}))
})
it('should use envelope.message as text by default', async () => {
await plugin.renderEnvelopes([createEnvelope({ message: 'Custom message' })])
expect(mockFire).toHaveBeenCalledWith(expect.objectContaining({
text: 'Custom message',
}))
})
it('should allow overriding icon via options', async () => {
await plugin.renderEnvelopes([createEnvelope({
type: 'success',
options: { icon: 'warning' },
})])
expect(mockFire).toHaveBeenCalledWith(expect.objectContaining({
icon: 'warning',
}))
})
it('should allow overriding text via options', async () => {
await plugin.renderEnvelopes([createEnvelope({
message: 'Original',
options: { text: 'Override' },
})])
expect(mockFire).toHaveBeenCalledWith(expect.objectContaining({
text: 'Override',
}))
})
it('should render envelopes sequentially', async () => {
const callOrder: string[] = []
mockFire
.mockImplementationOnce(async () => {
callOrder.push('first')
return { isConfirmed: true }
})
.mockImplementationOnce(async () => {
callOrder.push('second')
return { isConfirmed: true }
})
await plugin.renderEnvelopes([
createEnvelope({ message: 'First' }),
createEnvelope({ message: 'Second' }),
])
expect(callOrder).toEqual(['first', 'second'])
})
it('should dispatch promise event after fire', async () => {
const eventHandler = vi.fn()
window.addEventListener('flasher:sweetalert:promise', eventHandler)
await plugin.renderEnvelopes([createEnvelope()])
expect(eventHandler).toHaveBeenCalledWith(expect.objectContaining({
detail: expect.objectContaining({
promise: { isConfirmed: true },
envelope: expect.any(Object),
}),
}))
window.removeEventListener('flasher:sweetalert:promise', eventHandler)
})
it('should handle errors gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFire.mockRejectedValueOnce(new Error('Swal error'))
await plugin.renderEnvelopes([createEnvelope()])
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error rendering envelope'),
expect.any(Error),
expect.any(Object),
)
})
})
describe('renderOptions', () => {
it('should create mixin with options', () => {
plugin.renderOptions({ timer: 5000, showConfirmButton: false })
expect(mockMixin).toHaveBeenCalledWith(expect.objectContaining({
timer: 5000,
showConfirmButton: false,
}))
})
it('should use default timer if not provided', () => {
plugin.renderOptions({})
expect(mockMixin).toHaveBeenCalledWith(expect.objectContaining({
timer: 10000,
}))
})
it('should use default timerProgressBar if not provided', () => {
plugin.renderOptions({})
expect(mockMixin).toHaveBeenCalledWith(expect.objectContaining({
timerProgressBar: true,
}))
})
it('should setup Turbo compatibility', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
plugin.renderOptions({})
expect(addEventListenerSpy).toHaveBeenCalledWith(
'turbo:before-cache',
expect.any(Function),
)
})
it('should handle errors gracefully', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockMixin.mockImplementationOnce(() => {
throw new Error('Mixin error')
})
plugin.renderOptions({ timer: 5000 })
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error applying options'),
expect.any(Error),
)
})
})
describe('Turbo compatibility', () => {
it('should close visible Swal on turbo:before-cache', () => {
mockIsVisible.mockReturnValueOnce(true)
plugin.renderOptions({})
// Simulate turbo:before-cache event
document.dispatchEvent(new Event('turbo:before-cache'))
expect(mockClose).toHaveBeenCalled()
})
it('should set animation duration to 0ms before closing', () => {
mockIsVisible.mockReturnValueOnce(true)
const mockSetProperty = vi.fn()
mockGetPopup.mockReturnValueOnce({ style: { setProperty: mockSetProperty } })
plugin.renderOptions({})
document.dispatchEvent(new Event('turbo:before-cache'))
expect(mockSetProperty).toHaveBeenCalledWith('animation-duration', '0ms')
})
it('should not close if Swal is not visible', () => {
mockIsVisible.mockReturnValueOnce(false)
plugin.renderOptions({})
document.dispatchEvent(new Event('turbo:before-cache'))
expect(mockClose).not.toHaveBeenCalled()
})
})
describe('default initialization', () => {
it('should initialize with default options when rendering without prior options', async () => {
await plugin.renderEnvelopes([createEnvelope()])
expect(mockMixin).toHaveBeenCalledWith(expect.objectContaining({
timer: 10000,
timerProgressBar: true,
}))
})
})
describe('convenience methods (inherited from AbstractPlugin)', () => {
it('success() should create success notification', async () => {
await plugin.success('Success message')
expect(mockFire).toHaveBeenCalledWith(expect.objectContaining({
icon: 'success',
}))
})
it('error() should create error notification', async () => {
await plugin.error('Error message')
expect(mockFire).toHaveBeenCalledWith(expect.objectContaining({
icon: 'error',
}))
})
})
})
+221
View File
@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Envelope } from '@flasher/flasher/types'
// Use vi.hoisted to define mocks that will be available during vi.mock hoisting
const { mockToastr, mockJQuery } = vi.hoisted(() => {
const mockToastr = {
success: vi.fn().mockReturnValue({ parent: vi.fn().mockReturnValue({ attr: vi.fn() }) }),
error: vi.fn().mockReturnValue({ parent: vi.fn().mockReturnValue({ attr: vi.fn() }) }),
info: vi.fn().mockReturnValue({ parent: vi.fn().mockReturnValue({ attr: vi.fn() }) }),
warning: vi.fn().mockReturnValue({ parent: vi.fn().mockReturnValue({ attr: vi.fn() }) }),
options: {} as Record<string, unknown>,
}
const mockJQuery = vi.fn()
return { mockToastr, mockJQuery }
})
vi.mock('toastr', () => ({
default: mockToastr,
}))
// Import after mocks are set up
import ToastrPlugin from '@flasher/flasher-toastr/toastr'
const createEnvelope = (overrides: Partial<Envelope> = {}): Envelope => ({
type: 'success',
message: 'Test message',
title: 'Test title',
options: {},
metadata: { plugin: 'toastr' },
...overrides,
})
describe('ToastrPlugin', () => {
let plugin: ToastrPlugin
beforeEach(() => {
plugin = new ToastrPlugin()
vi.clearAllMocks()
mockToastr.options = {}
// Set up jQuery mock
;(window as any).jQuery = mockJQuery
;(window as any).$ = mockJQuery
})
describe('renderEnvelopes', () => {
it('should do nothing with empty envelopes', () => {
plugin.renderEnvelopes([])
expect(mockToastr.success).not.toHaveBeenCalled()
})
it('should do nothing with null/undefined envelopes', () => {
plugin.renderEnvelopes(null as unknown as Envelope[])
plugin.renderEnvelopes(undefined as unknown as Envelope[])
expect(mockToastr.success).not.toHaveBeenCalled()
})
it('should call toastr with correct type', () => {
plugin.renderEnvelopes([createEnvelope({ type: 'success' })])
expect(mockToastr.success).toHaveBeenCalled()
plugin.renderEnvelopes([createEnvelope({ type: 'error' })])
expect(mockToastr.error).toHaveBeenCalled()
plugin.renderEnvelopes([createEnvelope({ type: 'info' })])
expect(mockToastr.info).toHaveBeenCalled()
plugin.renderEnvelopes([createEnvelope({ type: 'warning' })])
expect(mockToastr.warning).toHaveBeenCalled()
})
it('should pass message, title, and options to toastr', () => {
plugin.renderEnvelopes([createEnvelope({
message: 'Hello World',
title: 'Greeting',
options: { timeOut: 5000 },
})])
expect(mockToastr.success).toHaveBeenCalledWith(
'Hello World',
'Greeting',
{ timeOut: 5000 },
)
})
it('should render multiple envelopes', () => {
plugin.renderEnvelopes([
createEnvelope({ type: 'success', message: 'First' }),
createEnvelope({ type: 'error', message: 'Second' }),
])
expect(mockToastr.success).toHaveBeenCalledTimes(1)
expect(mockToastr.error).toHaveBeenCalledTimes(1)
})
it('should set Turbo compatibility attribute', () => {
const mockParent = { attr: vi.fn() }
mockToastr.success.mockReturnValue({ parent: () => mockParent })
plugin.renderEnvelopes([createEnvelope()])
expect(mockParent.attr).toHaveBeenCalledWith('data-turbo-temporary', '')
})
it('should log error when jQuery is not available', () => {
delete (window as any).jQuery
delete (window as any).$
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
plugin.renderEnvelopes([createEnvelope()])
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('jQuery is required'),
)
expect(mockToastr.success).not.toHaveBeenCalled()
// Restore jQuery for other tests
;(window as any).jQuery = mockJQuery
;(window as any).$ = mockJQuery
})
it('should handle toastr errors gracefully', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockToastr.success.mockImplementationOnce(() => {
throw new Error('Toastr error')
})
plugin.renderEnvelopes([createEnvelope()])
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error rendering notification'),
expect.any(Error),
expect.any(Object),
)
})
it('should handle missing parent method gracefully', () => {
mockToastr.success.mockReturnValueOnce({})
// Should not throw
expect(() => plugin.renderEnvelopes([createEnvelope()])).not.toThrow()
})
it('should handle parent() returning null gracefully', () => {
mockToastr.success.mockReturnValueOnce({ parent: () => null })
// Should not throw
expect(() => plugin.renderEnvelopes([createEnvelope()])).not.toThrow()
})
})
describe('renderOptions', () => {
it('should set toastr options with defaults', () => {
plugin.renderOptions({ closeButton: true })
expect(mockToastr.options).toMatchObject({
timeOut: 10000,
progressBar: true,
closeButton: true,
})
})
it('should override default timeOut', () => {
plugin.renderOptions({ timeOut: 5000 })
expect(mockToastr.options.timeOut).toBe(5000)
})
it('should override default progressBar', () => {
plugin.renderOptions({ progressBar: false })
expect(mockToastr.options.progressBar).toBe(false)
})
it('should log error when jQuery is not available', () => {
delete (window as any).jQuery
delete (window as any).$
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
plugin.renderOptions({ timeOut: 5000 })
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('jQuery is required'),
)
// Restore jQuery
;(window as any).jQuery = mockJQuery
;(window as any).$ = mockJQuery
})
})
describe('convenience methods (inherited from AbstractPlugin)', () => {
it('success() should create success notification', () => {
plugin.success('Success message')
expect(mockToastr.success).toHaveBeenCalled()
})
it('error() should create error notification', () => {
plugin.error('Error message')
expect(mockToastr.error).toHaveBeenCalled()
})
it('info() should create info notification', () => {
plugin.info('Info message')
expect(mockToastr.info).toHaveBeenCalled()
})
it('warning() should create warning notification', () => {
plugin.warning('Warning message')
expect(mockToastr.warning).toHaveBeenCalled()
})
})
})
+491
View File
@@ -0,0 +1,491 @@
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) => `
<div class="fl-notification fl-${envelope.type}">
<div class="fl-content">
<strong class="fl-title">${envelope.title}</strong>
<span class="fl-message">${envelope.message}</span>
<button class="fl-close">&times;</button>
</div>
<span class="fl-progress-bar"></span>
</div>
`),
})
const createEnvelope = (overrides: Partial<Envelope> = {}): 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<string, unknown>)
plugin.renderOptions(undefined as unknown as Record<string, unknown>)
// 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: '<script>alert("xss")</script>',
title: '<b>Bold</b>',
})])
const message = document.querySelector('.fl-message')
const title = document.querySelector('.fl-title')
expect(message?.innerHTML).toContain('&lt;script&gt;')
expect(title?.innerHTML).toContain('&lt;b&gt;')
})
it('should not escape HTML when escapeHtml is false (default)', () => {
plugin.renderEnvelopes([createEnvelope({
message: '<b>Bold message</b>',
})])
const message = document.querySelector('.fl-message')
expect(message?.innerHTML).toContain('<b>Bold message</b>')
})
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('&amp;')
expect(message?.innerHTML).toContain('&lt;')
expect(message?.innerHTML).toContain('&gt;')
// 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: '<b>Bold</b>',
options: { escapeHtml: true }, // Per-envelope true
})])
const message = document.querySelector('.fl-message')
expect(message?.innerHTML).toContain('&lt;b&gt;')
})
})
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),
)
})
})
})
+553
View File
@@ -0,0 +1,553 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Flasher from '@flasher/flasher/flasher'
import type { Envelope, PluginInterface, Theme } from '@flasher/flasher/types'
// Mock plugin for testing
class MockPlugin implements PluginInterface {
public envelopes: Envelope[] = []
public options: Record<string, unknown> = {}
success = vi.fn()
error = vi.fn()
info = vi.fn()
warning = vi.fn()
flash = vi.fn()
renderEnvelopes(envelopes: Envelope[]): void {
this.envelopes.push(...envelopes)
}
renderOptions(options: Record<string, unknown>): void {
this.options = { ...this.options, ...options }
}
}
// Mock theme for testing
const mockTheme: Theme = {
styles: '/path/to/theme.css',
render: (envelope: Envelope) => `<div class="notification">${envelope.message}</div>`,
}
describe('Flasher', () => {
let flasher: Flasher
beforeEach(() => {
flasher = new Flasher()
})
describe('plugin registration', () => {
it('should register a plugin with addPlugin', () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
expect(flasher.use('test')).toBe(plugin)
})
it('should throw error when plugin name is missing', () => {
const plugin = new MockPlugin()
expect(() => flasher.addPlugin('', plugin)).toThrow('Both plugin name and instance are required')
})
it('should throw error when plugin instance is missing', () => {
expect(() => flasher.addPlugin('test', null as unknown as PluginInterface)).toThrow('Both plugin name and instance are required')
})
it('should throw error when using unregistered plugin', () => {
expect(() => flasher.use('nonexistent')).toThrow('Unable to resolve "nonexistent" plugin')
})
it('create() should be an alias for use()', () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
expect(flasher.create('test')).toBe(flasher.use('test'))
})
})
describe('theme registration', () => {
it('should register a theme with addTheme', () => {
flasher.addTheme('custom', mockTheme)
// Theme creates a FlasherPlugin when accessed
const plugin = flasher.use('theme.custom')
expect(plugin).toBeDefined()
})
it('should throw error when theme name is missing', () => {
expect(() => flasher.addTheme('', mockTheme)).toThrow('Both theme name and definition are required')
})
it('should throw error when theme definition is missing', () => {
expect(() => flasher.addTheme('test', null as unknown as Theme)).toThrow('Both theme name and definition are required')
})
})
describe('plugin alias resolution', () => {
it('should resolve "flasher" to "theme.flasher"', () => {
flasher.addTheme('flasher', mockTheme)
const plugin = flasher.use('flasher')
expect(plugin).toBeDefined()
})
it('should keep other plugin names unchanged', () => {
const plugin = new MockPlugin()
flasher.addPlugin('toastr', plugin)
expect(flasher.use('toastr')).toBe(plugin)
})
})
describe('renderEnvelopes', () => {
it('should do nothing with empty envelopes', () => {
flasher.renderEnvelopes([])
// Should not throw
})
it('should do nothing with null/undefined envelopes', () => {
flasher.renderEnvelopes(null as unknown as Envelope[])
flasher.renderEnvelopes(undefined as unknown as Envelope[])
// Should not throw
})
it('should group envelopes by plugin and render', () => {
const plugin1 = new MockPlugin()
const plugin2 = new MockPlugin()
flasher.addPlugin('plugin1', plugin1)
flasher.addPlugin('plugin2', plugin2)
const envelopes: Envelope[] = [
{ type: 'success', message: 'Message 1', title: 'Title 1', options: {}, metadata: { plugin: 'plugin1' } },
{ type: 'error', message: 'Message 2', title: 'Title 2', options: {}, metadata: { plugin: 'plugin2' } },
{ type: 'info', message: 'Message 3', title: 'Title 3', options: {}, metadata: { plugin: 'plugin1' } },
]
flasher.renderEnvelopes(envelopes)
expect(plugin1.envelopes).toHaveLength(2)
expect(plugin2.envelopes).toHaveLength(1)
})
it('should log error and continue when plugin throws', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const badPlugin: PluginInterface = {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
flash: vi.fn(),
renderEnvelopes: () => {
throw new Error('Plugin error')
},
renderOptions: vi.fn(),
}
flasher.addPlugin('bad', badPlugin)
flasher.renderEnvelopes([
{ type: 'success', message: 'Test', title: 'Test', options: {}, metadata: { plugin: 'bad' } },
])
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error rendering envelopes'),
expect.any(Error),
)
})
})
describe('renderOptions', () => {
it('should do nothing with null/undefined options', () => {
flasher.renderOptions(null as unknown as Record<string, unknown>)
flasher.renderOptions(undefined as unknown as Record<string, unknown>)
// Should not throw
})
it('should apply options to each plugin', () => {
const plugin1 = new MockPlugin()
const plugin2 = new MockPlugin()
flasher.addPlugin('plugin1', plugin1)
flasher.addPlugin('plugin2', plugin2)
flasher.renderOptions({
plugin1: { timeout: 5000 },
plugin2: { position: 'top-left' },
})
expect(plugin1.options).toEqual({ timeout: 5000 })
expect(plugin2.options).toEqual({ position: 'top-left' })
})
it('should log error and continue when plugin throws', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const badPlugin: PluginInterface = {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
flash: vi.fn(),
renderEnvelopes: vi.fn(),
renderOptions: () => {
throw new Error('Plugin error')
},
}
flasher.addPlugin('bad', badPlugin)
flasher.renderOptions({ bad: { some: 'option' } })
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error applying options'),
expect.any(Error),
)
})
})
describe('render()', () => {
it('should process response and render envelopes', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
await flasher.render({
envelopes: [
{ type: 'success', message: 'Test', title: 'Test', options: {}, metadata: { plugin: 'test' } },
],
options: { test: { timeout: 3000 } },
})
expect(plugin.envelopes).toHaveLength(1)
expect(plugin.options).toEqual({ timeout: 3000 })
})
it('should handle empty response gracefully', async () => {
await flasher.render({})
// Should not throw
})
it('should set default CSP nonces if not provided', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
await flasher.render({
envelopes: [
{ type: 'success', message: 'Test', title: 'Test', options: {}, metadata: { plugin: 'test' } },
],
})
expect(plugin.envelopes[0].context).toMatchObject({
csp_style_nonce: '',
csp_script_nonce: '',
})
})
it('should preserve provided CSP nonces', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
await flasher.render({
envelopes: [
{ type: 'success', message: 'Test', title: 'Test', options: {}, metadata: { plugin: 'test' } },
],
context: {
csp_style_nonce: 'style-nonce-123',
csp_script_nonce: 'script-nonce-456',
},
})
expect(plugin.envelopes[0].context).toMatchObject({
csp_style_nonce: 'style-nonce-123',
csp_script_nonce: 'script-nonce-456',
})
})
})
describe('function string conversion', () => {
it('should convert regular function strings to functions', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
await flasher.render({
envelopes: [{
type: 'success',
message: 'Test',
title: 'Test',
options: {
onClick: 'function(event) { return event.target }',
},
metadata: { plugin: 'test' },
}],
})
const onClick = plugin.envelopes[0].options.onClick
expect(typeof onClick).toBe('function')
})
it('should convert arrow function strings to functions', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
await flasher.render({
envelopes: [{
type: 'success',
message: 'Test',
title: 'Test',
options: {
callback: '(a, b) => a + b',
},
metadata: { plugin: 'test' },
}],
})
const callback = plugin.envelopes[0].options.callback as (a: number, b: number) => number
expect(typeof callback).toBe('function')
expect(callback(2, 3)).toBe(5)
})
it('should handle arrow functions with single parameter (no parens)', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
await flasher.render({
envelopes: [{
type: 'success',
message: 'Test',
title: 'Test',
options: {
// Single param with parens (more reliable parsing)
transform: '(x) => x * 2',
},
metadata: { plugin: 'test' },
}],
})
const transform = plugin.envelopes[0].options.transform as (x: number) => number
expect(typeof transform).toBe('function')
expect(transform(5)).toBe(10)
})
it('should leave non-function strings unchanged', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
await flasher.render({
envelopes: [{
type: 'success',
message: 'Test',
title: 'Test',
options: {
text: 'Hello World',
position: 'top-right',
},
metadata: { plugin: 'test' },
}],
})
expect(plugin.envelopes[0].options.text).toBe('Hello World')
expect(plugin.envelopes[0].options.position).toBe('top-right')
})
it('should leave non-string values unchanged', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
await flasher.render({
envelopes: [{
type: 'success',
message: 'Test',
title: 'Test',
options: {
timeout: 5000,
enabled: true,
data: { key: 'value' },
},
metadata: { plugin: 'test' },
}],
})
expect(plugin.envelopes[0].options.timeout).toBe(5000)
expect(plugin.envelopes[0].options.enabled).toBe(true)
expect(plugin.envelopes[0].options.data).toEqual({ key: 'value' })
})
it('should handle invalid function strings gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
await flasher.render({
envelopes: [{
type: 'success',
message: 'Test',
title: 'Test',
options: {
// This matches the regex but has invalid JS syntax
bad: 'function() { throw }',
},
metadata: { plugin: 'test' },
}],
})
// Should return original string on error
expect(plugin.envelopes[0].options.bad).toBe('function() { throw }')
expect(consoleSpy).toHaveBeenCalled()
})
})
describe('asset loading', () => {
it('should add style elements to document head', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
// Mock the load event
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = originalCreateElement(tag)
if (tag === 'link' || tag === 'script') {
setTimeout(() => el.onload?.({} as Event), 0)
}
return el
})
await flasher.render({
envelopes: [],
styles: ['/path/to/style.css'],
})
const link = document.head.querySelector('link[href="/path/to/style.css"]')
expect(link).toBeTruthy()
expect(link?.getAttribute('rel')).toBe('stylesheet')
})
it('should add script elements to document head', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = originalCreateElement(tag)
if (tag === 'link' || tag === 'script') {
setTimeout(() => el.onload?.({} as Event), 0)
}
return el
})
await flasher.render({
envelopes: [],
scripts: ['/path/to/script.js'],
})
const script = document.head.querySelector('script[src="/path/to/script.js"]')
expect(script).toBeTruthy()
expect(script?.getAttribute('type')).toBe('text/javascript')
})
it('should not load duplicate assets', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = originalCreateElement(tag)
if (tag === 'link' || tag === 'script') {
setTimeout(() => el.onload?.({} as Event), 0)
}
return el
})
await flasher.render({
envelopes: [],
styles: ['/path/to/style.css', '/path/to/style.css'],
})
const links = document.head.querySelectorAll('link[href="/path/to/style.css"]')
expect(links).toHaveLength(1)
})
it('should apply CSP nonce to loaded assets', async () => {
const plugin = new MockPlugin()
flasher.addPlugin('test', plugin)
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = originalCreateElement(tag)
if (tag === 'link' || tag === 'script') {
setTimeout(() => el.onload?.({} as Event), 0)
}
return el
})
await flasher.render({
envelopes: [],
styles: ['/path/to/style.css'],
context: {
csp_style_nonce: 'test-nonce-123',
},
})
const link = document.head.querySelector('link[href="/path/to/style.css"]')
expect(link?.getAttribute('nonce')).toBe('test-nonce-123')
})
})
describe('theme styles handling', () => {
it('should add theme styles to response when using theme plugin', async () => {
flasher.addTheme('custom', {
styles: '/custom/theme.css',
render: () => '<div></div>',
})
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = originalCreateElement(tag)
if (tag === 'link' || tag === 'script') {
setTimeout(() => el.onload?.({} as Event), 0)
}
return el
})
await flasher.render({
envelopes: [{
type: 'success',
message: 'Test',
title: 'Test',
options: {},
metadata: { plugin: 'theme.custom' },
}],
})
const link = document.head.querySelector('link[href="/custom/theme.css"]')
expect(link).toBeTruthy()
})
it('should handle array of theme styles', async () => {
flasher.addTheme('multi', {
styles: ['/style1.css', '/style2.css'],
render: () => '<div></div>',
})
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = originalCreateElement(tag)
if (tag === 'link' || tag === 'script') {
setTimeout(() => el.onload?.({} as Event), 0)
}
return el
})
await flasher.render({
envelopes: [{
type: 'success',
message: 'Test',
title: 'Test',
options: {},
metadata: { plugin: 'theme.multi' },
}],
})
expect(document.head.querySelector('link[href="/style1.css"]')).toBeTruthy()
expect(document.head.querySelector('link[href="/style2.css"]')).toBeTruthy()
})
})
})
+262
View File
@@ -0,0 +1,262 @@
import { describe, expect, it, vi } from 'vitest'
import { AbstractPlugin } from '@flasher/flasher/plugin'
import type { Envelope, Options } from '@flasher/flasher/types'
// Concrete implementation for testing
class TestPlugin extends AbstractPlugin {
public envelopes: Envelope[] = []
public options: Options = {}
renderEnvelopes(envelopes: Envelope[]): void {
this.envelopes = envelopes
}
renderOptions(options: Options): void {
this.options = options
}
}
describe('AbstractPlugin', () => {
describe('flash() argument normalization', () => {
it('should handle basic call: flash(type, message)', () => {
const plugin = new TestPlugin()
plugin.flash('success', 'Hello World')
expect(plugin.envelopes).toHaveLength(1)
expect(plugin.envelopes[0]).toMatchObject({
type: 'success',
message: 'Hello World',
title: 'Success',
options: {},
})
})
it('should handle call with title: flash(type, message, title)', () => {
const plugin = new TestPlugin()
plugin.flash('error', 'Something went wrong', 'Error Title')
expect(plugin.envelopes[0]).toMatchObject({
type: 'error',
message: 'Something went wrong',
title: 'Error Title',
})
})
it('should handle call with options: flash(type, message, title, options)', () => {
const plugin = new TestPlugin()
plugin.flash('info', 'Info message', 'Info', { timeout: 5000 })
expect(plugin.envelopes[0]).toMatchObject({
type: 'info',
message: 'Info message',
title: 'Info',
options: { timeout: 5000 },
})
})
it('should handle object as first argument: flash({ type, message, title })', () => {
const plugin = new TestPlugin()
plugin.flash({
type: 'warning',
message: 'Warning message',
title: 'Custom Title',
customOption: true,
})
expect(plugin.envelopes[0]).toMatchObject({
type: 'warning',
message: 'Warning message',
title: 'Custom Title',
options: { customOption: true },
})
})
it('should handle object as second argument: flash(type, { message, title })', () => {
const plugin = new TestPlugin()
plugin.flash('success', {
message: 'Success message',
title: 'Custom Title',
extra: 'data',
})
expect(plugin.envelopes[0]).toMatchObject({
type: 'success',
message: 'Success message',
title: 'Custom Title',
options: { extra: 'data' },
})
})
it('should handle object as third argument: flash(type, message, { title })', () => {
const plugin = new TestPlugin()
plugin.flash('info', 'Info message', { title: 'Object Title', key: 'value' })
expect(plugin.envelopes[0]).toMatchObject({
type: 'info',
message: 'Info message',
title: 'Object Title',
options: { key: 'value' },
})
})
it('should handle object as third argument without title: flash(type, message, { options })', () => {
const plugin = new TestPlugin()
plugin.flash('info', 'Info message', { timeout: 3000 })
expect(plugin.envelopes[0]).toMatchObject({
type: 'info',
message: 'Info message',
title: 'Info', // Auto-generated from type
options: { timeout: 3000 },
})
})
it('should merge options when third argument is object and fourth is also provided', () => {
const plugin = new TestPlugin()
plugin.flash('success', 'Message', { key1: 'value1' }, { key2: 'value2' })
expect(plugin.envelopes[0].options).toMatchObject({
key1: 'value1',
key2: 'value2',
})
})
it('should auto-generate title from type when not provided', () => {
const plugin = new TestPlugin()
plugin.flash('success', 'Message')
expect(plugin.envelopes[0].title).toBe('Success')
plugin.flash('error', 'Message')
expect(plugin.envelopes[0].title).toBe('Error')
plugin.flash('warning', 'Message')
expect(plugin.envelopes[0].title).toBe('Warning')
plugin.flash('info', 'Message')
expect(plugin.envelopes[0].title).toBe('Info')
})
it('should handle null title by auto-generating', () => {
const plugin = new TestPlugin()
plugin.flash('success', 'Message', null as unknown as string)
expect(plugin.envelopes[0].title).toBe('Success')
})
it('should handle undefined title by auto-generating', () => {
const plugin = new TestPlugin()
plugin.flash('success', 'Message', undefined)
expect(plugin.envelopes[0].title).toBe('Success')
})
it('should throw error when type is missing', () => {
const plugin = new TestPlugin()
expect(() => plugin.flash({
message: 'No type provided',
})).toThrow('Type is required for notifications')
})
it('should throw error when message is missing', () => {
const plugin = new TestPlugin()
expect(() => plugin.flash({
type: 'success',
})).toThrow('Message is required for notifications')
})
it('should throw error when message is null', () => {
const plugin = new TestPlugin()
expect(() => plugin.flash('success', null as unknown as string)).toThrow('Message is required for notifications')
})
it('should include metadata with empty plugin string', () => {
const plugin = new TestPlugin()
plugin.flash('success', 'Message')
expect(plugin.envelopes[0].metadata).toEqual({ plugin: '' })
})
})
describe('convenience methods', () => {
it('success() should call flash with type "success"', () => {
const plugin = new TestPlugin()
const flashSpy = vi.spyOn(plugin, 'flash')
plugin.success('Success message', 'Title', { option: true })
expect(flashSpy).toHaveBeenCalledWith('success', 'Success message', 'Title', { option: true })
})
it('error() should call flash with type "error"', () => {
const plugin = new TestPlugin()
const flashSpy = vi.spyOn(plugin, 'flash')
plugin.error('Error message')
expect(flashSpy).toHaveBeenCalledWith('error', 'Error message', undefined, undefined)
})
it('info() should call flash with type "info"', () => {
const plugin = new TestPlugin()
const flashSpy = vi.spyOn(plugin, 'flash')
plugin.info('Info message', 'Info Title')
expect(flashSpy).toHaveBeenCalledWith('info', 'Info message', 'Info Title', undefined)
})
it('warning() should call flash with type "warning"', () => {
const plugin = new TestPlugin()
const flashSpy = vi.spyOn(plugin, 'flash')
plugin.warning({ message: 'Warning', title: 'Warn' })
expect(flashSpy).toHaveBeenCalledWith('warning', { message: 'Warning', title: 'Warn' }, undefined, undefined)
})
})
describe('renderEnvelopes and renderOptions calls', () => {
it('should call renderOptions before renderEnvelopes', () => {
const plugin = new TestPlugin()
const callOrder: string[] = []
vi.spyOn(plugin, 'renderOptions').mockImplementation(() => {
callOrder.push('renderOptions')
})
vi.spyOn(plugin, 'renderEnvelopes').mockImplementation(() => {
callOrder.push('renderEnvelopes')
})
plugin.flash('success', 'Message')
expect(callOrder).toEqual(['renderOptions', 'renderEnvelopes'])
})
it('should pass empty object to renderOptions', () => {
const plugin = new TestPlugin()
const renderOptionsSpy = vi.spyOn(plugin, 'renderOptions')
plugin.flash('success', 'Message')
expect(renderOptionsSpy).toHaveBeenCalledWith({})
})
it('should pass envelope array to renderEnvelopes', () => {
const plugin = new TestPlugin()
const renderEnvelopesSpy = vi.spyOn(plugin, 'renderEnvelopes')
plugin.flash('success', 'Message')
expect(renderEnvelopesSpy).toHaveBeenCalledWith([
expect.objectContaining({
type: 'success',
message: 'Message',
}),
])
})
})
})
+14
View File
@@ -0,0 +1,14 @@
import { afterEach, vi } from 'vitest'
// Clean up DOM after each test
afterEach(() => {
document.body.innerHTML = ''
document.head.innerHTML = ''
vi.clearAllMocks()
vi.restoreAllMocks()
vi.useRealTimers()
})
// Mock CSS imports
vi.mock('*.scss', () => ({}))
vi.mock('*.css', () => ({}))
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it, vi } from 'vitest'
import type { Envelope } from '@flasher/flasher/types'
// Mock SCSS imports
vi.mock('@flasher/flasher/themes/flasher/flasher.scss', () => ({}))
// Import theme after mocking
import { flasherTheme } from '@flasher/flasher/themes/flasher/flasher'
const createEnvelope = (overrides: Partial<Envelope> = {}): Envelope => ({
type: 'success',
message: 'Test message',
title: 'Test title',
options: {},
metadata: { plugin: 'theme.flasher' },
...overrides,
})
describe('flasherTheme', () => {
describe('render function', () => {
it('should return valid HTML string', () => {
const html = flasherTheme.render(createEnvelope())
expect(html).toContain('fl-flasher')
expect(html).toContain('fl-success')
})
it('should include message and title', () => {
const html = flasherTheme.render(createEnvelope({
title: 'My Title',
message: 'My Message',
}))
expect(html).toContain('My Title')
expect(html).toContain('My Message')
})
it('should apply type-specific class', () => {
const types = ['success', 'error', 'warning', 'info']
types.forEach((type) => {
const html = flasherTheme.render(createEnvelope({ type }))
expect(html).toContain(`fl-${type}`)
})
})
it('should capitalize type for default title when title is empty', () => {
const html = flasherTheme.render(createEnvelope({
type: 'success',
title: '',
}))
expect(html).toContain('Success')
})
it('should include close button', () => {
const html = flasherTheme.render(createEnvelope())
expect(html).toContain('fl-close')
expect(html).toContain('&times;')
})
it('should include progress bar container', () => {
const html = flasherTheme.render(createEnvelope())
expect(html).toContain('fl-progress-bar')
})
it('should include icon container', () => {
const html = flasherTheme.render(createEnvelope())
expect(html).toContain('fl-icon')
})
})
describe('accessibility', () => {
it('should have role="alert" for error type', () => {
const html = flasherTheme.render(createEnvelope({ type: 'error' }))
expect(html).toContain('role="alert"')
})
it('should have role="alert" for warning type', () => {
const html = flasherTheme.render(createEnvelope({ type: 'warning' }))
expect(html).toContain('role="alert"')
})
it('should have role="status" for success type', () => {
const html = flasherTheme.render(createEnvelope({ type: 'success' }))
expect(html).toContain('role="status"')
})
it('should have role="status" for info type', () => {
const html = flasherTheme.render(createEnvelope({ type: 'info' }))
expect(html).toContain('role="status"')
})
it('should have aria-live="assertive" for error/warning', () => {
const errorHtml = flasherTheme.render(createEnvelope({ type: 'error' }))
const warningHtml = flasherTheme.render(createEnvelope({ type: 'warning' }))
expect(errorHtml).toContain('aria-live="assertive"')
expect(warningHtml).toContain('aria-live="assertive"')
})
it('should have aria-live="polite" for success/info', () => {
const successHtml = flasherTheme.render(createEnvelope({ type: 'success' }))
const infoHtml = flasherTheme.render(createEnvelope({ type: 'info' }))
expect(successHtml).toContain('aria-live="polite"')
expect(infoHtml).toContain('aria-live="polite"')
})
it('should have aria-atomic="true"', () => {
const html = flasherTheme.render(createEnvelope())
expect(html).toContain('aria-atomic="true"')
})
it('should have accessible close button label', () => {
const html = flasherTheme.render(createEnvelope({ type: 'success' }))
expect(html).toContain('aria-label="Close success message"')
})
})
describe('HTML structure', () => {
it('should have content wrapper', () => {
const html = flasherTheme.render(createEnvelope())
expect(html).toContain('fl-content')
})
it('should have title element with fl-title class', () => {
const html = flasherTheme.render(createEnvelope())
expect(html).toContain('fl-title')
expect(html).toContain('<strong')
})
it('should have message element with fl-message class', () => {
const html = flasherTheme.render(createEnvelope())
expect(html).toContain('fl-message')
expect(html).toContain('<span')
})
})
})
describe('Theme contract', () => {
it('flasherTheme should have required render function', () => {
expect(typeof flasherTheme.render).toBe('function')
})
it('render should return string', () => {
const result = flasherTheme.render(createEnvelope())
expect(typeof result).toBe('string')
})
it('should handle all standard notification types', () => {
const types = ['success', 'error', 'warning', 'info']
types.forEach((type) => {
expect(() => flasherTheme.render(createEnvelope({ type }))).not.toThrow()
})
})
it('should handle custom notification types', () => {
expect(() => flasherTheme.render(createEnvelope({ type: 'custom' }))).not.toThrow()
})
it('should handle empty message', () => {
expect(() => flasherTheme.render(createEnvelope({ message: '' }))).not.toThrow()
})
it('should handle empty title', () => {
expect(() => flasherTheme.render(createEnvelope({ title: '' }))).not.toThrow()
})
it('should handle special characters in message', () => {
const html = flasherTheme.render(createEnvelope({
message: 'Test <script>alert("xss")</script>',
}))
// Theme doesn't escape - that's FlasherPlugin's responsibility
expect(html).toContain('Test <script>alert("xss")</script>')
})
})