chore: add playwright cursor skill

This commit is contained in:
Anish Sarkar 2026-05-10 04:19:55 +05:30
parent 25aad38ca4
commit d52225c18d
57 changed files with 25244 additions and 0 deletions

View file

@ -0,0 +1,359 @@
# Accessibility Testing
## Table of Contents
1. [Axe-Core Integration](#axe-core-integration)
2. [Keyboard Navigation](#keyboard-navigation)
3. [ARIA Validation](#aria-validation)
4. [Focus Management](#focus-management)
5. [Color & Contrast](#color--contrast)
## Axe-Core Integration
### Setup
```bash
npm install -D @axe-core/playwright
```
### Basic A11y Test
```typescript
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("homepage should have no a11y violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
```
### Scoped Analysis
```typescript
test("form accessibility", async ({ page }) => {
await page.goto("/contact");
// Analyze only the form
const results = await new AxeBuilder({ page })
.include("#contact-form")
.analyze();
expect(results.violations).toEqual([]);
});
test("ignore known issues", async ({ page }) => {
await page.goto("/legacy-page");
const results = await new AxeBuilder({ page })
.exclude(".legacy-widget") // Skip legacy component
.disableRules(["color-contrast"]) // Disable specific rule
.analyze();
expect(results.violations).toEqual([]);
});
```
### A11y Fixture
```typescript
// fixtures/a11y.fixture.ts
import { test as base } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
type A11yFixtures = {
makeAxeBuilder: () => AxeBuilder;
};
export const test = base.extend<A11yFixtures>({
makeAxeBuilder: async ({ page }, use) => {
await use(() =>
new AxeBuilder({ page }).withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
]),
);
},
});
// Usage
test("dashboard a11y", async ({ page, makeAxeBuilder }) => {
await page.goto("/dashboard");
const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
});
```
### Detailed Violation Reporting
```typescript
test("report a11y issues", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).analyze();
// Custom failure message with details
const violations = results.violations.map((v) => ({
id: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.map((n) => n.html),
}));
expect(violations, JSON.stringify(violations, null, 2)).toHaveLength(0);
});
```
## Keyboard Navigation
### Tab Order Testing
```typescript
test("correct tab order in form", async ({ page }) => {
await page.goto("/signup");
// Start from the beginning
await page.keyboard.press("Tab");
await expect(page.getByLabel("Email")).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByLabel("Password")).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Sign up" })).toBeFocused();
});
```
### Keyboard-Only Interaction
```typescript
test("complete flow with keyboard only", async ({ page }) => {
await page.goto("/products");
// Navigate to product with keyboard
await page.keyboard.press("Tab"); // Skip to main content
await page.keyboard.press("Tab"); // First product
await page.keyboard.press("Enter"); // Open product
await expect(page).toHaveURL(/\/products\/\d+/);
// Add to cart with keyboard
await page.keyboard.press("Tab");
await page.keyboard.press("Tab"); // Navigate to "Add to Cart"
await page.keyboard.press("Enter");
await expect(page.getByRole("alert")).toContainText("Added to cart");
});
```
### Skip Links
```typescript
test("skip link works", async ({ page }) => {
await page.goto("/");
await page.keyboard.press("Tab");
const skipLink = page.getByRole("link", { name: /skip to main/i });
await expect(skipLink).toBeFocused();
await page.keyboard.press("Enter");
// Focus should move to main content
await expect(page.getByRole("main")).toBeFocused();
});
```
### Escape Key Handling
```typescript
test("escape closes modal", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("button", { name: "Settings" }).click();
const modal = page.getByRole("dialog");
await expect(modal).toBeVisible();
await page.keyboard.press("Escape");
await expect(modal).toBeHidden();
// Focus should return to trigger
await expect(page.getByRole("button", { name: "Settings" })).toBeFocused();
});
```
## ARIA Validation
### Role Verification
```typescript
test("correct ARIA roles", async ({ page }) => {
await page.goto("/dashboard");
// Verify landmark roles
await expect(page.getByRole("navigation")).toBeVisible();
await expect(page.getByRole("main")).toBeVisible();
await expect(page.getByRole("contentinfo")).toBeVisible(); // footer
// Verify interactive roles
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
await expect(page.getByRole("search")).toBeVisible();
});
```
### ARIA States
```typescript
test("aria-expanded updates correctly", async ({ page }) => {
await page.goto("/faq");
const accordion = page.getByRole("button", { name: "Shipping" });
// Initially collapsed
await expect(accordion).toHaveAttribute("aria-expanded", "false");
await accordion.click();
// Now expanded
await expect(accordion).toHaveAttribute("aria-expanded", "true");
// Content is visible
const panel = page.getByRole("region", { name: "Shipping" });
await expect(panel).toBeVisible();
});
```
### Live Regions
```typescript
test("live region announces updates", async ({ page }) => {
await page.goto("/checkout");
// Find live region
const liveRegion = page.locator('[aria-live="polite"]');
await page.getByLabel("Quantity").fill("3");
// Live region should update with new total
await expect(liveRegion).toContainText("Total: $29.97");
});
```
## Focus Management
### Focus Trap in Modal
```typescript
test("focus trapped in modal", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Open Modal" }).click();
const modal = page.getByRole("dialog");
await expect(modal).toBeVisible();
// Get all focusable elements in modal
const focusableElements = modal.locator(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const count = await focusableElements.count();
// Tab through all elements, should stay in modal
for (let i = 0; i < count + 1; i++) {
await page.keyboard.press("Tab");
const focused = page.locator(":focus");
await expect(modal).toContainText((await focused.textContent()) || "");
}
});
```
### Focus Restoration
```typescript
test("focus returns after modal close", async ({ page }) => {
await page.goto("/");
const trigger = page.getByRole("button", { name: "Delete Item" });
await trigger.click();
await page.getByRole("button", { name: "Cancel" }).click();
// Focus should return to the trigger
await expect(trigger).toBeFocused();
});
```
## Color & Contrast
### High Contrast Mode
```typescript
test("works in high contrast mode", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/");
// Verify key elements are visible
await expect(page.getByRole("navigation")).toBeVisible();
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
// Take screenshot for visual verification
await expect(page).toHaveScreenshot("high-contrast.png");
});
```
### Reduced Motion
```typescript
test("respects reduced motion preference", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
// Animations should be disabled
const hero = page.getByTestId("hero-animation");
const animation = await hero.evaluate(
(el) => getComputedStyle(el).animationDuration,
);
expect(animation).toBe("0s");
});
```
## CI Integration
### A11y as CI Gate
```typescript
// playwright.config.ts
export default defineConfig({
projects: [
{
name: "a11y",
testMatch: /.*\.a11y\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
],
});
```
```yaml
# .github/workflows/a11y.yml
- name: Run accessibility tests
run: npx playwright test --project=a11y
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ----------------------------- | ---------------------------- | ------------------------------------------ |
| Testing a11y only on homepage | Misses issues on other pages | Test all critical user flows |
| Ignoring all violations | No value from tests | Address or explicitly exclude known issues |
| Only automated testing | Misses many a11y issues | Combine with manual testing |
| Testing without screen reader | Misses interaction issues | Test with VoiceOver/NVDA periodically |
## Related References
- **Locators**: See [locators.md](../core/locators.md) for role-based selectors
- **Visual testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot comparison

View file

@ -0,0 +1,719 @@
# API Testing
## Table of Contents
1. [Patterns](#patterns)
2. [Decision Guide](#decision-guide)
3. [Anti-Patterns](#anti-patterns)
4. [Troubleshooting](#troubleshooting)
> **When to use**: Testing REST APIs directly — validating endpoints, seeding test data, or verifying backend behavior without browser overhead.
> **See also**: [graphql-testing.md](graphql-testing.md) for GraphQL-specific patterns.
## Patterns
### Request Fixtures for Authenticated Clients
**Use when**: Multiple tests need an authenticated API client with shared configuration.
**Avoid when**: A single test makes one-off API calls — use the built-in `request` fixture directly.
```typescript
// fixtures/api-fixtures.ts
import { test as base, expect, APIRequestContext } from "@playwright/test";
type ApiFixtures = {
authApi: APIRequestContext;
adminApi: APIRequestContext;
};
export const test = base.extend<ApiFixtures>({
authApi: async ({ playwright }, use) => {
const ctx = await playwright.request.newContext({
baseURL: "https://api.myapp.io",
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
Accept: "application/json",
},
});
await use(ctx);
await ctx.dispose();
},
adminApi: async ({ playwright }, use) => {
const loginCtx = await playwright.request.newContext({
baseURL: "https://api.myapp.io",
});
const loginResp = await loginCtx.post("/auth/login", {
data: {
email: process.env.ADMIN_EMAIL,
password: process.env.ADMIN_PASSWORD,
},
});
expect(loginResp.ok()).toBeTruthy();
const { token } = await loginResp.json();
await loginCtx.dispose();
const ctx = await playwright.request.newContext({
baseURL: "https://api.myapp.io",
extraHTTPHeaders: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
});
await use(ctx);
await ctx.dispose();
},
});
export { expect };
```
```typescript
// tests/api/admin.spec.ts
import { test, expect } from "../../fixtures/api-fixtures";
test("admin retrieves all accounts", async ({ adminApi }) => {
const resp = await adminApi.get("/admin/accounts");
expect(resp.status()).toBe(200);
const body = await resp.json();
expect(body.accounts.length).toBeGreaterThan(0);
});
```
### CRUD Operations
**Use when**: Making HTTP requests — GET, POST, PUT, PATCH, DELETE with headers, query params, and bodies.
**Avoid when**: You need to test browser-rendered responses (redirects, cookies with `HttpOnly`).
```typescript
import { test, expect } from "@playwright/test";
test("full CRUD cycle", async ({ request }) => {
// GET with query params
const listResp = await request.get("/api/items", {
params: { page: 1, limit: 10, category: "tools" },
});
expect(listResp.ok()).toBeTruthy();
// POST with JSON body
const createResp = await request.post("/api/items", {
data: {
title: "Hammer",
price: 19.99,
category: "tools",
},
});
expect(createResp.status()).toBe(201);
const created = await createResp.json();
// PUT — full replacement
const putResp = await request.put(`/api/items/${created.id}`, {
data: {
title: "Claw Hammer",
price: 24.99,
category: "tools",
},
});
expect(putResp.ok()).toBeTruthy();
// PATCH — partial update
const patchResp = await request.patch(`/api/items/${created.id}`, {
data: { price: 22.5 },
});
expect(patchResp.ok()).toBeTruthy();
const patched = await patchResp.json();
expect(patched.price).toBe(22.5);
// DELETE
const delResp = await request.delete(`/api/items/${created.id}`);
expect(delResp.status()).toBe(204);
// Verify deletion
const getDeleted = await request.get(`/api/items/${created.id}`);
expect(getDeleted.status()).toBe(404);
});
test("form-urlencoded body", async ({ request }) => {
const resp = await request.post("/oauth/token", {
form: {
grant_type: "client_credentials",
client_id: "my-client",
client_secret: "secret-value",
},
});
expect(resp.ok()).toBeTruthy();
const token = await resp.json();
expect(token).toHaveProperty("access_token");
});
```
### Dedicated API Project Configuration
**Use when**: Writing dedicated API test suites that do not need a browser.
```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
projects: [
{
name: "api",
testDir: "./tests/api",
use: {
baseURL: "https://api.myapp.io",
extraHTTPHeaders: { Accept: "application/json" },
},
},
{
name: "e2e",
testDir: "./tests/e2e",
use: {
baseURL: "https://myapp.io",
browserName: "chromium",
},
},
],
});
```
### Response Assertions
**Use when**: Validating response status, headers, and body structure.
**Avoid when**: Never skip these — every API test should assert on status and body.
```typescript
import { test, expect } from "@playwright/test";
test("comprehensive response validation", async ({ request }) => {
const resp = await request.get("/api/items/101");
// Status code — always check first
expect(resp.status()).toBe(200);
expect(resp.ok()).toBeTruthy();
// Headers
expect(resp.headers()["content-type"]).toContain("application/json");
expect(resp.headers()["cache-control"]).toMatch(/max-age=\d+/);
const item = await resp.json();
// Exact match on known fields
expect(item.id).toBe(101);
expect(item.title).toBe("Widget");
// Partial match — ignore fields you don't care about
expect(item).toMatchObject({
id: 101,
title: "Widget",
status: expect.stringMatching(/^(active|inactive|archived)$/),
});
// Type checks
expect(item).toMatchObject({
id: expect.any(Number),
title: expect.any(String),
createdAt: expect.any(String),
tags: expect.any(Array),
});
// Array content
expect(item.tags).toEqual(expect.arrayContaining(["featured"]));
expect(item.tags).not.toContain("deprecated");
// Nested object
expect(item.metadata).toMatchObject({
views: expect.any(Number),
rating: expect.any(Number),
});
// Date format
expect(new Date(item.createdAt).toISOString()).toBe(item.createdAt);
});
test("list response structure", async ({ request }) => {
const resp = await request.get("/api/items");
const body = await resp.json();
expect(body.items).toHaveLength(10);
for (const item of body.items) {
expect(item).toMatchObject({
id: expect.any(Number),
title: expect.any(String),
price: expect.any(Number),
});
}
expect(body.pagination).toEqual({
page: 1,
limit: 10,
total: expect.any(Number),
totalPages: expect.any(Number),
});
});
```
### API Data Seeding
**Use when**: E2E tests need specific data to exist before running. API seeding is 10-100x faster than UI-based setup.
**Avoid when**: The test specifically validates the creation flow through the UI.
```typescript
import { test as base, expect } from "@playwright/test";
type SeedFixtures = {
seedAccount: { id: number; email: string; password: string };
seedWorkspace: { id: number; name: string };
};
export const test = base.extend<SeedFixtures>({
seedAccount: async ({ request }, use) => {
const email = `account-${Date.now()}@test.io`;
const password = "SecurePass123!";
const resp = await request.post("/api/accounts", {
data: { name: "Test Account", email, password },
});
expect(resp.ok()).toBeTruthy();
const account = await resp.json();
await use({ id: account.id, email, password });
// Cleanup
await request.delete(`/api/accounts/${account.id}`);
},
seedWorkspace: async ({ request, seedAccount }, use) => {
const resp = await request.post("/api/workspaces", {
data: { name: `Workspace ${Date.now()}`, ownerId: seedAccount.id },
});
expect(resp.ok()).toBeTruthy();
const workspace = await resp.json();
await use({ id: workspace.id, name: workspace.name });
await request.delete(`/api/workspaces/${workspace.id}`);
},
});
export { expect };
```
```typescript
// tests/e2e/workspace-dashboard.spec.ts
import { test, expect } from "../../fixtures/seed-fixtures";
test("user sees workspace on dashboard", async ({
page,
seedAccount,
seedWorkspace,
}) => {
await page.goto("/login");
await page.getByLabel("Email").fill(seedAccount.email);
await page.getByLabel("Password").fill(seedAccount.password);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("/dashboard");
await expect(
page.getByRole("heading", { name: seedWorkspace.name })
).toBeVisible();
});
```
### Error Response Testing
**Use when**: Every API has error paths — test them. A missing 401 test today is a security hole tomorrow.
```typescript
import { test, expect } from "@playwright/test";
test.describe("Error responses", () => {
test("400 — validation error with details", async ({ request }) => {
const resp = await request.post("/api/items", {
data: { title: "", price: -5 },
});
expect(resp.status()).toBe(400);
const body = await resp.json();
expect(body).toMatchObject({
error: "Validation Error",
details: expect.any(Array),
});
expect(body.details).toEqual(
expect.arrayContaining([
expect.objectContaining({
field: "title",
message: expect.any(String),
}),
expect.objectContaining({
field: "price",
message: expect.any(String),
}),
])
);
});
test("401 — missing authentication", async ({ request }) => {
const resp = await request.get("/api/protected/resource", {
headers: { Authorization: "" },
});
expect(resp.status()).toBe(401);
const body = await resp.json();
expect(body.error).toMatch(/unauthorized|unauthenticated/i);
});
test("403 — insufficient permissions", async ({ request }) => {
const resp = await request.delete("/api/admin/items/1");
expect(resp.status()).toBe(403);
const body = await resp.json();
expect(body.error).toMatch(/forbidden|insufficient permissions/i);
});
test("404 — resource not found", async ({ request }) => {
const resp = await request.get("/api/items/999999");
expect(resp.status()).toBe(404);
const body = await resp.json();
expect(body).toMatchObject({ error: expect.stringMatching(/not found/i) });
});
test("409 — conflict on duplicate", async ({ request }) => {
const sku = `SKU-${Date.now()}`;
await request.post("/api/items", { data: { title: "First", sku } });
const resp = await request.post("/api/items", {
data: { title: "Duplicate", sku },
});
expect(resp.status()).toBe(409);
});
test("422 — unprocessable entity", async ({ request }) => {
const resp = await request.post("/api/orders", {
data: { items: [] },
});
expect(resp.status()).toBe(422);
const body = await resp.json();
expect(body.error).toContain("at least one item");
});
test("429 — rate limiting", async ({ request }) => {
const responses = await Promise.all(
Array.from({ length: 50 }, () =>
request.get("/api/search", { params: { q: "test" } })
)
);
const rateLimited = responses.filter((r) => r.status() === 429);
expect(rateLimited.length).toBeGreaterThan(0);
expect(rateLimited[0].headers()["retry-after"]).toBeDefined();
});
});
```
### File Upload via API
**Use when**: Testing file upload endpoints with multipart form data.
**Avoid when**: You need to test the browser file picker dialog — use `page.setInputFiles()` instead.
```typescript
import { test, expect } from "@playwright/test";
import path from "path";
import fs from "fs";
test("upload file via multipart", async ({ request }) => {
const filePath = path.resolve("tests/fixtures/report.pdf");
const resp = await request.post("/api/documents/upload", {
multipart: {
file: {
name: "report.pdf",
mimeType: "application/pdf",
buffer: fs.readFileSync(filePath),
},
description: "Monthly report",
category: "reports",
},
});
expect(resp.status()).toBe(201);
const body = await resp.json();
expect(body).toMatchObject({
id: expect.any(String),
filename: "report.pdf",
mimeType: "application/pdf",
size: expect.any(Number),
url: expect.stringMatching(/^https:\/\//),
});
});
test("rejects oversized files", async ({ request }) => {
const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB
const resp = await request.post("/api/documents/upload", {
multipart: {
file: {
name: "large-file.bin",
mimeType: "application/octet-stream",
buffer: largeBuffer,
},
},
});
expect(resp.status()).toBe(413);
});
```
### Chained API Calls
**Use when**: Testing multi-step workflows — create, read, update, delete sequences; order flows; state machine transitions.
**Avoid when**: You can test each endpoint in isolation and the interactions are trivial.
```typescript
import { test, expect } from "@playwright/test";
test("complete order workflow", async ({ request }) => {
// Step 1: Create a product
const productResp = await request.post("/api/products", {
data: { name: "Gadget", price: 49.99, stock: 50 },
});
expect(productResp.status()).toBe(201);
const product = await productResp.json();
// Step 2: Create a cart
const cartResp = await request.post("/api/carts", {
data: { items: [{ productId: product.id, quantity: 3 }] },
});
expect(cartResp.status()).toBe(201);
const cart = await cartResp.json();
expect(cart.total).toBe(149.97);
// Step 3: Checkout
const orderResp = await request.post("/api/orders", {
data: {
cartId: cart.id,
shippingAddress: {
street: "456 Main Ave",
city: "Metropolis",
zip: "54321",
},
},
});
expect(orderResp.status()).toBe(201);
const order = await orderResp.json();
expect(order.status).toBe("pending");
expect(order.items).toHaveLength(1);
// Step 4: Verify order in list
const ordersResp = await request.get("/api/orders");
const orders = await ordersResp.json();
expect(orders.items.map((o: any) => o.id)).toContain(order.id);
// Step 5: Verify stock decreased
const updatedProduct = await (
await request.get(`/api/products/${product.id}`)
).json();
expect(updatedProduct.stock).toBe(47);
// Cleanup
await request.delete(`/api/orders/${order.id}`);
await request.delete(`/api/products/${product.id}`);
});
test("state machine transitions — publish workflow", async ({ request }) => {
const createResp = await request.post("/api/articles", {
data: { title: "Draft Article", body: "Content here." },
});
const article = await createResp.json();
expect(article.status).toBe("draft");
// Submit for review
const reviewResp = await request.patch(`/api/articles/${article.id}/status`, {
data: { status: "in_review" },
});
expect(reviewResp.ok()).toBeTruthy();
expect((await reviewResp.json()).status).toBe("in_review");
// Approve
const approveResp = await request.patch(
`/api/articles/${article.id}/status`,
{
data: { status: "published" },
}
);
expect(approveResp.ok()).toBeTruthy();
expect((await approveResp.json()).status).toBe("published");
// Cannot revert to draft from published
const revertResp = await request.patch(`/api/articles/${article.id}/status`, {
data: { status: "draft" },
});
expect(revertResp.status()).toBe(422);
await request.delete(`/api/articles/${article.id}`);
});
test("API + E2E hybrid — seed via API, verify in browser", async ({
request,
page,
}) => {
const resp = await request.post("/api/products", {
data: {
name: `Hybrid Product ${Date.now()}`,
price: 35.0,
published: true,
},
});
const product = await resp.json();
await page.goto("/products");
await expect(page.getByRole("heading", { name: product.name })).toBeVisible();
await expect(page.getByText("$35.00")).toBeVisible();
await request.delete(`/api/products/${product.id}`);
});
```
### Schema Validation with Zod
**Use when**: Verifying API responses match a contract — field types, required fields, value constraints.
**Avoid when**: You only need to check one or two specific fields — use `toMatchObject` instead.
```typescript
import { test, expect } from "@playwright/test";
import { z } from "zod";
const ItemSchema = z.object({
id: z.number().positive(),
title: z.string().min(1),
price: z.number().nonnegative(),
status: z.enum(["active", "inactive", "archived"]),
createdAt: z.string().datetime(),
metadata: z.object({
views: z.number().int().nonnegative(),
rating: z.number().min(0).max(5).nullable(),
}),
});
const PaginatedItemsSchema = z.object({
items: z.array(ItemSchema),
pagination: z.object({
page: z.number().int().positive(),
limit: z.number().int().positive(),
total: z.number().int().nonnegative(),
}),
});
test("GET /api/items matches schema", async ({ request }) => {
const resp = await request.get("/api/items");
expect(resp.ok()).toBeTruthy();
const body = await resp.json();
const result = PaginatedItemsSchema.safeParse(body);
if (!result.success) {
throw new Error(
`Schema validation failed:\n${result.error.issues
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
.join("\n")}`
);
}
});
```
## Decision Guide
| Scenario | Use API Tests | Use E2E Tests | Why |
| ------------------------------------------------ | --------------------------- | ------------------------------ | ------------------------------------------------------------------ |
| Validate response status/body/headers | Yes | No | No browser needed; 10-100x faster |
| Test business logic (calculations, rules) | Yes | No | API tests isolate backend logic from UI |
| Verify form submission creates correct data | Seed via API, submit via UI | Yes | UI test validates the form; API check confirms persistence |
| Test error messages shown to user | No | Yes | Error rendering is a UI concern |
| Validate pagination, filtering, sorting | Yes | Maybe both | API test for correctness; E2E test only if the UI logic is complex |
| Seed test data for E2E tests | Yes (fixture) | No | API seeding is fast and reliable |
| Test auth flows (login/logout/RBAC) | Yes for token/session logic | Yes for UI flow | Both matter: API protects resources, UI guides users |
| Verify file upload processing | Yes | Only if testing file picker UI | API test validates backend processing |
| Contract/schema regression testing | Yes | No | Schema tests run in milliseconds |
| Test third-party webhook handling | Yes | No | Webhooks are API-to-API; no UI involved |
| Verify redirect behavior after action | No | Yes | Redirects are browser/navigation concerns |
| Test real-time updates (WebSocket + API trigger) | API triggers | E2E verifies | Seed via API, observe in browser |
## Anti-Patterns
| Don't Do This | Problem | Do This Instead |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| Use E2E tests to validate pure API responses | Slow, flaky, launches a browser for no reason | Use `request` fixture — no browser, direct HTTP |
| Ignore `response.status()` | A 500 with a fallback body can pass all body assertions | Always assert status first: `expect(response.status()).toBe(200)` |
| Skip response header checks | Missing `Content-Type`, `Cache-Control`, CORS headers cause production bugs | Assert critical headers |
| Only test the happy path | Real users trigger 400, 401, 403, 404, 409, 422 — every one needs a test | Dedicate a `describe` block to error responses |
| Hardcode IDs in API tests | Tests break when database is reset or IDs are reassigned | Create resources in the test, use returned IDs |
| Share mutable state between tests | Tests that depend on execution order are flaky and cannot run in parallel | Each test creates and cleans up its own data |
| Parse `response.text()` then `JSON.parse()` manually | Playwright's `response.json()` handles this and throws clear errors on non-JSON | Use `await response.json()` |
| Forget cleanup after creating resources | Test pollution: subsequent tests may see stale data or hit unique constraints | Use fixtures with teardown or explicit `delete` calls |
| Use `page.request` when you don't need a page | `page.request` shares cookies with the browser context, which may cause auth confusion | Use the standalone `request` fixture for pure API tests |
## Troubleshooting
### "Request failed: connect ECONNREFUSED 127.0.0.1:3000"
**Cause**: The API server is not running, or `baseURL` points to the wrong host/port.
**Fix**: Verify the server is running before tests. Use `webServer` in config to start it automatically.
```typescript
// playwright.config.ts
export default defineConfig({
webServer: {
command: "npm run start:api",
url: "http://localhost:3000/api/health",
reuseExistingServer: !process.env.CI,
},
use: { baseURL: "http://localhost:3000" },
});
```
### "response.json() failed — body is not valid JSON"
**Cause**: The endpoint returned HTML (error page), plain text, or an empty body instead of JSON.
**Fix**: Check `response.status()` first — a 500 or 302 often returns HTML. Log `await response.text()` to see the actual body. Verify the `Accept: application/json` header is set.
```typescript
const resp = await request.get("/api/endpoint");
if (!resp.ok()) {
console.error(`Status: ${resp.status()}, Body: ${await resp.text()}`);
}
const body = await resp.json();
```
### "401 Unauthorized" when using `request` fixture
**Cause**: The built-in `request` fixture does not carry browser cookies or auth tokens automatically.
**Fix**: Set `extraHTTPHeaders` in config or create a custom authenticated fixture. If you need cookies from a browser login, use `page.request` instead.
```typescript
// Option A: config-level headers
export default defineConfig({
use: {
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
},
});
// Option B: per-request headers
const resp = await request.get("/api/resource", {
headers: { Authorization: `Bearer ${token}` },
});
// Option C: use page.request to inherit browser cookies
test("API call with browser auth", async ({ page }) => {
await page.goto("/login");
// ... login via UI ...
const resp = await page.request.get("/api/profile");
expect(resp.ok()).toBeTruthy();
});
```
### Tests pass locally but fail in CI
**Cause**: Different environments, database state, or missing environment variables.
**Fix**: Use `process.env` for secrets and base URLs. Run database seeds or migrations in `globalSetup`. Use unique identifiers (timestamps, UUIDs) for test data. Check that the CI `baseURL` matches the deployed service.

View file

@ -0,0 +1,506 @@
# Browser Extension Testing
## Table of Contents
1. [Setup & Configuration](#setup--configuration)
2. [Loading Extensions](#loading-extensions)
3. [Popup Testing](#popup-testing)
4. [Background Script Testing](#background-script-testing)
5. [Content Script Testing](#content-script-testing)
6. [Extension APIs](#extension-apis)
7. [Cross-Browser Testing](#cross-browser-testing)
## Setup & Configuration
### Prerequisites
```bash
npm install -D @playwright/test
npx playwright install chromium # Extensions only work in Chromium
```
### Basic Configuration
```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";
import path from "path";
export default defineConfig({
testDir: "./tests",
use: {
// Extensions require non-headless Chromium
headless: false,
},
projects: [
{
name: "chromium-extension",
use: {
browserName: "chromium",
},
},
],
});
```
### Extension Fixture
```typescript
// fixtures/extension.ts
import { test as base, chromium, BrowserContext, Page } from "@playwright/test";
import path from "path";
type ExtensionFixtures = {
context: BrowserContext;
extensionId: string;
backgroundPage: Page;
};
export const test = base.extend<ExtensionFixtures>({
context: async ({}, use) => {
const pathToExtension = path.join(__dirname, "../extension");
const context = await chromium.launchPersistentContext("", {
headless: false,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
await use(context);
await context.close();
},
extensionId: async ({ context }, use) => {
// Get extension ID from service worker URL
let extensionId = "";
// Wait for service worker to be registered
const serviceWorker =
context.serviceWorkers()[0] ||
(await context.waitForEvent("serviceworker"));
extensionId = serviceWorker.url().split("/")[2];
await use(extensionId);
},
backgroundPage: async ({ context }, use) => {
// For Manifest V2 extensions
const backgroundPage =
context.backgroundPages()[0] ||
(await context.waitForEvent("backgroundpage"));
await use(backgroundPage);
},
});
export { expect } from "@playwright/test";
```
## Loading Extensions
### Manifest V3 (Service Worker)
```typescript
test("load MV3 extension", async () => {
const pathToExtension = path.join(__dirname, "../my-extension");
const context = await chromium.launchPersistentContext("", {
headless: false,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
// Wait for service worker
const serviceWorker = await context.waitForEvent("serviceworker");
expect(serviceWorker.url()).toContain("chrome-extension://");
await context.close();
});
```
### Manifest V2 (Background Page)
```typescript
test("load MV2 extension", async () => {
const pathToExtension = path.join(__dirname, "../my-extension-v2");
const context = await chromium.launchPersistentContext("", {
headless: false,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
// Wait for background page
const backgroundPage = await context.waitForEvent("backgroundpage");
expect(backgroundPage.url()).toContain("chrome-extension://");
await context.close();
});
```
### Multiple Extensions
```typescript
test("load multiple extensions", async () => {
const extension1 = path.join(__dirname, "../extension1");
const extension2 = path.join(__dirname, "../extension2");
const context = await chromium.launchPersistentContext("", {
headless: false,
args: [
`--disable-extensions-except=${extension1},${extension2}`,
`--load-extension=${extension1},${extension2}`,
],
});
// Both service workers should be available
await context.waitForEvent("serviceworker");
await context.waitForEvent("serviceworker");
expect(context.serviceWorkers().length).toBe(2);
await context.close();
});
```
## Popup Testing
### Opening Extension Popup
```typescript
test("test popup UI", async ({ context, extensionId }) => {
// Open popup directly by URL
const popupPage = await context.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
// Test popup interactions
await expect(popupPage.getByRole("heading")).toHaveText("My Extension");
await popupPage.getByRole("button", { name: "Enable" }).click();
await expect(popupPage.getByText("Enabled")).toBeVisible();
});
```
### Popup State Persistence
```typescript
test("popup remembers state", async ({ context, extensionId }) => {
// First interaction
const popup1 = await context.newPage();
await popup1.goto(`chrome-extension://${extensionId}/popup.html`);
await popup1.getByRole("checkbox", { name: "Dark Mode" }).check();
await popup1.close();
// Reopen popup
const popup2 = await context.newPage();
await popup2.goto(`chrome-extension://${extensionId}/popup.html`);
// State should persist
await expect(
popup2.getByRole("checkbox", { name: "Dark Mode" }),
).toBeChecked();
});
```
### Popup Communication with Background
```typescript
test("popup sends message to background", async ({ context, extensionId }) => {
const popup = await context.newPage();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
// Set up listener for response
const responsePromise = popup.evaluate(() => {
return new Promise((resolve) => {
chrome.runtime.onMessage.addListener((message) => {
if (message.type === "RESPONSE") resolve(message.data);
});
});
});
// Click button that sends message
await popup.getByRole("button", { name: "Fetch Data" }).click();
// Verify response
const response = await responsePromise;
expect(response).toBeDefined();
});
```
## Background Script Testing
### Manifest V3 Service Worker
```typescript
test("service worker handles messages", async ({ context, extensionId }) => {
const page = await context.newPage();
await page.goto("https://example.com");
// Send message to service worker from page
const response = await page.evaluate(async (extId) => {
return new Promise((resolve) => {
chrome.runtime.sendMessage(extId, { type: "GET_STATUS" }, resolve);
});
}, extensionId);
expect(response).toEqual({ status: "active" });
});
```
### Testing Background Logic
```typescript
test("background script logic", async ({ context }) => {
const serviceWorker =
context.serviceWorkers()[0] ||
(await context.waitForEvent("serviceworker"));
// Evaluate in service worker context
const result = await serviceWorker.evaluate(async () => {
// Access extension APIs
const storage = await chrome.storage.local.get("settings");
return storage;
});
expect(result.settings).toBeDefined();
});
```
### Alarms and Timers
```typescript
test("alarm triggers correctly", async ({ context }) => {
const serviceWorker = await context.waitForEvent("serviceworker");
// Create alarm
await serviceWorker.evaluate(async () => {
await chrome.alarms.create("test-alarm", { delayInMinutes: 0.01 });
});
// Wait for alarm handler
await serviceWorker.evaluate(() => {
return new Promise<void>((resolve) => {
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "test-alarm") resolve();
});
});
});
// Verify alarm was handled (check side effects)
const wasHandled = await serviceWorker.evaluate(async () => {
const { alarmTriggered } = await chrome.storage.local.get("alarmTriggered");
return alarmTriggered;
});
expect(wasHandled).toBe(true);
});
```
## Content Script Testing
### Injected Content Script
```typescript
test("content script injects UI", async ({ context }) => {
const page = await context.newPage();
await page.goto("https://example.com");
// Wait for content script to inject elements
await expect(page.locator("#my-extension-widget")).toBeVisible();
// Interact with injected UI
await page.locator("#my-extension-widget button").click();
await expect(page.locator("#my-extension-widget .result")).toHaveText(
"Success",
);
});
```
### Content Script Communication
```typescript
test("content script communicates with background", async ({
context,
extensionId,
}) => {
const page = await context.newPage();
await page.goto("https://example.com");
// Trigger content script action
await page.locator("#my-extension-button").click();
// Wait for background response reflected in UI
await expect(page.locator("#my-extension-status")).toHaveText("Connected");
});
```
### Page Modification Testing
```typescript
test("content script modifies page", async ({ context }) => {
const page = await context.newPage();
await page.goto("https://example.com");
// Verify content script modifications
const hasModification = await page.evaluate(() => {
// Check for injected styles
const styles = document.querySelectorAll('style[data-extension="my-ext"]');
return styles.length > 0;
});
expect(hasModification).toBe(true);
// Check DOM modifications
const modifiedElements = await page
.locator("[data-modified-by-extension]")
.count();
expect(modifiedElements).toBeGreaterThan(0);
});
```
## Extension APIs
### Storage API
```typescript
test("chrome.storage operations", async ({ context }) => {
const serviceWorker = await context.waitForEvent("serviceworker");
// Set storage
await serviceWorker.evaluate(async () => {
await chrome.storage.local.set({ key: "value", count: 42 });
});
// Get storage
const data = await serviceWorker.evaluate(async () => {
return await chrome.storage.local.get(["key", "count"]);
});
expect(data).toEqual({ key: "value", count: 42 });
// Test storage.sync
await serviceWorker.evaluate(async () => {
await chrome.storage.sync.set({ synced: true });
});
const syncData = await serviceWorker.evaluate(async () => {
return await chrome.storage.sync.get("synced");
});
expect(syncData.synced).toBe(true);
});
```
### Tabs API
```typescript
test("chrome.tabs operations", async ({ context }) => {
const serviceWorker = await context.waitForEvent("serviceworker");
// Create a tab
const page = await context.newPage();
await page.goto("https://example.com");
// Query tabs from service worker
const tabs = await serviceWorker.evaluate(async () => {
return await chrome.tabs.query({ url: "*://example.com/*" });
});
expect(tabs.length).toBeGreaterThan(0);
expect(tabs[0].url).toContain("example.com");
// Send message to tab
await serviceWorker.evaluate(async (tabId) => {
await chrome.tabs.sendMessage(tabId, { type: "PING" });
}, tabs[0].id);
});
```
### Context Menus
```typescript
test("context menu actions", async ({ context, extensionId }) => {
const serviceWorker = await context.waitForEvent("serviceworker");
// Create context menu
await serviceWorker.evaluate(async () => {
await chrome.contextMenus.create({
id: "test-menu",
title: "Test Action",
contexts: ["selection"],
});
});
// Simulate context menu click
const page = await context.newPage();
await page.goto("https://example.com");
// Select text
await page.evaluate(() => {
const range = document.createRange();
range.selectNodeContents(document.body.firstChild!);
window.getSelection()?.addRange(range);
});
// Trigger context menu action programmatically
await serviceWorker.evaluate(async () => {
// Simulate the click handler
chrome.contextMenus.onClicked.dispatch(
{ menuItemId: "test-menu", selectionText: "selected text" },
{ id: 1, url: "https://example.com" },
);
});
});
```
### Permissions API
```typescript
test("request permissions", async ({ context, extensionId }) => {
const popup = await context.newPage();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
// Check current permissions
const hasPermission = await popup.evaluate(async () => {
return await chrome.permissions.contains({
origins: ["https://*.github.com/*"],
});
});
// Request new permission (will show prompt in real scenario)
// For testing, we check the request is made correctly
const permissionRequest = popup.evaluate(async () => {
try {
return await chrome.permissions.request({
origins: ["https://*.github.com/*"],
});
} catch (e) {
return false;
}
});
// In automated tests, permission prompts are typically auto-granted or mocked
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------------ | --------------------- | ---------------------------------------- |
| Testing in headless mode | Extensions don't load | Use `headless: false` |
| Not waiting for service worker | Race conditions | Wait for `serviceworker` event |
| Hardcoding extension ID | ID changes on reload | Extract ID from service worker URL |
| Testing packed extensions only | Slow iteration | Test unpacked during development |
| Ignoring MV3 differences | Breaking changes | Test both MV2 and MV3 if supporting both |
## Related References
- **Service Workers**: See [service-workers.md](../browser-apis/service-workers.md) for SW testing patterns
- **Multi-Context**: See [multi-context.md](../advanced/multi-context.md) for popup handling
- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions testing

View file

@ -0,0 +1,493 @@
# Canvas & WebGL Testing
## Table of Contents
1. [Canvas Basics](#canvas-basics)
2. [Visual Comparison](#visual-comparison)
3. [Interaction Testing](#interaction-testing)
4. [WebGL Testing](#webgl-testing)
5. [Chart Libraries](#chart-libraries)
6. [Game & Animation Testing](#game--animation-testing)
## Canvas Basics
### Locating Canvas Elements
```typescript
test("find canvas", async ({ page }) => {
await page.goto("/canvas-app");
// By tag
const canvas = page.locator("canvas");
// By ID or class
const gameCanvas = page.locator("canvas#game");
const chartCanvas = page.locator("canvas.chart-canvas");
// Verify canvas is present and visible
await expect(canvas).toBeVisible();
// Get canvas dimensions
const box = await canvas.boundingBox();
console.log(`Canvas size: ${box?.width}x${box?.height}`);
});
```
### Canvas Screenshot Testing
```typescript
test("canvas renders correctly", async ({ page }) => {
await page.goto("/chart");
// Wait for canvas to be ready (check for specific content)
await page.waitForFunction(() => {
const canvas = document.querySelector("canvas");
const ctx = canvas?.getContext("2d");
// Check if canvas has been drawn to
return ctx && !isCanvasBlank(canvas);
function isCanvasBlank(canvas) {
const ctx = canvas.getContext("2d");
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
return !data.some((channel) => channel !== 0);
}
});
// Screenshot just the canvas
const canvas = page.locator("canvas");
await expect(canvas).toHaveScreenshot("chart.png");
});
```
### Extracting Canvas Data
```typescript
test("verify canvas content", async ({ page }) => {
await page.goto("/drawing-app");
// Get canvas image data
const imageData = await page.evaluate(() => {
const canvas = document.querySelector("canvas") as HTMLCanvasElement;
return canvas.toDataURL("image/png");
});
// Verify it's not empty
expect(imageData).toMatch(/^data:image\/png;base64,.+/);
// Get pixel data at specific location
const pixelColor = await page.evaluate(() => {
const canvas = document.querySelector("canvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d")!;
const pixel = ctx.getImageData(100, 100, 1, 1).data;
return { r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] };
});
// Verify specific pixel color
expect(pixelColor.r).toBeGreaterThan(200); // Expecting red-ish
});
```
## Visual Comparison
### Screenshot Assertions
```typescript
test("chart matches baseline", async ({ page }) => {
await page.goto("/dashboard");
// Wait for chart animation to complete
await page.waitForTimeout(1000); // Or better: wait for specific state
// Full page screenshot
await expect(page).toHaveScreenshot("dashboard.png", {
maxDiffPixels: 100, // Allow small differences
});
// Just the canvas
const chart = page.locator("canvas#sales-chart");
await expect(chart).toHaveScreenshot("sales-chart.png", {
maxDiffPixelRatio: 0.01, // 1% difference allowed
});
});
```
### Handling Animation
```typescript
test("animated canvas", async ({ page }) => {
await page.goto("/animated-chart");
// Pause animation before screenshot
await page.evaluate(() => {
// Common pattern: chart libraries expose pause method
window.chartInstance?.stop?.();
// Or override requestAnimationFrame
window.requestAnimationFrame = () => 0;
});
await expect(page.locator("canvas")).toHaveScreenshot();
});
test("wait for animation complete", async ({ page }) => {
await page.goto("/chart-with-animation");
// Wait for animation complete event
await page.evaluate(() => {
return new Promise<void>((resolve) => {
if (window.chart?.isAnimating === false) {
resolve();
} else {
window.chart?.on("animationComplete", resolve);
}
});
});
await expect(page.locator("canvas")).toHaveScreenshot();
});
```
### Threshold Configuration
```typescript
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
// Increased threshold for canvas (anti-aliasing differences)
maxDiffPixelRatio: 0.02,
threshold: 0.3, // Per-pixel color threshold
animations: "disabled",
},
},
});
```
## Interaction Testing
### Click on Canvas
```typescript
test("click on canvas element", async ({ page }) => {
await page.goto("/interactive-map");
const canvas = page.locator("canvas");
// Click at specific coordinates
await canvas.click({ position: { x: 150, y: 200 } });
// Verify click was registered
await expect(page.locator("#info-panel")).toContainText("Location: Paris");
});
```
### Drawing on Canvas
```typescript
test("draw on canvas", async ({ page }) => {
await page.goto("/whiteboard");
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
// Draw a line using mouse
await page.mouse.move(box!.x + 50, box!.y + 50);
await page.mouse.down();
await page.mouse.move(box!.x + 200, box!.y + 200, { steps: 10 });
await page.mouse.up();
// Verify something was drawn
const hasDrawing = await page.evaluate(() => {
const canvas = document.querySelector("canvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d")!;
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
return data.some((v, i) => i % 4 !== 3 && v !== 255); // Non-white pixels
});
expect(hasDrawing).toBe(true);
});
```
### Drag and Drop
```typescript
test("drag canvas element", async ({ page }) => {
await page.goto("/diagram-editor");
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
// Drag shape from position A to B
await page.mouse.move(box!.x + 100, box!.y + 100);
await page.mouse.down();
await page.mouse.move(box!.x + 300, box!.y + 200, { steps: 20 });
await page.mouse.up();
// Verify via screenshot or state check
await expect(canvas).toHaveScreenshot("shape-moved.png");
});
```
### Touch Gestures on Canvas
```typescript
test("pinch zoom on canvas", async ({ page }) => {
await page.goto("/map");
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
const centerX = box!.x + box!.width / 2;
const centerY = box!.y + box!.height / 2;
// Simulate pinch zoom using two touch points
await page.touchscreen.tap(centerX, centerY);
// Use evaluate for complex gestures
await page.evaluate(
async ({ x, y }) => {
const target = document.querySelector("canvas")!;
// Simulate pinch start
const touch1 = new Touch({
identifier: 1,
target,
clientX: x - 50,
clientY: y,
});
const touch2 = new Touch({
identifier: 2,
target,
clientX: x + 50,
clientY: y,
});
target.dispatchEvent(
new TouchEvent("touchstart", {
touches: [touch1, touch2],
targetTouches: [touch1, touch2],
bubbles: true,
}),
);
// Simulate pinch out
const touch1End = new Touch({
identifier: 1,
target,
clientX: x - 100,
clientY: y,
});
const touch2End = new Touch({
identifier: 2,
target,
clientX: x + 100,
clientY: y,
});
target.dispatchEvent(
new TouchEvent("touchmove", {
touches: [touch1End, touch2End],
targetTouches: [touch1End, touch2End],
bubbles: true,
}),
);
target.dispatchEvent(new TouchEvent("touchend", { bubbles: true }));
},
{ x: centerX, y: centerY },
);
// Verify zoom level changed
const zoomLevel = await page.locator("#zoom-indicator").textContent();
expect(parseFloat(zoomLevel!)).toBeGreaterThan(1);
});
```
## WebGL Testing
### Checking WebGL Support
```typescript
test("WebGL is supported", async ({ page }) => {
await page.goto("/3d-viewer");
const hasWebGL = await page.evaluate(() => {
const canvas = document.createElement("canvas");
const gl =
canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
return !!gl;
});
expect(hasWebGL).toBe(true);
});
```
### WebGL Screenshot Testing
```typescript
test("3D scene renders", async ({ page }) => {
await page.goto("/3d-model-viewer");
// Wait for WebGL scene to render
await page.waitForFunction(() => {
const canvas = document.querySelector("canvas");
if (!canvas) return false;
const gl = canvas.getContext("webgl") || canvas.getContext("webgl2");
if (!gl) return false;
// Check if something has been drawn
const pixels = new Uint8Array(4);
gl.readPixels(
canvas.width / 2,
canvas.height / 2,
1,
1,
gl.RGBA,
gl.UNSIGNED_BYTE,
pixels,
);
return pixels.some((p) => p > 0);
});
// Screenshot comparison (higher threshold for WebGL)
await expect(page.locator("canvas")).toHaveScreenshot("3d-scene.png", {
maxDiffPixelRatio: 0.05, // WebGL can have more variation
});
});
```
### Testing Three.js Applications
```typescript
test("Three.js scene interaction", async ({ page }) => {
await page.goto("/three-demo");
// Wait for scene to be ready
await page.waitForFunction(() => window.scene?.children?.length > 0);
// Interact with scene (orbit controls)
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
// Rotate camera by dragging
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.mouse.down();
await page.mouse.move(
box!.x + box!.width / 2 + 100,
box!.y + box!.height / 2,
{
steps: 10,
},
);
await page.mouse.up();
// Verify camera position changed
const cameraRotation = await page.evaluate(() => {
return window.camera?.rotation?.y;
});
expect(cameraRotation).not.toBe(0);
});
```
## Chart Libraries
### Chart.js Testing
```typescript
test("Chart.js renders data", async ({ page }) => {
await page.goto("/chartjs-demo");
// Wait for Chart.js to initialize
await page.waitForFunction(() => {
return window.Chart && document.querySelector("canvas")?.__chart__;
});
// Get chart data via Chart.js API
const chartData = await page.evaluate(() => {
const canvas = document.querySelector("canvas") as any;
const chart = canvas.__chart__;
return chart.data.datasets[0].data;
});
expect(chartData).toEqual([12, 19, 3, 5, 2, 3]);
// Screenshot test
await expect(page.locator("canvas")).toHaveScreenshot();
});
```
### D3.js / ECharts Testing
```typescript
test("chart library interaction", async ({ page }) => {
await page.goto("/chart-demo");
// Wait for chart to render
await page.waitForFunction(() => document.querySelector("canvas, svg.chart"));
// For SVG charts (D3)
const bars = page.locator("svg.chart rect.bar");
if ((await bars.count()) > 0) {
await bars.first().hover();
await expect(page.locator(".tooltip")).toBeVisible();
}
// For canvas charts (ECharts, Chart.js)
const canvas = page.locator("canvas");
await canvas.click({ position: { x: 200, y: 150 } });
});
```
## Game & Animation Testing
### Frame-by-Frame Testing
```typescript
test("game frame control", async ({ page }) => {
await page.goto("/game");
// Pause and step through frames
await page.evaluate(() => window.gameLoop?.pause());
await page.evaluate(() => window.gameLoop?.tick());
await expect(page.locator("canvas")).toHaveScreenshot("frame-1.png");
for (let i = 0; i < 10; i++) {
await page.evaluate(() => window.gameLoop?.tick());
}
await expect(page.locator("canvas")).toHaveScreenshot("frame-11.png");
});
```
### Testing Game State
```typescript
test("game state changes", async ({ page }) => {
await page.goto("/game");
const initialScore = await page.evaluate(() => window.game?.score);
expect(initialScore).toBe(0);
await page.keyboard.press("Space"); // Action
await page.waitForTimeout(500);
const newScore = await page.evaluate(() => window.game?.score);
expect(newScore).toBeGreaterThan(0);
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------ | ------------------------ | ----------------------------------- |
| Pixel-perfect assertions | Fails across browsers/OS | Use maxDiffPixelRatio threshold |
| Not waiting for render | Blank canvas screenshots | Wait for draw completion |
| Testing raw pixel data | Brittle and slow | Use visual comparison |
| Ignoring animation | Flaky screenshots | Pause/disable animations |
| Hardcoded coordinates | Breaks on resize | Calculate relative to canvas bounds |
## Related References
- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for visual regression setup
- **Mobile Gestures**: See [mobile-testing.md](../advanced/mobile-testing.md) for touch interactions
- **Performance**: See [performance-testing.md](performance-testing.md) for FPS monitoring

View file

@ -0,0 +1,500 @@
# Component Testing
## Table of Contents
1. [Setup & Configuration](#setup--configuration)
2. [Mounting Components](#mounting-components)
3. [Props & State Testing](#props--state-testing)
4. [Events & Interactions](#events--interactions)
5. [Slots & Children](#slots--children)
6. [Mocking Dependencies](#mocking-dependencies)
7. [Framework-Specific Patterns](#framework-specific-patterns)
## Setup & Configuration
### Installation
```bash
# React
npm init playwright@latest -- --ct
# Vue
npm init playwright@latest -- --ct
# Svelte
npm init playwright@latest -- --ct
# Solid
npm init playwright@latest -- --ct
```
### Configuration
```typescript
// playwright-ct.config.ts
import { defineConfig, devices } from "@playwright/experimental-ct-react";
export default defineConfig({
testDir: "./tests/components",
snapshotDir: "./tests/components/__snapshots__",
use: {
ctPort: 3100,
ctViteConfig: {
resolve: {
alias: {
"@": "/src",
},
},
},
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
});
```
### Project Structure
```
src/
components/
Button.tsx
Modal.tsx
tests/
components/
Button.spec.tsx
Modal.spec.tsx
playwright/
index.html # CT entry point
index.tsx # CT setup (providers, styles)
```
## Mounting Components
### Basic Mount
```tsx
// Button.spec.tsx
import { test, expect } from "@playwright/experimental-ct-react";
import { Button } from "@/components/Button";
test("renders button with text", async ({ mount }) => {
const component = await mount(<Button>Click me</Button>);
await expect(component).toContainText("Click me");
await expect(component).toBeVisible();
});
```
### Mount with Props
```tsx
test("renders with all props", async ({ mount }) => {
const component = await mount(
<Button variant="primary" size="large" disabled={false} icon="check">
Submit
</Button>,
);
await expect(component).toHaveClass(/primary/);
await expect(component).toHaveClass(/large/);
await expect(component.locator("svg")).toBeVisible(); // icon
});
```
### Mount with Wrapper/Provider
```tsx
// playwright/index.tsx - Global providers
import { ThemeProvider } from "@/providers/theme";
import { QueryClientProvider } from "@tanstack/react-query";
import "@/styles/globals.css";
export default function PlaywrightWrapper({ children }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
}
```
```tsx
// Or per-test wrapper
test("with custom provider", async ({ mount }) => {
const component = await mount(
<AuthProvider initialUser={{ name: "Test" }}>
<UserProfile />
</AuthProvider>,
);
await expect(component.getByText("Test")).toBeVisible();
});
```
## Props & State Testing
### Testing Prop Variations
```tsx
test.describe("Button variants", () => {
const variants = ["primary", "secondary", "danger", "ghost"] as const;
for (const variant of variants) {
test(`renders ${variant} variant`, async ({ mount }) => {
const component = await mount(<Button variant={variant}>Button</Button>);
await expect(component).toHaveClass(new RegExp(variant));
});
}
});
```
### Updating Props
```tsx
test("responds to prop changes", async ({ mount }) => {
const component = await mount(<Counter initialCount={0} />);
await expect(component.getByTestId("count")).toHaveText("0");
// Update props
await component.update(<Counter initialCount={10} />);
await expect(component.getByTestId("count")).toHaveText("10");
});
```
### Testing Controlled Components
```tsx
test("controlled input", async ({ mount }) => {
let externalValue = "";
const component = await mount(
<Input
value={externalValue}
onChange={(e) => {
externalValue = e.target.value;
}}
/>,
);
await component.locator("input").fill("hello");
// For controlled components, update with new value
await component.update(
<Input value="hello" onChange={(e) => (externalValue = e.target.value)} />,
);
await expect(component.locator("input")).toHaveValue("hello");
});
```
### Testing Internal State
```tsx
test("internal state updates", async ({ mount }) => {
const component = await mount(<Toggle defaultChecked={false} />);
// Initial state
await expect(component.locator('[role="switch"]')).toHaveAttribute(
"aria-checked",
"false",
);
// Trigger state change
await component.click();
// Verify state updated
await expect(component.locator('[role="switch"]')).toHaveAttribute(
"aria-checked",
"true",
);
});
```
## Events & Interactions
### Testing Click Events
```tsx
test("click event fires", async ({ mount }) => {
let clicked = false;
const component = await mount(
<Button onClick={() => (clicked = true)}>Click</Button>,
);
await component.click();
expect(clicked).toBe(true);
});
```
### Testing Event Payloads
```tsx
test("onChange provides correct value", async ({ mount }) => {
const values: string[] = [];
const component = await mount(
<Select
options={["a", "b", "c"]}
onChange={(value) => values.push(value)}
/>,
);
await component.getByRole("combobox").click();
await component.getByRole("option", { name: "b" }).click();
expect(values).toEqual(["b"]);
});
```
### Testing Form Submission
```tsx
test("form submission", async ({ mount }) => {
let submittedData: FormData | null = null;
const component = await mount(
<LoginForm
onSubmit={(data) => {
submittedData = data;
}}
/>,
);
await component.getByLabel("Email").fill("test@example.com");
await component.getByLabel("Password").fill("secret123");
await component.getByRole("button", { name: "Sign in" }).click();
expect(submittedData).toEqual({
email: "test@example.com",
password: "secret123",
});
});
```
### Testing Keyboard Interactions
```tsx
test("keyboard navigation", async ({ mount }) => {
const component = await mount(
<Dropdown options={["Apple", "Banana", "Cherry"]} />,
);
// Open dropdown
await component.getByRole("button").click();
// Navigate with keyboard
await component.press("ArrowDown");
await component.press("ArrowDown");
await component.press("Enter");
await expect(component.getByRole("button")).toHaveText("Banana");
});
```
## Slots & Children
### Testing Children Content
```tsx
test("renders children", async ({ mount }) => {
const component = await mount(
<Card>
<h2>Title</h2>
<p>Description</p>
</Card>,
);
await expect(component.getByRole("heading")).toHaveText("Title");
await expect(component.getByText("Description")).toBeVisible();
});
```
### Testing Named Slots (Vue)
```tsx
// Vue component with slots
test("renders named slots", async ({ mount }) => {
const component = await mount(Modal, {
slots: {
header: "<h2>Modal Title</h2>",
default: "<p>Modal content</p>",
footer: "<button>Close</button>",
},
});
await expect(component.getByRole("heading")).toHaveText("Modal Title");
await expect(component.getByRole("button")).toHaveText("Close");
});
```
### Testing Render Props
```tsx
test("render prop pattern", async ({ mount }) => {
const component = await mount(
<DataFetcher url="/api/users">
{({ data, loading }) =>
loading ? <span>Loading...</span> : <span>{data.name}</span>
}
</DataFetcher>,
);
// Initially loading
await expect(component.getByText("Loading...")).toBeVisible();
// After data loads
await expect(component.getByText(/User/)).toBeVisible();
});
```
## Mocking Dependencies
### Mocking Imports
```tsx
// playwright/index.tsx - Mock at setup level
import { beforeMount } from "@playwright/experimental-ct-react/hooks";
beforeMount(async ({ hooksConfig }) => {
// Mock analytics
window.analytics = {
track: () => {},
identify: () => {},
};
// Mock feature flags
if (hooksConfig?.featureFlags) {
window.__FEATURE_FLAGS__ = hooksConfig.featureFlags;
}
});
```
```tsx
// Test with mocked config
test("with feature flag", async ({ mount }) => {
const component = await mount(<FeatureComponent />, {
hooksConfig: {
featureFlags: { newFeature: true },
},
});
await expect(component.getByText("New Feature")).toBeVisible();
});
```
### Mocking API Calls
```tsx
test("component with API", async ({ mount, page }) => {
// Mock API before mounting
await page.route("**/api/user", (route) => {
route.fulfill({
json: { id: 1, name: "Test User" },
});
});
const component = await mount(<UserProfile userId={1} />);
await expect(component.getByText("Test User")).toBeVisible();
});
```
### Mocking Hooks
```tsx
// Mock custom hook via module mock
test("with mocked hook", async ({ mount }) => {
const component = await mount(<Dashboard />, {
hooksConfig: {
mockAuth: { user: { name: "Admin" }, isAdmin: true },
},
});
await expect(component.getByText("Admin Panel")).toBeVisible();
});
```
## Framework-Specific Patterns
### React Testing
```tsx
// React with refs
test("exposes ref methods", async ({ mount }) => {
let inputRef: HTMLInputElement | null = null;
const component = await mount(<Input ref={(el) => (inputRef = el)} />);
await component.locator("input").fill("test");
expect(inputRef?.value).toBe("test");
});
// React with context
test("uses context", async ({ mount }) => {
const component = await mount(
<UserContext.Provider value={{ name: "Test" }}>
<UserGreeting />
</UserContext.Provider>,
);
await expect(component).toContainText("Hello, Test");
});
```
### Vue Testing
```tsx
import { test, expect } from "@playwright/experimental-ct-vue";
import MyInput from "@/components/MyInput.vue";
// With v-model
test("v-model binding", async ({ mount }) => {
let modelValue = "";
const component = await mount(MyInput, {
props: {
modelValue,
"onUpdate:modelValue": (v: string) => (modelValue = v),
},
});
await component.locator("input").fill("test");
expect(modelValue).toBe("test");
});
```
### Svelte Testing
```tsx
import { test, expect } from "@playwright/experimental-ct-svelte";
import Counter from "./Counter.svelte";
test("Svelte component", async ({ mount }) => {
const component = await mount(Counter, { props: { initialCount: 5 } });
await expect(component.getByTestId("count")).toHaveText("5");
await component.getByRole("button", { name: "+" }).click();
await expect(component.getByTestId("count")).toHaveText("6");
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------------ | ------------------- | --------------------------------- |
| Testing implementation details | Brittle tests | Test behavior, not internal state |
| Snapshot testing everything | Maintenance burden | Use for visual regression only |
| Not isolating components | Hidden dependencies | Mock all external dependencies |
| Testing framework behavior | Redundant | Focus on your component logic |
| Skipping accessibility | Misses real issues | Include a11y checks in CT |
## Related References
- **Accessibility**: See [accessibility.md](accessibility.md) for a11y testing in components
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for shared test setup

View file

@ -0,0 +1,576 @@
# Drag and Drop Testing
## Table of Contents
1. [Kanban Board (Cross-Column Movement)](#kanban-board-cross-column-movement)
2. [Sortable Lists (Reordering)](#sortable-lists-reordering)
3. [Native HTML5 Drag and Drop](#native-html5-drag-and-drop)
4. [File Drop Zone](#file-drop-zone)
5. [Canvas Coordinate-Based Dragging](#canvas-coordinate-based-dragging)
6. [Custom Drag Preview](#custom-drag-preview)
7. [Variations](#variations)
8. [Tips](#tips)
> **When to use**: Testing drag-and-drop interactions — sortable lists, kanban boards, file drop zones, or repositionable elements.
---
## Kanban Board (Cross-Column Movement)
```typescript
import { test, expect } from '@playwright/test';
test('moves card between columns', async ({ page }) => {
await page.goto('/board');
const backlog = page.locator('[data-column="backlog"]');
const active = page.locator('[data-column="active"]');
const ticket = backlog.getByText('Update API docs');
await expect(ticket).toBeVisible();
const backlogCountBefore = await backlog.getByRole('article').count();
const activeCountBefore = await active.getByRole('article').count();
await ticket.dragTo(active);
await expect(active.getByText('Update API docs')).toBeVisible();
await expect(backlog.getByText('Update API docs')).not.toBeVisible();
await expect(backlog.getByRole('article')).toHaveCount(backlogCountBefore - 1);
await expect(active.getByRole('article')).toHaveCount(activeCountBefore + 1);
});
test('progresses card through workflow stages', async ({ page }) => {
await page.goto('/board');
const cols = {
backlog: page.locator('[data-column="backlog"]'),
active: page.locator('[data-column="active"]'),
review: page.locator('[data-column="review"]'),
complete: page.locator('[data-column="complete"]'),
};
await cols.backlog.getByText('Update API docs').dragTo(cols.active);
await expect(cols.active.getByText('Update API docs')).toBeVisible();
await cols.active.getByText('Update API docs').dragTo(cols.review);
await expect(cols.review.getByText('Update API docs')).toBeVisible();
await cols.review.getByText('Update API docs').dragTo(cols.complete);
await expect(cols.complete.getByText('Update API docs')).toBeVisible();
await expect(cols.backlog.getByText('Update API docs')).not.toBeVisible();
await expect(cols.active.getByText('Update API docs')).not.toBeVisible();
await expect(cols.review.getByText('Update API docs')).not.toBeVisible();
});
test('reorders cards within same column', async ({ page }) => {
await page.goto('/board');
const backlog = page.locator('[data-column="backlog"]');
const itemX = backlog.getByRole('article').filter({ hasText: 'Item X' });
const itemZ = backlog.getByRole('article').filter({ hasText: 'Item Z' });
await itemZ.dragTo(itemX);
const cards = await backlog.getByRole('article').allTextContents();
expect(cards.indexOf('Item Z')).toBeLessThan(cards.indexOf('Item X'));
});
test('verifies drag persists via API', async ({ page }) => {
await page.goto('/board');
const backlog = page.locator('[data-column="backlog"]');
const active = page.locator('[data-column="active"]');
const responsePromise = page.waitForResponse(
(r) => r.url().includes('/api/tickets') && r.request().method() === 'PATCH'
);
await backlog.getByText('Update API docs').dragTo(active);
const response = await responsePromise;
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.column).toBe('active');
await page.reload();
await expect(active.getByText('Update API docs')).toBeVisible();
});
```
---
## Sortable Lists (Reordering)
```typescript
import { test, expect } from '@playwright/test';
test('reorders list items', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const initial = await list.getByRole('listitem').allTextContents();
expect(initial[0]).toContain('Priority A');
expect(initial[1]).toContain('Priority B');
expect(initial[2]).toContain('Priority C');
const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });
await priorityC.dragTo(priorityA);
const reordered = await list.getByRole('listitem').allTextContents();
expect(reordered[0]).toContain('Priority C');
expect(reordered[1]).toContain('Priority A');
expect(reordered[2]).toContain('Priority B');
});
test('reorders via drag handle', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const handle = list
.getByRole('listitem')
.filter({ hasText: 'Priority C' })
.getByRole('button', { name: /drag|reorder|grip/i });
const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });
await handle.dragTo(target);
const items = await list.getByRole('listitem').allTextContents();
expect(items[0]).toContain('Priority C');
});
test('reorder persists after reload', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });
await priorityC.dragTo(priorityA);
await page.waitForResponse((response) =>
response.url().includes('/api/priorities/reorder') && response.status() === 200
);
await page.reload();
const items = await list.getByRole('listitem').allTextContents();
expect(items[0]).toContain('Priority C');
expect(items[1]).toContain('Priority A');
expect(items[2]).toContain('Priority B');
});
```
### Incremental Mouse Movement for Custom Libraries
Some drag libraries (react-beautiful-dnd, dnd-kit) require incremental mouse movements:
```typescript
test('reorders with incremental mouse movements', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });
const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });
const sourceBox = await source.boundingBox();
const targetBox = await target.boundingBox();
await source.hover();
await page.mouse.down();
const steps = 10;
for (let i = 1; i <= steps; i++) {
await page.mouse.move(
sourceBox!.x + sourceBox!.width / 2,
sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / steps),
{ steps: 1 }
);
}
await page.mouse.up();
const items = await list.getByRole('listitem').allTextContents();
expect(items[0]).toContain('Priority C');
});
```
---
## Native HTML5 Drag and Drop
```typescript
import { test, expect } from '@playwright/test';
test('drags item to drop zone', async ({ page }) => {
await page.goto('/drag-example');
const source = page.getByText('Movable Element');
const dropArea = page.locator('#target-zone');
await expect(source).toBeVisible();
await expect(dropArea).not.toContainText('Movable Element');
await source.dragTo(dropArea);
await expect(dropArea).toContainText('Movable Element');
});
test('drags between zones', async ({ page }) => {
await page.goto('/drag-example');
const item = page.locator('[data-testid="element-1"]');
const areaA = page.locator('[data-testid="area-a"]');
const areaB = page.locator('[data-testid="area-b"]');
await expect(areaA).toContainText('Element 1');
await item.dragTo(areaB);
await expect(areaB).toContainText('Element 1');
await expect(areaA).not.toContainText('Element 1');
await areaB.getByText('Element 1').dragTo(areaA);
await expect(areaA).toContainText('Element 1');
await expect(areaB).not.toContainText('Element 1');
});
test('verifies drag visual feedback', async ({ page }) => {
await page.goto('/drag-example');
const source = page.getByText('Movable Element');
const dropArea = page.locator('#target-zone');
await source.hover();
await page.mouse.down();
const dropBox = await dropArea.boundingBox();
await page.mouse.move(dropBox!.x + dropBox!.width / 2, dropBox!.y + dropBox!.height / 2);
await expect(dropArea).toHaveClass(/drag-over|highlight/);
await page.mouse.up();
await expect(dropArea).not.toHaveClass(/drag-over|highlight/);
await expect(dropArea).toContainText('Movable Element');
});
```
---
## File Drop Zone
```typescript
import { test, expect } from '@playwright/test';
import path from 'path';
test('uploads file via drop zone', async ({ page }) => {
await page.goto('/upload');
const dropZone = page.locator('[data-testid="file-drop-zone"]');
await expect(dropZone).toContainText('Drag files here');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/report.pdf'));
await expect(page.getByText('report.pdf')).toBeVisible();
await expect(page.getByText(/\d+ KB/)).toBeVisible();
});
test('simulates drag-over visual feedback', async ({ page }) => {
await page.goto('/upload');
const dropZone = page.locator('[data-testid="file-drop-zone"]');
await dropZone.dispatchEvent('dragenter', {
dataTransfer: { types: ['Files'] },
});
await expect(dropZone).toHaveClass(/drag-active|drop-highlight/);
await expect(dropZone).toContainText(/drop.*here|release.*upload/i);
await dropZone.dispatchEvent('dragleave');
await expect(dropZone).not.toHaveClass(/drag-active|drop-highlight/);
});
test('rejects invalid file types', async ({ page }) => {
await page.goto('/upload');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'script.exe',
mimeType: 'application/x-msdownload',
buffer: Buffer.from('fake-content'),
});
await expect(page.getByRole('alert')).toContainText(/not allowed|invalid file type/i);
await expect(page.getByText('script.exe')).not.toBeVisible();
});
```
---
## Canvas Coordinate-Based Dragging
```typescript
import { test, expect } from '@playwright/test';
test('drags element to specific coordinates', async ({ page }) => {
await page.goto('/design-tool');
const canvas = page.locator('#editor-canvas');
const shape = page.locator('[data-testid="shape-1"]');
const canvasBox = await canvas.boundingBox();
const targetX = canvasBox!.x + 300;
const targetY = canvasBox!.y + 200;
await shape.hover();
await page.mouse.down();
await page.mouse.move(targetX, targetY, { steps: 10 });
await page.mouse.up();
const newBox = await shape.boundingBox();
expect(newBox!.x).toBeCloseTo(targetX - newBox!.width / 2, -1);
expect(newBox!.y).toBeCloseTo(targetY - newBox!.height / 2, -1);
});
test('snaps element to grid', async ({ page }) => {
await page.goto('/design-tool');
const shape = page.locator('[data-testid="shape-1"]');
const canvas = page.locator('#editor-canvas');
const canvasBox = await canvas.boundingBox();
await shape.hover();
await page.mouse.down();
await page.mouse.move(canvasBox!.x + 147, canvasBox!.y + 83, { steps: 10 });
await page.mouse.up();
const snappedBox = await shape.boundingBox();
expect(snappedBox!.x % 20).toBeCloseTo(0, 0);
expect(snappedBox!.y % 20).toBeCloseTo(0, 0);
});
test('constrains drag within boundaries', async ({ page }) => {
await page.goto('/design-tool');
const shape = page.locator('[data-testid="bounded-shape"]');
const container = page.locator('#bounds-container');
const containerBox = await container.boundingBox();
await shape.hover();
await page.mouse.down();
await page.mouse.move(containerBox!.x + containerBox!.width + 500, containerBox!.y - 200, {
steps: 10,
});
await page.mouse.up();
const shapeBox = await shape.boundingBox();
expect(shapeBox!.x).toBeGreaterThanOrEqual(containerBox!.x);
expect(shapeBox!.y).toBeGreaterThanOrEqual(containerBox!.y);
expect(shapeBox!.x + shapeBox!.width).toBeLessThanOrEqual(
containerBox!.x + containerBox!.width
);
expect(shapeBox!.y + shapeBox!.height).toBeLessThanOrEqual(
containerBox!.y + containerBox!.height
);
});
test('resizes element via handle', async ({ page }) => {
await page.goto('/design-tool');
const shape = page.locator('[data-testid="shape-1"]');
await shape.click();
const resizeHandle = shape.locator('.resize-handle-se');
const handleBox = await resizeHandle.boundingBox();
const initialBox = await shape.boundingBox();
await resizeHandle.hover();
await page.mouse.down();
await page.mouse.move(handleBox!.x + 100, handleBox!.y + 80, { steps: 5 });
await page.mouse.up();
const newBox = await shape.boundingBox();
expect(newBox!.width).toBeCloseTo(initialBox!.width + 100, -1);
expect(newBox!.height).toBeCloseTo(initialBox!.height + 80, -1);
});
```
---
## Custom Drag Preview
```typescript
import { test, expect } from '@playwright/test';
test('shows custom drag preview', async ({ page }) => {
await page.goto('/board');
const card = page.locator('[data-testid="ticket-1"]');
const targetCol = page.locator('[data-column="active"]');
const cardBox = await card.boundingBox();
const targetBox = await targetCol.boundingBox();
await card.hover();
await page.mouse.down();
const midX = (cardBox!.x + targetBox!.x) / 2;
const midY = (cardBox!.y + targetBox!.y) / 2;
await page.mouse.move(midX, midY, { steps: 5 });
await expect(page.locator('.drag-preview')).toBeVisible();
await expect(card).toHaveClass(/dragging|placeholder/);
await page.mouse.move(
targetBox!.x + targetBox!.width / 2,
targetBox!.y + targetBox!.height / 2,
{ steps: 5 }
);
await page.mouse.up();
await expect(page.locator('.drag-preview')).not.toBeVisible();
});
test('multi-select drag shows item count', async ({ page }) => {
await page.goto('/board');
await page.locator('[data-testid="ticket-1"]').click();
await page.locator('[data-testid="ticket-2"]').click({ modifiers: ['Shift'] });
await page.locator('[data-testid="ticket-3"]').click({ modifiers: ['Shift'] });
const card = page.locator('[data-testid="ticket-1"]');
const targetCol = page.locator('[data-column="complete"]');
await card.hover();
await page.mouse.down();
const targetBox = await targetCol.boundingBox();
await page.mouse.move(targetBox!.x + 50, targetBox!.y + 50, { steps: 5 });
await expect(page.locator('.drag-preview')).toContainText('3 items');
await page.mouse.up();
await expect(targetCol.locator('[data-testid="ticket-1"]')).toBeVisible();
await expect(targetCol.locator('[data-testid="ticket-2"]')).toBeVisible();
await expect(targetCol.locator('[data-testid="ticket-3"]')).toBeVisible();
});
```
---
## Variations
### Keyboard-Based Reordering
```typescript
test('reorders using keyboard', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
await priorityC.focus();
await page.keyboard.press('Space');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Space');
const items = await list.getByRole('listitem').allTextContents();
expect(items[0]).toContain('Priority C');
});
```
### Cross-Frame Dragging
```typescript
test('drags between main page and iframe', async ({ page }) => {
await page.goto('/composer');
const sourceWidget = page.getByText('Component A');
const iframe = page.frameLocator('#preview-frame');
const iframeElement = page.locator('#preview-frame');
const sourceBox = await sourceWidget.boundingBox();
const iframeBox = await iframeElement.boundingBox();
const targetX = iframeBox!.x + 100;
const targetY = iframeBox!.y + 100;
await sourceWidget.hover();
await page.mouse.down();
await page.mouse.move(targetX, targetY, { steps: 20 });
await page.mouse.up();
await expect(iframe.getByText('Component A')).toBeVisible();
});
```
### Touch-Based Drag on Mobile
```typescript
test('drags via touch events', async ({ page }) => {
await page.goto('/priorities');
const list = page.getByRole('list', { name: 'Priority list' });
const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });
const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });
const sourceBox = await source.boundingBox();
const targetBox = await target.boundingBox();
await source.dispatchEvent('touchstart', {
touches: [{ clientX: sourceBox!.x + 10, clientY: sourceBox!.y + 10 }],
});
for (let i = 1; i <= 5; i++) {
const y = sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / 5);
await source.dispatchEvent('touchmove', {
touches: [{ clientX: sourceBox!.x + 10, clientY: y }],
});
}
await source.dispatchEvent('touchend');
const items = await list.getByRole('listitem').allTextContents();
expect(items[0]).toContain('Priority C');
});
```
---
## Tips
1. **Start with `dragTo()`, fall back to manual mouse events**. Playwright's `dragTo()` handles most HTML5 drag-and-drop. Use `page.mouse.down()` / `move()` / `up()` only for custom libraries (react-beautiful-dnd, dnd-kit, SortableJS) that need specific event sequences.
2. **Add intermediate mouse steps for drag libraries**. Libraries like `react-beautiful-dnd` require multiple `mousemove` events. Use `{ steps: 10 }` or a manual loop — a single jump often fails silently.
3. **Assert final state, not just the drop event**. Verify DOM reflects the change — item order, column contents, position coordinates. Visual feedback during drag is secondary to the persisted state.
4. **Use `boundingBox()` for coordinate assertions**. For canvas editors or position-sensitive drops, capture bounding box after the operation and compare with `toBeCloseTo()` for tolerance.
5. **Test undo after drag operations**. If your app supports Ctrl+Z, verify the drag is reversible — this catches state management bugs.

View file

@ -0,0 +1,509 @@
# Electron Testing
## Table of Contents
1. [Setup & Configuration](#setup--configuration)
2. [Launching Electron Apps](#launching-electron-apps)
3. [Main Process Testing](#main-process-testing)
4. [Renderer Process Testing](#renderer-process-testing)
5. [IPC Communication](#ipc-communication)
6. [Native Features](#native-features)
7. [Packaging & Distribution](#packaging--distribution)
## Setup & Configuration
### Installation
```bash
npm install -D @playwright/test electron
```
### Basic Configuration
```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 30000,
use: {
trace: "on-first-retry",
},
});
```
### Electron Test Fixture
```typescript
// fixtures/electron.ts
import {
test as base,
_electron as electron,
ElectronApplication,
Page,
} from "@playwright/test";
type ElectronFixtures = {
electronApp: ElectronApplication;
window: Page;
};
export const test = base.extend<ElectronFixtures>({
electronApp: async ({}, use) => {
// Launch Electron app
const electronApp = await electron.launch({
args: [".", "--no-sandbox"],
env: {
...process.env,
NODE_ENV: "test",
},
});
await use(electronApp);
// Cleanup
await electronApp.close();
},
window: async ({ electronApp }, use) => {
// Wait for first window
const window = await electronApp.firstWindow();
// Wait for app to be ready
await window.waitForLoadState("domcontentloaded");
await use(window);
},
});
export { expect } from "@playwright/test";
```
### Launch Options
```typescript
// Advanced launch configuration
const electronApp = await electron.launch({
args: ["main.js", "--custom-flag"],
cwd: "/path/to/app",
env: {
...process.env,
ELECTRON_ENABLE_LOGGING: "1",
NODE_ENV: "test",
},
timeout: 30000,
// For packaged apps
executablePath: "/path/to/MyApp.app/Contents/MacOS/MyApp",
});
```
## Launching Electron Apps
### Development Mode
```typescript
test("launch in dev mode", async () => {
const electronApp = await electron.launch({
args: ["."], // Points to package.json main
});
const window = await electronApp.firstWindow();
await expect(window.locator("h1")).toContainText("My App");
await electronApp.close();
});
```
### Packaged Application
```typescript
test("launch packaged app", async () => {
const appPath =
process.platform === "darwin"
? "/Applications/MyApp.app/Contents/MacOS/MyApp"
: process.platform === "win32"
? "C:\\Program Files\\MyApp\\MyApp.exe"
: "/usr/bin/myapp";
const electronApp = await electron.launch({
executablePath: appPath,
});
const window = await electronApp.firstWindow();
await expect(window).toHaveTitle(/MyApp/);
await electronApp.close();
});
```
### Multiple Windows
```typescript
test("handle multiple windows", async ({ electronApp }) => {
const mainWindow = await electronApp.firstWindow();
// Trigger new window
await mainWindow.getByRole("button", { name: "Open Settings" }).click();
// Wait for new window
const settingsWindow = await electronApp.waitForEvent("window");
// Both windows are now accessible
await expect(settingsWindow.locator("h1")).toHaveText("Settings");
await expect(mainWindow.locator("h1")).toHaveText("Main");
// Get all windows
const windows = electronApp.windows();
expect(windows.length).toBe(2);
});
```
## Main Process Testing
### Evaluate in Main Process
```typescript
test("access main process", async ({ electronApp }) => {
// Evaluate in main process context
const appPath = await electronApp.evaluate(async ({ app }) => {
return app.getAppPath();
});
expect(appPath).toContain("my-electron-app");
});
```
### Access Electron APIs
```typescript
test("electron API access", async ({ electronApp }) => {
// Get app version
const version = await electronApp.evaluate(async ({ app }) => {
return app.getVersion();
});
expect(version).toMatch(/^\d+\.\d+\.\d+$/);
// Get platform info
const platform = await electronApp.evaluate(async ({ app }) => {
return process.platform;
});
expect(["darwin", "win32", "linux"]).toContain(platform);
// Check if app is ready
const isReady = await electronApp.evaluate(async ({ app }) => {
return app.isReady();
});
expect(isReady).toBe(true);
});
```
### BrowserWindow Properties
```typescript
test("check window properties", async ({ electronApp, window }) => {
// Get BrowserWindow from main process
const windowBounds = await electronApp.evaluate(async ({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
return win.getBounds();
});
expect(windowBounds.width).toBeGreaterThan(0);
expect(windowBounds.height).toBeGreaterThan(0);
// Check window state
const isMaximized = await electronApp.evaluate(async ({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
return win.isMaximized();
});
// Check window title
const title = await electronApp.evaluate(async ({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
return win.getTitle();
});
expect(title).toBeTruthy();
});
```
## Renderer Process Testing
### Standard Page Testing
```typescript
test("renderer interactions", async ({ window }) => {
// Standard Playwright page interactions
await window.getByRole("button", { name: "Click Me" }).click();
await expect(window.getByText("Clicked!")).toBeVisible();
// Fill forms
await window.getByLabel("Username").fill("testuser");
await window.getByLabel("Password").fill("password123");
await window.getByRole("button", { name: "Login" }).click();
// Verify navigation
await expect(window).toHaveURL(/dashboard/);
});
```
### Access Node.js in Renderer
```typescript
test("node integration", async ({ window }) => {
// If nodeIntegration is enabled
const nodeVersion = await window.evaluate(() => {
return (window as any).process?.version;
});
// Check if Node APIs are available
const hasFs = await window.evaluate(() => {
return typeof (window as any).require === "function";
});
});
```
### Context Isolation Testing
```typescript
test("context isolation", async ({ window }) => {
// Test preload script exposed APIs
const apiAvailable = await window.evaluate(() => {
return typeof (window as any).electronAPI !== "undefined";
});
expect(apiAvailable).toBe(true);
// Call exposed API
const result = await window.evaluate(async () => {
return await (window as any).electronAPI.getAppVersion();
});
expect(result).toMatch(/^\d+\.\d+\.\d+$/);
});
```
## IPC Communication
### Testing IPC from Renderer
```typescript
test("IPC invoke", async ({ window }) => {
// Test preload-exposed IPC call
const result = await window.evaluate(async () => {
return await (window as any).electronAPI.getData("user-settings");
});
expect(result).toHaveProperty("theme");
});
```
### Testing IPC from Main Process
```typescript
test("main to renderer IPC", async ({ electronApp, window }) => {
// Set up listener in renderer
await window.evaluate(() => {
(window as any).receivedMessage = null;
(window as any).electronAPI.onMessage((msg: string) => {
(window as any).receivedMessage = msg;
});
});
// Send from main process
await electronApp.evaluate(async ({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
win.webContents.send("message", "Hello from main!");
});
// Verify receipt
await window.waitForFunction(() => (window as any).receivedMessage !== null);
const message = await window.evaluate(() => (window as any).receivedMessage);
expect(message).toBe("Hello from main!");
});
```
### Mock IPC Handlers
```typescript
// In test setup or fixture
test("mock IPC handler", async ({ electronApp, window }) => {
// Override IPC handler in main process
await electronApp.evaluate(async ({ ipcMain }) => {
// Remove existing handler
ipcMain.removeHandler("fetch-data");
// Add mock handler
ipcMain.handle("fetch-data", async () => {
return { mocked: true, data: "test-data" };
});
});
// Test with mocked handler
const result = await window.evaluate(async () => {
return await (window as any).electronAPI.fetchData();
});
expect(result.mocked).toBe(true);
});
```
## Native Features
### File System Dialogs
```typescript
test("file dialog", async ({ electronApp, window }) => {
// Mock dialog response
await electronApp.evaluate(async ({ dialog }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: ["/mock/path/file.txt"],
});
});
// Trigger file open
await window.getByRole("button", { name: "Open File" }).click();
// Verify file was "opened"
await expect(window.getByText("file.txt")).toBeVisible();
});
test("save dialog", async ({ electronApp, window }) => {
await electronApp.evaluate(async ({ dialog }) => {
dialog.showSaveDialog = async () => ({
canceled: false,
filePath: "/mock/path/saved-file.txt",
});
});
await window.getByRole("button", { name: "Save" }).click();
await expect(window.getByText("Saved successfully")).toBeVisible();
});
```
### Menu Testing
```typescript
test("application menu", async ({ electronApp }) => {
// Get menu structure
const menuLabels = await electronApp.evaluate(async ({ Menu }) => {
const menu = Menu.getApplicationMenu();
return menu?.items.map((item) => item.label) || [];
});
expect(menuLabels).toContain("File");
expect(menuLabels).toContain("Edit");
// Trigger menu action
await electronApp.evaluate(async ({ Menu }) => {
const menu = Menu.getApplicationMenu();
const fileMenu = menu?.items.find((item) => item.label === "File");
const newItem = fileMenu?.submenu?.items.find(
(item) => item.label === "New",
);
newItem?.click();
});
});
```
### Native Notifications
```typescript
test("notifications", async ({ electronApp, window }) => {
// Mock Notification
let notificationShown = false;
await electronApp.evaluate(async ({ Notification }) => {
const OriginalNotification = Notification;
(global as any).Notification = class extends OriginalNotification {
constructor(options: any) {
super(options);
(global as any).lastNotification = options;
}
};
});
// Trigger notification
await window.getByRole("button", { name: "Notify" }).click();
// Verify notification was created
const notification = await electronApp.evaluate(async () => {
return (global as any).lastNotification;
});
expect(notification.title).toBe("New Message");
});
```
### Clipboard
```typescript
test("clipboard operations", async ({ electronApp, window }) => {
// Write to clipboard
await electronApp.evaluate(async ({ clipboard }) => {
clipboard.writeText("Test clipboard content");
});
// Paste in app
await window.getByRole("textbox").focus();
await window.keyboard.press("ControlOrMeta+v");
// Read clipboard
const clipboardContent = await electronApp.evaluate(async ({ clipboard }) => {
return clipboard.readText();
});
expect(clipboardContent).toBe("Test clipboard content");
});
```
## Packaging & Distribution
### Testing Packaged Apps
```typescript
// fixtures/packaged-electron.ts
import { test as base, _electron as electron } from "@playwright/test";
import path from "path";
import { execSync } from "child_process";
export const test = base.extend({
electronApp: async ({}, use) => {
// Build the app first (or use pre-built)
const distPath = path.join(__dirname, "../dist");
let executablePath: string;
if (process.platform === "darwin") {
executablePath = path.join(
distPath,
"mac",
"MyApp.app",
"Contents",
"MacOS",
"MyApp",
);
} else if (process.platform === "win32") {
executablePath = path.join(distPath, "win-unpacked", "MyApp.exe");
} else {
executablePath = path.join(distPath, "linux-unpacked", "myapp");
}
const electronApp = await electron.launch({ executablePath });
await use(electronApp);
await electronApp.close();
},
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------------------- | ---------------------------- | -------------------------------------------- |
| Not closing ElectronApplication | Resource leaks | Always call `electronApp.close()` in cleanup |
| Hardcoded executable paths | Breaks cross-platform | Use platform detection |
| Testing packaged app without building | Outdated code | Build before testing or test dev mode |
| Ignoring IPC in tests | Missing coverage | Test IPC communication explicitly |
| Not mocking native dialogs | Tests hang waiting for input | Mock dialog responses |
## Related References
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for custom fixture patterns
- **Component Testing**: See [component-testing.md](component-testing.md) for renderer testing patterns
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting

View file

@ -0,0 +1,377 @@
# File Upload & Download Testing
> For advanced patterns (progress tracking, cancellation, retry logic), see [file-upload-download.md](./file-upload-download.md)
## Table of Contents
1. [File Downloads](#file-downloads)
2. [File Uploads](#file-uploads)
3. [Drag and Drop](#drag-and-drop)
4. [File Content Verification](#file-content-verification)
## File Downloads
### Basic Download
```typescript
test("download PDF report", async ({ page }) => {
await page.goto("/reports");
// Start waiting for download before clicking
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Download PDF" }).click();
const download = await downloadPromise;
// Verify filename
expect(download.suggestedFilename()).toBe("report.pdf");
// Save to specific path
await download.saveAs("./downloads/report.pdf");
});
```
### Download with Custom Path
```typescript
test("download to temp directory", async ({ page }, testInfo) => {
await page.goto("/exports");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("link", { name: "Export CSV" }).click();
const download = await downloadPromise;
// Save to test output directory
const path = testInfo.outputPath(download.suggestedFilename());
await download.saveAs(path);
// Attach to test report
await testInfo.attach("downloaded-file", { path });
});
```
### Verify Download Content
```typescript
import fs from "fs";
import path from "path";
test("verify CSV content", async ({ page }, testInfo) => {
await page.goto("/data");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export" }).click();
const download = await downloadPromise;
const filePath = testInfo.outputPath("export.csv");
await download.saveAs(filePath);
// Read and verify content
const content = fs.readFileSync(filePath, "utf-8");
expect(content).toContain("Name,Email,Status");
expect(content).toContain("John Doe");
// Verify row count
const rows = content.trim().split("\n");
expect(rows.length).toBeGreaterThan(1);
});
```
### Multiple Downloads
```typescript
test("download multiple files", async ({ page }) => {
await page.goto("/batch-export");
await page.getByRole("checkbox", { name: "Select All" }).check();
// Collect all downloads
const downloads: Download[] = [];
page.on("download", (download) => downloads.push(download));
await page.getByRole("button", { name: "Download Selected" }).click();
// Wait for all downloads
await expect.poll(() => downloads.length, { timeout: 30000 }).toBe(5);
// Verify each download
for (const download of downloads) {
expect(download.suggestedFilename()).toMatch(/\.pdf$/);
}
});
```
### Download Fixture
```typescript
// fixtures/download.fixture.ts
import { test as base, Download } from "@playwright/test";
import fs from "fs";
import path from "path";
type DownloadFixtures = {
downloadDir: string;
downloadAndVerify: (
trigger: () => Promise<void>,
expectedFilename: string,
) => Promise<string>;
};
export const test = base.extend<DownloadFixtures>({
downloadDir: async ({}, use, testInfo) => {
const dir = testInfo.outputPath("downloads");
fs.mkdirSync(dir, { recursive: true });
await use(dir);
},
downloadAndVerify: async ({ page, downloadDir }, use) => {
await use(async (trigger, expectedFilename) => {
const downloadPromise = page.waitForEvent("download");
await trigger();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(expectedFilename);
const filePath = path.join(downloadDir, expectedFilename);
await download.saveAs(filePath);
return filePath;
});
},
});
```
## File Uploads
### Basic Upload
```typescript
test("upload profile picture", async ({ page }) => {
await page.goto("/settings/profile");
// Upload file
await page
.getByLabel("Profile Picture")
.setInputFiles("./fixtures/avatar.png");
// Verify preview
await expect(page.getByAltText("Profile preview")).toBeVisible();
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Profile updated")).toBeVisible();
});
```
### Multiple File Upload
```typescript
test("upload multiple documents", async ({ page }) => {
await page.goto("/documents/upload");
await page
.getByLabel("Documents")
.setInputFiles([
"./fixtures/doc1.pdf",
"./fixtures/doc2.pdf",
"./fixtures/doc3.pdf",
]);
// Verify all files listed
await expect(page.getByText("doc1.pdf")).toBeVisible();
await expect(page.getByText("doc2.pdf")).toBeVisible();
await expect(page.getByText("doc3.pdf")).toBeVisible();
await page.getByRole("button", { name: "Upload All" }).click();
await expect(page.getByText("3 files uploaded")).toBeVisible();
});
```
### Upload with File Chooser
```typescript
test("upload via file chooser dialog", async ({ page }) => {
await page.goto("/upload");
// Handle file chooser
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByRole("button", { name: "Choose File" }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles("./fixtures/document.pdf");
await expect(page.getByText("document.pdf")).toBeVisible();
});
```
### Clear and Re-upload
```typescript
test("replace uploaded file", async ({ page }) => {
await page.goto("/upload");
const input = page.getByLabel("Document");
// Upload first file
await input.setInputFiles("./fixtures/old.pdf");
await expect(page.getByText("old.pdf")).toBeVisible();
// Clear selection
await input.setInputFiles([]);
// Upload new file
await input.setInputFiles("./fixtures/new.pdf");
await expect(page.getByText("new.pdf")).toBeVisible();
await expect(page.getByText("old.pdf")).toBeHidden();
});
```
### Upload from Buffer
```typescript
test("upload generated file", async ({ page }) => {
await page.goto("/upload");
// Create file content dynamically
const content = "Name,Email\nJohn,john@example.com";
await page.getByLabel("CSV File").setInputFiles({
name: "users.csv",
mimeType: "text/csv",
buffer: Buffer.from(content),
});
await expect(page.getByText("users.csv")).toBeVisible();
});
```
## Drag and Drop
### Drag and Drop Upload
```typescript
test("drag and drop file upload", async ({ page }) => {
await page.goto("/upload");
const dropzone = page.getByTestId("dropzone");
// Create a DataTransfer with the file
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
// Read file and add to DataTransfer
const buffer = fs.readFileSync("./fixtures/image.png");
await page.evaluate(
async ([dataTransfer, data]) => {
const file = new File([new Uint8Array(data)], "image.png", {
type: "image/png",
});
dataTransfer.items.add(file);
},
[dataTransfer, [...buffer]] as const,
);
// Dispatch drop event
await dropzone.dispatchEvent("drop", { dataTransfer });
await expect(page.getByText("image.png uploaded")).toBeVisible();
});
```
### Simpler Drag and Drop
```typescript
test("drag and drop with setInputFiles", async ({ page }) => {
await page.goto("/upload");
// Most dropzones have a hidden file input
const input = page.locator('input[type="file"]');
// This works even if the input is hidden
await input.setInputFiles("./fixtures/document.pdf");
await expect(page.getByText("document.pdf")).toBeVisible();
});
```
## File Content Verification
### Verify PDF Content
```typescript
import pdf from "pdf-parse";
test("verify PDF content", async ({ page }, testInfo) => {
await page.goto("/invoice/123");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Download Invoice" }).click();
const download = await downloadPromise;
const path = testInfo.outputPath("invoice.pdf");
await download.saveAs(path);
// Parse PDF
const dataBuffer = fs.readFileSync(path);
const data = await pdf(dataBuffer);
expect(data.text).toContain("Invoice #123");
expect(data.text).toContain("Total: $99.99");
});
```
### Verify Excel Content
```typescript
import XLSX from "xlsx";
test("verify Excel export", async ({ page }, testInfo) => {
await page.goto("/reports");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export Excel" }).click();
const download = await downloadPromise;
const path = testInfo.outputPath("report.xlsx");
await download.saveAs(path);
// Parse Excel
const workbook = XLSX.readFile(path);
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const data = XLSX.utils.sheet_to_json(sheet);
expect(data).toHaveLength(10);
expect(data[0]).toHaveProperty("Name");
expect(data[0]).toHaveProperty("Email");
});
```
### Verify JSON Download
```typescript
test("verify JSON export", async ({ page }, testInfo) => {
await page.goto("/api-data");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export JSON" }).click();
const download = await downloadPromise;
const path = testInfo.outputPath("data.json");
await download.saveAs(path);
const content = JSON.parse(fs.readFileSync(path, "utf-8"));
expect(content.users).toHaveLength(5);
expect(content.exportDate).toBeDefined();
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------------------- | ------------------------------- | --------------------------------------------- |
| Not waiting for download | Race condition, test fails | Always use `waitForEvent("download")` |
| Hardcoded download paths | Conflicts in parallel runs | Use `testInfo.outputPath()` |
| Skipping content verification | Download might be empty/corrupt | Verify file content when possible |
| Using `force: true` for hidden inputs | May not trigger proper events | Use `setInputFiles` on hidden inputs directly |
## Related References
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for download fixture patterns
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting download issues

View file

@ -0,0 +1,562 @@
# File Upload and Download Testing
> **When to use**: Testing file uploads (single, multiple, drag-and-drop), downloads (content verification, filename, type), upload progress indicators, or file type/size restrictions.
## Table of Contents
1. [Downloading Files](#downloading-files)
2. [Single File Upload](#single-file-upload)
3. [Multiple File Upload](#multiple-file-upload)
4. [Drag-and-Drop Zones](#drag-and-drop-zones)
5. [File Chooser Dialog](#file-chooser-dialog)
6. [Upload Progress and Cancellation](#upload-progress-and-cancellation)
7. [Retry After Failure](#retry-after-failure)
8. [File Type and Size Restrictions](#file-type-and-size-restrictions)
9. [Image Preview](#image-preview)
10. [Authenticated Downloads](#authenticated-downloads)
11. [Tips](#tips)
---
## Downloading Files
### Capturing Downloads and Verifying Content
```typescript
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
test('verifies downloaded CSV content', async ({ page }) => {
await page.goto('/exports');
// Set up download listener BEFORE triggering the download
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'transactions.csv' }).click();
const download = await downloadPromise;
const savePath = path.join(__dirname, '../tmp', download.suggestedFilename());
await download.saveAs(savePath);
const content = fs.readFileSync(savePath, 'utf-8');
expect(content).toContain('id,amount,date');
expect(content).toContain('1001,250.00,2025-01-15');
const rows = content.trim().split('\n');
expect(rows.length).toBeGreaterThan(1);
fs.unlinkSync(savePath);
});
test('reads download via stream without disk I/O', async ({ page }) => {
await page.goto('/exports');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'transactions.csv' }).click();
const download = await downloadPromise;
const readable = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of readable!) {
chunks.push(Buffer.from(chunk));
}
const content = Buffer.concat(chunks).toString('utf-8');
expect(content).toContain('id,amount,date');
});
```
### Verifying Filename and Format
```typescript
test('export filename matches selected format', async ({ page }) => {
await page.goto('/analytics');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export PDF' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^analytics-\d{4}-\d{2}-\d{2}\.pdf$/);
});
test('format selector changes output extension', async ({ page }) => {
await page.goto('/analytics');
await page.getByLabel('Format').selectOption('csv');
const csvDownload = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download' }).click();
expect((await csvDownload).suggestedFilename()).toMatch(/\.csv$/);
await page.getByLabel('Format').selectOption('xlsx');
const xlsxDownload = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download' }).click();
expect((await xlsxDownload).suggestedFilename()).toMatch(/\.xlsx$/);
});
```
### Checking Response Headers
```typescript
test('download response has correct MIME type', async ({ page }) => {
await page.goto('/analytics');
const responsePromise = page.waitForResponse('**/api/analytics/export**');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export PDF' }).click();
const response = await responsePromise;
expect(response.headers()['content-type']).toContain('application/pdf');
expect(response.headers()['content-disposition']).toContain('attachment');
await downloadPromise;
});
```
### Handling Download Failures
```typescript
test('shows error when download fails', async ({ page }) => {
await page.route('**/api/analytics/export**', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Generation failed' }),
});
});
await page.goto('/analytics');
await page.getByRole('button', { name: 'Export PDF' }).click();
await expect(page.getByRole('alert')).toContainText(/failed|error/i);
});
```
---
## Single File Upload
### From Fixture File
```typescript
import path from 'path';
test('uploads document from fixture', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/invoice.pdf'));
await expect(page.getByText('invoice.pdf')).toBeVisible();
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
await expect(page.getByRole('link', { name: 'invoice.pdf' })).toBeVisible();
});
```
### From In-Memory Buffer
```typescript
test('uploads in-memory CSV', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'contacts.csv',
mimeType: 'text/csv',
buffer: Buffer.from('name,email\nAlice,alice@acme.com\nBob,bob@acme.com'),
});
await expect(page.getByText('contacts.csv')).toBeVisible();
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
});
```
### Clearing Selection
```typescript
test('clears selected file', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'draft.txt',
mimeType: 'text/plain',
buffer: Buffer.from('draft content'),
});
await expect(page.getByText('draft.txt')).toBeVisible();
// Clear via API
await fileInput.setInputFiles([]);
await expect(page.getByText('draft.txt')).not.toBeVisible();
});
```
---
## Multiple File Upload
```typescript
test('uploads multiple files at once', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles([
{ name: 'doc1.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf1') },
{ name: 'doc2.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf2') },
{ name: 'doc3.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf3') },
]);
await expect(page.getByText('doc1.pdf')).toBeVisible();
await expect(page.getByText('doc2.pdf')).toBeVisible();
await expect(page.getByText('doc3.pdf')).toBeVisible();
await expect(page.getByText('3 files selected')).toBeVisible();
await page.getByRole('button', { name: 'Upload all' }).click();
await expect(page.getByRole('alert')).toContainText('3 files uploaded');
});
test('removes one file from selection', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles([
{ name: 'keep.txt', mimeType: 'text/plain', buffer: Buffer.from('keep') },
{ name: 'discard.txt', mimeType: 'text/plain', buffer: Buffer.from('discard') },
]);
const discardRow = page.getByText('discard.txt').locator('..');
await discardRow.getByRole('button', { name: /remove|delete|×/i }).click();
await expect(page.getByText('discard.txt')).not.toBeVisible();
await expect(page.getByText('keep.txt')).toBeVisible();
});
```
---
## Drag-and-Drop Zones
Drop zones always have an underlying `input[type="file"]`—target it directly instead of simulating OS-level drag events.
```typescript
test('uploads via drop zone', async ({ page }) => {
await page.goto('/attachments');
const dropZone = page.locator('[data-testid="drop-zone"]');
await expect(dropZone).toContainText(/drag.*here|drop.*files/i);
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'dropped.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('pdf-content'),
});
await expect(dropZone.getByText('dropped.pdf')).toBeVisible();
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
});
test('shows visual feedback on drag-over', async ({ page }) => {
await page.goto('/attachments');
const dropZone = page.locator('[data-testid="drop-zone"]');
await dropZone.dispatchEvent('dragenter', {
dataTransfer: { types: ['Files'], files: [] },
});
await expect(dropZone).toHaveClass(/active|highlight|drag-over/);
await expect(dropZone).toContainText(/release|drop now/i);
await dropZone.dispatchEvent('dragleave');
await expect(dropZone).not.toHaveClass(/active|highlight|drag-over/);
});
```
---
## File Chooser Dialog
```typescript
test('uploads via native file chooser', async ({ page }) => {
await page.goto('/attachments');
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Choose file' }).click();
const fileChooser = await fileChooserPromise;
expect(fileChooser.isMultiple()).toBe(false);
await fileChooser.setFiles({
name: 'selected.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('pdf-content'),
});
await expect(page.getByText('selected.pdf')).toBeVisible();
});
```
---
## Upload Progress and Cancellation
```typescript
test('displays upload progress for large file', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
const largeBuffer = Buffer.alloc(5 * 1024 * 1024, 'x');
await fileInput.setInputFiles({
name: 'dataset.bin',
mimeType: 'application/octet-stream',
buffer: largeBuffer,
});
await page.getByRole('button', { name: 'Upload' }).click();
const progressBar = page.getByRole('progressbar');
await expect(progressBar).toBeVisible();
await expect(async () => {
const value = await progressBar.getAttribute('aria-valuenow');
expect(Number(value)).toBeGreaterThan(0);
}).toPass({ timeout: 10000 });
await expect(progressBar).not.toBeVisible({ timeout: 60000 });
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
});
test('cancels in-progress upload', async ({ page }) => {
await page.route('**/api/attachments/upload', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 10000));
await route.continue();
});
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'large.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(5 * 1024 * 1024, 'x'),
});
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByRole('progressbar')).toBeVisible();
await page.getByRole('button', { name: 'Cancel upload' }).click();
await expect(page.getByRole('progressbar')).not.toBeVisible();
await expect(page.getByText(/cancelled|aborted/i)).toBeVisible();
await expect(page.getByRole('link', { name: 'large.bin' })).not.toBeVisible();
});
```
---
## Retry After Failure
```typescript
test('retries failed upload', async ({ page }) => {
let attempt = 0;
await page.route('**/api/attachments/upload', async (route) => {
attempt++;
if (attempt === 1) {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Server error' }),
});
} else {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'abc', name: 'data.csv' }),
});
}
});
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'data.csv',
mimeType: 'text/csv',
buffer: Buffer.from('col1,col2\nval1,val2'),
});
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByText(/upload failed|error/i)).toBeVisible();
await page.getByRole('button', { name: /retry/i }).click();
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
expect(attempt).toBe(2);
});
```
---
## File Type and Size Restrictions
### Validating Allowed Types
```typescript
test('accepts allowed file types', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
await expect(fileInput).toHaveAttribute('accept', /\.pdf|\.doc|\.docx|\.txt/);
await fileInput.setInputFiles({
name: 'report.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('pdf-content'),
});
await expect(page.getByText('report.pdf')).toBeVisible();
await expect(page.getByText(/not allowed|invalid/i)).not.toBeVisible();
});
test('rejects disallowed file types', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
// setInputFiles bypasses the accept attribute—tests JavaScript validation
await fileInput.setInputFiles({
name: 'malware.exe',
mimeType: 'application/x-msdownload',
buffer: Buffer.from('exe-content'),
});
await expect(page.getByRole('alert')).toContainText(
/not allowed|unsupported file type|only .pdf, .doc/i
);
await expect(page.getByText('malware.exe')).not.toBeVisible();
});
```
### Enforcing Size Limits
```typescript
test('rejects oversized file', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
const oversizedBuffer = Buffer.alloc(11 * 1024 * 1024, 'x');
await fileInput.setInputFiles({
name: 'huge.pdf',
mimeType: 'application/pdf',
buffer: oversizedBuffer,
});
await expect(page.getByRole('alert')).toContainText(/file.*too large|exceeds.*10 ?MB/i);
await expect(page.getByText('huge.pdf')).not.toBeVisible();
});
```
### Enforcing File Count Limits
```typescript
test('rejects too many files', async ({ page }) => {
await page.goto('/attachments');
const fileInput = page.locator('input[type="file"]');
const files = Array.from({ length: 6 }, (_, i) => ({
name: `file-${i + 1}.txt`,
mimeType: 'text/plain' as const,
buffer: Buffer.from(`content ${i + 1}`),
}));
await fileInput.setInputFiles(files);
await expect(page.getByRole('alert')).toContainText(/maximum.*5 files|too many files/i);
});
```
### Validating Image Dimensions
```typescript
test('rejects image below minimum dimensions', async ({ page }) => {
await page.goto('/profile/avatar');
const fileInput = page.locator('input[type="file"]');
// Minimal 1x1 PNG
const tinyPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64'
);
await fileInput.setInputFiles({
name: 'tiny.png',
mimeType: 'image/png',
buffer: tinyPng,
});
await expect(page.getByRole('alert')).toContainText(/minimum.*dimensions|too small/i);
});
```
---
## Image Preview
```typescript
test('shows image preview after selection', async ({ page }) => {
await page.goto('/profile/avatar');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/photo.jpg'));
const preview = page.getByRole('img', { name: /preview|avatar/i });
await expect(preview).toBeVisible();
const src = await preview.getAttribute('src');
expect(src).toMatch(/^(blob:|data:image)/);
});
```
---
## Authenticated Downloads
```typescript
test('downloads file requiring authentication', async ({ page, request }) => {
await page.goto('/attachments');
// Browser download works because cookies are sent
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'confidential.pdf' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('confidential.pdf');
// Verify via API request (carries auth context)
const response = await request.get('/api/attachments/456/download');
expect(response.ok()).toBeTruthy();
expect(response.headers()['content-type']).toContain('application/pdf');
});
```
---
## Tips
1. **Use `setInputFiles` for uploads**. Even drag-and-drop zones have an underlying `input[type="file"]`. Target it directly instead of simulating OS-level drag events.
2. **Prefer in-memory buffers**. Creating files with `Buffer.from()` keeps tests self-contained. Use fixture files only when you need real content (e.g., a valid PDF your app parses).
3. **Set up download listener before clicking**. Call `page.waitForEvent('download')` before the click that triggers the download—otherwise you may miss the event.
4. **Use `createReadStream()` for content verification**. Reading directly from the stream avoids disk I/O and cleanup of temporary files.
5. **Test both `accept` attribute and JavaScript validation**. The HTML `accept` attribute only filters the OS file dialog. `setInputFiles()` bypasses it, which is exactly what you need to test your app's JavaScript validation.

View file

@ -0,0 +1,561 @@
# Form Testing Patterns
## Table of Contents
1. [Quick Reference](#quick-reference)
2. [Patterns](#patterns)
3. [Decision Guide](#decision-guide)
4. [Anti-Patterns](#anti-patterns)
5. [Troubleshooting](#troubleshooting)
> **When to use**: Testing form filling, submission, validation messages, multi-step wizards, dynamic fields, and auto-complete interactions.
## Quick Reference
```typescript
// Text input
await page.getByLabel("Username").fill("john_doe");
// Select dropdown
await page.getByLabel("Region").selectOption("EU");
await page.getByLabel("Region").selectOption({ label: "Europe" });
// Checkbox and radio
await page.getByLabel("Subscribe").check();
await page.getByLabel("Priority shipping").click();
// Date input
await page.getByLabel("Departure").fill("2025-08-20");
// Clear a field
await page.getByLabel("Username").clear();
// Submit
await page.getByRole("button", { name: "Register" }).click();
// Verify validation error
await expect(page.getByText("Username is required")).toBeVisible();
```
## Patterns
### Auto-Complete and Typeahead Fields
**Use when**: Testing search fields, address lookups, mention pickers, or any input that shows suggestions as the user types.
```typescript
test("select from typeahead suggestions", async ({ page }) => {
await page.goto("/products");
const searchBox = page.getByRole("combobox", { name: "Find product" });
await searchBox.pressSequentially("lapt", { delay: 100 });
const suggestionList = page.getByRole("listbox");
await expect(suggestionList).toBeVisible();
await suggestionList.getByRole("option", { name: "Laptop Pro" }).click();
await expect(searchBox).toHaveValue("Laptop Pro");
});
test("typeahead with API-driven suggestions", async ({ page }) => {
await page.goto("/shipping");
const streetField = page.getByLabel("Street");
const responsePromise = page.waitForResponse("**/api/address-lookup*");
await streetField.pressSequentially("456 Elm", { delay: 50 });
await responsePromise;
await page.getByRole("option", { name: /456 Elm St/ }).click();
await expect(page.getByLabel("Town")).toHaveValue("Austin");
await expect(page.getByLabel("State")).toHaveValue("TX");
await expect(page.getByLabel("Postal code")).toHaveValue("78701");
});
test("dismiss suggestions and enter custom value", async ({ page }) => {
await page.goto("/labels");
const labelInput = page.getByLabel("New label");
await labelInput.pressSequentially("my-label");
await labelInput.press("Escape");
await expect(page.getByRole("listbox")).not.toBeVisible();
await labelInput.press("Enter");
await expect(page.getByText("my-label")).toBeVisible();
});
```
### Dynamic Forms — Conditional Fields
**Use when**: Form fields appear, disappear, or change based on the value of other fields.
```typescript
test("conditional fields appear based on selection", async ({ page }) => {
await page.goto("/loan/apply");
await page.getByLabel("Applicant type").selectOption("corporate");
await expect(page.getByLabel("Business name")).toBeVisible();
await expect(page.getByLabel("EIN")).toBeVisible();
await page.getByLabel("Business name").fill("TechCorp Inc");
await page.getByLabel("EIN").fill("98-7654321");
await page.getByLabel("Applicant type").selectOption("individual");
await expect(page.getByLabel("Business name")).not.toBeVisible();
await expect(page.getByLabel("EIN")).not.toBeVisible();
});
test("checkbox toggles additional section", async ({ page }) => {
await page.goto("/delivery");
await page.getByLabel("Separate invoice address").check();
const invoiceSection = page.getByRole("group", { name: "Invoice address" });
await expect(invoiceSection).toBeVisible();
await invoiceSection.getByLabel("Address").fill("789 Pine Rd");
await invoiceSection.getByLabel("City").fill("Denver");
await page.getByLabel("Separate invoice address").uncheck();
await expect(invoiceSection).not.toBeVisible();
});
test("dependent dropdown chains", async ({ page }) => {
await page.goto("/region-selector");
await page.getByLabel("Country").selectOption("CA");
const provinceDropdown = page.getByLabel("Province");
await expect(provinceDropdown.getByRole("option")).not.toHaveCount(0);
await provinceDropdown.selectOption("ON");
const cityDropdown = page.getByLabel("City");
await expect(cityDropdown.getByRole("option")).not.toHaveCount(0);
await cityDropdown.selectOption({ label: "Toronto" });
});
```
### Multi-Step Forms and Wizards
**Use when**: The form spans multiple pages or steps, with next/previous navigation and per-step validation.
```typescript
test("complete a multi-step booking wizard", async ({ page }) => {
await page.goto("/booking");
await test.step("enter guest information", async () => {
await expect(
page.getByRole("heading", { name: "Guest Info" }),
).toBeVisible();
await page.getByLabel("Full name").fill("Alice Smith");
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Phone").fill("555-1234");
await page.getByRole("button", { name: "Next" }).click();
});
await test.step("select room options", async () => {
await expect(
page.getByRole("heading", { name: "Room Selection" }),
).toBeVisible();
await page.getByLabel("Room type").selectOption("suite");
await page.getByLabel("Check-in").fill("2025-09-01");
await page.getByLabel("Check-out").fill("2025-09-05");
await page.getByRole("button", { name: "Next" }).click();
});
await test.step("confirm booking", async () => {
await expect(
page.getByRole("heading", { name: "Confirmation" }),
).toBeVisible();
await expect(page.getByText("Alice Smith")).toBeVisible();
await expect(page.getByText("suite")).toBeVisible();
await page.getByRole("button", { name: "Confirm booking" }).click();
});
await expect(
page.getByRole("heading", { name: "Booking complete" }),
).toBeVisible();
});
test("wizard validates each step before proceeding", async ({ page }) => {
await page.goto("/booking");
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByRole("heading", { name: "Guest Info" })).toBeVisible();
await expect(page.getByText("Full name is required")).toBeVisible();
});
test("wizard supports going back without losing data", async ({ page }) => {
await page.goto("/booking");
await page.getByLabel("Full name").fill("Alice Smith");
await page.getByLabel("Email").fill("alice@test.com");
await page.getByLabel("Phone").fill("555-1234");
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("button", { name: "Previous" }).click();
await expect(page.getByLabel("Full name")).toHaveValue("Alice Smith");
await expect(page.getByLabel("Email")).toHaveValue("alice@test.com");
});
```
### Form Submission and Response Handling
**Use when**: Testing what happens after a form is submitted — success messages, redirects, error responses from the server, and loading states during submission.
```typescript
test("successful form submission shows confirmation", async ({ page }) => {
await page.goto("/feedback");
await page.getByLabel("Subject").fill("Feature request");
await page.getByLabel("Email").fill("user@test.com");
await page.getByLabel("Details").fill("Please add dark mode");
const responsePromise = page.waitForResponse("**/api/feedback");
await page.getByRole("button", { name: "Submit feedback" }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
await expect(page.getByText("Feedback received")).toBeVisible();
});
test("form submission shows server-side validation errors", async ({
page,
}) => {
await page.goto("/signup");
await page.getByLabel("Email").fill("existing@test.com");
await page.getByLabel("Password", { exact: true }).fill("Secure1@pass");
await page.getByRole("button", { name: "Sign up" }).click();
await expect(
page.getByText("Email address already registered"),
).toBeVisible();
});
test("form shows loading state during submission", async ({ page }) => {
await page.goto("/feedback");
await page.getByLabel("Subject").fill("Bug report");
await page.getByLabel("Email").fill("user@test.com");
await page.getByLabel("Details").fill("Found an issue");
const submit = page.getByRole("button", {
name: /Submit feedback|Submitting/,
});
await submit.click();
await expect(submit).toHaveText(/Submitting/);
await expect(submit).toBeDisabled();
await expect(submit).toHaveText("Submit feedback");
await expect(submit).toBeEnabled();
});
test("form redirects after successful submission", async ({ page }) => {
await page.goto("/auth/login");
await page.getByLabel("Email").fill("admin@test.com");
await page.getByLabel("Password").fill("admin123");
await page.getByRole("button", { name: "Log in" }).click();
await page.waitForURL("/home");
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
});
```
### Filling Basic Form Fields
**Use when**: Testing any form with standard HTML inputs — text, email, password, number, textarea, select, checkbox, radio.
```typescript
test("fill and submit a signup form", async ({ page }) => {
await page.goto("/signup");
await page.getByLabel("First name").fill("Bob");
await page.getByLabel("Last name").fill("Wilson");
await page.getByLabel("Email").fill("bob@test.com");
await page.getByLabel("Password", { exact: true }).fill("P@ssw0rd!");
await page.getByLabel("Confirm password").fill("P@ssw0rd!");
await page.getByLabel("About you").fill("Developer with 5 years experience.");
await page.getByLabel("Years of experience").fill("5");
await page.getByLabel("Country").selectOption("UK");
await page.getByLabel("City").selectOption({ label: "London" });
await page
.getByLabel("Skills")
.selectOption(["typescript", "playwright", "nodejs"]);
await page.getByLabel("Accept terms").check();
await expect(page.getByLabel("Accept terms")).toBeChecked();
await page.getByLabel("Annual billing").check();
await expect(page.getByLabel("Annual billing")).toBeChecked();
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
});
```
### Date and Time Inputs
**Use when**: Testing native `<input type="date">`, `<input type="time">`, `<input type="datetime-local">`, or third-party date pickers.
```typescript
test("fill native date and time inputs", async ({ page }) => {
await page.goto("/reservation");
await page.getByLabel("Reservation date").fill("2025-07-10");
await expect(page.getByLabel("Reservation date")).toHaveValue("2025-07-10");
await page.getByLabel("Time slot").fill("18:00");
await page.getByLabel("Reminder").fill("2025-07-10T17:30");
});
test("interact with a third-party date picker", async ({ page }) => {
await page.goto("/reservation");
await page.getByLabel("Event date").click();
await page.getByRole("button", { name: "Next month" }).click();
await page.getByRole("gridcell", { name: "25" }).click();
await expect(page.getByLabel("Event date")).toHaveValue(/2025/);
});
```
### Required Field Validation
**Use when**: Testing that the form shows appropriate error messages when required fields are empty.
```typescript
test("shows validation errors for empty required fields", async ({ page }) => {
await page.goto("/inquiry");
await page.getByRole("button", { name: "Send inquiry" }).click();
await expect(page.getByText("Name is required")).toBeVisible();
await expect(page.getByText("Email is required")).toBeVisible();
await expect(page.getByText("Question is required")).toBeVisible();
await expect(page).toHaveURL(/\/inquiry/);
});
test("clears validation errors when fields are filled", async ({ page }) => {
await page.goto("/inquiry");
await page.getByRole("button", { name: "Send inquiry" }).click();
await expect(page.getByText("Name is required")).toBeVisible();
await page.getByLabel("Name").fill("Carol Brown");
await page.getByLabel("Email").focus();
await expect(page.getByText("Name is required")).not.toBeVisible();
});
test("native HTML5 validation with required attribute", async ({ page }) => {
await page.goto("/basic-form");
await page.getByRole("button", { name: "Submit" }).click();
const emailInput = page.getByLabel("Email");
const validationMessage = await emailInput.evaluate(
(el: HTMLInputElement) => el.validationMessage,
);
expect(validationMessage).toBeTruthy();
});
```
### Format Validation and Custom Rules
**Use when**: Testing email format, phone number format, password strength, and business-specific validation rules.
```typescript
test("validates email format", async ({ page }) => {
await page.goto("/signup");
const emailField = page.getByLabel("Email");
const invalidEmails = [
"invalid",
"missing@",
"@nodomain.com",
"has spaces@mail.com",
];
for (const email of invalidEmails) {
await emailField.fill(email);
await emailField.blur();
await expect(page.getByText("Enter a valid email address")).toBeVisible();
}
await emailField.fill("correct@domain.com");
await emailField.blur();
await expect(page.getByText("Enter a valid email address")).not.toBeVisible();
});
test("validates password strength rules", async ({ page }) => {
await page.goto("/signup");
const passwordField = page.getByLabel("Password", { exact: true });
await passwordField.fill("Xy1!");
await passwordField.blur();
await expect(page.getByText("Minimum 8 characters")).toBeVisible();
await passwordField.fill("lowercase1!");
await passwordField.blur();
await expect(page.getByText("Include an uppercase letter")).toBeVisible();
await passwordField.fill("SecureP@ss1");
await passwordField.blur();
await expect(page.getByText(/Minimum|Include/)).not.toBeVisible();
});
test("validates custom business rule — minimum amount", async ({ page }) => {
await page.goto("/transfer");
await page.getByLabel("Amount").fill("5");
await page.getByLabel("Amount").blur();
await expect(page.getByText("Minimum transfer is $10")).toBeVisible();
await page.getByLabel("Amount").fill("1000000");
await page.getByLabel("Amount").blur();
await expect(page.getByText("Maximum transfer is $100,000")).toBeVisible();
await page.getByLabel("Amount").fill("500");
await page.getByLabel("Amount").blur();
await expect(page.getByText(/Minimum|Maximum/)).not.toBeVisible();
});
```
### Form Reset Testing
**Use when**: Testing "clear form" or "reset" functionality, verifying that fields return to their default values.
```typescript
test("reset button clears all fields to defaults", async ({ page }) => {
await page.goto("/preferences");
await page.getByLabel("Nickname").fill("CustomNick");
await page.getByLabel("Language").selectOption("es");
await page.getByLabel("Email alerts").uncheck();
await page.getByRole("button", { name: "Reset" }).click();
await expect(page.getByLabel("Nickname")).toHaveValue("");
await expect(page.getByLabel("Language")).toHaveValue("en");
await expect(page.getByLabel("Email alerts")).toBeChecked();
});
test("confirmation dialog before resetting a dirty form", async ({ page }) => {
await page.goto("/document");
await page.getByLabel("Document title").fill("Draft document");
page.on("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Clear changes" }).click();
await expect(page.getByLabel("Document title")).toHaveValue("");
});
```
## Decision Guide
| Scenario | Approach | Key API |
| ------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------ |
| Standard text input | `fill()` (clears, then types) | `page.getByLabel('Field').fill('value')` |
| Need keystroke events (autocomplete) | `pressSequentially()` with delay | `locator.pressSequentially('text', { delay: 100 })` |
| Native `<select>` dropdown | `selectOption()` by value or label | `locator.selectOption('US')` or `{ label: 'United States' }` |
| Custom dropdown (ARIA listbox) | Click trigger, then select option role | `getByRole('option', { name: '...' }).click()` |
| Checkbox | `check()` / `uncheck()` (idempotent) | `locator.check()` — safe to call even if already checked |
| Radio button | `check()` on the target radio | `page.getByLabel('Option').check()` |
| Date input (native) | `fill()` with ISO format | `locator.fill('2025-03-15')` |
| Date picker (third-party) | Click to open, navigate, select day | `getByRole('gridcell', { name: '15' }).click()` |
| Validation errors | Submit, then assert error text | `expect(page.getByText('Required')).toBeVisible()` |
| Multi-step wizard | `test.step()` per step, assert heading | `await test.step('Step 1', async () => { ... })` |
| Conditional/dynamic fields | Change trigger field, assert new field visibility | `expect(locator).toBeVisible()` / `.not.toBeVisible()` |
| Form submission | `waitForResponse` + click submit | Register response listener before click |
| Auto-complete | `pressSequentially()`, wait for listbox, select option | `getByRole('option', { name }).click()` |
| Form reset | Click reset, assert default values | `expect(locator).toHaveValue('')` |
## Anti-Patterns
| Don't Do This | Problem | Do This Instead |
| ------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ |
| `await page.getByLabel('Field').type('value')` | `type()` appends to existing content; does not clear first | `await page.getByLabel('Field').fill('value')` |
| `await page.getByLabel('Option').click()` | `click()` toggles — if already checked, it unchecks | `await page.getByLabel('Option').check()` |
| `await page.fill('#email', 'test@test.com')` | CSS selector is fragile | `await page.getByLabel('Email').fill('test@test.com')` |
| `await page.selectOption('select', 'US')` without label | Targets first `<select>` on page; ambiguous | `await page.getByLabel('Country').selectOption('US')` |
| Testing every invalid input in one test | Test becomes huge, slow, and hard to debug | One test per validation rule or group related rules |
| `expect(await input.inputValue()).toBe('value')` | Resolves once — no retry. Race condition. | `await expect(input).toHaveValue('value')` |
| Filling fields with `page.evaluate()` | Bypasses event handlers (no `input`, `change` events fire) | Use `fill()` or `pressSequentially()` |
| Not waiting for conditional fields before filling | `fill()` fails on hidden/detached elements | `await expect(field).toBeVisible()` first |
| Hardcoding wait after selecting a dropdown | `waitForTimeout(500)` is flaky and slow | Wait for the dependent element to appear |
| Skipping server-side validation tests | Client-side validation can be bypassed | Test both client-side UX and server response |
## Troubleshooting
### `fill()` does nothing or clears but doesn't type
**Cause**: The input field uses a contenteditable div (rich text editors), not a real `<input>` or `<textarea>`.
```typescript
const isContentEditable = await page
.getByTestId("editor")
.evaluate((el) => el.getAttribute("contenteditable"));
if (isContentEditable) {
await page.getByTestId("editor").click();
await page.getByTestId("editor").pressSequentially("Hello world");
}
```
### Date picker does not accept `fill()` value
**Cause**: Third-party date pickers often render custom UI over a hidden input. `fill()` sets the hidden input but the UI does not update.
```typescript
await page.getByLabel("Date").click();
await page.getByRole("button", { name: "Next month" }).click();
await page.getByRole("gridcell", { name: "15" }).click();
// Alternatively, if the library reads from the input on change:
await page.getByLabel("Date").fill("2025-06-15");
await page.getByLabel("Date").dispatchEvent("change");
```
### `selectOption()` throws "not a select element"
**Cause**: The dropdown is a custom component (ARIA listbox), not a native `<select>`.
```typescript
await page.getByRole("combobox", { name: "Country" }).click();
await page.getByRole("option", { name: "United States" }).click();
```
### Validation errors do not appear after `fill()` and submit
**Cause**: The validation triggers on `blur` (focus leaving the field), but `fill()` does not trigger blur automatically.
```typescript
await page.getByLabel("Email").fill("invalid");
await page.getByLabel("Email").blur();
await expect(page.getByText("Enter a valid email")).toBeVisible();
// Or move focus to the next field
await page.getByLabel("Password").focus();
```

View file

@ -0,0 +1,331 @@
# GraphQL Testing
## Table of Contents
1. [Patterns](#patterns)
2. [Anti-Patterns](#anti-patterns)
3. [Troubleshooting](#troubleshooting)
> **When to use**: Testing GraphQL APIs — queries, mutations, variables, and error handling.
## Patterns
### Basic Query with Variables
All GraphQL requests go through `POST` to a single endpoint. Send `query`, `variables`, and optionally `operationName` in the JSON body.
```typescript
import { test, expect } from "@playwright/test";
const GQL_ENDPOINT = "/graphql";
test("query with variables", async ({ request }) => {
const resp = await request.post(GQL_ENDPOINT, {
data: {
query: `
query FetchItem($id: ID!) {
item(id: $id) {
id
title
price
reviews { id rating }
}
}
`,
variables: { id: "101" },
},
});
expect(resp.ok()).toBeTruthy();
const { data, errors } = await resp.json();
// GraphQL returns 200 even on errors — always check both
expect(errors).toBeUndefined();
expect(data.item).toMatchObject({
id: "101",
title: expect.any(String),
price: expect.any(Number),
});
expect(data.item.reviews).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
rating: expect.any(Number),
}),
])
);
});
```
### Mutations
```typescript
import { test, expect } from "@playwright/test";
const GQL_ENDPOINT = "/graphql";
test("mutation creates resource", async ({ request }) => {
const resp = await request.post(GQL_ENDPOINT, {
data: {
query: `
mutation AddItem($input: ItemInput!) {
addItem(input: $input) {
id
title
status
}
}
`,
variables: {
input: {
title: "New Widget",
price: 15.0,
status: "DRAFT",
},
},
},
});
const { data, errors } = await resp.json();
expect(errors).toBeUndefined();
expect(data.addItem).toMatchObject({
id: expect.any(String),
title: "New Widget",
status: "DRAFT",
});
});
```
### Validation Errors
```typescript
import { test, expect } from "@playwright/test";
const GQL_ENDPOINT = "/graphql";
test("handles validation errors", async ({ request }) => {
const resp = await request.post(GQL_ENDPOINT, {
data: {
query: `
mutation AddItem($input: ItemInput!) {
addItem(input: $input) { id }
}
`,
variables: { input: { title: "" } },
},
});
const { data, errors } = await resp.json();
expect(errors).toBeDefined();
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].message).toContain("title");
expect(errors[0].extensions?.code).toBe("BAD_USER_INPUT");
});
```
### Authorization Errors
```typescript
import { test, expect } from "@playwright/test";
const GQL_ENDPOINT = "/graphql";
test("handles authorization errors", async ({ request }) => {
const resp = await request.post(GQL_ENDPOINT, {
data: {
query: `
query AdminDashboard {
adminMetrics { revenue activeUsers }
}
`,
},
});
const { data, errors } = await resp.json();
expect(errors).toBeDefined();
expect(errors[0].extensions?.code).toBe("UNAUTHORIZED");
expect(data?.adminMetrics).toBeNull();
});
```
### Authenticated GraphQL Fixture
```typescript
// fixtures/graphql-fixtures.ts
import { test as base, expect, APIRequestContext } from "@playwright/test";
type GraphQLFixtures = {
gqlClient: APIRequestContext;
adminGqlClient: APIRequestContext;
};
export const test = base.extend<GraphQLFixtures>({
gqlClient: async ({ playwright }, use) => {
const ctx = await playwright.request.newContext({
baseURL: "https://api.myapp.io",
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
"Content-Type": "application/json",
},
});
await use(ctx);
await ctx.dispose();
},
adminGqlClient: async ({ playwright }, use) => {
const loginCtx = await playwright.request.newContext({
baseURL: "https://api.myapp.io",
});
const loginResp = await loginCtx.post("/graphql", {
data: {
query: `
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) { token }
}
`,
variables: {
email: process.env.ADMIN_EMAIL,
password: process.env.ADMIN_PASSWORD,
},
},
});
const { data } = await loginResp.json();
if (!data?.login?.token) {
throw new Error(`Admin login failed: status ${loginResp.status()}, response: ${JSON.stringify(data)}`);
}
await loginCtx.dispose();
const ctx = await playwright.request.newContext({
baseURL: "https://api.myapp.io",
extraHTTPHeaders: {
Authorization: `Bearer ${data.login.token}`,
"Content-Type": "application/json",
},
});
await use(ctx);
await ctx.dispose();
},
});
export { expect };
```
### GraphQL Helper Function
```typescript
// utils/graphql.ts
import { APIRequestContext, expect } from "@playwright/test";
export async function gqlQuery<T = any>(
request: APIRequestContext,
query: string,
variables?: Record<string, any>
): Promise<{ data: T; errors?: any[] }> {
const resp = await request.post("/graphql", {
data: { query, variables },
});
expect(resp.ok()).toBeTruthy();
return resp.json();
}
export async function gqlMutation<T = any>(
request: APIRequestContext,
mutation: string,
variables?: Record<string, any>
): Promise<{ data: T; errors?: any[] }> {
return gqlQuery<T>(request, mutation, variables);
}
```
```typescript
// tests/api/items.spec.ts
import { test, expect } from "@playwright/test";
import { gqlQuery, gqlMutation } from "../../utils/graphql";
test("fetch and update item", async ({ request }) => {
const { data: fetchData } = await gqlQuery(
request,
`query GetItem($id: ID!) { item(id: $id) { id title } }`,
{ id: "101" }
);
expect(fetchData.item.title).toBeDefined();
const { data: updateData, errors } = await gqlMutation(
request,
`mutation UpdateItem($id: ID!, $title: String!) {
updateItem(id: $id, title: $title) { id title }
}`,
{ id: "101", title: "Updated Title" }
);
expect(errors).toBeUndefined();
expect(updateData.updateItem.title).toBe("Updated Title");
});
```
## Anti-Patterns
| Don't Do This | Problem | Do This Instead |
| --- | --- | --- |
| Check only `response.ok()` | GraphQL returns 200 even on errors — `errors` array is the real signal | Always check both `data` and `errors` in the response body |
| Ignore `errors` array | Validation and auth errors appear in `errors`, not HTTP status | Destructure and assert: `expect(errors).toBeUndefined()` |
| Hardcode query strings inline everywhere | Duplicated queries are hard to maintain | Extract queries to constants or use a helper function |
| Skip variable validation | Invalid variables cause cryptic server errors | Validate input shape before sending |
## Troubleshooting
### GraphQL returns 200 but data is null
**Cause**: GraphQL servers return HTTP 200 even when the query has errors. The actual error is in the `errors` array.
**Fix**: Always destructure and check both `data` and `errors`.
```typescript
const { data, errors } = await resp.json();
if (errors) {
console.error("GraphQL errors:", JSON.stringify(errors, null, 2));
}
expect(errors).toBeUndefined();
expect(data.item).toBeDefined();
```
### "Cannot query field X on type Y"
**Cause**: The field doesn't exist in the schema, or you're querying the wrong type.
**Fix**: Verify the schema. Use introspection or check your GraphQL IDE for available fields.
```typescript
// Introspection query to debug schema
const { data } = await request.post("/graphql", {
data: {
query: `{ __type(name: "Item") { fields { name type { name } } } }`,
},
});
console.log(data.__type.fields);
```
### Variables not being applied
**Cause**: Variable names in the query don't match the `variables` object keys, or types don't match.
**Fix**: Ensure variable names match exactly (case-sensitive) and types align with the schema.
```typescript
// Wrong: variable name mismatch
const resp = await request.post("/graphql", {
data: {
query: `query GetItem($itemId: ID!) { item(id: $itemId) { id } }`,
variables: { id: "101" }, // Should be { itemId: "101" }
},
});
// Correct
const resp = await request.post("/graphql", {
data: {
query: `query GetItem($itemId: ID!) { item(id: $itemId) { id } }`,
variables: { itemId: "101" },
},
});
```

View file

@ -0,0 +1,508 @@
# Internationalization (i18n) Testing
## Table of Contents
1. [Locale Configuration](#locale-configuration)
2. [Testing Multiple Locales](#testing-multiple-locales)
3. [RTL Layout Testing](#rtl-layout-testing)
4. [Date, Time & Number Formats](#date-time--number-formats)
5. [Translation Verification](#translation-verification)
6. [Visual Regression for i18n](#visual-regression-for-i18n)
## Locale Configuration
### Setting Browser Locale
```typescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
projects: [
{
name: "english",
use: {
...devices["Desktop Chrome"],
locale: "en-US",
timezoneId: "America/New_York",
},
},
{
name: "german",
use: {
...devices["Desktop Chrome"],
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
},
{
name: "japanese",
use: {
...devices["Desktop Chrome"],
locale: "ja-JP",
timezoneId: "Asia/Tokyo",
},
},
{
name: "arabic",
use: {
...devices["Desktop Chrome"],
locale: "ar-SA",
timezoneId: "Asia/Riyadh",
},
},
],
});
```
### Per-Test Locale Override
```typescript
test("test in French locale", async ({ browser }) => {
const context = await browser.newContext({
locale: "fr-FR",
timezoneId: "Europe/Paris",
});
const page = await context.newPage();
await page.goto("/");
// Verify French content
await expect(page.getByRole("button", { name: "Connexion" })).toBeVisible();
await context.close();
});
```
### Accept-Language Header
```typescript
test("server-side locale detection", async ({ browser }) => {
const context = await browser.newContext({
locale: "es-ES",
extraHTTPHeaders: {
"Accept-Language": "es-ES,es;q=0.9,en;q=0.8",
},
});
const page = await context.newPage();
await page.goto("/");
// Server should respond with Spanish content
await expect(page.locator("html")).toHaveAttribute("lang", "es");
});
```
## Testing Multiple Locales
### Parameterized Locale Tests
```typescript
const locales = [
{ locale: "en-US", greeting: "Hello", button: "Sign In" },
{ locale: "de-DE", greeting: "Hallo", button: "Anmelden" },
{ locale: "fr-FR", greeting: "Bonjour", button: "Se connecter" },
{ locale: "ja-JP", greeting: "こんにちは", button: "ログイン" },
];
for (const { locale, greeting, button } of locales) {
test(`login page in ${locale}`, async ({ browser }) => {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto("/login");
await expect(page.getByText(greeting)).toBeVisible();
await expect(page.getByRole("button", { name: button })).toBeVisible();
await context.close();
});
}
```
### Locale Fixture
```typescript
// fixtures/i18n.ts
import { test as base } from "@playwright/test";
type LocaleFixtures = {
localePage: (locale: string) => Promise<Page>;
};
export const test = base.extend<LocaleFixtures>({
localePage: async ({ browser }, use) => {
const pages: Page[] = [];
const createLocalePage = async (locale: string) => {
const context = await browser.newContext({ locale });
const page = await context.newPage();
pages.push(page);
return page;
};
await use(createLocalePage);
// Cleanup
for (const page of pages) {
await page.context().close();
}
},
});
// Usage
test("compare locales", async ({ localePage }) => {
const enPage = await localePage("en-US");
const dePage = await localePage("de-DE");
await enPage.goto("/pricing");
await dePage.goto("/pricing");
const enPrice = await enPage.getByTestId("price").textContent();
const dePrice = await dePage.getByTestId("price").textContent();
expect(enPrice).toContain("$");
expect(dePrice).toContain("€");
});
```
### Testing Locale Switching
```typescript
test("user can switch locale", async ({ page }) => {
await page.goto("/");
// Initial locale (from browser)
await expect(page.locator("html")).toHaveAttribute("lang", "en");
// Switch to German
await page.getByRole("button", { name: "Language" }).click();
await page.getByRole("menuitem", { name: "Deutsch" }).click();
// Verify switch
await expect(page.locator("html")).toHaveAttribute("lang", "de");
await expect(page.getByRole("heading", { level: 1 })).toContainText(
/Willkommen/,
);
// Verify persistence (reload)
await page.reload();
await expect(page.locator("html")).toHaveAttribute("lang", "de");
});
```
## RTL Layout Testing
### Setting Up RTL Tests
```typescript
// playwright.config.ts
export default defineConfig({
projects: [
{
name: "rtl-arabic",
use: {
locale: "ar-SA",
// RTL is usually set by the app based on locale
},
},
{
name: "rtl-hebrew",
use: {
locale: "he-IL",
},
},
],
});
```
### Verifying RTL Direction
```typescript
test("RTL layout is applied", async ({ page }) => {
await page.goto("/");
// Check document direction
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
// Or check computed style
const direction = await page.evaluate(() => {
return window.getComputedStyle(document.body).direction;
});
expect(direction).toBe("rtl");
});
```
### RTL-Specific Element Positioning
```typescript
test("sidebar is on the right in RTL", async ({ page }) => {
await page.goto("/dashboard");
const sidebar = page.getByTestId("sidebar");
const main = page.getByTestId("main-content");
const sidebarBox = await sidebar.boundingBox();
const mainBox = await main.boundingBox();
// In RTL, sidebar should be to the right of main content
expect(sidebarBox!.x).toBeGreaterThan(mainBox!.x);
});
```
### RTL Visual Regression
```typescript
test("RTL layout matches snapshot", async ({ page }) => {
await page.goto("/");
// Screenshot for RTL comparison
await expect(page).toHaveScreenshot("homepage-rtl.png", {
// Separate snapshots per locale/direction
fullPage: true,
});
});
// LTR comparison
test("LTR layout matches snapshot", async ({ browser }) => {
const context = await browser.newContext({ locale: "en-US" });
const page = await context.newPage();
await page.goto("/");
await expect(page).toHaveScreenshot("homepage-ltr.png", { fullPage: true });
});
```
### Testing Bidirectional Text
```typescript
test("bidirectional text renders correctly", async ({ page }) => {
await page.goto("/profile");
// Mixed LTR/RTL content
const nameField = page.getByTestId("full-name");
// Arabic name with English email
await expect(nameField).toContainText("محمد (mohammed@example.com)");
// Verify text doesn't overlap or break
const box = await nameField.boundingBox();
expect(box!.width).toBeGreaterThan(100); // Content not collapsed
});
```
## Date, Time & Number Formats
### Testing Date Formats
```typescript
test("dates are formatted per locale", async ({ browser }) => {
const testDate = new Date("2024-03-15");
const formats = [
{ locale: "en-US", expected: "March 15, 2024" },
{ locale: "en-GB", expected: "15 March 2024" },
{ locale: "de-DE", expected: "15. März 2024" },
{ locale: "ja-JP", expected: "2024年3月15日" },
];
for (const { locale, expected } of formats) {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto(`/event?date=${testDate.toISOString()}`);
const dateDisplay = page.getByTestId("event-date");
await expect(dateDisplay).toContainText(expected);
await context.close();
}
});
```
### Testing Number Formats
```typescript
test("numbers are formatted per locale", async ({ browser }) => {
const testNumber = 1234567.89;
const formats = [
{ locale: "en-US", expected: "1,234,567.89" },
{ locale: "de-DE", expected: "1.234.567,89" },
{ locale: "fr-FR", expected: "1 234 567,89" },
];
for (const { locale, expected } of formats) {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto(`/stats?value=${testNumber}`);
await expect(page.getByTestId("formatted-number")).toHaveText(expected);
await context.close();
}
});
```
### Testing Currency Formats
```typescript
test("currency displays correctly", async ({ browser }) => {
const price = 99.99;
const currencies = [
{ locale: "en-US", currency: "USD", expected: "$99.99" },
{ locale: "de-DE", currency: "EUR", expected: "99,99 €" },
{ locale: "ja-JP", currency: "JPY", expected: "¥100" }, // JPY has no decimals
{ locale: "en-GB", currency: "GBP", expected: "£99.99" },
];
for (const { locale, currency, expected } of currencies) {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto(`/product?price=${price}&currency=${currency}`);
await expect(page.getByTestId("price")).toContainText(expected);
await context.close();
}
});
```
## Translation Verification
### Checking for Missing Translations
```typescript
test("no missing translations", async ({ page }) => {
await page.goto("/");
// Common patterns for missing translations
const missingPatterns = [
/\{\{.*\}\}/, // Handlebars-style
/\$\{.*\}/, // Template literal style
/t\(["'][\w.]+["']\)/, // i18n key exposed
/MISSING_TRANSLATION/, // Common placeholder
/\[UNTRANSLATED\]/, // Another placeholder
];
const bodyText = await page.locator("body").textContent();
for (const pattern of missingPatterns) {
expect(bodyText).not.toMatch(pattern);
}
});
```
### Detecting Text Overflow
```typescript
test("translations fit UI containers", async ({ browser }) => {
const locales = ["en-US", "de-DE", "fr-FR", "es-ES"];
const issues: string[] = [];
for (const locale of locales) {
const context = await browser.newContext({ locale });
const page = await context.newPage();
await page.goto("/");
const overflowing = await page.evaluate(() => {
const elements = document.querySelectorAll("button, .label, h1, h2, h3");
return Array.from(elements)
.filter(
(el) =>
(el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth,
)
.map((el) => `${el.tagName}: "${el.textContent?.substring(0, 20)}..."`);
});
if (overflowing.length > 0)
issues.push(`${locale}: ${overflowing.join(", ")}`);
await context.close();
}
expect(issues).toEqual([]);
});
```
## Visual Regression for i18n
### Locale-Specific Snapshots
```typescript
// playwright.config.ts
export default defineConfig({
snapshotPathTemplate:
"{testDir}/__snapshots__/{projectName}/{testFilePath}/{arg}{ext}",
projects: [
{ name: "en-US", use: { locale: "en-US" } },
{ name: "de-DE", use: { locale: "de-DE" } },
{ name: "ja-JP", use: { locale: "ja-JP" } },
{ name: "ar-SA", use: { locale: "ar-SA" } },
],
});
```
```typescript
// test file
test("homepage visual", async ({ page }) => {
await page.goto("/");
// Snapshot auto-saved to {projectName}/homepage.png
await expect(page).toHaveScreenshot("homepage.png");
});
```
### Critical Element Screenshots
```typescript
test("navigation in all locales", async ({ page }) => {
await page.goto("/");
// Just the nav - catches overflow, truncation
const nav = page.getByRole("navigation");
await expect(nav).toHaveScreenshot("navigation.png");
});
test("buttons dont truncate", async ({ page }) => {
await page.goto("/checkout");
const ctaButton = page.getByRole("button", {
name: /checkout|kaufen|acheter/i,
});
await expect(ctaButton).toHaveScreenshot("checkout-button.png");
});
```
### Font Loading for i18n
```typescript
test("wait for fonts before screenshot", async ({ page }) => {
await page.goto("/");
// Wait for fonts (important for CJK, Arabic)
await page.evaluate(() => document.fonts.ready);
await page.waitForFunction(() =>
document.fonts.check("16px 'Noto Sans Arabic'"),
);
await expect(page).toHaveScreenshot("with-fonts.png");
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------- | ------------------------------- | ------------------------------- |
| Hardcoded text assertions | Breaks in other locales | Use test IDs or parameterize |
| Single locale testing | Misses i18n bugs | Test multiple locales |
| Ignoring RTL | Layout broken for RTL users | Dedicated RTL project |
| No font wait | Screenshots with fallback fonts | Wait for `document.fonts.ready` |
## Related References
- **Clock Mocking**: See [clock-mocking.md](../advanced/clock-mocking.md) for timezone testing
- **Mobile Testing**: See [mobile-testing.md](../advanced/mobile-testing.md) for device-specific locales

View file

@ -0,0 +1,476 @@
# Performance Testing & Web Vitals
## Table of Contents
1. [Core Web Vitals](#core-web-vitals)
2. [Performance Metrics](#performance-metrics)
3. [Performance Budgets](#performance-budgets)
4. [Lighthouse Integration](#lighthouse-integration)
5. [Performance Fixtures](#performance-fixtures)
6. [CI Performance Monitoring](#ci-performance-monitoring)
## Core Web Vitals
### Measure LCP, FID, CLS
```typescript
test("core web vitals within thresholds", async ({ page }) => {
// Inject web-vitals library
await page.addInitScript(() => {
(window as any).__webVitals = {};
// Simplified web vitals collection
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === "largest-contentful-paint") {
(window as any).__webVitals.lcp = entry.startTime;
}
}
}).observe({ type: "largest-contentful-paint", buffered: true });
new PerformanceObserver((list) => {
let cls = 0;
for (const entry of list.getEntries() as any[]) {
if (!entry.hadRecentInput) {
cls += entry.value;
}
}
(window as any).__webVitals.cls = cls;
}).observe({ type: "layout-shift", buffered: true });
});
await page.goto("/");
// Wait for page to stabilize
await page.waitForLoadState("networkidle");
// Get metrics
const vitals = await page.evaluate(() => (window as any).__webVitals);
// Assert thresholds (Google's "good" thresholds)
expect(vitals.lcp).toBeLessThan(2500); // LCP < 2.5s
expect(vitals.cls).toBeLessThan(0.1); // CLS < 0.1
});
```
### Using web-vitals Library
```typescript
test("web vitals with library", async ({ page }) => {
await page.addInitScript(() => {
(window as any).__vitals = {};
});
// Inject web-vitals after navigation
await page.goto("/");
await page.addScriptTag({
url: "https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js",
});
await page.evaluate(() => {
const { onLCP, onFID, onCLS, onFCP, onTTFB } = (window as any).webVitals;
onLCP((metric: any) => ((window as any).__vitals.lcp = metric.value));
onFID((metric: any) => ((window as any).__vitals.fid = metric.value));
onCLS((metric: any) => ((window as any).__vitals.cls = metric.value));
onFCP((metric: any) => ((window as any).__vitals.fcp = metric.value));
onTTFB((metric: any) => ((window as any).__vitals.ttfb = metric.value));
});
// Trigger FID by clicking
await page.getByRole("button").first().click();
// Wait and collect
await page.waitForTimeout(1000);
const vitals = await page.evaluate(() => (window as any).__vitals);
console.log("Web Vitals:", vitals);
// Assertions
if (vitals.lcp) expect(vitals.lcp).toBeLessThan(2500);
if (vitals.fid) expect(vitals.fid).toBeLessThan(100);
if (vitals.cls) expect(vitals.cls).toBeLessThan(0.1);
});
```
## Performance Metrics
### Navigation Timing
```typescript
test("page load performance", async ({ page }) => {
await page.goto("/");
const timing = await page.evaluate(() => {
const nav = performance.getEntriesByType(
"navigation",
)[0] as PerformanceNavigationTiming;
return {
// Time to First Byte
ttfb: nav.responseStart - nav.requestStart,
// DOM Content Loaded
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
// Full page load
loadComplete: nav.loadEventEnd - nav.startTime,
// DNS lookup
dns: nav.domainLookupEnd - nav.domainLookupStart,
// Connection time
connection: nav.connectEnd - nav.connectStart,
// Download time
download: nav.responseEnd - nav.responseStart,
// DOM processing
domProcessing: nav.domComplete - nav.domInteractive,
};
});
console.log("Performance timing:", timing);
// Assertions
expect(timing.ttfb).toBeLessThan(600); // TTFB < 600ms
expect(timing.domContentLoaded).toBeLessThan(2000); // DCL < 2s
expect(timing.loadComplete).toBeLessThan(4000); // Load < 4s
});
```
### Resource Timing
```typescript
test("resource loading performance", async ({ page }) => {
await page.goto("/");
const resources = await page.evaluate(() => {
return performance.getEntriesByType("resource").map((entry) => ({
name: entry.name.split("/").pop(),
type: (entry as PerformanceResourceTiming).initiatorType,
duration: entry.duration,
size: (entry as PerformanceResourceTiming).transferSize,
}));
});
// Find slow resources
const slowResources = resources.filter((r) => r.duration > 1000);
if (slowResources.length > 0) {
console.warn("Slow resources:", slowResources);
}
// Find large resources
const largeResources = resources.filter((r) => r.size > 500000); // > 500KB
expect(largeResources.length).toBe(0);
});
```
### Memory Usage
```typescript
test("memory usage is reasonable", async ({ page }) => {
await page.goto("/dashboard");
// Check memory (Chrome only)
const memory = await page.evaluate(() => {
if ((performance as any).memory) {
return {
usedJSHeapSize: (performance as any).memory.usedJSHeapSize,
totalJSHeapSize: (performance as any).memory.totalJSHeapSize,
};
}
return null;
});
if (memory) {
const usedMB = memory.usedJSHeapSize / 1024 / 1024;
console.log(`Memory usage: ${usedMB.toFixed(2)} MB`);
// Assert reasonable memory usage
expect(usedMB).toBeLessThan(100); // < 100MB
}
});
```
## Performance Budgets
### Define Budgets
```typescript
// performance-budgets.ts
export const budgets = {
homepage: {
lcp: 2500,
cls: 0.1,
fcp: 1800,
ttfb: 600,
totalSize: 1500000, // 1.5MB
jsSize: 500000, // 500KB
imageCount: 20,
},
dashboard: {
lcp: 3000,
cls: 0.1,
fcp: 2000,
ttfb: 800,
totalSize: 2000000,
jsSize: 800000,
},
};
```
### Test Against Budgets
```typescript
import { budgets } from "./performance-budgets";
test("homepage meets performance budget", async ({ page }) => {
const budget = budgets.homepage;
await page.goto("/");
await page.waitForLoadState("networkidle");
// Measure 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 });
});
});
// Measure resources
const resources = await page.evaluate(() => {
const entries = performance.getEntriesByType(
"resource",
) as PerformanceResourceTiming[];
return {
totalSize: entries.reduce((sum, e) => sum + (e.transferSize || 0), 0),
jsSize: entries
.filter((e) => e.initiatorType === "script")
.reduce((sum, e) => sum + (e.transferSize || 0), 0),
imageCount: entries.filter((e) => e.initiatorType === "img").length,
};
});
// Assert budgets
expect(lcp, "LCP exceeds budget").toBeLessThan(budget.lcp);
expect(resources.totalSize, "Total size exceeds budget").toBeLessThan(
budget.totalSize,
);
expect(resources.jsSize, "JS size exceeds budget").toBeLessThan(
budget.jsSize,
);
expect(resources.imageCount, "Too many images").toBeLessThanOrEqual(
budget.imageCount,
);
});
```
### Budget Fixture
```typescript
// fixtures/performance.fixture.ts
type PerformanceBudget = {
lcp?: number;
cls?: number;
ttfb?: number;
totalSize?: number;
};
type PerformanceFixtures = {
assertBudget: (budget: PerformanceBudget) => Promise<void>;
};
export const test = base.extend<PerformanceFixtures>({
assertBudget: async ({ page }, use) => {
await use(async (budget) => {
const metrics = await page.evaluate(() => {
const nav = performance.getEntriesByType(
"navigation",
)[0] as PerformanceNavigationTiming;
const resources = performance.getEntriesByType(
"resource",
) as PerformanceResourceTiming[];
return {
ttfb: nav.responseStart - nav.requestStart,
totalSize: resources.reduce(
(sum, r) => sum + (r.transferSize || 0),
0,
),
};
});
if (budget.ttfb) {
expect(
metrics.ttfb,
`TTFB ${metrics.ttfb}ms exceeds budget ${budget.ttfb}ms`,
).toBeLessThan(budget.ttfb);
}
if (budget.totalSize) {
expect(metrics.totalSize, `Total size exceeds budget`).toBeLessThan(
budget.totalSize,
);
}
});
},
});
```
## Lighthouse Integration
### Using playwright-lighthouse
```bash
npm install -D playwright-lighthouse lighthouse
```
```typescript
import { playAudit } from "playwright-lighthouse";
test("lighthouse audit", async ({ page }) => {
await page.goto("/");
// Run Lighthouse
const audit = await playAudit({
page,
port: 9222, // Chrome debugging port
thresholds: {
performance: 80,
accessibility: 90,
"best-practices": 80,
seo: 80,
},
});
// Assertions
expect(audit.lhr.categories.performance.score * 100).toBeGreaterThanOrEqual(
80,
);
expect(audit.lhr.categories.accessibility.score * 100).toBeGreaterThanOrEqual(
90,
);
});
```
### Lighthouse with Config
```typescript
test("lighthouse with custom config", async ({ page }, testInfo) => {
await page.goto("/");
const audit = await playAudit({
page,
port: 9222,
thresholds: {
performance: 70,
},
config: {
extends: "lighthouse:default",
settings: {
onlyCategories: ["performance"],
throttling: {
rttMs: 40,
throughputKbps: 10240,
cpuSlowdownMultiplier: 1,
},
},
},
});
// Save report
const reportPath = testInfo.outputPath("lighthouse-report.html");
// Save audit.report to file
// Attach to test report
await testInfo.attach("lighthouse", {
body: JSON.stringify(audit.lhr),
contentType: "application/json",
});
});
```
## CI Performance Monitoring
### Track Performance Over Time
```typescript
// reporters/perf-reporter.ts
import { Reporter, TestResult } from "@playwright/test/reporter";
class PerfReporter implements Reporter {
private metrics: any[] = [];
onTestEnd(test: any, result: TestResult) {
const perfAnnotation = test.annotations.find(
(a: any) => a.type === "performance",
);
if (perfAnnotation) {
this.metrics.push({
test: test.title,
...JSON.parse(perfAnnotation.description),
timestamp: new Date().toISOString(),
});
}
}
async onEnd() {
// Send to metrics service
if (process.env.METRICS_ENDPOINT) {
await fetch(process.env.METRICS_ENDPOINT, {
method: "POST",
body: JSON.stringify({
commit: process.env.GITHUB_SHA,
branch: process.env.GITHUB_REF,
metrics: this.metrics,
}),
});
}
}
}
export default PerfReporter;
```
### Performance Regression Detection
```typescript
test("no performance regression", async ({ page }) => {
await page.goto("/");
const metrics = await page.evaluate(() => {
const nav = performance.getEntriesByType(
"navigation",
)[0] as PerformanceNavigationTiming;
return {
loadTime: nav.loadEventEnd - nav.startTime,
};
});
// Compare against baseline (could be from file or API)
const baseline = 2000; // ms
const threshold = 1.1; // 10% regression allowed
expect(
metrics.loadTime,
`Load time ${metrics.loadTime}ms is ${((metrics.loadTime / baseline - 1) * 100).toFixed(1)}% slower than baseline`,
).toBeLessThan(baseline * threshold);
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| --------------------------- | ------------------------- | -------------------------------- |
| Testing only once | Results vary | Run multiple times, use averages |
| Ignoring network conditions | Unrealistic results | Test with throttling |
| No baseline comparison | Can't detect regressions | Track metrics over time |
| Testing in dev mode | Slow, not production-like | Test production builds |
## Related References
- **Performance Optimization**: See [performance.md](../infrastructure-ci-cd/performance.md) for test execution performance
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI integration

View file

@ -0,0 +1,430 @@
# Security Testing Basics
## Table of Contents
1. [XSS Prevention](#xss-prevention)
2. [CSRF Protection](#csrf-protection)
3. [Authentication Security](#authentication-security)
4. [Authorization Testing](#authorization-testing)
5. [Input Validation](#input-validation)
6. [Security Headers](#security-headers)
## XSS Prevention
### Test Reflected XSS
```typescript
test("input is properly escaped", async ({ page }) => {
const xssPayloads = [
'<script>alert("xss")</script>',
'<img src="x" onerror="alert(1)">',
'"><script>alert(1)</script>',
"javascript:alert(1)",
'<svg onload="alert(1)">',
];
for (const payload of xssPayloads) {
await page.goto(`/search?q=${encodeURIComponent(payload)}`);
// Verify script didn't execute
const alertTriggered = await page.evaluate(() => {
return (window as any).__xssTriggered === true;
});
expect(alertTriggered).toBe(false);
// Verify payload is escaped in HTML
const content = await page.content();
expect(content).not.toContain("<script>alert");
expect(content).not.toContain("onerror=");
}
});
```
### Test Stored XSS
```typescript
test("user content is sanitized", async ({ page }) => {
await page.goto("/create-post");
// Try to inject script via form
await page.getByLabel("Content").fill('<script>alert("xss")</script>Hello');
await page.getByRole("button", { name: "Submit" }).click();
// View the post
await page.goto("/posts/latest");
// Script should not be in page
const scripts = await page.locator("script").count();
const pageContent = await page.content();
// The script tag should be escaped or removed
expect(pageContent).not.toContain("<script>alert");
// Text should still be visible (just sanitized)
await expect(page.getByText("Hello")).toBeVisible();
});
```
### Monitor for XSS Execution
```typescript
test("no XSS execution", async ({ page }) => {
// Set up XSS detection
await page.addInitScript(() => {
(window as any).__xssDetected = false;
// Override alert/confirm/prompt
window.alert = () => {
(window as any).__xssDetected = true;
};
window.confirm = () => {
(window as any).__xssDetected = true;
return false;
};
window.prompt = () => {
(window as any).__xssDetected = true;
return null;
};
});
// Perform test actions
await page.goto("/vulnerable-page");
await page.getByLabel("Search").fill('"><img src=x onerror=alert(1)>');
await page.getByLabel("Search").press("Enter");
// Check if XSS triggered
const xssDetected = await page.evaluate(() => (window as any).__xssDetected);
expect(xssDetected).toBe(false);
});
```
## CSRF Protection
### Verify CSRF Token Present
```typescript
test("forms include CSRF token", async ({ page }) => {
await page.goto("/settings");
// Check form has CSRF token
const csrfInput = page.locator(
'input[name="_csrf"], input[name="csrf_token"]',
);
await expect(csrfInput).toBeAttached();
const csrfValue = await csrfInput.getAttribute("value");
expect(csrfValue).toBeTruthy();
expect(csrfValue!.length).toBeGreaterThan(20);
});
```
### Test CSRF Token Validation
```typescript
test("rejects requests without CSRF token", async ({ page, request }) => {
await page.goto("/settings");
// Try to submit without CSRF token
const response = await request.post("/api/settings", {
data: { theme: "dark" },
headers: {
"Content-Type": "application/json",
},
});
// Should be rejected
expect(response.status()).toBe(403);
});
test("rejects requests with invalid CSRF token", async ({ page, request }) => {
await page.goto("/settings");
const response = await request.post("/api/settings", {
data: { theme: "dark" },
headers: {
"X-CSRF-Token": "invalid-token",
},
});
expect(response.status()).toBe(403);
});
```
### Test CSRF with Valid Token
```typescript
test("accepts requests with valid CSRF token", async ({ page }) => {
await page.goto("/settings");
// Get CSRF token from page
const csrfToken = await page
.locator('meta[name="csrf-token"]')
.getAttribute("content");
// Submit form normally
await page.getByLabel("Theme").selectOption("dark");
await page.getByRole("button", { name: "Save" }).click();
// Should succeed
await expect(page.getByText("Settings saved")).toBeVisible();
});
```
## Authentication Security
### Test Session Expiry
```typescript
test("session expires after timeout", async ({ page, context }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("user@example.com");
await page.getByLabel("Password").fill("password");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
// Simulate time passing (if using clock mocking)
await page.clock.fastForward("02:00:00"); // 2 hours
// Try to access protected page
await page.goto("/profile");
// Should redirect to login
await expect(page).toHaveURL(/\/login/);
await expect(page.getByText("Session expired")).toBeVisible();
});
```
### Test Concurrent Sessions
```typescript
test("handles concurrent session limit", async ({ browser }) => {
// Login from first browser
const context1 = await browser.newContext();
const page1 = await context1.newPage();
await page1.goto("/login");
await page1.getByLabel("Email").fill("user@example.com");
await page1.getByLabel("Password").fill("password");
await page1.getByRole("button", { name: "Sign in" }).click();
await expect(page1).toHaveURL("/dashboard");
// Login from second browser (same user)
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await page2.goto("/login");
await page2.getByLabel("Email").fill("user@example.com");
await page2.getByLabel("Password").fill("password");
await page2.getByRole("button", { name: "Sign in" }).click();
// First session should be invalidated (or warning shown)
await page1.reload();
await expect(
page1.getByText(/session.*another device|logged out/i),
).toBeVisible();
await context1.close();
await context2.close();
});
```
### Test Password Reset Security
```typescript
test("password reset token is single-use", async ({ page, request }) => {
// Request password reset
await page.goto("/forgot-password");
await page.getByLabel("Email").fill("user@example.com");
await page.getByRole("button", { name: "Reset" }).click();
// Get token (in test env, might be exposed or use email mock)
const resetToken = "mock-reset-token";
// Use token first time
await page.goto(`/reset-password?token=${resetToken}`);
await page.getByLabel("New Password").fill("NewPassword123");
await page.getByRole("button", { name: "Reset" }).click();
await expect(page.getByText("Password updated")).toBeVisible();
// Try to use same token again
await page.goto(`/reset-password?token=${resetToken}`);
await expect(page.getByText("Invalid or expired token")).toBeVisible();
});
```
## Authorization Testing
### Test Unauthorized Access
```typescript
test.describe("authorization", () => {
test("cannot access admin routes as user", async ({ browser }) => {
const context = await browser.newContext({
storageState: ".auth/user.json", // Regular user
});
const page = await context.newPage();
// Try to access admin page
await page.goto("/admin/users");
// Should be denied
await expect(page).not.toHaveURL("/admin/users");
expect(
(await page.getByText("Access denied").isVisible()) ||
(await page.url()).includes("/login") ||
(await page.url()).includes("/403"),
).toBe(true);
await context.close();
});
test("cannot access other user's data", async ({ page }) => {
// Logged in as user 1, try to access user 2's profile
await page.goto("/users/other-user-id/settings");
await expect(page.getByText("Access denied")).toBeVisible();
});
});
```
### Test IDOR (Insecure Direct Object Reference)
```typescript
test("cannot access other user resources by changing ID", async ({
page,
request,
}) => {
// Get current user's order
await page.goto("/orders/my-order-123");
await expect(page.getByText("Order #my-order-123")).toBeVisible();
// Try to access another user's order
const response = await request.get("/api/orders/other-user-order-456");
// Should be forbidden
expect(response.status()).toBe(403);
});
```
## Input Validation
### Test SQL Injection Prevention
```typescript
test("SQL injection is prevented", async ({ page }) => {
const sqlPayloads = [
"'; DROP TABLE users; --",
"1' OR '1'='1",
"1; DELETE FROM orders",
"' UNION SELECT * FROM users --",
];
for (const payload of sqlPayloads) {
await page.goto("/search");
await page.getByLabel("Search").fill(payload);
await page.getByRole("button", { name: "Search" }).click();
// Should not error (injection blocked/escaped)
await expect(page.getByText("Error")).not.toBeVisible();
// Should show no results or escaped text
const hasError = await page
.getByText(/database error|sql|syntax/i)
.isVisible();
expect(hasError).toBe(false);
}
});
```
### Test Input Length Limits
```typescript
test("enforces input length limits", async ({ page }) => {
await page.goto("/profile");
// Try to submit very long input
const longString = "a".repeat(10000);
await page.getByLabel("Bio").fill(longString);
await page.getByRole("button", { name: "Save" }).click();
// Should show validation error or truncate
const bioValue = await page.getByLabel("Bio").inputValue();
expect(bioValue.length).toBeLessThanOrEqual(500); // Expected max
});
```
## Security Headers
### Verify Security Headers
```typescript
test("response includes security headers", async ({ page }) => {
const response = await page.goto("/");
const headers = response!.headers();
// Content Security Policy
expect(headers["content-security-policy"]).toBeTruthy();
// Prevent clickjacking
expect(headers["x-frame-options"]).toMatch(/DENY|SAMEORIGIN/);
// Prevent MIME type sniffing
expect(headers["x-content-type-options"]).toBe("nosniff");
// XSS Protection (legacy but good to have)
expect(headers["x-xss-protection"]).toBeTruthy();
// HTTPS enforcement
if (!page.url().includes("localhost")) {
expect(headers["strict-transport-security"]).toBeTruthy();
}
});
```
### Test CSP Violations
```typescript
test("CSP blocks inline scripts", async ({ page }) => {
const cspViolations: string[] = [];
// Listen for CSP violations via console
page.on("console", (msg) => {
if (msg.text().includes("Content Security Policy")) {
cspViolations.push(msg.text());
}
});
await page.goto("/");
// Try to inject inline script - CSP should block it
await page.evaluate(() => {
const script = document.createElement("script");
script.textContent = 'console.log("injected")';
document.body.appendChild(script);
});
expect(cspViolations.length).toBeGreaterThan(0);
});
```
> **For comprehensive console monitoring** (fixtures, allowed patterns, fail on errors), see [console-errors.md](../debugging/console-errors.md).
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| -------------------------- | --------------------- | ----------------------------- |
| Testing only happy path | Misses security holes | Test malicious inputs |
| Hardcoded test credentials | Security risk | Use environment variables |
| Skipping auth tests in dev | Bugs reach production | Test auth in all environments |
| Not testing authorization | Access control bugs | Test all role combinations |
## Related References
- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth fixtures
- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for role-based testing
- **Error Testing**: See [error-testing.md](../debugging/error-testing.md) for validation testing

View file

@ -0,0 +1,634 @@
# Visual Regression Testing
## Table of Contents
1. [Quick Reference](#quick-reference)
2. [Patterns](#patterns)
3. [Decision Guide](#decision-guide)
4. [Anti-Patterns](#anti-patterns)
5. [Troubleshooting](#troubleshooting)
> **When to use**: Detecting unintended visual changes—layout shifts, style regressions, broken responsive designs—that functional assertions miss.
## Quick Reference
```typescript
// Element screenshot
await expect(page.getByTestId('product-card')).toHaveScreenshot();
// Full page screenshot
await expect(page).toHaveScreenshot('landing-hero.png');
// Threshold for minor pixel variance
await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.01 });
// Mask volatile content
await expect(page).toHaveScreenshot({
mask: [page.getByTestId('clock'), page.getByRole('img', { name: 'User photo' })],
});
// Disable CSS animations
await expect(page).toHaveScreenshot({ animations: 'disabled' });
// Update baselines
npx playwright test --update-snapshots
```
## Patterns
### Masking Volatile Content
**Use when**: Page contains timestamps, avatars, ad slots, relative dates, random images, or A/B variants.
The `mask` option overlays a solid box over specified locators before capturing.
```typescript
test('analytics panel with masked dynamic elements', async ({ page }) => {
await page.goto('/analytics');
await expect(page).toHaveScreenshot('analytics.png', {
mask: [
page.getByTestId('last-updated'),
page.getByTestId('profile-avatar'),
page.getByTestId('active-users'),
page.locator('.promo-banner'),
],
maskColor: '#FF00FF',
});
});
test('activity stream with relative times', async ({ page }) => {
await page.goto('/activity');
await expect(page).toHaveScreenshot('activity.png', {
mask: [page.locator('time[datetime]')],
});
});
```
**Alternative: freeze content with JavaScript** when masking affects layout:
```typescript
test('freeze timestamps before capture', async ({ page }) => {
await page.goto('/analytics');
await page.evaluate(() => {
document.querySelectorAll('[data-testid="time-display"]').forEach((el) => {
el.textContent = 'Jan 1, 2025 12:00 PM';
});
});
await expect(page).toHaveScreenshot('analytics-frozen.png');
});
```
### Disabling Animations
**Use when**: Always. CSS animations and transitions are the primary cause of flaky visual diffs.
```typescript
test('renders without animation interference', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png', {
animations: 'disabled',
});
});
```
**Set globally** in config:
```typescript
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
animations: 'disabled',
},
},
});
```
When `animations: 'disabled'` is set, Playwright injects CSS forcing animation/transition duration to 0s, waits for running animations to finish, then captures.
For JavaScript-driven animations (GSAP, Framer Motion), wait for stability:
```typescript
test('page with JS animations', async ({ page }) => {
await page.goto('/animated-hero');
const heroBanner = page.getByTestId('hero-banner');
await heroBanner.waitFor({ state: 'visible' });
// Wait for animation to complete by checking for stable state
await expect(heroBanner).not.toHaveClass(/animating/);
await expect(page).toHaveScreenshot('hero.png', {
animations: 'disabled',
});
});
```
### Configuring Thresholds
**Use when**: Minor rendering differences from anti-aliasing, font hinting, or sub-pixel rendering cause false failures.
| Option | Controls | Typical Value |
|---|---|---|
| `maxDiffPixels` | Absolute pixel count that can differ | `100` for pages, `10` for components |
| `maxDiffPixelRatio` | Fraction of total pixels (0-1) | `0.01` (1%) for pages |
| `threshold` | Per-pixel color tolerance (0-1) | `0.2` for most UIs, `0.1` for design systems |
```typescript
test('control panel allows minor variance', async ({ page }) => {
await page.goto('/control-panel');
await expect(page).toHaveScreenshot('control-panel.png', {
maxDiffPixelRatio: 0.01,
});
});
test('brand logo renders pixel-perfect', async ({ page }) => {
await page.goto('/brand');
await expect(page.getByTestId('brand-logo')).toHaveScreenshot('brand-logo.png', {
maxDiffPixels: 0,
threshold: 0,
});
});
test('graph allows anti-aliasing differences', async ({ page }) => {
await page.goto('/reports');
await expect(page.getByTestId('sales-graph')).toHaveScreenshot('sales-graph.png', {
threshold: 0.3,
maxDiffPixels: 200,
});
});
```
**Global thresholds** in config:
```typescript
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
threshold: 0.2,
animations: 'disabled',
},
},
});
```
### CI Configuration
**Use when**: Running visual tests in CI. Consistent rendering is critical—the same test must produce identical screenshots every time.
**The problem**: Font rendering and anti-aliasing differ across operating systems. macOS snapshots won't match Linux.
**The solution**: Run visual tests in Docker using the official Playwright container. Generate and update snapshots from the same container.
**GitHub Actions with Docker**
```yaml
# .github/workflows/visual-tests.yml
name: Visual Regression Tests
on: [push, pull_request]
jobs:
visual-tests:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.48.0-noble
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- run: npm ci
- name: Run visual tests
run: npx playwright test --project=visual
env:
HOME: /root
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-test-report
path: playwright-report/
retention-days: 14
```
**Updating snapshots locally using Docker**:
```bash
docker run --rm -v $(pwd):/work -w /work \
mcr.microsoft.com/playwright:v1.48.0-noble \
npx playwright test --update-snapshots --project=visual
```
**Add script to `package.json`**:
```json
{
"scripts": {
"test:visual": "npx playwright test --project=visual",
"test:visual:update": "docker run --rm -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.48.0-noble npx playwright test --update-snapshots --project=visual"
}
}
```
**Platform-agnostic snapshots** (requires Docker for generation):
```typescript
// playwright.config.ts
export default defineConfig({
snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}',
projects: [
{
name: 'visual',
testMatch: '**/*.visual.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
],
});
```
### Full Page vs Element Screenshots
**Use when**: Deciding scope. Full page catches layout shifts. Element screenshots isolate components and are more stable.
```typescript
test('full page captures layout shifts', async ({ page }) => {
await page.goto('/');
// Visible viewport
await expect(page).toHaveScreenshot('home-viewport.png');
// Entire scrollable page
await expect(page).toHaveScreenshot('home-full.png', {
fullPage: true,
});
});
test('element screenshot isolates component', async ({ page }) => {
await page.goto('/catalog');
await expect(page.getByRole('table')).toHaveScreenshot('catalog-table.png');
await expect(page.getByTestId('featured-item')).toHaveScreenshot('featured-item.png');
});
```
**Rule of thumb**: Element screenshots for independently changing components. Full page screenshots for key layouts where spacing matters.
### Responsive Visual Testing
**Use when**: Application has responsive breakpoints requiring verification at different viewport sizes.
```typescript
const breakpoints = [
{ name: 'phone', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1440, height: 900 },
];
for (const bp of breakpoints) {
test(`landing at ${bp.name} (${bp.width}x${bp.height})`, async ({ page }) => {
await page.setViewportSize({ width: bp.width, height: bp.height });
await page.goto('/');
await expect(page).toHaveScreenshot(`landing-${bp.name}.png`, {
animations: 'disabled',
fullPage: true,
});
});
}
```
**Alternative: use projects for responsive testing**:
```typescript
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'desktop',
testMatch: '**/*.visual.spec.ts',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
name: 'tablet',
testMatch: '**/*.visual.spec.ts',
use: { ...devices['iPad (gen 7)'] },
},
{
name: 'mobile',
testMatch: '**/*.visual.spec.ts',
use: { ...devices['iPhone 14'] },
},
],
});
```
### Component Visual Testing
**Use when**: Testing individual UI components in isolation—buttons, cards, forms, modals. Faster and more stable than full-page screenshots.
```typescript
test.describe('Button visual states', () => {
test('primary button', async ({ page }) => {
await page.goto('/storybook/iframe.html?id=button--primary');
const btn = page.getByRole('button');
await expect(btn).toHaveScreenshot('btn-primary.png', {
animations: 'disabled',
});
});
test('primary button hover', async ({ page }) => {
await page.goto('/storybook/iframe.html?id=button--primary');
const btn = page.getByRole('button');
await btn.hover();
await expect(btn).toHaveScreenshot('btn-primary-hover.png', {
animations: 'disabled',
});
});
test('button sizes', async ({ page }) => {
for (const size of ['small', 'medium', 'large']) {
await page.goto(`/storybook/iframe.html?id=button--${size}`);
const btn = page.getByRole('button');
await expect(btn).toHaveScreenshot(`btn-${size}.png`, {
animations: 'disabled',
});
}
});
});
```
**Using a dedicated test harness** instead of Storybook:
```typescript
test.describe('Card component', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/test-harness/card');
});
test('default state', async ({ page }) => {
await expect(page.getByTestId('card')).toHaveScreenshot('card-default.png', {
animations: 'disabled',
});
});
test('truncates long content', async ({ page }) => {
await page.goto('/test-harness/card?content=long');
await expect(page.getByTestId('card')).toHaveScreenshot('card-long.png', {
animations: 'disabled',
});
});
});
```
### Updating Snapshots
**Use when**: Intentionally changed UI—design refresh, rebrand, new feature. Never update when diff is unexpected.
```bash
# Update all snapshots
npx playwright test --update-snapshots
# Update for specific file
npx playwright test tests/landing.spec.ts --update-snapshots
# Update for specific project
npx playwright test --project=chromium --update-snapshots
```
**Workflow for reviewing changes:**
1. Run tests and view failures in HTML report:
```bash
npx playwright test
npx playwright show-report
```
The report shows expected, actual, and diff images side-by-side.
2. If changes are intentional, update:
```bash
npx playwright test --update-snapshots
```
3. Review updated snapshots before committing:
```bash
git diff --name-only
```
**Tag visual tests for selective updates:**
```typescript
test('landing visual @visual', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('landing.png', {
animations: 'disabled',
});
});
```
```bash
npx playwright test --grep @visual --update-snapshots
```
### Cross-Browser Visual Testing
**Use when**: Users span Chrome, Firefox, Safari and you need per-browser rendering verification.
Playwright separates snapshots by project name automatically. Each browser gets its own baseline—browsers render fonts and shadows differently.
```typescript
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
animations: 'disabled',
maxDiffPixelRatio: 0.01,
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
```
**Strategy**: Run visual tests in a single browser (Chromium on Linux in CI) to minimize snapshot count. Add other browsers only when you have actual cross-browser rendering bugs:
```typescript
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'visual',
testMatch: '**/*.visual.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'chromium',
testIgnore: '**/*.visual.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
testIgnore: '**/*.visual.spec.ts',
use: { ...devices['Desktop Firefox'] },
},
],
});
```
## Decision Guide
| Scenario | Approach | Rationale |
|---|---|---|
| Key landing/marketing pages | Full page, `fullPage: true` | Catches layout shifts, spacing, overall harmony |
| Individual components | Element screenshot | Isolated, fast, immune to unrelated changes |
| Page with dynamic content | Full page + `mask` | Covers layout while ignoring volatile content |
| Design system library | Element per variant, zero threshold | Pixel-perfect enforcement |
| Responsive verification | Screenshot per viewport | Catches breakpoint bugs |
| Cross-browser consistency | Separate snapshots per browser | Browsers render differently |
| CI pipeline | Docker container, Linux-only snapshots | Consistent rendering |
| Threshold: design system | `threshold: 0`, `maxDiffPixels: 0` | Zero tolerance |
| Threshold: content pages | `maxDiffPixelRatio: 0.01`, `threshold: 0.2` | Minor anti-aliasing variance |
| Threshold: charts/graphs | `maxDiffPixels: 200`, `threshold: 0.3` | Anti-aliasing on curves varies |
## Anti-Patterns
| Don't | Problem | Do Instead |
|---|---|---|
| Visual test every page | Massive maintenance, constant false failures | Pick 5-10 key pages and critical components |
| Skip masking dynamic content | Screenshots differ every run, permanently flaky | Use `mask` for all volatile elements |
| Run across macOS, Linux, Windows | Font rendering differs, snapshots never match | Standardize on Linux via Docker |
| Skip Docker in CI | OS updates shift rendering silently | Pin specific Playwright Docker image |
| Blindly run `--update-snapshots` | Accepts unintentional regressions | Always review diff in HTML report first |
| Skip `animations: 'disabled'` | CSS transitions create random diffs | Set globally in config |
| Replace functional assertions with visual tests | Diffs don't tell you *what* broke | Visual tests complement, never replace |
| Commit snapshots from different platforms | Tests fail for everyone | All team members use same Docker container |
| Set threshold too high (`0.1`) | 10% pixel change passes, defeats purpose | Start with `0.01`, adjust per-test |
| Full page on infinite scroll pages | Page height nondeterministic | Element screenshots on above-the-fold content |
## Troubleshooting
### "Screenshot comparison failed" on first CI run after local development
**Cause**: Snapshots generated on macOS locally. CI runs on Linux. Font rendering differs.
**Fix**: Generate snapshots using Docker:
```bash
docker run --rm -v $(pwd):/work -w /work \
mcr.microsoft.com/playwright:v1.48.0-noble \
npx playwright test --update-snapshots --project=visual
```
Commit Linux-generated snapshots.
### "Expected screenshot to match but X pixels differ"
**Cause**: Anti-aliasing, font hinting, sub-pixel rendering differences.
**Fix**: Add tolerance:
```typescript
await expect(page).toHaveScreenshot('page.png', {
maxDiffPixelRatio: 0.01,
threshold: 0.2,
});
```
Check HTML report diff image to determine if it's regression or noise.
### Visual tests pass locally but fail in CI (even with Docker)
**Cause**: Different Playwright versions locally vs CI.
**Fix**: Ensure `package.json` version matches Docker image tag:
```json
{
"devDependencies": {
"@playwright/test": "latest"
}
}
```
```yaml
container:
image: mcr.microsoft.com/playwright:v1.48.0-noble
```
### Animations cause random diff failures
**Cause**: CSS animations captured mid-frame.
**Fix**: Set `animations: 'disabled'` globally:
```typescript
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
animations: 'disabled',
},
},
});
```
For JS animations, wait for stable state before capture.
### Snapshot file names conflict between tests
**Cause**: Two tests use same screenshot name without unique paths.
**Fix**: Use explicit unique names:
```typescript
await expect(page).toHaveScreenshot('auth-home.png');
await expect(page).toHaveScreenshot('public-home.png');
```
Or customize snapshot path template:
```typescript
export default defineConfig({
snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}',
});
```
### Too many snapshot files to maintain
**Cause**: Visual tests for every page, browser, viewport.
**Fix**: Be selective. Visual test only high-risk pages:
- Landing and marketing pages
- Design system components
- Complex layouts (dashboards, data tables)
- Pages after major refactor
Skip pages where functional assertions cover key elements.