mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
609 lines
21 KiB
Markdown
609 lines
21 KiB
Markdown
|
|
# Performance Testing
|
||
|
|
|
||
|
|
> **When to use**: Measuring and enforcing Web Vitals, resource loading timing, bundle sizes, and runtime performance. Use Playwright to catch performance regressions in CI before users notice them.
|
||
|
|
> **Prerequisites**: [core/configuration.md](configuration.md), [core/assertions-and-waiting.md](assertions-and-waiting.md)
|
||
|
|
|
||
|
|
## Quick Reference
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Measure Largest Contentful Paint (LCP)
|
||
|
|
const lcp = await page.evaluate(() => {
|
||
|
|
return new Promise<number>((resolve) => {
|
||
|
|
new PerformanceObserver((list) => {
|
||
|
|
const entries = list.getEntries();
|
||
|
|
resolve(entries[entries.length - 1].startTime);
|
||
|
|
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
expect(lcp).toBeLessThan(2500); // Good LCP threshold
|
||
|
|
|
||
|
|
// Throttle network to 3G
|
||
|
|
const client = await page.context().newCDPSession(page);
|
||
|
|
await client.send('Network.emulateNetworkConditions', {
|
||
|
|
offline: false, downloadThroughput: 1.6 * 1024 * 1024 / 8,
|
||
|
|
uploadThroughput: 750 * 1024 / 8, latency: 150,
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Patterns
|
||
|
|
|
||
|
|
### Web Vitals Measurement (LCP, CLS, FID/INP)
|
||
|
|
|
||
|
|
**Use when**: Enforcing Core Web Vitals thresholds as part of your test suite.
|
||
|
|
**Avoid when**: You only need aggregate field data -- use Chrome UX Report or RUM tools instead.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test('Core Web Vitals meet thresholds on homepage', async ({ page }) => {
|
||
|
|
// Inject Web Vitals observer before navigation
|
||
|
|
await page.addInitScript(() => {
|
||
|
|
(window as any).__webVitals = { lcp: 0, cls: 0, fid: 0 };
|
||
|
|
|
||
|
|
new PerformanceObserver((list) => {
|
||
|
|
const entries = list.getEntries();
|
||
|
|
(window as any).__webVitals.lcp = entries[entries.length - 1].startTime;
|
||
|
|
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||
|
|
|
||
|
|
new PerformanceObserver((list) => {
|
||
|
|
let clsValue = 0;
|
||
|
|
for (const entry of list.getEntries()) {
|
||
|
|
if (!(entry as any).hadRecentInput) {
|
||
|
|
clsValue += (entry as any).value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
(window as any).__webVitals.cls = clsValue;
|
||
|
|
}).observe({ type: 'layout-shift', buffered: true });
|
||
|
|
|
||
|
|
new PerformanceObserver((list) => {
|
||
|
|
const entries = list.getEntries();
|
||
|
|
(window as any).__webVitals.fid = entries[0]?.processingStart - entries[0]?.startTime;
|
||
|
|
}).observe({ type: 'first-input', buffered: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
// Trigger a user interaction to measure FID
|
||
|
|
await page.getByRole('button', { name: 'Get started' }).click();
|
||
|
|
|
||
|
|
// Wait for LCP to settle
|
||
|
|
await page.waitForTimeout(1000); // Acceptable here: waiting for metric to finalize
|
||
|
|
|
||
|
|
const vitals = await page.evaluate(() => (window as any).__webVitals);
|
||
|
|
|
||
|
|
expect(vitals.lcp).toBeLessThan(2500); // Good: <2.5s
|
||
|
|
expect(vitals.cls).toBeLessThan(0.1); // Good: <0.1
|
||
|
|
// FID may be 0 in automated tests due to no real user delay
|
||
|
|
if (vitals.fid > 0) {
|
||
|
|
expect(vitals.fid).toBeLessThan(100); // Good: <100ms
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test('LCP meets threshold on homepage', async ({ page }) => {
|
||
|
|
await page.addInitScript(() => {
|
||
|
|
window.__lcp = 0;
|
||
|
|
new PerformanceObserver((list) => {
|
||
|
|
const entries = list.getEntries();
|
||
|
|
window.__lcp = entries[entries.length - 1].startTime;
|
||
|
|
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
|
||
|
|
const lcp = await page.evaluate(() => window.__lcp);
|
||
|
|
expect(lcp).toBeLessThan(2500);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Performance API Access
|
||
|
|
|
||
|
|
**Use when**: Measuring navigation timing, resource loading, or custom performance marks.
|
||
|
|
**Avoid when**: Web Vitals alone cover your needs.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test('page load timing is within budget', async ({ page }) => {
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
const timing = await page.evaluate(() => {
|
||
|
|
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||
|
|
return {
|
||
|
|
dns: nav.domainLookupEnd - nav.domainLookupStart,
|
||
|
|
tcp: nav.connectEnd - nav.connectStart,
|
||
|
|
ttfb: nav.responseStart - nav.requestStart,
|
||
|
|
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
|
||
|
|
loadComplete: nav.loadEventEnd - nav.startTime,
|
||
|
|
domInteractive: nav.domInteractive - nav.startTime,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(timing.ttfb).toBeLessThan(600); // TTFB under 600ms
|
||
|
|
expect(timing.domContentLoaded).toBeLessThan(2000); // DOM ready under 2s
|
||
|
|
expect(timing.loadComplete).toBeLessThan(5000); // Full load under 5s
|
||
|
|
});
|
||
|
|
|
||
|
|
test('critical API calls complete within budget', async ({ page }) => {
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
const apiTimings = await page.evaluate(() => {
|
||
|
|
return performance
|
||
|
|
.getEntriesByType('resource')
|
||
|
|
.filter((r) => r.name.includes('/api/'))
|
||
|
|
.map((r) => ({
|
||
|
|
name: r.name.split('/api/')[1],
|
||
|
|
duration: r.duration,
|
||
|
|
size: (r as PerformanceResourceTiming).transferSize,
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
|
||
|
|
for (const api of apiTimings) {
|
||
|
|
expect(api.duration, `API ${api.name} too slow`).toBeLessThan(1000);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test('page load timing is within budget', async ({ page }) => {
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
const timing = await page.evaluate(() => {
|
||
|
|
const nav = performance.getEntriesByType('navigation')[0];
|
||
|
|
return {
|
||
|
|
ttfb: nav.responseStart - nav.requestStart,
|
||
|
|
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
|
||
|
|
loadComplete: nav.loadEventEnd - nav.startTime,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(timing.ttfb).toBeLessThan(600);
|
||
|
|
expect(timing.domContentLoaded).toBeLessThan(2000);
|
||
|
|
expect(timing.loadComplete).toBeLessThan(5000);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Resource Loading and Bundle Size Monitoring
|
||
|
|
|
||
|
|
**Use when**: Enforcing bundle size budgets and catching unexpected large resources.
|
||
|
|
**Avoid when**: Bundle analysis is handled by webpack-bundle-analyzer or similar build tools.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test('JavaScript bundle sizes are within budget', async ({ page }) => {
|
||
|
|
const resourceSizes: { name: string; size: number }[] = [];
|
||
|
|
|
||
|
|
page.on('response', async (response) => {
|
||
|
|
const url = response.url();
|
||
|
|
if (url.endsWith('.js') || url.includes('.js?')) {
|
||
|
|
const headers = response.headers();
|
||
|
|
const size = parseInt(headers['content-length'] || '0');
|
||
|
|
resourceSizes.push({
|
||
|
|
name: url.split('/').pop()!.split('?')[0],
|
||
|
|
size,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
// No single JS bundle should exceed 250KB compressed
|
||
|
|
for (const resource of resourceSizes) {
|
||
|
|
expect(
|
||
|
|
resource.size,
|
||
|
|
`Bundle ${resource.name} is ${(resource.size / 1024).toFixed(1)}KB`
|
||
|
|
).toBeLessThan(250 * 1024);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Total JS payload should not exceed 500KB
|
||
|
|
const totalSize = resourceSizes.reduce((sum, r) => sum + r.size, 0);
|
||
|
|
expect(totalSize, `Total JS: ${(totalSize / 1024).toFixed(1)}KB`).toBeLessThan(500 * 1024);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('no unexpected large images', async ({ page }) => {
|
||
|
|
const largeImages: { url: string; size: number }[] = [];
|
||
|
|
|
||
|
|
page.on('response', async (response) => {
|
||
|
|
const contentType = response.headers()['content-type'] || '';
|
||
|
|
if (contentType.startsWith('image/')) {
|
||
|
|
const size = parseInt(response.headers()['content-length'] || '0');
|
||
|
|
if (size > 200 * 1024) {
|
||
|
|
largeImages.push({ url: response.url(), size });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
expect(
|
||
|
|
largeImages,
|
||
|
|
`Found ${largeImages.length} images over 200KB: ${largeImages.map(i => i.url).join(', ')}`
|
||
|
|
).toHaveLength(0);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test('JavaScript bundle sizes are within budget', async ({ page }) => {
|
||
|
|
const resourceSizes = [];
|
||
|
|
|
||
|
|
page.on('response', async (response) => {
|
||
|
|
const url = response.url();
|
||
|
|
if (url.endsWith('.js') || url.includes('.js?')) {
|
||
|
|
const size = parseInt(response.headers()['content-length'] || '0');
|
||
|
|
resourceSizes.push({ name: url.split('/').pop().split('?')[0], size });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
const totalSize = resourceSizes.reduce((sum, r) => sum + r.size, 0);
|
||
|
|
expect(totalSize).toBeLessThan(500 * 1024);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Slow Network Simulation via CDP
|
||
|
|
|
||
|
|
**Use when**: Testing your app's behavior and performance under constrained network conditions.
|
||
|
|
**Avoid when**: Playwright's built-in `offline` option is sufficient for your test.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect, type Page } from '@playwright/test';
|
||
|
|
|
||
|
|
// Network presets
|
||
|
|
const NETWORK_PRESETS = {
|
||
|
|
slow3G: {
|
||
|
|
offline: false,
|
||
|
|
downloadThroughput: (500 * 1024) / 8, // 500 Kbps
|
||
|
|
uploadThroughput: (500 * 1024) / 8,
|
||
|
|
latency: 400,
|
||
|
|
},
|
||
|
|
fast3G: {
|
||
|
|
offline: false,
|
||
|
|
downloadThroughput: (1.6 * 1024 * 1024) / 8, // 1.6 Mbps
|
||
|
|
uploadThroughput: (750 * 1024) / 8,
|
||
|
|
latency: 150,
|
||
|
|
},
|
||
|
|
regularLTE: {
|
||
|
|
offline: false,
|
||
|
|
downloadThroughput: (4 * 1024 * 1024) / 8, // 4 Mbps
|
||
|
|
uploadThroughput: (3 * 1024 * 1024) / 8,
|
||
|
|
latency: 20,
|
||
|
|
},
|
||
|
|
} as const;
|
||
|
|
|
||
|
|
async function throttleNetwork(page: Page, preset: keyof typeof NETWORK_PRESETS) {
|
||
|
|
const client = await page.context().newCDPSession(page);
|
||
|
|
await client.send('Network.enable');
|
||
|
|
await client.send('Network.emulateNetworkConditions', NETWORK_PRESETS[preset]);
|
||
|
|
return client;
|
||
|
|
}
|
||
|
|
|
||
|
|
test('app shows loading states on slow network', async ({ page }) => {
|
||
|
|
await throttleNetwork(page, 'slow3G');
|
||
|
|
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
// Loading skeleton should appear while content loads slowly
|
||
|
|
await expect(page.getByTestId('loading-skeleton')).toBeVisible();
|
||
|
|
|
||
|
|
// Content should eventually load
|
||
|
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({ timeout: 30000 });
|
||
|
|
});
|
||
|
|
|
||
|
|
test('images lazy-load on slow connection', async ({ page }) => {
|
||
|
|
await throttleNetwork(page, 'fast3G');
|
||
|
|
await page.goto('/gallery');
|
||
|
|
|
||
|
|
// Only above-the-fold images should be loaded initially
|
||
|
|
const loadedImages = await page.evaluate(() =>
|
||
|
|
Array.from(document.querySelectorAll('img'))
|
||
|
|
.filter((img) => img.complete && img.naturalWidth > 0).length
|
||
|
|
);
|
||
|
|
|
||
|
|
// Scroll to trigger lazy loading
|
||
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||
|
|
await page.waitForTimeout(2000);
|
||
|
|
|
||
|
|
const allLoadedImages = await page.evaluate(() =>
|
||
|
|
Array.from(document.querySelectorAll('img'))
|
||
|
|
.filter((img) => img.complete && img.naturalWidth > 0).length
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(allLoadedImages).toBeGreaterThan(loadedImages);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
async function throttleNetwork(page, preset) {
|
||
|
|
const presets = {
|
||
|
|
slow3G: { offline: false, downloadThroughput: (500 * 1024) / 8, uploadThroughput: (500 * 1024) / 8, latency: 400 },
|
||
|
|
fast3G: { offline: false, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8, latency: 150 },
|
||
|
|
};
|
||
|
|
const client = await page.context().newCDPSession(page);
|
||
|
|
await client.send('Network.enable');
|
||
|
|
await client.send('Network.emulateNetworkConditions', presets[preset]);
|
||
|
|
return client;
|
||
|
|
}
|
||
|
|
|
||
|
|
test('app shows loading states on slow network', async ({ page }) => {
|
||
|
|
await throttleNetwork(page, 'slow3G');
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
await expect(page.getByTestId('loading-skeleton')).toBeVisible();
|
||
|
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({ timeout: 30000 });
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### CPU Throttling via CDP
|
||
|
|
|
||
|
|
**Use when**: Simulating low-powered devices to test animation smoothness, interaction responsiveness, or heavy computation.
|
||
|
|
**Avoid when**: Network performance is the bottleneck, not CPU.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
test('animations remain smooth under CPU throttling', async ({ page }) => {
|
||
|
|
const client = await page.context().newCDPSession(page);
|
||
|
|
|
||
|
|
// 4x slowdown simulates a mid-tier mobile device
|
||
|
|
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||
|
|
|
||
|
|
await page.goto('/animations-demo');
|
||
|
|
await page.getByRole('button', { name: 'Start animation' }).click();
|
||
|
|
|
||
|
|
// Measure frame rate during animation
|
||
|
|
const fps = await page.evaluate(() => {
|
||
|
|
return new Promise<number>((resolve) => {
|
||
|
|
let frames = 0;
|
||
|
|
const start = performance.now();
|
||
|
|
function count() {
|
||
|
|
frames++;
|
||
|
|
if (performance.now() - start < 1000) {
|
||
|
|
requestAnimationFrame(count);
|
||
|
|
} else {
|
||
|
|
resolve(frames);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
requestAnimationFrame(count);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should maintain at least 30fps even on throttled CPU
|
||
|
|
expect(fps).toBeGreaterThan(30);
|
||
|
|
|
||
|
|
// Reset throttling
|
||
|
|
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 });
|
||
|
|
});
|
||
|
|
|
||
|
|
test('search input responds quickly under CPU constraint', async ({ page }) => {
|
||
|
|
const client = await page.context().newCDPSession(page);
|
||
|
|
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||
|
|
|
||
|
|
await page.goto('/search');
|
||
|
|
|
||
|
|
const start = Date.now();
|
||
|
|
await page.getByRole('textbox', { name: 'Search' }).fill('test query');
|
||
|
|
await expect(page.getByRole('listbox')).toBeVisible();
|
||
|
|
const elapsed = Date.now() - start;
|
||
|
|
|
||
|
|
// Autocomplete should appear within 500ms even under 4x CPU throttle
|
||
|
|
expect(elapsed).toBeLessThan(500);
|
||
|
|
|
||
|
|
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 });
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
test('animations remain smooth under CPU throttling', async ({ page }) => {
|
||
|
|
const client = await page.context().newCDPSession(page);
|
||
|
|
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||
|
|
|
||
|
|
await page.goto('/animations-demo');
|
||
|
|
await page.getByRole('button', { name: 'Start animation' }).click();
|
||
|
|
|
||
|
|
const fps = await page.evaluate(() => {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
let frames = 0;
|
||
|
|
const start = performance.now();
|
||
|
|
function count() {
|
||
|
|
frames++;
|
||
|
|
if (performance.now() - start < 1000) {
|
||
|
|
requestAnimationFrame(count);
|
||
|
|
} else {
|
||
|
|
resolve(frames);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
requestAnimationFrame(count);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(fps).toBeGreaterThan(30);
|
||
|
|
await client.send('Emulation.setCPUThrottlingRate', { rate: 1 });
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Performance Budgets in CI
|
||
|
|
|
||
|
|
**Use when**: Enforcing hard performance limits that block merges when thresholds are exceeded.
|
||
|
|
**Avoid when**: Performance varies too much in CI environment -- use trend-based monitoring instead.
|
||
|
|
|
||
|
|
**TypeScript**
|
||
|
|
```typescript
|
||
|
|
import { test, expect } from '@playwright/test';
|
||
|
|
|
||
|
|
// Define budgets in a shared config
|
||
|
|
const PERFORMANCE_BUDGETS = {
|
||
|
|
homepage: {
|
||
|
|
lcp: 2500,
|
||
|
|
cls: 0.1,
|
||
|
|
ttfb: 600,
|
||
|
|
totalJsSize: 500 * 1024,
|
||
|
|
totalImageSize: 1000 * 1024,
|
||
|
|
domContentLoaded: 2000,
|
||
|
|
},
|
||
|
|
dashboard: {
|
||
|
|
lcp: 3000,
|
||
|
|
cls: 0.1,
|
||
|
|
ttfb: 800,
|
||
|
|
totalJsSize: 750 * 1024,
|
||
|
|
totalImageSize: 500 * 1024,
|
||
|
|
domContentLoaded: 3000,
|
||
|
|
},
|
||
|
|
} as const;
|
||
|
|
|
||
|
|
test.describe('performance budgets', () => {
|
||
|
|
test('homepage meets performance budget', async ({ page }) => {
|
||
|
|
const budget = PERFORMANCE_BUDGETS.homepage;
|
||
|
|
let totalJsSize = 0;
|
||
|
|
|
||
|
|
page.on('response', (response) => {
|
||
|
|
if (response.url().endsWith('.js') || response.url().includes('.js?')) {
|
||
|
|
totalJsSize += parseInt(response.headers()['content-length'] || '0');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Inject LCP observer
|
||
|
|
await page.addInitScript(() => {
|
||
|
|
(window as any).__lcp = 0;
|
||
|
|
new PerformanceObserver((list) => {
|
||
|
|
const entries = list.getEntries();
|
||
|
|
(window as any).__lcp = entries[entries.length - 1].startTime;
|
||
|
|
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
const metrics = await page.evaluate(() => {
|
||
|
|
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||
|
|
return {
|
||
|
|
lcp: (window as any).__lcp,
|
||
|
|
ttfb: nav.responseStart - nav.requestStart,
|
||
|
|
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(metrics.lcp, 'LCP budget exceeded').toBeLessThan(budget.lcp);
|
||
|
|
expect(metrics.ttfb, 'TTFB budget exceeded').toBeLessThan(budget.ttfb);
|
||
|
|
expect(metrics.domContentLoaded, 'DOMContentLoaded budget exceeded').toBeLessThan(budget.domContentLoaded);
|
||
|
|
expect(totalJsSize, 'JS bundle budget exceeded').toBeLessThan(budget.totalJsSize);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
**JavaScript**
|
||
|
|
```javascript
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
|
||
|
|
const PERFORMANCE_BUDGETS = {
|
||
|
|
homepage: { lcp: 2500, ttfb: 600, totalJsSize: 500 * 1024, domContentLoaded: 2000 },
|
||
|
|
};
|
||
|
|
|
||
|
|
test('homepage meets performance budget', async ({ page }) => {
|
||
|
|
const budget = PERFORMANCE_BUDGETS.homepage;
|
||
|
|
let totalJsSize = 0;
|
||
|
|
|
||
|
|
page.on('response', (response) => {
|
||
|
|
if (response.url().endsWith('.js')) {
|
||
|
|
totalJsSize += parseInt(response.headers()['content-length'] || '0');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.addInitScript(() => {
|
||
|
|
window.__lcp = 0;
|
||
|
|
new PerformanceObserver((list) => {
|
||
|
|
const entries = list.getEntries();
|
||
|
|
window.__lcp = entries[entries.length - 1].startTime;
|
||
|
|
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
await page.goto('/');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
const metrics = await page.evaluate(() => {
|
||
|
|
const nav = performance.getEntriesByType('navigation')[0];
|
||
|
|
return {
|
||
|
|
lcp: window.__lcp,
|
||
|
|
ttfb: nav.responseStart - nav.requestStart,
|
||
|
|
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(metrics.lcp).toBeLessThan(budget.lcp);
|
||
|
|
expect(metrics.ttfb).toBeLessThan(budget.ttfb);
|
||
|
|
expect(totalJsSize).toBeLessThan(budget.totalJsSize);
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Decision Guide
|
||
|
|
|
||
|
|
| What to Measure | Technique | When to Use |
|
||
|
|
|---|---|---|
|
||
|
|
| LCP, CLS, FID/INP | `PerformanceObserver` via `addInitScript` | Core Web Vitals regression testing |
|
||
|
|
| TTFB, DOM load times | `performance.getEntriesByType('navigation')` | Server response and page load budgets |
|
||
|
|
| API call durations | `performance.getEntriesByType('resource')` | Backend performance regression |
|
||
|
|
| JS/CSS bundle sizes | `page.on('response')` + `content-length` header | Bundle size budgets in CI |
|
||
|
|
| Slow network behavior | CDP `Network.emulateNetworkConditions` | Testing loading states, lazy loading, offline |
|
||
|
|
| Low-end device behavior | CDP `Emulation.setCPUThrottlingRate` | Animation smoothness, interaction latency |
|
||
|
|
| Full Lighthouse audit | `@playwright/test` + Lighthouse CLI via CDP port | Comprehensive performance scoring |
|
||
|
|
| Runtime performance | `page.evaluate` + `requestAnimationFrame` FPS count | Animation and rendering performance |
|
||
|
|
|
||
|
|
## Anti-Patterns
|
||
|
|
|
||
|
|
| Don't Do This | Problem | Do This Instead |
|
||
|
|
|---|---|---|
|
||
|
|
| Setting absolute thresholds based on local dev machine | CI machines are slower; thresholds flap | Calibrate budgets on CI hardware or use relative comparisons |
|
||
|
|
| Using `networkidle` as a performance measurement point | `networkidle` includes analytics, ads, non-critical resources | Measure specific metrics (LCP, TTFB) directly via Performance API |
|
||
|
|
| Running performance tests with `--headed` in CI | Headed mode adds GPU overhead and inconsistency | Use headless mode for consistent measurement |
|
||
|
|
| Measuring FID in automated tests | No real user input delay exists in automation | Measure INP or use Lighthouse for FID estimates |
|
||
|
|
| Running perf tests in parallel with other CI jobs | CPU contention skews results | Run performance tests in isolation or on dedicated CI runners |
|
||
|
|
| Ignoring `content-length` being `0` | Compressed responses may not report size | Use `response.body().length` for actual transfer size |
|
||
|
|
| Only testing happy-path performance | Slow error paths degrade user experience | Test performance of error states, empty states, and large datasets |
|
||
|
|
| Hard-failing CI on minor regressions | Causes merge friction for non-performance changes | Use warning thresholds with mandatory review, fail only on large regressions |
|
||
|
|
|
||
|
|
## Troubleshooting
|
||
|
|
|
||
|
|
| Symptom | Likely Cause | Fix |
|
||
|
|
|---|---|---|
|
||
|
|
| LCP is 0 or unrealistically low | Observer did not fire; page has no qualifying LCP element | Verify the page has images or large text blocks; add `buffered: true` to observer |
|
||
|
|
| CLS is always 0 | Layout shifts occur before observer is registered | Use `addInitScript` to inject observer before page load |
|
||
|
|
| CDP session errors with Firefox/WebKit | CDP is Chromium-only | Guard CDP code: `test.skip(browserName !== 'chromium')` |
|
||
|
|
| Performance numbers vary wildly between runs | CI machine load fluctuates | Run performance tests multiple times and take the median; use dedicated runners |
|
||
|
|
| `content-length` header is missing | Server uses chunked transfer encoding | Use `response.body()` then check `Buffer.byteLength()` |
|
||
|
|
| Network throttling has no effect | CDP session created on wrong page | Create the CDP session from the page's context, not a separate browser |
|
||
|
|
| Bundle size test passes but app feels slow | Measuring compressed size, not parsed size | Also check `performance.getEntriesByType('resource')` for `decodedBodySize` |
|
||
|
|
|
||
|
|
## Related
|
||
|
|
|
||
|
|
- [core/configuration.md](configuration.md) -- timeout and retry settings for performance-sensitive tests
|
||
|
|
- [core/network-mocking.md](network-mocking.md) -- mocking slow APIs for performance boundary testing
|
||
|
|
- [core/browser-apis.md](browser-apis.md) -- using browser APIs for measurement
|
||
|
|
- [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI configuration for performance budgets
|
||
|
|
- [core/clock-and-time-mocking.md](clock-and-time-mocking.md) -- time-related performance testing
|