- Add comprehensive performance test suite with Playwright-Lighthouse integration - Include Lighthouse audits with 90% thresholds for performance, accessibility, best-practices, and SEO - Test all locales (de, en, sr) for homepage performance - Add critical pages performance tests (homepage, about, portfolio, contact) - Include mobile performance testing with iPhone viewport - Add Core Web Vitals verification tests (LCP, CLS, error tracking) - Add resource loading tests (JS bundle size, image optimization, modern formats) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
321 lines
9.0 KiB
TypeScript
321 lines
9.0 KiB
TypeScript
import { test, expect, chromium, Browser, Page } from '@playwright/test';
|
|
import { playAudit } from 'playwright-lighthouse';
|
|
|
|
/**
|
|
* Performance Test Suite with Playwright-Lighthouse Integration
|
|
*
|
|
* This suite runs Lighthouse performance audits against all locales
|
|
* to ensure PageSpeed scores meet the required 90% thresholds.
|
|
*
|
|
* Requirements:
|
|
* - Performance >= 90%
|
|
* - Accessibility >= 90%
|
|
* - Best Practices >= 90%
|
|
* - SEO >= 90%
|
|
*
|
|
* Note: These tests require Chromium with remote debugging enabled.
|
|
* See playwright.config.ts for launch configuration.
|
|
*/
|
|
|
|
// Thresholds for Lighthouse scores (0-100)
|
|
const LIGHTHOUSE_THRESHOLDS = {
|
|
performance: 90,
|
|
accessibility: 90,
|
|
'best-practices': 90,
|
|
seo: 90,
|
|
};
|
|
|
|
// Locales to test
|
|
const LOCALES = ['de', 'en', 'sr'] as const;
|
|
|
|
// Pages to audit per locale
|
|
const PAGES_TO_AUDIT = [
|
|
{ path: '', name: 'Homepage' },
|
|
{ path: '/about', name: 'About' },
|
|
{ path: '/portfolio', name: 'Portfolio' },
|
|
{ path: '/contact', name: 'Contact' },
|
|
] as const;
|
|
|
|
test.describe('Performance Audits', () => {
|
|
// Use only Chromium project for Lighthouse tests
|
|
test.skip(({ browserName }) => browserName !== 'chromium', 'Lighthouse requires Chromium');
|
|
|
|
// Increase timeout for performance tests (Lighthouse audits take time)
|
|
test.setTimeout(120000);
|
|
|
|
/**
|
|
* Test Homepage for all locales meets performance thresholds
|
|
*/
|
|
test.describe('Homepage Performance', () => {
|
|
for (const locale of LOCALES) {
|
|
test(`Homepage (${locale}) meets performance thresholds`, async () => {
|
|
// Launch browser with remote debugging port for Lighthouse
|
|
const browser = await chromium.launch({
|
|
args: ['--remote-debugging-port=9222'],
|
|
});
|
|
|
|
try {
|
|
const page = await browser.newPage();
|
|
await page.goto(`http://localhost:3000/${locale}`, {
|
|
waitUntil: 'networkidle',
|
|
});
|
|
|
|
// Wait for page to be fully loaded
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
// Run Lighthouse audit
|
|
await playAudit({
|
|
page,
|
|
port: 9222,
|
|
thresholds: LIGHTHOUSE_THRESHOLDS,
|
|
});
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Test critical pages meet performance thresholds
|
|
* Only run for default locale (de) to avoid excessive test time
|
|
*/
|
|
test.describe('Critical Pages Performance', () => {
|
|
for (const pageConfig of PAGES_TO_AUDIT) {
|
|
test(`${pageConfig.name} page meets performance thresholds`, async () => {
|
|
const browser = await chromium.launch({
|
|
args: ['--remote-debugging-port=9222'],
|
|
});
|
|
|
|
try {
|
|
const page = await browser.newPage();
|
|
const url = `http://localhost:3000/de${pageConfig.path}`;
|
|
|
|
await page.goto(url, {
|
|
waitUntil: 'networkidle',
|
|
});
|
|
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
await playAudit({
|
|
page,
|
|
port: 9222,
|
|
thresholds: LIGHTHOUSE_THRESHOLDS,
|
|
});
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Test mobile performance thresholds
|
|
* Mobile performance is typically lower, but should still meet 90% threshold
|
|
*/
|
|
test.describe('Mobile Performance', () => {
|
|
test('Homepage (de) mobile meets performance thresholds', async () => {
|
|
const browser = await chromium.launch({
|
|
args: ['--remote-debugging-port=9222'],
|
|
});
|
|
|
|
try {
|
|
const context = await browser.newContext({
|
|
viewport: { width: 375, height: 812 },
|
|
userAgent:
|
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148',
|
|
});
|
|
|
|
const page = await context.newPage();
|
|
await page.goto('http://localhost:3000/de', {
|
|
waitUntil: 'networkidle',
|
|
});
|
|
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
await playAudit({
|
|
page,
|
|
port: 9222,
|
|
thresholds: LIGHTHOUSE_THRESHOLDS,
|
|
});
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Core Web Vitals Verification Tests
|
|
*
|
|
* These tests verify that Core Web Vitals metrics are within acceptable ranges:
|
|
* - LCP (Largest Contentful Paint): < 2.5s (good)
|
|
* - INP (Interaction to Next Paint): < 200ms (good)
|
|
* - CLS (Cumulative Layout Shift): < 0.1 (good)
|
|
*/
|
|
test.describe('Core Web Vitals', () => {
|
|
test.setTimeout(60000);
|
|
|
|
test('Homepage loads within acceptable LCP threshold', async ({ page }) => {
|
|
// Navigate to page and measure performance
|
|
const startTime = Date.now();
|
|
|
|
await page.goto('/de', {
|
|
waitUntil: 'domcontentloaded',
|
|
});
|
|
|
|
// Wait for LCP element to be visible (hero section typically)
|
|
await page.waitForSelector('[data-testid="hero"], section, h1', {
|
|
state: 'visible',
|
|
timeout: 5000,
|
|
});
|
|
|
|
const loadTime = Date.now() - startTime;
|
|
|
|
// LCP should be under 2500ms for "good" rating
|
|
expect(loadTime).toBeLessThan(5000); // Allow some buffer for test environment
|
|
});
|
|
|
|
test('Page does not have excessive layout shift', async ({ page }) => {
|
|
// Inject CLS measurement script
|
|
await page.addInitScript(() => {
|
|
(window as unknown as { clsValue: number }).clsValue = 0;
|
|
const observer = new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
if (!(entry as unknown as { hadRecentInput: boolean }).hadRecentInput) {
|
|
(window as unknown as { clsValue: number }).clsValue +=
|
|
(entry as unknown as { value: number }).value;
|
|
}
|
|
}
|
|
});
|
|
observer.observe({ type: 'layout-shift', buffered: true });
|
|
});
|
|
|
|
await page.goto('/de', {
|
|
waitUntil: 'networkidle',
|
|
});
|
|
|
|
// Allow time for any delayed content
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Get CLS value
|
|
const clsValue = await page.evaluate(() => {
|
|
return (window as unknown as { clsValue: number }).clsValue || 0;
|
|
});
|
|
|
|
// CLS should be under 0.1 for "good" rating (allow some buffer for test)
|
|
expect(clsValue).toBeLessThan(0.25);
|
|
});
|
|
|
|
test('All critical resources load without errors', async ({ page }) => {
|
|
const errors: string[] = [];
|
|
|
|
// Listen for console errors
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
errors.push(msg.text());
|
|
}
|
|
});
|
|
|
|
// Listen for failed requests
|
|
page.on('requestfailed', (request) => {
|
|
errors.push(`Failed: ${request.url()}`);
|
|
});
|
|
|
|
await page.goto('/de', {
|
|
waitUntil: 'networkidle',
|
|
});
|
|
|
|
// Filter out known non-critical errors (e.g., analytics, tracking)
|
|
const criticalErrors = errors.filter(
|
|
(error) =>
|
|
!error.includes('analytics') &&
|
|
!error.includes('gtag') &&
|
|
!error.includes('favicon')
|
|
);
|
|
|
|
expect(criticalErrors).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Resource Loading Performance Tests
|
|
*/
|
|
test.describe('Resource Loading', () => {
|
|
test('JavaScript bundles are reasonably sized', async ({ page }) => {
|
|
const jsRequests: { url: string; size: number }[] = [];
|
|
|
|
page.on('response', async (response) => {
|
|
const url = response.url();
|
|
if (url.includes('.js') && !url.includes('_next/static/chunks/webpack')) {
|
|
try {
|
|
const buffer = await response.body();
|
|
jsRequests.push({
|
|
url: url.split('/').pop() || url,
|
|
size: buffer.length,
|
|
});
|
|
} catch {
|
|
// Ignore failed body reads
|
|
}
|
|
}
|
|
});
|
|
|
|
await page.goto('/de', {
|
|
waitUntil: 'networkidle',
|
|
});
|
|
|
|
// Main bundles should not exceed 500KB each
|
|
for (const req of jsRequests) {
|
|
expect(req.size).toBeLessThan(500 * 1024);
|
|
}
|
|
});
|
|
|
|
test('Images are optimized and lazy-loaded', async ({ page }) => {
|
|
await page.goto('/de', {
|
|
waitUntil: 'domcontentloaded',
|
|
});
|
|
|
|
// Check that images below the fold have loading="lazy"
|
|
const images = await page.locator('img').all();
|
|
|
|
let lazyLoadedCount = 0;
|
|
for (const img of images) {
|
|
const loading = await img.getAttribute('loading');
|
|
if (loading === 'lazy') {
|
|
lazyLoadedCount++;
|
|
}
|
|
}
|
|
|
|
// At least some images should be lazy-loaded if there are many images
|
|
if (images.length > 2) {
|
|
expect(lazyLoadedCount).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test('Page uses modern image formats', async ({ page }) => {
|
|
const imageFormats: string[] = [];
|
|
|
|
page.on('response', (response) => {
|
|
const contentType = response.headers()['content-type'] || '';
|
|
if (contentType.startsWith('image/')) {
|
|
imageFormats.push(contentType);
|
|
}
|
|
});
|
|
|
|
await page.goto('/de', {
|
|
waitUntil: 'networkidle',
|
|
});
|
|
|
|
// If there are images, check for modern formats
|
|
if (imageFormats.length > 0) {
|
|
const modernFormats = imageFormats.filter(
|
|
(format) => format.includes('webp') || format.includes('avif')
|
|
);
|
|
|
|
// Log for debugging
|
|
// Modern formats should be used where possible
|
|
expect(imageFormats.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|