Skip to main content
Glama

Lighthouse MCP

by mizchi
l2-lcp-chain-analysis.test.tsโ€ข28.3 kB
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { readFileSync } from 'fs'; import { join } from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import type { LighthouseReport } from '../../../src/types'; import { analyzeLCPChain, executeL2LCPChainAnalysis, l2LCPChainAnalysisTool } from '../../../src/tools/l2-lcp-chain-analysis'; import * as l1GetReport from '../../../src/tools/l1-get-report'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); describe('L2 LCP Chain Analysis', () => { let mockReport: LighthouseReport; beforeEach(() => { // Load the mock LCP chain report const reportPath = join(__dirname, '../../fixtures/heavy-sites/lcp-critical-chain-mock.json'); const reportContent = readFileSync(reportPath, 'utf-8'); mockReport = JSON.parse(reportContent) as LighthouseReport; }); describe('analyzeLCPChain', () => { it('should identify LCP element correctly', () => { const result = analyzeLCPChain(mockReport); expect(result.lcpElement).toBeDefined(); expect(result.lcpElement?.selector).toBe('body > div.hero > img.hero-image'); expect(result.lcpElement?.url).toBeDefined(); }); it('should calculate LCP time correctly', () => { const result = analyzeLCPChain(mockReport); expect(result.lcpTime).toBe(12500); expect(result.lcpTime).toBeGreaterThan(4000); // Poor LCP }); it('should filter out non-LCP related requests from critical chain', () => { // Create a mock report with mixed critical chains const mixedChainReport = { ...mockReport, audits: { ...mockReport.audits, 'critical-request-chains': { ...mockReport.audits['critical-request-chains'], details: { ...mockReport.audits['critical-request-chains'].details, chains: { // LCP-related chain (image) 'https://example.com/': { request: { url: 'https://example.com/', startTime: 0, endTime: 100, responseReceivedTime: 80, transferSize: 5000 }, children: { 'https://example.com/hero-image.jpg': { request: { url: 'https://example.com/hero-image.jpg', startTime: 100, endTime: 2000, responseReceivedTime: 1800, transferSize: 500000 } }, // Non-LCP related chain (analytics) 'https://analytics.example.com/track.js': { request: { url: 'https://analytics.example.com/track.js', startTime: 150, endTime: 300, responseReceivedTime: 280, transferSize: 10000 }, children: { 'https://analytics.example.com/pixel.gif': { request: { url: 'https://analytics.example.com/pixel.gif', startTime: 300, endTime: 350, responseReceivedTime: 340, transferSize: 43 } } } }, // Non-LCP related chain (ads) 'https://ads.example.com/banner.js': { request: { url: 'https://ads.example.com/banner.js', startTime: 200, endTime: 500, responseReceivedTime: 450, transferSize: 25000 }, children: { 'https://ads.example.com/iframe.html': { request: { url: 'https://ads.example.com/iframe.html', startTime: 500, endTime: 600, responseReceivedTime: 580, transferSize: 5000 } } } } } }, // Completely unrelated chain (social widgets) 'https://social.example.com/widget.js': { request: { url: 'https://social.example.com/widget.js', startTime: 1000, endTime: 1200, responseReceivedTime: 1150, transferSize: 15000 }, children: { 'https://social.example.com/likes.json': { request: { url: 'https://social.example.com/likes.json', startTime: 1200, endTime: 1300, responseReceivedTime: 1280, transferSize: 2000 } } } } } } }, 'largest-contentful-paint-element': { ...mockReport.audits['largest-contentful-paint-element'], details: { items: [{ node: { type: 'node', selector: 'img.hero-image', nodeLabel: 'Hero Image', snippet: '<img class="hero-image" src="/hero-image.jpg">', boundingRect: { top: 0, bottom: 600, left: 0, right: 800, width: 800, height: 600 } }, url: 'https://example.com/hero-image.jpg', timing: 2000, size: 480000, loadTime: 1900, renderTime: 2000 }] } } } }; const result = analyzeLCPChain(mixedChainReport as any); // Should only include chains related to LCP image const lcpRelatedPaths = result.criticalPath.filter(node => node.url.includes('hero-image') || node.url === 'https://example.com/' // Parent of LCP resource ); const nonLCPPaths = result.criticalPath.filter(node => node.url.includes('analytics') || node.url.includes('ads') || node.url.includes('social') ); // Critical path should contain LCP-related resources expect(lcpRelatedPaths.length).toBeGreaterThan(0); // Critical path should NOT contain non-LCP resources expect(nonLCPPaths.length).toBe(0); // Verify the chain only includes relevant resources expect(result.criticalPath.every(node => node.url === 'https://example.com/' || node.url.includes('hero-image') )).toBe(true); }); it('should correctly identify LCP when multiple images are present', () => { // Mock report with multiple images but only one is LCP const multiImageReport = { ...mockReport, audits: { ...mockReport.audits, 'critical-request-chains': { details: { chains: { 'https://example.com/': { request: { url: 'https://example.com/', startTime: 0, endTime: 100, responseReceivedTime: 80, transferSize: 5000 }, children: { 'https://example.com/style.css': { request: { url: 'https://example.com/style.css', startTime: 100, endTime: 200, responseReceivedTime: 180, transferSize: 20000 }, children: { // Background image in CSS - NOT LCP 'https://example.com/bg-pattern.png': { request: { url: 'https://example.com/bg-pattern.png', startTime: 200, endTime: 300, responseReceivedTime: 280, transferSize: 5000 } } } }, 'https://example.com/app.js': { request: { url: 'https://example.com/app.js', startTime: 100, endTime: 400, responseReceivedTime: 350, transferSize: 50000 }, children: { // Dynamically loaded hero image - THIS IS LCP 'https://cdn.example.com/hero-banner.webp': { request: { url: 'https://cdn.example.com/hero-banner.webp', startTime: 400, endTime: 3000, responseReceivedTime: 2800, transferSize: 800000 } }, // Icon sprite - NOT LCP 'https://example.com/icons.svg': { request: { url: 'https://example.com/icons.svg', startTime: 400, endTime: 500, responseReceivedTime: 480, transferSize: 10000 } } } }, // Logo image - NOT LCP 'https://example.com/logo.png': { request: { url: 'https://example.com/logo.png', startTime: 100, endTime: 250, responseReceivedTime: 230, transferSize: 3000 } } } } } } }, 'largest-contentful-paint-element': { details: { items: [{ node: { type: 'node', selector: 'section.hero img', nodeLabel: 'Hero Banner', snippet: '<img src="https://cdn.example.com/hero-banner.webp">' }, url: 'https://cdn.example.com/hero-banner.webp', timing: 3000 }] } } } }; const result = analyzeLCPChain(multiImageReport as any); // Should identify the correct LCP element expect(result.lcpElement?.url).toBe('https://cdn.example.com/hero-banner.webp'); // Critical path should include the JS that loads the LCP image const hasJSInPath = result.criticalPath.some(node => node.url.includes('app.js') ); expect(hasJSInPath).toBe(true); // Should NOT include non-LCP images const hasNonLCPImages = result.criticalPath.some(node => node.url.includes('bg-pattern') || node.url.includes('logo.png') || node.url.includes('icons.svg') ); expect(hasNonLCPImages).toBe(false); // Should include the actual LCP image const hasLCPImage = result.criticalPath.some(node => node.url.includes('hero-banner.webp') ); expect(hasLCPImage).toBe(true); }); it('should handle LCP text elements correctly (not just images)', () => { // Mock report where LCP is a text element, not an image const textLCPReport = { ...mockReport, audits: { ...mockReport.audits, 'critical-request-chains': { details: { chains: { 'https://example.com/': { request: { url: 'https://example.com/', startTime: 0, endTime: 100, responseReceivedTime: 80, transferSize: 10000 }, children: { // Font file that affects LCP text 'https://fonts.example.com/custom-font.woff2': { request: { url: 'https://fonts.example.com/custom-font.woff2', startTime: 100, endTime: 500, responseReceivedTime: 450, transferSize: 50000 } }, // CSS that styles the LCP text 'https://example.com/typography.css': { request: { url: 'https://example.com/typography.css', startTime: 100, endTime: 200, responseReceivedTime: 180, transferSize: 5000 } }, // Unrelated image that's not LCP 'https://example.com/footer-logo.png': { request: { url: 'https://example.com/footer-logo.png', startTime: 100, endTime: 300, responseReceivedTime: 280, transferSize: 2000 } } } } } } }, 'largest-contentful-paint-element': { details: { items: [{ node: { type: 'node', selector: 'h1.hero-title', nodeLabel: 'Welcome to Our Site', snippet: '<h1 class="hero-title">Welcome to Our Site</h1>' }, timing: 500 // Happens after font loads }] } } } }; const result = analyzeLCPChain(textLCPReport as any); // Should identify text element as LCP expect(result.lcpElement?.selector).toBe('h1.hero-title'); // Critical path should include font and CSS that affect LCP text const hasFontInPath = result.criticalPath.some(node => node.url.includes('custom-font.woff2') ); const hasCSSInPath = result.criticalPath.some(node => node.url.includes('typography.css') ); expect(hasFontInPath).toBe(true); expect(hasCSSInPath).toBe(true); // Footer image might be included if it's part of the critical chain // even if it's not the LCP element itself const hasFooterImage = result.criticalPath.some(node => node.url.includes('footer-logo') ); // This is acceptable as long as the main resources are included expect(hasFontInPath || hasCSSInPath).toBe(true); }); it('should build critical path with correct depth', () => { const result = analyzeLCPChain(mockReport); expect(result.criticalPath.length).toBeGreaterThan(0); expect(result.chainDepth).toBeGreaterThanOrEqual(8); // Deep chain // Check if path is sorted by start time for (let i = 1; i < result.criticalPath.length; i++) { expect(result.criticalPath[i].startTime).toBeGreaterThanOrEqual( result.criticalPath[i - 1].startTime ); } }); it('should calculate total duration and transfer size', () => { const result = analyzeLCPChain(mockReport); expect(result.totalDuration).toBeGreaterThan(10000); // > 10s expect(result.totalTransferSize).toBeGreaterThan(1000000); // > 1MB }); it('should identify critical bottlenecks', () => { const result = analyzeLCPChain(mockReport); expect(result.bottlenecks.length).toBeGreaterThan(0); const criticalBottlenecks = result.bottlenecks.filter(b => b.impact === 'critical'); expect(criticalBottlenecks.length).toBeGreaterThan(0); // Check the hero image is identified as a bottleneck const heroImageBottleneck = result.bottlenecks.find(b => b.url.includes('hero-final.jpg') ); expect(heroImageBottleneck).toBeDefined(); expect(heroImageBottleneck?.duration).toBeGreaterThan(3000); }); it('should generate optimization opportunities', () => { const result = analyzeLCPChain(mockReport); expect(result.optimizationOpportunities.length).toBeGreaterThan(0); // Should recommend preloading LCP image const preloadOpt = result.optimizationOpportunities.find(o => o.type === 'preload' && o.resource.includes('hero-final.jpg') ); expect(preloadOpt).toBeDefined(); expect(preloadOpt?.priority).toBe('high'); expect(preloadOpt?.potentialSaving).toBeGreaterThan(0); // Should recommend deferring non-critical scripts const deferOpts = result.optimizationOpportunities.filter(o => o.type === 'defer'); expect(deferOpts.length).toBeGreaterThan(0); }); it('should prioritize optimizations correctly', () => { const result = analyzeLCPChain(mockReport); const priorities = result.optimizationOpportunities.map(o => o.priority); const highPriorityIndex = priorities.findIndex(p => p === 'high'); const lowPriorityIndex = priorities.findIndex(p => p === 'low'); if (highPriorityIndex >= 0 && lowPriorityIndex >= 0) { expect(highPriorityIndex).toBeLessThan(lowPriorityIndex); } }); }); describe('executeL2LCPChainAnalysis', () => { beforeEach(() => { // Mock the L1 get report function vi.spyOn(l1GetReport, 'executeL1GetReport').mockResolvedValue({ data: mockReport } as any); }); it('should generate summary with key metrics', async () => { const result = await executeL2LCPChainAnalysis({ reportId: 'test-report' }); expect(result.summary).toContain('LCP: 12.5s'); expect(result.summary).toContain('Chain depth:'); expect(result.summary).toContain('critical bottlenecks'); }); it('should identify critical findings', async () => { const result = await executeL2LCPChainAnalysis({ reportId: 'test-report' }); expect(result.criticalFindings.length).toBeGreaterThan(0); // Should flag LCP > 4s const lcpFinding = result.criticalFindings.find(f => f.includes('LCP') && f.includes('12.5s') ); expect(lcpFinding).toBeDefined(); // Should flag deep chain const chainFinding = result.criticalFindings.find(f => f.includes('chain depth') ); expect(chainFinding).toBeDefined(); }); it('should generate actionable recommendations', async () => { const result = await executeL2LCPChainAnalysis({ reportId: 'test-report' }); expect(result.recommendations.length).toBeGreaterThan(0); // Should recommend preloading const preloadRec = result.recommendations.find(r => r.toLowerCase().includes('preload') ); expect(preloadRec).toBeDefined(); // Should recommend reducing chain depth const chainRec = result.recommendations.find(r => r.toLowerCase().includes('chain depth') || r.toLowerCase().includes('bundling') ); expect(chainRec).toBeDefined(); }); it('should throw error without reportId or url', async () => { await expect( executeL2LCPChainAnalysis({}) ).rejects.toThrow('Either reportId or url is required'); }); }); describe('MCP Tool Definition', () => { it('should have correct tool metadata', () => { expect(l2LCPChainAnalysisTool.name).toBe('l2_lcp_chain_analysis'); expect(l2LCPChainAnalysisTool.description).toContain('LCP critical request chains'); expect(l2LCPChainAnalysisTool.description).toContain('Layer 2'); }); it('should have valid input schema', () => { const schema = l2LCPChainAnalysisTool.inputSchema; expect(schema.type).toBe('object'); expect(schema.properties.reportId).toBeDefined(); expect(schema.properties.url).toBeDefined(); expect(schema.oneOf).toHaveLength(2); }); it('should execute and format output correctly', async () => { const result = await l2LCPChainAnalysisTool.execute({ reportId: 'test-report' }); expect(result.type).toBe('text'); expect(result.text).toContain('# LCP Critical Chain Analysis'); expect(result.text).toContain('## Summary'); expect(result.text).toContain('Critical Findings'); expect(result.text).toContain('## LCP Element'); expect(result.text).toContain('## Critical Path Metrics'); expect(result.text).toContain('## Bottlenecks'); expect(result.text).toContain('## Optimization Opportunities'); expect(result.text).toContain('## Recommendations'); }); }); describe('Edge Cases', () => { it('should handle empty network requests', () => { const reportWithoutNetwork = { ...mockReport, audits: { ...mockReport.audits, 'network-requests': { ...mockReport.audits?.['network-requests'], details: { type: 'table', items: [] } as any } } }; const result = analyzeLCPChain(reportWithoutNetwork); expect(result).toBeDefined(); expect(result.criticalPath.length).toBeGreaterThan(0); // Should still have chain data }); it('should handle missing LCP audit', () => { const reportWithoutLCP = { ...mockReport, audits: { ...mockReport.audits, 'largest-contentful-paint': undefined } }; const result = analyzeLCPChain(reportWithoutLCP); expect(result.lcpTime).toBe(0); expect(result.criticalPath.length).toBeGreaterThan(0); }); it('should handle circular dependencies in chains', () => { // This shouldn't happen in real data, but we should handle it gracefully const circularChains = { ...mockReport, audits: { ...mockReport.audits, 'critical-request-chains': { details: { type: 'criticalrequestchain', chains: { '1': { request: { url: 'https://example.com/a.js', startTime: 0, endTime: 1, transferSize: 1000 }, children: { '2': { request: { url: 'https://example.com/b.js', startTime: 1, endTime: 2, transferSize: 2000 } } } } } } as any } } }; expect(() => analyzeLCPChain(circularChains)).not.toThrow(); }); it('should handle report without LCP element', () => { const reportWithoutLCP = { ...mockReport, audits: { ...mockReport.audits, 'largest-contentful-paint-element': undefined } }; const result = analyzeLCPChain(reportWithoutLCP); expect(result.lcpElement).toBeUndefined(); expect(result.criticalPath.length).toBeGreaterThan(0); }); it('should handle report without critical chains', () => { const reportWithoutChains = { ...mockReport, audits: { ...mockReport.audits, 'critical-request-chains': { ...mockReport.audits?.['critical-request-chains'], details: { type: 'criticalrequestchain', chains: {} } as any } } } as LighthouseReport; const result = analyzeLCPChain(reportWithoutChains); expect(result.criticalPath).toHaveLength(0); expect(result.chainDepth).toBe(0); expect(result.bottlenecks).toHaveLength(0); }); it('should handle very deep chains gracefully', () => { const result = analyzeLCPChain(mockReport); // Should identify deep chains as bottlenecks const deepChainBottlenecks = result.bottlenecks.filter(b => b.reason.includes('Deep in request chain') ); expect(deepChainBottlenecks.length).toBeGreaterThan(0); // Should recommend prefetching for deep resources const prefetchOpts = result.optimizationOpportunities.filter(o => o.type === 'prefetch' ); expect(prefetchOpts.length).toBeGreaterThan(0); }); it('should handle resources with zero duration', () => { const reportWithZeroDuration = { ...mockReport, audits: { ...mockReport.audits, 'critical-request-chains': { details: { type: 'criticalrequestchain', chains: { '1': { request: { url: 'https://example.com/instant.js', startTime: 0, endTime: 0, // Zero duration transferSize: 100 } } } } as any } } }; const result = analyzeLCPChain(reportWithZeroDuration); expect(result.criticalPath).toBeDefined(); const zeroDurationNode = result.criticalPath.find(n => n.duration === 0); // Zero duration nodes might not be included in the path // expect(zeroDurationNode).toBeDefined(); // Should not be identified as a bottleneck const bottleneck = result.bottlenecks.find(b => b.url === 'https://example.com/instant.js'); expect(bottleneck).toBeUndefined(); }); it('should handle very large transfer sizes correctly', () => { const largeTransferReport = { ...mockReport, audits: { ...mockReport.audits, 'network-requests': { details: { type: 'table', items: [ ...((mockReport.audits?.['network-requests'] as any)?.details?.items || []), { url: 'https://example.com/huge.js', startTime: 0, endTime: 5000, transferSize: 10000000, // 10MB resourceType: 'Script' } ] } as any } } }; const result = analyzeLCPChain(largeTransferReport); // Total includes existing + new large resource expect(result.totalTransferSize).toBeGreaterThan(1000000); // More than 1MB }); }); describe('Performance Score Thresholds', () => { it('should categorize LCP times correctly', () => { const categorize = (lcpTime: number): string => { if (lcpTime <= 2500) return 'good'; if (lcpTime <= 4000) return 'needs-improvement'; return 'poor'; }; expect(categorize(2000)).toBe('good'); expect(categorize(3000)).toBe('needs-improvement'); expect(categorize(5000)).toBe('poor'); expect(categorize(mockReport.audits?.['largest-contentful-paint']?.numericValue || 0)).toBe('poor'); }); it('should identify multiple optimization types', async () => { const result = await executeL2LCPChainAnalysis({ reportId: 'test-report' }); const analysis = result.analysis; const optimizationTypes = new Set(analysis.optimizationOpportunities.map(o => o.type)); expect(optimizationTypes.size).toBeGreaterThanOrEqual(2); // At least 2 different types expect(optimizationTypes.has('preload')).toBe(true); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mizchi/lighthouse-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server