mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 01:32:40 +02:00
chore: add playwright cursor skill
This commit is contained in:
parent
25aad38ca4
commit
d52225c18d
57 changed files with 25244 additions and 0 deletions
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
576
.cursor/skills/playwright-testing/testing-patterns/drag-drop.md
Normal file
576
.cursor/skills/playwright-testing/testing-patterns/drag-drop.md
Normal 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.
|
||||
509
.cursor/skills/playwright-testing/testing-patterns/electron.md
Normal file
509
.cursor/skills/playwright-testing/testing-patterns/electron.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
```
|
||||
|
|
@ -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" },
|
||||
},
|
||||
});
|
||||
```
|
||||
508
.cursor/skills/playwright-testing/testing-patterns/i18n.md
Normal file
508
.cursor/skills/playwright-testing/testing-patterns/i18n.md
Normal 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}¤cy=${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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue