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
+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()
})
})
})