import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; /** * Unit tests for the Web Vitals monitoring module (webVitals.ts). * * Tests metric collection initialization, rating calculations against * Core Web Vitals 2024 thresholds, and the default reporter behavior * (console logging in debug mode, sendBeacon/fetch in production). */ // Capture the callbacks registered by initWebVitals const mockOnLCP = vi.fn(); const mockOnCLS = vi.fn(); const mockOnINP = vi.fn(); const mockOnFCP = vi.fn(); const mockOnTTFB = vi.fn(); vi.mock('web-vitals', () => ({ onLCP: mockOnLCP, onCLS: mockOnCLS, onINP: mockOnINP, onFCP: mockOnFCP, onTTFB: mockOnTTFB })); describe('webVitals monitoring', () => { let webVitalsModule: typeof import('$lib/monitoring/webVitals'); beforeEach(async () => { vi.clearAllMocks(); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.resetModules(); webVitalsModule = await import('$lib/monitoring/webVitals'); }); afterEach(() => { vi.restoreAllMocks(); }); // ========================================================================== // initWebVitals // ========================================================================== describe('initWebVitals', () => { it('should register callbacks for all five web vitals', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); expect(mockOnLCP).toHaveBeenCalledOnce(); expect(mockOnCLS).toHaveBeenCalledOnce(); expect(mockOnINP).toHaveBeenCalledOnce(); expect(mockOnFCP).toHaveBeenCalledOnce(); expect(mockOnTTFB).toHaveBeenCalledOnce(); }); it('should pass metrics to the reporter when LCP fires', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); // Simulate LCP metric firing const lcpCallback = mockOnLCP.mock.calls[0]![0]; lcpCallback({ name: 'LCP', value: 2000, delta: 2000, id: 'v1-lcp' }); expect(reporter).toHaveBeenCalledOnce(); expect(reporter).toHaveBeenCalledWith( expect.objectContaining({ name: 'LCP', value: 2000, rating: 'good', delta: 2000, id: 'v1-lcp' }) ); }); it('should pass metrics to the reporter when CLS fires', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const clsCallback = mockOnCLS.mock.calls[0]![0]; clsCallback({ name: 'CLS', value: 0.05, delta: 0.05, id: 'v1-cls' }); expect(reporter).toHaveBeenCalledWith( expect.objectContaining({ name: 'CLS', value: 0.05, rating: 'good', delta: 0.05, id: 'v1-cls' }) ); }); it('should pass metrics to the reporter when INP fires', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const inpCallback = mockOnINP.mock.calls[0]![0]; inpCallback({ name: 'INP', value: 150, delta: 150, id: 'v1-inp' }); expect(reporter).toHaveBeenCalledWith( expect.objectContaining({ name: 'INP', value: 150, rating: 'good' }) ); }); it('should pass metrics to the reporter when FCP fires', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const fcpCallback = mockOnFCP.mock.calls[0]![0]; fcpCallback({ name: 'FCP', value: 1500, delta: 1500, id: 'v1-fcp' }); expect(reporter).toHaveBeenCalledWith( expect.objectContaining({ name: 'FCP', value: 1500, rating: 'good' }) ); }); it('should pass metrics to the reporter when TTFB fires', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const ttfbCallback = mockOnTTFB.mock.calls[0]![0]; ttfbCallback({ name: 'TTFB', value: 600, delta: 600, id: 'v1-ttfb' }); expect(reporter).toHaveBeenCalledWith( expect.objectContaining({ name: 'TTFB', value: 600, rating: 'good' }) ); }); }); // ========================================================================== // Rating calculations (via initWebVitals callback pipeline) // ========================================================================== describe('rating calculations', () => { // LCP thresholds: good <= 2500, needs-improvement <= 4000, poor > 4000 describe('LCP ratings', () => { it('should rate LCP as good when value <= 2500', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const lcpCallback = mockOnLCP.mock.calls[0]![0]; lcpCallback({ name: 'LCP', value: 2500, delta: 2500, id: 'lcp-1' }); expect(reporter.mock.calls[0]![0].rating).toBe('good'); }); it('should rate LCP as needs-improvement when 2500 < value <= 4000', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const lcpCallback = mockOnLCP.mock.calls[0]![0]; lcpCallback({ name: 'LCP', value: 3000, delta: 3000, id: 'lcp-2' }); expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement'); }); it('should rate LCP as poor when value > 4000', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const lcpCallback = mockOnLCP.mock.calls[0]![0]; lcpCallback({ name: 'LCP', value: 5000, delta: 5000, id: 'lcp-3' }); expect(reporter.mock.calls[0]![0].rating).toBe('poor'); }); }); // FCP thresholds: good <= 1800, needs-improvement <= 3000, poor > 3000 describe('FCP ratings', () => { it('should rate FCP as good when value <= 1800', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const fcpCallback = mockOnFCP.mock.calls[0]![0]; fcpCallback({ name: 'FCP', value: 1800, delta: 1800, id: 'fcp-1' }); expect(reporter.mock.calls[0]![0].rating).toBe('good'); }); it('should rate FCP as needs-improvement when 1800 < value <= 3000', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const fcpCallback = mockOnFCP.mock.calls[0]![0]; fcpCallback({ name: 'FCP', value: 2500, delta: 2500, id: 'fcp-2' }); expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement'); }); it('should rate FCP as poor when value > 3000', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const fcpCallback = mockOnFCP.mock.calls[0]![0]; fcpCallback({ name: 'FCP', value: 4000, delta: 4000, id: 'fcp-3' }); expect(reporter.mock.calls[0]![0].rating).toBe('poor'); }); }); // INP thresholds: good <= 200, needs-improvement <= 500, poor > 500 describe('INP ratings', () => { it('should rate INP as good when value <= 200', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const inpCallback = mockOnINP.mock.calls[0]![0]; inpCallback({ name: 'INP', value: 200, delta: 200, id: 'inp-1' }); expect(reporter.mock.calls[0]![0].rating).toBe('good'); }); it('should rate INP as needs-improvement when 200 < value <= 500', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const inpCallback = mockOnINP.mock.calls[0]![0]; inpCallback({ name: 'INP', value: 350, delta: 350, id: 'inp-2' }); expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement'); }); it('should rate INP as poor when value > 500', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const inpCallback = mockOnINP.mock.calls[0]![0]; inpCallback({ name: 'INP', value: 600, delta: 600, id: 'inp-3' }); expect(reporter.mock.calls[0]![0].rating).toBe('poor'); }); }); // CLS thresholds: good <= 0.1, needs-improvement <= 0.25, poor > 0.25 describe('CLS ratings', () => { it('should rate CLS as good when value <= 0.1', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const clsCallback = mockOnCLS.mock.calls[0]![0]; clsCallback({ name: 'CLS', value: 0.1, delta: 0.1, id: 'cls-1' }); expect(reporter.mock.calls[0]![0].rating).toBe('good'); }); it('should rate CLS as needs-improvement when 0.1 < value <= 0.25', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const clsCallback = mockOnCLS.mock.calls[0]![0]; clsCallback({ name: 'CLS', value: 0.15, delta: 0.15, id: 'cls-2' }); expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement'); }); it('should rate CLS as poor when value > 0.25', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const clsCallback = mockOnCLS.mock.calls[0]![0]; clsCallback({ name: 'CLS', value: 0.5, delta: 0.5, id: 'cls-3' }); expect(reporter.mock.calls[0]![0].rating).toBe('poor'); }); }); // TTFB thresholds: good <= 800, needs-improvement <= 1800, poor > 1800 describe('TTFB ratings', () => { it('should rate TTFB as good when value <= 800', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const ttfbCallback = mockOnTTFB.mock.calls[0]![0]; ttfbCallback({ name: 'TTFB', value: 800, delta: 800, id: 'ttfb-1' }); expect(reporter.mock.calls[0]![0].rating).toBe('good'); }); it('should rate TTFB as needs-improvement when 800 < value <= 1800', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const ttfbCallback = mockOnTTFB.mock.calls[0]![0]; ttfbCallback({ name: 'TTFB', value: 1200, delta: 1200, id: 'ttfb-2' }); expect(reporter.mock.calls[0]![0].rating).toBe('needs-improvement'); }); it('should rate TTFB as poor when value > 1800', () => { const reporter = vi.fn(); webVitalsModule.initWebVitals(reporter); const ttfbCallback = mockOnTTFB.mock.calls[0]![0]; ttfbCallback({ name: 'TTFB', value: 2500, delta: 2500, id: 'ttfb-3' }); expect(reporter.mock.calls[0]![0].rating).toBe('poor'); }); }); }); // ========================================================================== // createDefaultReporter // ========================================================================== describe('createDefaultReporter', () => { it('should return a function', () => { const reporter = webVitalsModule.createDefaultReporter({}); expect(typeof reporter).toBe('function'); }); it('should log to console when debug is true', () => { const reporter = webVitalsModule.createDefaultReporter({ debug: true }); reporter({ name: 'LCP', value: 2100.5, rating: 'good', delta: 2100.5, id: 'v1-lcp' }); expect(console.log).toHaveBeenCalledWith( '[WebVitals] LCP: 2100.50 (good)' ); }); it('should not log to console when debug is false', () => { const reporter = webVitalsModule.createDefaultReporter({ debug: false }); reporter({ name: 'LCP', value: 2100, rating: 'good', delta: 2100, id: 'v1-lcp' }); expect(console.log).not.toHaveBeenCalled(); }); it('should not log to console when debug is not set', () => { const reporter = webVitalsModule.createDefaultReporter({}); reporter({ name: 'FCP', value: 1500, rating: 'good', delta: 1500, id: 'v1-fcp' }); expect(console.log).not.toHaveBeenCalled(); }); it('should use sendBeacon when endpoint is set and sendBeacon is available', () => { const mockSendBeacon = vi.fn().mockReturnValue(true); vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon }); const reporter = webVitalsModule.createDefaultReporter({ endpoint: 'https://analytics.classeo.fr/vitals' }); const metric = { name: 'CLS' as const, value: 0.05, rating: 'good' as const, delta: 0.05, id: 'v1-cls' }; reporter(metric); expect(mockSendBeacon).toHaveBeenCalledWith( 'https://analytics.classeo.fr/vitals', expect.any(String) ); // Verify the payload structure const payload = JSON.parse(mockSendBeacon.mock.calls[0]![1]); expect(payload).toEqual( expect.objectContaining({ metric: 'CLS', value: 0.05, rating: 'good', delta: 0.05, id: 'v1-cls' }) ); expect(payload.timestamp).toEqual(expect.any(Number)); expect(payload.url).toEqual(expect.any(String)); }); it('should fall back to fetch when sendBeacon is not available', () => { vi.stubGlobal('navigator', { sendBeacon: undefined }); const mockFetch = vi.fn().mockResolvedValue({}); vi.stubGlobal('fetch', mockFetch); const reporter = webVitalsModule.createDefaultReporter({ endpoint: 'https://analytics.classeo.fr/vitals' }); reporter({ name: 'LCP', value: 2000, rating: 'good', delta: 2000, id: 'v1-lcp' }); expect(mockFetch).toHaveBeenCalledWith( 'https://analytics.classeo.fr/vitals', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true }) ); }); it('should not send data when no endpoint is configured', () => { const mockSendBeacon = vi.fn(); vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon }); const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); const reporter = webVitalsModule.createDefaultReporter({}); reporter({ name: 'INP', value: 100, rating: 'good', delta: 100, id: 'v1-inp' }); expect(mockSendBeacon).not.toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled(); }); it('should support both debug and endpoint at the same time', () => { const mockSendBeacon = vi.fn().mockReturnValue(true); vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon }); const reporter = webVitalsModule.createDefaultReporter({ debug: true, endpoint: 'https://analytics.classeo.fr/vitals' }); reporter({ name: 'TTFB', value: 500.123, rating: 'good', delta: 500.123, id: 'v1-ttfb' }); expect(console.log).toHaveBeenCalledWith( '[WebVitals] TTFB: 500.12 (good)' ); expect(mockSendBeacon).toHaveBeenCalled(); }); }); });