#!/usr/bin/env node /** * Lighthouse Testing Script * * This script builds the production bundle, starts the server, and runs Lighthouse tests. * Usage: * npm run lighthouse:build - Test production build * npm run lighthouse:dev - Test dev server (less accurate) */ const { execSync, spawn } = require('child_process'); const chromeLauncher = require('chrome-launcher'); const path = require('path'); // Dynamic import for lighthouse (ES module) let lighthouse; async function getLighthouse() { if (!lighthouse) { const lighthouseModule = await import('lighthouse'); lighthouse = lighthouseModule.default || lighthouseModule; } return lighthouse; } const PORT = 3000; const BASE_URL = `http://localhost:${PORT}`; const TEST_PAGES = ['/', '/vending-machines', '/services', '/service-areas']; // Check if we should test dev server const isDev = process.argv.includes('--dev'); async function buildProduction() { if (isDev) { console.log('āš ļø Testing dev server (less accurate for Lighthouse scores)'); return; } console.log('šŸ“¦ Building production bundle...'); try { execSync('pnpm run build', { stdio: 'inherit', cwd: path.join(__dirname, '..') }); console.log('āœ… Build complete\n'); } catch (error) { console.error('āŒ Build failed:', error.message); process.exit(1); } } async function startServer() { console.log(`šŸš€ Starting ${isDev ? 'dev' : 'production'} server on port ${PORT}...`); const command = isDev ? 'pnpm run dev' : 'pnpm run start'; const serverProcess = spawn(command, [], { shell: true, stdio: 'pipe', cwd: path.join(__dirname, '..') }); // Wait for server to be ready await new Promise((resolve, reject) => { const timeout = setTimeout(() => { serverProcess.kill(); reject(new Error('Server startup timeout')); }, 60000); serverProcess.stdout.on('data', (data) => { const output = data.toString(); if (output.includes('ready') || output.includes('Local:') || output.includes('started server')) { clearTimeout(timeout); // Give server a moment to fully start setTimeout(resolve, 2000); } }); serverProcess.stderr.on('data', (data) => { const output = data.toString(); if (output.includes('ready') || output.includes('Local:') || output.includes('started server')) { clearTimeout(timeout); setTimeout(resolve, 2000); } }); }); console.log('āœ… Server ready\n'); return serverProcess; } async function runLighthouse(url, options = {}) { const deviceType = options.formFactor || 'desktop'; console.log(`šŸ” Running Lighthouse on ${url} (${deviceType})...`); const lighthouseFn = await getLighthouse(); const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] }); const runnerResult = await lighthouseFn(url, { port: chrome.port, output: 'json', onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'], ...options }); await chrome.kill(); const scores = { performance: Math.round(runnerResult.lhr.categories.performance.score * 100), accessibility: Math.round(runnerResult.lhr.categories.accessibility.score * 100), 'best-practices': Math.round(runnerResult.lhr.categories['best-practices'].score * 100), seo: Math.round(runnerResult.lhr.categories.seo.score * 100), }; const metrics = { fcp: runnerResult.lhr.audits['first-contentful-paint'].numericValue, lcp: runnerResult.lhr.audits['largest-contentful-paint'].numericValue, cls: runnerResult.lhr.audits['cumulative-layout-shift'].numericValue, tbt: runnerResult.lhr.audits['total-blocking-time'].numericValue, si: runnerResult.lhr.audits['speed-index'].numericValue, }; // Get failing audits for debugging const failingAudits = Object.values(runnerResult.lhr.audits) .filter(audit => audit.score !== null && audit.score < 1) .map(audit => ({ id: audit.id, title: audit.title, score: audit.score, displayValue: audit.displayValue, })) .sort((a, b) => (a.score || 0) - (b.score || 0)); return { scores, metrics, lhr: runnerResult.lhr, deviceType, failingAudits }; } async function testPages() { const results = []; // Test desktop first console.log('\n' + '='.repeat(60)); console.log('šŸ–„ļø TESTING DESKTOP'); console.log('='.repeat(60) + '\n'); for (const page of TEST_PAGES) { const url = `${BASE_URL}${page}`; try { const result = await runLighthouse(url, { formFactor: 'desktop', throttling: { rttMs: 40, throughputKbps: 10 * 1024, cpuSlowdownMultiplier: 1, }, screenEmulation: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, }, }); results.push({ page, ...result, device: 'desktop' }); console.log(`\nšŸ“Š Desktop Results for ${page}:`); console.log(` Performance: ${result.scores.performance}/100`); console.log(` Accessibility: ${result.scores.accessibility}/100`); console.log(` Best Practices: ${result.scores['best-practices']}/100`); console.log(` SEO: ${result.scores.seo}/100`); console.log(` FCP: ${result.metrics.fcp.toFixed(0)}ms`); console.log(` LCP: ${result.metrics.lcp.toFixed(0)}ms`); console.log(` CLS: ${result.metrics.cls.toFixed(3)}`); console.log(` TBT: ${result.metrics.tbt.toFixed(0)}ms`); console.log(` SI: ${result.metrics.si.toFixed(0)}ms`); // Show top failing audits if scores aren't 100% if (result.scores.performance < 100 || result.scores.accessibility < 100 || result.scores['best-practices'] < 100 || result.scores.seo < 100) { const topFailures = result.failingAudits.slice(0, 5); if (topFailures.length > 0) { console.log(` Top Issues:`); topFailures.forEach(audit => { console.log(` - ${audit.title} (${(audit.score * 100).toFixed(0)}%)`); }); } } console.log(); } catch (error) { console.error(`āŒ Failed to test ${page} (desktop):`, error.message); results.push({ page, error: error.message, device: 'desktop' }); } } // Test mobile console.log('\n' + '='.repeat(60)); console.log('šŸ“± TESTING MOBILE'); console.log('='.repeat(60) + '\n'); for (const page of TEST_PAGES) { const url = `${BASE_URL}${page}`; try { const result = await runLighthouse(url, { formFactor: 'mobile', throttling: { rttMs: 150, throughputKbps: 1.6 * 1024, cpuSlowdownMultiplier: 4, }, screenEmulation: { mobile: true, width: 412, height: 823, deviceScaleFactor: 2.625, }, }); results.push({ page, ...result, device: 'mobile' }); console.log(`\nšŸ“Š Mobile Results for ${page}:`); console.log(` Performance: ${result.scores.performance}/100`); console.log(` Accessibility: ${result.scores.accessibility}/100`); console.log(` Best Practices: ${result.scores['best-practices']}/100`); console.log(` SEO: ${result.scores.seo}/100`); console.log(` FCP: ${result.metrics.fcp.toFixed(0)}ms`); console.log(` LCP: ${result.metrics.lcp.toFixed(0)}ms`); console.log(` CLS: ${result.metrics.cls.toFixed(3)}`); console.log(` TBT: ${result.metrics.tbt.toFixed(0)}ms`); console.log(` SI: ${result.metrics.si.toFixed(0)}ms`); // Show top failing audits if scores aren't 100% if (result.scores.performance < 100 || result.scores.accessibility < 100 || result.scores['best-practices'] < 100 || result.scores.seo < 100) { const topFailures = result.failingAudits.slice(0, 5); if (topFailures.length > 0) { console.log(` Top Issues:`); topFailures.forEach(audit => { console.log(` - ${audit.title} (${(audit.score * 100).toFixed(0)}%)`); }); } } console.log(); } catch (error) { console.error(`āŒ Failed to test ${page} (mobile):`, error.message); results.push({ page, error: error.message, device: 'mobile' }); } } return results; } function checkScores(results) { console.log('\n' + '='.repeat(60)); console.log('šŸ“ˆ FINAL SCORES SUMMARY'); console.log('='.repeat(60) + '\n'); let allPassed = true; const minScore = 100; // Group results by device const desktopResults = results.filter(r => r.device === 'desktop'); const mobileResults = results.filter(r => r.device === 'mobile'); console.log('šŸ–„ļø DESKTOP RESULTS:\n'); for (const result of desktopResults) { if (result.error) { console.log(`āŒ ${result.page}: ERROR - ${result.error}\n`); allPassed = false; continue; } const { scores } = result; const passed = scores.performance >= minScore && scores.accessibility >= minScore && scores['best-practices'] >= minScore && scores.seo >= minScore; if (passed) { console.log(`āœ… ${result.page}: All scores at 100%`); } else { console.log(`āŒ ${result.page}:`); if (scores.performance < minScore) console.log(` Performance: ${scores.performance}/100`); if (scores.accessibility < minScore) console.log(` Accessibility: ${scores.accessibility}/100`); if (scores['best-practices'] < minScore) console.log(` Best Practices: ${scores['best-practices']}/100`); if (scores.seo < minScore) console.log(` SEO: ${scores.seo}/100`); allPassed = false; } console.log(); } console.log('\nšŸ“± MOBILE RESULTS:\n'); for (const result of mobileResults) { if (result.error) { console.log(`āŒ ${result.page}: ERROR - ${result.error}\n`); allPassed = false; continue; } const { scores } = result; const passed = scores.performance >= minScore && scores.accessibility >= minScore && scores['best-practices'] >= minScore && scores.seo >= minScore; if (passed) { console.log(`āœ… ${result.page}: All scores at 100%`); } else { console.log(`āŒ ${result.page}:`); if (scores.performance < minScore) console.log(` Performance: ${scores.performance}/100`); if (scores.accessibility < minScore) console.log(` Accessibility: ${scores.accessibility}/100`); if (scores['best-practices'] < minScore) console.log(` Best Practices: ${scores['best-practices']}/100`); if (scores.seo < minScore) console.log(` SEO: ${scores.seo}/100`); allPassed = false; } console.log(); } if (allPassed) { console.log('šŸŽ‰ All pages passed with 100% scores on both desktop and mobile!\n'); } else { console.log('āš ļø Some pages did not achieve 100% scores. Review the results above.\n'); process.exit(1); } } async function main() { try { await buildProduction(); const serverProcess = await startServer(); try { const results = await testPages(); checkScores(results); } finally { console.log('šŸ›‘ Stopping server...'); serverProcess.kill(); } } catch (error) { console.error('āŒ Error:', error.message); process.exit(1); } } main();