mirror of
https://github.com/php-flasher/php-flasher.git
synced 2026-03-31 15:07:47 +01:00
add vitest for JS/TS testing with comprehensive test coverage
This commit is contained in:
@@ -5,7 +5,28 @@ on:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Run Tests Before Publishing
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔧 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 📦 Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ✅ Run Tests
|
||||
run: npm run test
|
||||
|
||||
publish-prime:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -30,6 +51,7 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
publish-plugin:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -10,6 +10,29 @@ on:
|
||||
- cron: '0 0 * * *' # Daily at midnight
|
||||
|
||||
jobs:
|
||||
javascript-tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: JavaScript Tests
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔧 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 📦 Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ✅ Run Tests
|
||||
run: npm run test
|
||||
|
||||
- name: 📊 Run Coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
static-analysis:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
/vendor/
|
||||
/node_modules/
|
||||
|
||||
/coverage/
|
||||
|
||||
/.cache/php-cs-fixer/
|
||||
/.cache/phplint/
|
||||
/.cache/phpstan/
|
||||
|
||||
Generated
+2032
-36
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -41,10 +41,16 @@
|
||||
"build": "cross-env NODE_ENV=production rollup -c",
|
||||
"clean": "rimraf src/*/Prime/Resources/dist",
|
||||
"link": "npm link --workspaces",
|
||||
"ncu": "ncu -u && npm run ncu --workspaces"
|
||||
"ncu": "ncu -u && npm run ncu --workspaces",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^2.27.3",
|
||||
"@vitest/coverage-v8": "^3.0.7",
|
||||
"@vitest/ui": "^3.0.7",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
@@ -84,6 +90,8 @@
|
||||
"sass": "^1.97.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.0.7",
|
||||
"jsdom": "^26.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,9 @@ export default class FlasherPlugin extends AbstractPlugin {
|
||||
// Apply custom styles
|
||||
Object.entries(options.style).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
container.style.setProperty(key, String(value))
|
||||
// Convert camelCase to kebab-case for CSS property names
|
||||
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase()
|
||||
container.style.setProperty(cssKey, String(value))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export default class Flasher extends AbstractPlugin {
|
||||
envelope.metadata.plugin = this.resolvePluginAlias(envelope.metadata.plugin)
|
||||
this.addThemeStyles(resolved, envelope.metadata.plugin)
|
||||
envelope.options = this.resolveOptions(envelope.options)
|
||||
envelope.context = response.context as Context
|
||||
envelope.context = resolved.context
|
||||
})
|
||||
|
||||
return resolved
|
||||
@@ -156,17 +156,32 @@ export default class Flasher extends AbstractPlugin {
|
||||
const functionRegex = /^function\s*(\w*)\s*\(([^)]*)\)\s*\{([\s\S]*)\}$/
|
||||
const arrowFunctionRegex = /^\s*(\(([^)]*)\)|[^=]+)\s*=>\s*([\s\S]+)$/
|
||||
|
||||
const match = func.match(functionRegex) || func.match(arrowFunctionRegex)
|
||||
if (!match) {
|
||||
const functionMatch = func.match(functionRegex)
|
||||
const arrowMatch = func.match(arrowFunctionRegex)
|
||||
|
||||
if (!functionMatch && !arrowMatch) {
|
||||
return func
|
||||
}
|
||||
|
||||
const args = match[2]?.split(',').map((arg) => arg.trim()) ?? []
|
||||
let body = match[3].trim()
|
||||
let args: string[]
|
||||
let body: string
|
||||
|
||||
// Arrow functions with a single expression can omit the curly braces and the return keyword
|
||||
if (functionMatch) {
|
||||
// Regular function: body is already complete statements
|
||||
args = functionMatch[2]?.split(',').map((arg) => arg.trim()).filter(Boolean) ?? []
|
||||
body = functionMatch[3].trim()
|
||||
} else {
|
||||
// Arrow function: may need to wrap expression body with return
|
||||
args = arrowMatch![2]?.split(',').map((arg) => arg.trim()).filter(Boolean) ?? []
|
||||
body = arrowMatch![3].trim()
|
||||
|
||||
// Arrow functions with a single expression need return added
|
||||
if (!body.startsWith('{')) {
|
||||
body = `{ return ${body}; }`
|
||||
body = `return ${body};`
|
||||
} else {
|
||||
// Remove outer braces for arrow functions with block body
|
||||
body = body.slice(1, -1).trim()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
+21
-8
@@ -190,7 +190,8 @@ class FlasherPlugin extends AbstractPlugin {
|
||||
container.dataset.position = options.position;
|
||||
Object.entries(options.style).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
container.style.setProperty(key, String(value));
|
||||
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
container.style.setProperty(cssKey, String(value));
|
||||
}
|
||||
});
|
||||
document.body.appendChild(container);
|
||||
@@ -413,7 +414,7 @@ class Flasher extends AbstractPlugin {
|
||||
envelope.metadata.plugin = this.resolvePluginAlias(envelope.metadata.plugin);
|
||||
this.addThemeStyles(resolved, envelope.metadata.plugin);
|
||||
envelope.options = this.resolveOptions(envelope.options);
|
||||
envelope.context = response.context;
|
||||
envelope.context = resolved.context;
|
||||
});
|
||||
return resolved;
|
||||
}
|
||||
@@ -428,20 +429,32 @@ class Flasher extends AbstractPlugin {
|
||||
return resolved;
|
||||
}
|
||||
resolveFunction(func) {
|
||||
var _a, _b;
|
||||
var _a, _b, _c, _d;
|
||||
if (typeof func !== 'string') {
|
||||
return func;
|
||||
}
|
||||
const functionRegex = /^function\s*(\w*)\s*\(([^)]*)\)\s*\{([\s\S]*)\}$/;
|
||||
const arrowFunctionRegex = /^\s*(\(([^)]*)\)|[^=]+)\s*=>\s*([\s\S]+)$/;
|
||||
const match = func.match(functionRegex) || func.match(arrowFunctionRegex);
|
||||
if (!match) {
|
||||
const functionMatch = func.match(functionRegex);
|
||||
const arrowMatch = func.match(arrowFunctionRegex);
|
||||
if (!functionMatch && !arrowMatch) {
|
||||
return func;
|
||||
}
|
||||
const args = (_b = (_a = match[2]) === null || _a === void 0 ? void 0 : _a.split(',').map((arg) => arg.trim())) !== null && _b !== void 0 ? _b : [];
|
||||
let body = match[3].trim();
|
||||
let args;
|
||||
let body;
|
||||
if (functionMatch) {
|
||||
args = (_b = (_a = functionMatch[2]) === null || _a === void 0 ? void 0 : _a.split(',').map((arg) => arg.trim()).filter(Boolean)) !== null && _b !== void 0 ? _b : [];
|
||||
body = functionMatch[3].trim();
|
||||
}
|
||||
else {
|
||||
args = (_d = (_c = arrowMatch[2]) === null || _c === void 0 ? void 0 : _c.split(',').map((arg) => arg.trim()).filter(Boolean)) !== null && _d !== void 0 ? _d : [];
|
||||
body = arrowMatch[3].trim();
|
||||
if (!body.startsWith('{')) {
|
||||
body = `{ return ${body}; }`;
|
||||
body = `return ${body};`;
|
||||
}
|
||||
else {
|
||||
body = body.slice(1, -1).trim();
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new Function(...args, body);
|
||||
|
||||
Vendored
+21
-8
@@ -196,7 +196,8 @@
|
||||
container.dataset.position = options.position;
|
||||
Object.entries(options.style).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
container.style.setProperty(key, String(value));
|
||||
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
container.style.setProperty(cssKey, String(value));
|
||||
}
|
||||
});
|
||||
document.body.appendChild(container);
|
||||
@@ -419,7 +420,7 @@
|
||||
envelope.metadata.plugin = this.resolvePluginAlias(envelope.metadata.plugin);
|
||||
this.addThemeStyles(resolved, envelope.metadata.plugin);
|
||||
envelope.options = this.resolveOptions(envelope.options);
|
||||
envelope.context = response.context;
|
||||
envelope.context = resolved.context;
|
||||
});
|
||||
return resolved;
|
||||
}
|
||||
@@ -434,20 +435,32 @@
|
||||
return resolved;
|
||||
}
|
||||
resolveFunction(func) {
|
||||
var _a, _b;
|
||||
var _a, _b, _c, _d;
|
||||
if (typeof func !== 'string') {
|
||||
return func;
|
||||
}
|
||||
const functionRegex = /^function\s*(\w*)\s*\(([^)]*)\)\s*\{([\s\S]*)\}$/;
|
||||
const arrowFunctionRegex = /^\s*(\(([^)]*)\)|[^=]+)\s*=>\s*([\s\S]+)$/;
|
||||
const match = func.match(functionRegex) || func.match(arrowFunctionRegex);
|
||||
if (!match) {
|
||||
const functionMatch = func.match(functionRegex);
|
||||
const arrowMatch = func.match(arrowFunctionRegex);
|
||||
if (!functionMatch && !arrowMatch) {
|
||||
return func;
|
||||
}
|
||||
const args = (_b = (_a = match[2]) === null || _a === void 0 ? void 0 : _a.split(',').map((arg) => arg.trim())) !== null && _b !== void 0 ? _b : [];
|
||||
let body = match[3].trim();
|
||||
let args;
|
||||
let body;
|
||||
if (functionMatch) {
|
||||
args = (_b = (_a = functionMatch[2]) === null || _a === void 0 ? void 0 : _a.split(',').map((arg) => arg.trim()).filter(Boolean)) !== null && _b !== void 0 ? _b : [];
|
||||
body = functionMatch[3].trim();
|
||||
}
|
||||
else {
|
||||
args = (_d = (_c = arrowMatch[2]) === null || _c === void 0 ? void 0 : _c.split(',').map((arg) => arg.trim()).filter(Boolean)) !== null && _d !== void 0 ? _d : [];
|
||||
body = arrowMatch[3].trim();
|
||||
if (!body.startsWith('{')) {
|
||||
body = `{ return ${body}; }`;
|
||||
body = `return ${body};`;
|
||||
}
|
||||
else {
|
||||
body = body.slice(1, -1).trim();
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new Function(...args, body);
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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">×</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('<script>')
|
||||
expect(title?.innerHTML).toContain('<b>')
|
||||
})
|
||||
|
||||
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('&')
|
||||
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: '<b>Bold</b>',
|
||||
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),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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', () => ({}))
|
||||
@@ -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('×')
|
||||
})
|
||||
|
||||
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>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/*/Resources/assets/**/*.ts'],
|
||||
exclude: [
|
||||
'**/index.ts',
|
||||
'**/*.d.ts',
|
||||
'**/themes/**',
|
||||
],
|
||||
},
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@flasher/flasher/dist/plugin': resolve(__dirname, 'src/Prime/Resources/assets/plugin'),
|
||||
'@flasher/flasher/dist/types': resolve(__dirname, 'src/Prime/Resources/assets/types'),
|
||||
'@flasher/flasher': resolve(__dirname, 'src/Prime/Resources/assets'),
|
||||
'@flasher/flasher-noty': resolve(__dirname, 'src/Noty/Prime/Resources/assets'),
|
||||
'@flasher/flasher-notyf': resolve(__dirname, 'src/Notyf/Prime/Resources/assets'),
|
||||
'@flasher/flasher-sweetalert': resolve(__dirname, 'src/SweetAlert/Prime/Resources/assets'),
|
||||
'@flasher/flasher-toastr': resolve(__dirname, 'src/Toastr/Prime/Resources/assets'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user