mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 09:42:40 +02:00
chore: add playwright cursor skill
This commit is contained in:
parent
25aad38ca4
commit
d52225c18d
57 changed files with 25244 additions and 0 deletions
469
.cursor/skills/playwright-testing/frameworks/nextjs.md
Normal file
469
.cursor/skills/playwright-testing/frameworks/nextjs.md
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
# Next.js Testing Patterns
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Setup](#setup)
|
||||
2. [App Router Patterns](#app-router-patterns)
|
||||
3. [Pages Router Patterns](#pages-router-patterns)
|
||||
4. [Dynamic Routes](#dynamic-routes)
|
||||
5. [API Routes](#api-routes)
|
||||
6. [Middleware Testing](#middleware-testing)
|
||||
7. [Hydration Testing](#hydration-testing)
|
||||
8. [next/image Testing](#nextimage-testing)
|
||||
9. [NextAuth.js Authentication](#nextauthjs-authentication)
|
||||
10. [Tips](#tips)
|
||||
11. [Anti-Patterns](#anti-patterns)
|
||||
12. [Related](#related)
|
||||
|
||||
> **When to use**: Testing Next.js applications with App Router, Pages Router, API routes, middleware, SSR, dynamic routes, and server components.
|
||||
> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)
|
||||
|
||||
## Setup
|
||||
|
||||
### Configuration with webServer
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? '50%' : undefined,
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npm run build && npm run start'
|
||||
: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
NODE_ENV: process.env.CI ? 'production' : 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Next.js loads `.env.test` when `NODE_ENV=test`:
|
||||
|
||||
```bash
|
||||
# .env.test (commit this)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
DATABASE_URL=postgresql://localhost:5432/test_db
|
||||
|
||||
# .env.test.local (gitignored)
|
||||
NEXTAUTH_SECRET=test-secret-local
|
||||
```
|
||||
|
||||
## App Router Patterns
|
||||
|
||||
### Server Component Content
|
||||
|
||||
```typescript
|
||||
test('renders server component content', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Loading States with Streaming
|
||||
|
||||
```typescript
|
||||
test('loading state during data streaming', async ({ page }) => {
|
||||
await page.route('**/api/stats', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByRole('progressbar')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('progressbar')).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Nested Layouts
|
||||
|
||||
```typescript
|
||||
test('layouts persist across navigation', async ({ page }) => {
|
||||
await page.goto('/dashboard/analytics');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Settings' }).click();
|
||||
await page.waitForURL('/dashboard/settings');
|
||||
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Pages Router Patterns
|
||||
|
||||
### SSR with getServerSideProps
|
||||
|
||||
```typescript
|
||||
test('page with getServerSideProps renders data', async ({ page }) => {
|
||||
await page.goto('/blog');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole('article')).toHaveCount(10);
|
||||
await expect(page.getByRole('article').first()).toContainText(/\w+/);
|
||||
});
|
||||
```
|
||||
|
||||
### Static Generation with getStaticProps
|
||||
|
||||
```typescript
|
||||
test('static page shows pre-rendered content', async ({ page }) => {
|
||||
await page.goto('/about');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
|
||||
await expect(page.getByText('Founded in 2020')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
### Slug Parameters
|
||||
|
||||
```typescript
|
||||
test('dynamic [slug] renders correct content', async ({ page }) => {
|
||||
await page.goto('/blog/testing-guide');
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Testing Guide');
|
||||
await expect(page.getByText('Page not found')).toBeHidden();
|
||||
});
|
||||
|
||||
test('non-existent slug shows 404', async ({ page }) => {
|
||||
const response = await page.goto('/blog/nonexistent-post');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Catch-All Routes
|
||||
|
||||
```typescript
|
||||
test('catch-all handles nested paths', async ({ page }) => {
|
||||
await page.goto('/docs/getting-started/installation');
|
||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||
|
||||
await page.goto('/docs/api/configuration');
|
||||
await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
```typescript
|
||||
test('query parameters filter content', async ({ page }) => {
|
||||
await page.goto('/products?category=electronics&sort=price-asc');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
|
||||
|
||||
const prices = await page.getByTestId('product-price').allTextContents();
|
||||
const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
|
||||
expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
|
||||
});
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
### Direct API Testing
|
||||
|
||||
```typescript
|
||||
test('GET /api/products returns list', async ({ request }) => {
|
||||
const response = await request.get('/api/products');
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body.products).toBeInstanceOf(Array);
|
||||
expect(body.products[0]).toHaveProperty('id');
|
||||
expect(body.products[0]).toHaveProperty('name');
|
||||
});
|
||||
|
||||
test('POST /api/products creates item', async ({ request }) => {
|
||||
const response = await request.post('/api/products', {
|
||||
data: { name: 'Test Product', price: 29.99 },
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
expect(body.product.name).toBe('Test Product');
|
||||
});
|
||||
|
||||
test('POST /api/products validates fields', async ({ request }) => {
|
||||
const response = await request.post('/api/products', {
|
||||
data: { name: '' },
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContainEqual(expect.objectContaining({ field: 'price' }));
|
||||
});
|
||||
```
|
||||
|
||||
### API Through UI
|
||||
|
||||
```typescript
|
||||
test('form submission calls API', async ({ page }) => {
|
||||
await page.goto('/products/new');
|
||||
|
||||
await page.getByLabel('Product name').fill('Widget');
|
||||
await page.getByLabel('Price').fill('19.99');
|
||||
await page.getByRole('button', { name: 'Create product' }).click();
|
||||
|
||||
await expect(page.getByText('Product created successfully')).toBeVisible();
|
||||
await page.waitForURL('/products/**');
|
||||
});
|
||||
```
|
||||
|
||||
## Middleware Testing
|
||||
|
||||
### Auth Redirects
|
||||
|
||||
```typescript
|
||||
test('unauthenticated user redirected to login', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
|
||||
expect(page.url()).toContain('/login');
|
||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('redirect preserves return URL', async ({ page }) => {
|
||||
await page.goto('/dashboard/settings');
|
||||
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe('/login');
|
||||
expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
|
||||
.toContain('/dashboard/settings');
|
||||
});
|
||||
```
|
||||
|
||||
### Security Headers
|
||||
|
||||
```typescript
|
||||
test('middleware sets security headers', async ({ page }) => {
|
||||
const response = await page.goto('/');
|
||||
|
||||
const headers = response!.headers();
|
||||
expect(headers['x-frame-options']).toBe('DENY');
|
||||
expect(headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
```
|
||||
|
||||
### Locale Rewrites
|
||||
|
||||
```typescript
|
||||
test('middleware rewrites based on locale', async ({ page, context }) => {
|
||||
await context.setExtraHTTPHeaders({
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9',
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Bienvenue')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Hydration Testing
|
||||
|
||||
### Console Error Detection
|
||||
|
||||
```typescript
|
||||
test('no hydration errors in console', async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Get started' }).click();
|
||||
|
||||
const hydrationErrors = consoleErrors.filter(
|
||||
(e) =>
|
||||
e.includes('Hydration') ||
|
||||
e.includes('hydration') ||
|
||||
e.includes('did not match')
|
||||
);
|
||||
expect(hydrationErrors).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
### Interactive Elements After Hydration
|
||||
|
||||
```typescript
|
||||
test('interactive elements work after hydration', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const counter = page.getByTestId('counter-value');
|
||||
await expect(counter).toHaveText('0');
|
||||
|
||||
await page.getByRole('button', { name: 'Increment' }).click();
|
||||
await expect(counter).toHaveText('1');
|
||||
});
|
||||
```
|
||||
|
||||
## next/image Testing
|
||||
|
||||
```typescript
|
||||
test('hero image loads with srcset', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const heroImage = page.getByRole('img', { name: 'Hero banner' });
|
||||
await expect(heroImage).toBeVisible();
|
||||
|
||||
const srcset = await heroImage.getAttribute('srcset');
|
||||
expect(srcset).toBeTruthy();
|
||||
expect(srcset).toContain('w=');
|
||||
|
||||
const loading = await heroImage.getAttribute('loading');
|
||||
expect(loading).not.toBe('lazy');
|
||||
});
|
||||
|
||||
test('offscreen images lazy load', async ({ page }) => {
|
||||
await page.goto('/gallery');
|
||||
|
||||
const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });
|
||||
|
||||
await offscreenImage.scrollIntoViewIfNeeded();
|
||||
await expect(offscreenImage).toBeVisible();
|
||||
|
||||
const naturalWidth = await offscreenImage.evaluate(
|
||||
(img: HTMLImageElement) => img.naturalWidth
|
||||
);
|
||||
expect(naturalWidth).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
## NextAuth.js Authentication
|
||||
|
||||
### Setup Project
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||
{
|
||||
name: 'authenticated',
|
||||
use: { storageState: 'playwright/.auth/user.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{ name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts' },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Auth Setup
|
||||
|
||||
```typescript
|
||||
// tests/auth.setup.ts
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'playwright/.auth/user.json';
|
||||
|
||||
setup('authenticate via credentials', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill('test@example.com');
|
||||
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await page.waitForURL('/dashboard');
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
```
|
||||
|
||||
### Authenticated Tests
|
||||
|
||||
```typescript
|
||||
test('authenticated user sees dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('test@example.com')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
### Dev Server vs Production Build
|
||||
|
||||
| Scenario | Command | Trade-off |
|
||||
|---|---|---|
|
||||
| Local development | `npm run dev` | Fast iteration, no production behavior |
|
||||
| CI pipeline | `npm run build && npm run start` | Tests real production bundle |
|
||||
|
||||
### Turbopack
|
||||
|
||||
```typescript
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npm run build && npm run start'
|
||||
: 'npx next dev --turbopack',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
```
|
||||
|
||||
### Multiple webServer Entries
|
||||
|
||||
```typescript
|
||||
webServer: [
|
||||
{
|
||||
command: 'npm run dev:api',
|
||||
url: 'http://localhost:4000/health',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
|---|---|---|
|
||||
| `await page.waitForTimeout(3000)` | Arbitrary waits are fragile | `await page.waitForURL('/path')` or `await expect(locator).toBeVisible()` |
|
||||
| Test `getServerSideProps` directly | Depends on req/res context | Navigate to page and verify rendered output |
|
||||
| Mock your own API routes | Hides real API bugs | Let real API handle requests; mock only external services |
|
||||
| `page.goto('http://localhost:3000/path')` | Breaks when port changes | Use `page.goto('/path')` with `baseURL` |
|
||||
| Run `npm run build` locally for every test | Extremely slow | Use `npm run dev` locally with `reuseExistingServer: true` |
|
||||
| Test `next/image` by checking exact URLs | Paths change between dev/prod | Assert on `alt`, visibility, `naturalWidth > 0`, `srcset` |
|
||||
| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons) |
|
||||
|
||||
## Related
|
||||
|
||||
- [configuration.md](../core/configuration.md) -- Playwright configuration including `webServer`
|
||||
- [authentication.md](../advanced/authentication.md) -- authentication setup and `storageState`
|
||||
- [api-testing.md](../testing-patterns/api-testing.md) -- testing API routes with `request` context
|
||||
- [react.md](react.md) -- React patterns for Next.js client components
|
||||
Loading…
Add table
Add a link
Reference in a new issue