mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +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,360 @@
|
|||
# Complex Authentication Flow Patterns
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Email Verification Flows](#email-verification-flows)
|
||||
2. [Password Reset](#password-reset)
|
||||
3. [Session Timeout](#session-timeout)
|
||||
4. [Remember Me Persistence](#remember-me-persistence)
|
||||
5. [Logout Patterns](#logout-patterns)
|
||||
6. [Tips](#tips)
|
||||
7. [Related](#related)
|
||||
|
||||
> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md).
|
||||
|
||||
---
|
||||
|
||||
## Email Verification Flows
|
||||
|
||||
### Capturing Verification Tokens
|
||||
|
||||
Intercept API responses to capture verification tokens for testing:
|
||||
|
||||
```typescript
|
||||
test('completes registration with email verification', async ({ page }) => {
|
||||
let capturedToken = '';
|
||||
|
||||
await page.route('**/api/auth/register', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
capturedToken = body.verificationToken;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
await page.goto('/register');
|
||||
await page.getByLabel('Name').fill('New User');
|
||||
await page.getByLabel('Email').fill('newuser@test.com');
|
||||
await page.getByLabel('Password', { exact: true }).fill('SecurePass!');
|
||||
await page.getByLabel('Confirm password').fill('SecurePass!');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
|
||||
await expect(page.getByText('Check your inbox')).toBeVisible();
|
||||
|
||||
expect(capturedToken).toBeTruthy();
|
||||
await page.goto(`/verify?token=${capturedToken}`);
|
||||
|
||||
await expect(page.getByText('Email confirmed')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Fully Mocked Verification
|
||||
|
||||
```typescript
|
||||
test('verifies email with mocked endpoints', async ({ page }) => {
|
||||
const mockToken = 'test-verification-abc123';
|
||||
|
||||
await page.route('**/api/auth/register', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Verification sent', verificationToken: mockToken }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ verified: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/register');
|
||||
await page.getByLabel('Email').fill('test@example.com');
|
||||
await page.getByLabel('Password', { exact: true }).fill('Password123!');
|
||||
await page.getByRole('button', { name: 'Sign up' }).click();
|
||||
|
||||
await expect(page.getByText('Check your inbox')).toBeVisible();
|
||||
|
||||
await page.goto(`/verify?token=${mockToken}`);
|
||||
await expect(page.getByText('Email confirmed')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Password Reset
|
||||
|
||||
### Complete Reset Flow
|
||||
|
||||
```typescript
|
||||
test('resets password through email link', async ({ page }) => {
|
||||
let resetToken = '';
|
||||
|
||||
await page.route('**/api/auth/forgot-password', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
resetToken = body.resetToken;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
await page.goto('/forgot-password');
|
||||
await page.getByLabel('Email').fill('user@test.com');
|
||||
await page.getByRole('button', { name: 'Send link' }).click();
|
||||
|
||||
await expect(page.getByText('Reset email sent')).toBeVisible();
|
||||
|
||||
expect(resetToken).toBeTruthy();
|
||||
await page.goto(`/reset-password?token=${resetToken}`);
|
||||
|
||||
await page.getByLabel('New password', { exact: true }).fill('NewPassword456!');
|
||||
await page.getByLabel('Confirm password').fill('NewPassword456!');
|
||||
await page.getByRole('button', { name: 'Update password' }).click();
|
||||
|
||||
await expect(page.getByText('Password updated')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Expired Token Handling
|
||||
|
||||
```typescript
|
||||
test('shows error for expired reset token', async ({ page }) => {
|
||||
await page.goto('/reset-password?token=expired-token');
|
||||
|
||||
await page.getByLabel('New password', { exact: true }).fill('NewPass!');
|
||||
await page.getByLabel('Confirm password').fill('NewPass!');
|
||||
await page.getByRole('button', { name: 'Update password' }).click();
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(/expired|invalid/i);
|
||||
});
|
||||
```
|
||||
|
||||
### Password Strength Validation
|
||||
|
||||
```typescript
|
||||
test('enforces password requirements on reset', async ({ page }) => {
|
||||
await page.goto('/reset-password?token=valid-token');
|
||||
|
||||
await page.getByLabel('New password', { exact: true }).fill('weak');
|
||||
await page.getByLabel('Confirm password').fill('weak');
|
||||
await page.getByRole('button', { name: 'Update password' }).click();
|
||||
|
||||
await expect(page.getByText(/at least 8 characters/i)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Timeout
|
||||
|
||||
### Detecting Expired Sessions
|
||||
|
||||
```typescript
|
||||
test('redirects to signin after session expires', async ({ page, context }) => {
|
||||
await page.goto('/signin');
|
||||
await page.getByLabel('Email').fill('user@test.com');
|
||||
await page.getByLabel('Password').fill('Password!');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page).toHaveURL('/home');
|
||||
|
||||
const cookies = await context.cookies();
|
||||
const sessionCookie = cookies.find((c) => c.name.includes('session'));
|
||||
|
||||
if (sessionCookie) {
|
||||
await context.clearCookies({ name: sessionCookie.name });
|
||||
}
|
||||
|
||||
await page.goto('/profile');
|
||||
await expect(page).toHaveURL(/\/signin/);
|
||||
await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Session Extension Warning
|
||||
|
||||
```typescript
|
||||
test('shows warning before session expires', async ({ page }) => {
|
||||
await page.route('**/api/auth/session', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ valid: true, expiresIn: 60 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/home');
|
||||
|
||||
await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Session Extension Action
|
||||
|
||||
```typescript
|
||||
test('extends session when user clicks extend', async ({ page }) => {
|
||||
let sessionExtended = false;
|
||||
|
||||
await page.route('**/api/auth/session', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ valid: true, expiresIn: 60 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/auth/refresh', async (route) => {
|
||||
sessionExtended = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ valid: true, expiresIn: 3600 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/home');
|
||||
|
||||
await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole('button', { name: /extend|stay signed in/i }).click();
|
||||
|
||||
expect(sessionExtended).toBe(true);
|
||||
await expect(page.getByText(/session.*expir/i)).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Remember Me Persistence
|
||||
|
||||
### Persistent Session
|
||||
|
||||
```typescript
|
||||
test('persists session with remember me enabled', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext();
|
||||
const page1 = await ctx1.newPage();
|
||||
|
||||
await page1.goto('/signin');
|
||||
await page1.getByLabel('Email').fill('user@test.com');
|
||||
await page1.getByLabel('Password').fill('Password!');
|
||||
await page1.getByLabel('Keep me signed in').check();
|
||||
await page1.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await expect(page1).toHaveURL('/home');
|
||||
|
||||
const state = await ctx1.storageState();
|
||||
await ctx1.close();
|
||||
|
||||
const ctx2 = await browser.newContext({ storageState: state });
|
||||
const page2 = await ctx2.newPage();
|
||||
|
||||
await page2.goto('/home');
|
||||
await expect(page2).toHaveURL('/home');
|
||||
await expect(page2.getByText('Welcome')).toBeVisible();
|
||||
|
||||
await ctx2.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Session-Only Login
|
||||
|
||||
```typescript
|
||||
test('session-only login does not persist across browser restarts', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext();
|
||||
const page1 = await ctx1.newPage();
|
||||
|
||||
await page1.goto('/signin');
|
||||
await page1.getByLabel('Email').fill('user@test.com');
|
||||
await page1.getByLabel('Password').fill('Password!');
|
||||
// Leave "Remember me" unchecked
|
||||
await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked();
|
||||
await page1.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await expect(page1).toHaveURL('/home');
|
||||
|
||||
// Only keep persistent cookies (filter out session cookies)
|
||||
const cookies = await ctx1.cookies();
|
||||
await ctx1.close();
|
||||
|
||||
const persistentCookies = cookies.filter((c) => c.expires > 0);
|
||||
const ctx2 = await browser.newContext();
|
||||
await ctx2.addCookies(persistentCookies);
|
||||
const page2 = await ctx2.newPage();
|
||||
|
||||
await page2.goto('/home');
|
||||
|
||||
// Should redirect to login since session was not persisted
|
||||
await expect(page2).toHaveURL(/\/signin/);
|
||||
|
||||
await ctx2.close();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logout Patterns
|
||||
|
||||
### Standard Logout with Session Cleanup
|
||||
|
||||
```typescript
|
||||
test.use({ storageState: '.auth/user.json' });
|
||||
|
||||
test('logs out and clears session', async ({ page, context }) => {
|
||||
await page.goto('/home');
|
||||
|
||||
await page.getByRole('button', { name: /account|menu/i }).click();
|
||||
await page.getByRole('menuitem', { name: 'Sign out' }).click();
|
||||
|
||||
await expect(page).toHaveURL('/signin');
|
||||
|
||||
const cookies = await context.cookies();
|
||||
const sessionCookies = cookies.filter((c) => c.name.includes('session') || c.name.includes('token'));
|
||||
expect(sessionCookies).toHaveLength(0);
|
||||
|
||||
await page.goto('/home');
|
||||
await expect(page).toHaveURL(/\/signin/);
|
||||
});
|
||||
```
|
||||
|
||||
### Logout from All Devices
|
||||
|
||||
```typescript
|
||||
test('logs out from all devices', async ({ page }) => {
|
||||
let logoutAllCalled = false;
|
||||
|
||||
await page.route('**/api/auth/logout-all', async (route) => {
|
||||
logoutAllCalled = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Logged out everywhere' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/settings/security');
|
||||
|
||||
await page.getByRole('button', { name: 'Sign out everywhere' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
expect(logoutAllCalled).toBe(true);
|
||||
await expect(page).toHaveURL(/\/signin/);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests
|
||||
2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens
|
||||
3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared
|
||||
4. **Test the full flow end-to-end** — Password reset should verify login with new password works
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login
|
||||
- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures
|
||||
- [third-party.md](./third-party.md) — Mocking external auth providers
|
||||
871
.cursor/skills/playwright-testing/advanced/authentication.md
Normal file
871
.cursor/skills/playwright-testing/advanced/authentication.md
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
# Authentication 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)
|
||||
6. [Related](#related)
|
||||
|
||||
> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```typescript
|
||||
// Storage state reuse — the #1 pattern for fast auth
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("testuser@example.com");
|
||||
await page.getByLabel("Password").fill("secretPass123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.context().storageState({ path: ".auth/session.json" });
|
||||
|
||||
// Reuse in config — every test starts authenticated
|
||||
{
|
||||
use: {
|
||||
storageState: ".auth/session.json"
|
||||
}
|
||||
}
|
||||
|
||||
// API login — skip the UI entirely
|
||||
const context = await browser.newContext();
|
||||
const response = await context.request.post("/api/auth/login", {
|
||||
data: { email: "testuser@example.com", password: "secretPass123" },
|
||||
});
|
||||
await context.storageState({ path: ".auth/session.json" });
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Storage State Reuse
|
||||
|
||||
**Use when**: You need authenticated tests and want to avoid logging in before every test.
|
||||
**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself.
|
||||
|
||||
`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.
|
||||
|
||||
```typescript
|
||||
// scripts/generate-auth.ts — run once to generate the state file
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
async function generateAuthState() {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("http://localhost:4000/login");
|
||||
await page.getByLabel("Username").fill("testuser@example.com");
|
||||
await page.getByLabel("Password").fill("secretPass123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.waitForURL("/home");
|
||||
|
||||
await context.storageState({ path: ".auth/session.json" });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
generateAuthState();
|
||||
```
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts — load saved state for all tests
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
use: {
|
||||
baseURL: "http://localhost:4000",
|
||||
storageState: ".auth/session.json",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/home.spec.ts — test starts already logged in
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("authenticated user sees home page", async ({ page }) => {
|
||||
await page.goto("/home");
|
||||
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Global Setup Authentication
|
||||
|
||||
**Use when**: You want to authenticate once before the entire test suite runs.
|
||||
**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { chromium, type FullConfig } from "@playwright/test";
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${baseURL}/login`);
|
||||
await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);
|
||||
await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.waitForURL("**/home");
|
||||
|
||||
await context.storageState({ path: ".auth/session.json" });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
globalSetup: require.resolve("./global-setup"),
|
||||
use: {
|
||||
baseURL: "http://localhost:4000",
|
||||
storageState: ".auth/session.json",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Add `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed.
|
||||
|
||||
### Per-Worker Authentication
|
||||
|
||||
**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state.
|
||||
**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account.
|
||||
|
||||
> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix.
|
||||
|
||||
```typescript
|
||||
// fixtures/auth.ts
|
||||
import { test as base, type BrowserContext } from "@playwright/test";
|
||||
|
||||
type AuthFixtures = {
|
||||
authenticatedContext: BrowserContext;
|
||||
};
|
||||
|
||||
export const test = base.extend<{}, AuthFixtures>({
|
||||
authenticatedContext: [
|
||||
async ({ browser }, use) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/login");
|
||||
await page
|
||||
.getByLabel("Username")
|
||||
.fill(`worker-${test.info().parallelIndex}@example.com`);
|
||||
await page.getByLabel("Password").fill("secretPass123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.waitForURL("/home");
|
||||
await page.close();
|
||||
|
||||
await use(context);
|
||||
await context.close();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/settings.spec.ts
|
||||
import { test, expect } from "../fixtures/auth";
|
||||
|
||||
test("update display name", async ({ authenticatedContext }) => {
|
||||
const page = await authenticatedContext.newPage();
|
||||
await page.goto("/settings/profile");
|
||||
await page.getByLabel("Display name").fill("Updated Name");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByText("Profile saved")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Roles
|
||||
|
||||
**Use when**: Your app has role-based access control and you need to test different permission levels.
|
||||
**Avoid when**: Your app has a single user role.
|
||||
|
||||
```typescript
|
||||
// global-setup.ts — authenticate all roles
|
||||
import { chromium, type FullConfig } from "@playwright/test";
|
||||
|
||||
const accounts = [
|
||||
{
|
||||
role: "admin",
|
||||
email: "admin@example.com",
|
||||
password: process.env.ADMIN_PASSWORD!,
|
||||
},
|
||||
{
|
||||
role: "member",
|
||||
email: "member@example.com",
|
||||
password: process.env.MEMBER_PASSWORD!,
|
||||
},
|
||||
{
|
||||
role: "guest",
|
||||
email: "guest@example.com",
|
||||
password: process.env.GUEST_PASSWORD!,
|
||||
},
|
||||
];
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
|
||||
for (const { role, email, password } of accounts) {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${baseURL}/login`);
|
||||
await page.getByLabel("Username").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.waitForURL("**/home");
|
||||
|
||||
await context.storageState({ path: `.auth/${role}.json` });
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts — one project per role
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
globalSetup: require.resolve("./global-setup"),
|
||||
projects: [
|
||||
{
|
||||
name: "admin",
|
||||
use: { storageState: ".auth/admin.json" },
|
||||
testMatch: "**/*.admin.spec.ts",
|
||||
},
|
||||
{
|
||||
name: "member",
|
||||
use: { storageState: ".auth/member.json" },
|
||||
testMatch: "**/*.member.spec.ts",
|
||||
},
|
||||
{
|
||||
name: "guest",
|
||||
use: { storageState: ".auth/guest.json" },
|
||||
testMatch: "**/*.guest.spec.ts",
|
||||
},
|
||||
{
|
||||
name: "anonymous",
|
||||
use: { storageState: { cookies: [], origins: [] } },
|
||||
testMatch: "**/*.anon.spec.ts",
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/admin-panel.admin.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("admin can access user management", async ({ page }) => {
|
||||
await page.goto("/admin/users");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "User Management" })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Remove user" })).toBeEnabled();
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/admin-panel.guest.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("guest cannot access admin panel", async ({ page }) => {
|
||||
await page.goto("/admin/users");
|
||||
await expect(page.getByText("Access denied")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.
|
||||
|
||||
```typescript
|
||||
// fixtures/auth.ts — role-based fixture
|
||||
import { test as base, type Page } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
|
||||
type RoleFixtures = {
|
||||
loginAs: (role: "admin" | "member" | "guest") => Promise<Page>;
|
||||
};
|
||||
|
||||
export const test = base.extend<RoleFixtures>({
|
||||
loginAs: async ({ browser }, use) => {
|
||||
const pages: Page[] = [];
|
||||
|
||||
await use(async (role) => {
|
||||
const statePath = `.auth/${role}.json`;
|
||||
if (!fs.existsSync(statePath)) {
|
||||
throw new Error(
|
||||
`Auth state for role "${role}" not found at ${statePath}`
|
||||
);
|
||||
}
|
||||
const context = await browser.newContext({ storageState: statePath });
|
||||
const page = await context.newPage();
|
||||
pages.push(page);
|
||||
return page;
|
||||
});
|
||||
|
||||
for (const page of pages) {
|
||||
await page.context().close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/role-comparison.spec.ts
|
||||
import { test, expect } from "../fixtures/auth";
|
||||
|
||||
test("admin sees remove button, guest does not", async ({ loginAs }) => {
|
||||
const adminPage = await loginAs("admin");
|
||||
await adminPage.goto("/admin/users");
|
||||
await expect(
|
||||
adminPage.getByRole("button", { name: "Remove user" })
|
||||
).toBeVisible();
|
||||
|
||||
const guestPage = await loginAs("guest");
|
||||
await guestPage.goto("/admin/users");
|
||||
await expect(guestPage.getByText("Access denied")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### OAuth/SSO Mocking
|
||||
|
||||
**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests.
|
||||
**Avoid when**: You have a dedicated test tenant on the OAuth provider.
|
||||
|
||||
A typical OAuth flow works like this:
|
||||
|
||||
1. User clicks "Sign in with Provider" → browser navigates to `https://accounts.provider.com/authorize?...`
|
||||
2. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`)
|
||||
3. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page
|
||||
|
||||
In tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.
|
||||
|
||||
For cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly.
|
||||
|
||||
```typescript
|
||||
// tests/oauth-login.spec.ts — mock the callback route
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("login via mocked OAuth flow", async ({ page }) => {
|
||||
await page.route("https://accounts.provider.com/**", async (route) => {
|
||||
const callbackUrl = new URL("http://localhost:4000/auth/callback");
|
||||
callbackUrl.searchParams.set("code", "mock-auth-code-xyz");
|
||||
callbackUrl.searchParams.set("state", "expected-state-value");
|
||||
await route.fulfill({
|
||||
status: 302,
|
||||
headers: { location: callbackUrl.toString() },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Sign in with Provider" }).click();
|
||||
|
||||
await page.waitForURL("/home");
|
||||
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/oauth-login.spec.ts — API-based session injection
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("bypass OAuth entirely via API session injection", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Call a test-only endpoint that creates a session without OAuth
|
||||
const response = await page.request.post("/api/test/create-session", {
|
||||
data: {
|
||||
email: "oauth-user@example.com",
|
||||
provider: "provider",
|
||||
role: "member",
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
await page.context().storageState({ path: ".auth/oauth-user.json" });
|
||||
await page.goto("/home");
|
||||
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code.
|
||||
|
||||
### MFA Handling
|
||||
|
||||
**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes).
|
||||
**Avoid when**: MFA is optional and you can disable it for test accounts.
|
||||
|
||||
**Strategy 1**: Generate real TOTP codes from a shared secret.
|
||||
|
||||
```typescript
|
||||
// helpers/totp.ts
|
||||
import * as OTPAuth from "otpauth";
|
||||
|
||||
export function generateTOTP(secret: string): string {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
digits: 6,
|
||||
period: 30,
|
||||
algorithm: "SHA1",
|
||||
});
|
||||
return totp.generate();
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/mfa-login.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { generateTOTP } from "../helpers/totp";
|
||||
|
||||
test("login with TOTP two-factor auth", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("mfa-user@example.com");
|
||||
await page.getByLabel("Password").fill("secretPass123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
|
||||
await expect(page.getByText("Enter your authentication code")).toBeVisible();
|
||||
|
||||
const code = generateTOTP(process.env.MFA_TOTP_SECRET!);
|
||||
await page.getByLabel("Authentication code").fill(code);
|
||||
await page.getByRole("button", { name: "Verify" }).click();
|
||||
|
||||
await page.waitForURL("/home");
|
||||
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.
|
||||
|
||||
**Strategy 3**: Disable MFA for test accounts at the infrastructure level.
|
||||
|
||||
### Session Refresh
|
||||
|
||||
**Use when**: Your tokens expire during long test runs.
|
||||
**Avoid when**: Your test suite runs quickly and tokens outlast the entire run.
|
||||
|
||||
```typescript
|
||||
// fixtures/auth-with-refresh.ts
|
||||
import { test as base, type BrowserContext } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
|
||||
type AuthFixtures = {
|
||||
authenticatedPage: import("@playwright/test").Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<AuthFixtures>({
|
||||
authenticatedPage: async ({ browser }, use) => {
|
||||
const statePath = ".auth/session.json";
|
||||
|
||||
let context: BrowserContext;
|
||||
if (fs.existsSync(statePath)) {
|
||||
context = await browser.newContext({ storageState: statePath });
|
||||
const page = await context.newPage();
|
||||
|
||||
const response = await page.request.get("/api/auth/me");
|
||||
if (response.ok()) {
|
||||
await use(page);
|
||||
await context.close();
|
||||
return;
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
|
||||
context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);
|
||||
await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.waitForURL("/home");
|
||||
|
||||
await context.storageState({ path: statePath });
|
||||
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
### Login Page Object
|
||||
|
||||
**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic.
|
||||
**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests.
|
||||
|
||||
```typescript
|
||||
// page-objects/LoginPage.ts
|
||||
import { type Page, type Locator, expect } from "@playwright/test";
|
||||
|
||||
export class LoginPage {
|
||||
readonly page: Page;
|
||||
readonly usernameInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly loginButton: Locator;
|
||||
readonly errorMessage: Locator;
|
||||
readonly forgotPasswordLink: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.usernameInput = page.getByLabel("Username");
|
||||
this.passwordInput = page.getByLabel("Password");
|
||||
this.loginButton = page.getByRole("button", { name: "Log in" });
|
||||
this.errorMessage = page.getByRole("alert");
|
||||
this.forgotPasswordLink = page.getByRole("link", {
|
||||
name: "Forgot password",
|
||||
});
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/login");
|
||||
await expect(this.loginButton).toBeVisible();
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
await this.usernameInput.fill(username);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.loginButton.click();
|
||||
}
|
||||
|
||||
async loginAndWaitForHome(username: string, password: string) {
|
||||
await this.login(username, password);
|
||||
await this.page.waitForURL("/home");
|
||||
}
|
||||
|
||||
async expectError(message: string | RegExp) {
|
||||
await expect(this.errorMessage).toContainText(message);
|
||||
}
|
||||
|
||||
async expectFieldError(field: "username" | "password", message: string) {
|
||||
const input =
|
||||
field === "username" ? this.usernameInput : this.passwordInput;
|
||||
await expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
const errorId = await input.getAttribute("aria-describedby");
|
||||
if (errorId) {
|
||||
await expect(this.page.locator(`#${errorId}`)).toContainText(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/login.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { LoginPage } from "../page-objects/LoginPage";
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe("login page", () => {
|
||||
let loginPage: LoginPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
});
|
||||
|
||||
test("successful login redirects to home", async ({ page }) => {
|
||||
await loginPage.loginAndWaitForHome(
|
||||
"testuser@example.com",
|
||||
"secretPass123"
|
||||
);
|
||||
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("wrong password shows error", async () => {
|
||||
await loginPage.login("testuser@example.com", "wrong-password");
|
||||
await loginPage.expectError("Invalid username or password");
|
||||
});
|
||||
|
||||
test("empty fields show validation errors", async () => {
|
||||
await loginPage.loginButton.click();
|
||||
await loginPage.expectFieldError("username", "Username is required");
|
||||
});
|
||||
|
||||
test("forgot password link navigates correctly", async ({ page }) => {
|
||||
await loginPage.forgotPasswordLink.click();
|
||||
await page.waitForURL("/forgot-password");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Reset password" })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### API-Based Login
|
||||
|
||||
**Use when**: You want the fastest possible authentication without any browser interaction.
|
||||
**Avoid when**: You are specifically testing the login UI.
|
||||
|
||||
API login is typically 5-10x faster than UI login.
|
||||
|
||||
```typescript
|
||||
// global-setup.ts — API-based login (fastest)
|
||||
import { request, type FullConfig } from "@playwright/test";
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
|
||||
const requestContext = await request.newContext({ baseURL });
|
||||
|
||||
const response = await requestContext.post("/api/auth/login", {
|
||||
data: {
|
||||
email: process.env.TEST_USER_EMAIL!,
|
||||
password: process.env.TEST_USER_PASSWORD!,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`API login failed: ${response.status()} ${await response.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
await requestContext.storageState({ path: ".auth/session.json" });
|
||||
await requestContext.dispose();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// fixtures/api-auth.ts — fixture version for per-test authentication
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
export const test = base.extend({
|
||||
authenticatedPage: async ({ browser, playwright }, use) => {
|
||||
const apiContext = await playwright.request.newContext({
|
||||
baseURL: "http://localhost:4000",
|
||||
});
|
||||
|
||||
await apiContext.post("/api/auth/login", {
|
||||
data: {
|
||||
email: "testuser@example.com",
|
||||
password: "secretPass123",
|
||||
},
|
||||
});
|
||||
|
||||
const state = await apiContext.storageState();
|
||||
const context = await browser.newContext({ storageState: state });
|
||||
const page = await context.newPage();
|
||||
|
||||
await use(page);
|
||||
|
||||
await context.close();
|
||||
await apiContext.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
### Unauthenticated Tests
|
||||
|
||||
**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users.
|
||||
**Avoid when**: The test requires a logged-in user.
|
||||
|
||||
When your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests.
|
||||
|
||||
```typescript
|
||||
// tests/public-pages.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe("unauthenticated access", () => {
|
||||
test("homepage is accessible without login", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Log in" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("protected route redirects to login", async ({ page }) => {
|
||||
await page.goto("/home");
|
||||
await page.waitForURL("**/login**");
|
||||
expect(page.url()).toContain("redirect=%2Fhome");
|
||||
});
|
||||
|
||||
test("expired session shows re-login prompt", async ({ page, context }) => {
|
||||
await page.goto("/home");
|
||||
await context.clearCookies();
|
||||
|
||||
await page.goto("/settings");
|
||||
await page.waitForURL("**/login**");
|
||||
await expect(page.getByText("Your session has expired")).toBeVisible();
|
||||
});
|
||||
|
||||
test("signup flow creates account", async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
await page.getByLabel("Name").fill("New User");
|
||||
await page.getByLabel("Email").fill(`test-${Date.now()}@example.com`);
|
||||
await page.getByLabel("Password", { exact: true }).fill("secretPass123");
|
||||
await page.getByLabel("Confirm password").fill("secretPass123");
|
||||
await page.getByRole("button", { name: "Create account" }).click();
|
||||
|
||||
await page.waitForURL("/onboarding");
|
||||
await expect(page.getByText("Welcome, New User")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Approach | Speed | Isolation | When to Choose |
|
||||
| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- |
|
||||
| Most tests need auth | Global setup + `storageState` | Fastest | Shared session | Default for nearly every project |
|
||||
| Tests modify user state | Per-worker fixture | Fast | Per worker | Tests update profile, change settings, or mutate data |
|
||||
| Multiple user roles | Per-project `storageState` | Fastest | Per role | App has admin/member/guest roles |
|
||||
| Testing the login page | No `storageState` | N/A | Full | Use `test.use({ storageState: { cookies: [], origins: [] } })` |
|
||||
| OAuth/SSO provider | Mock the callback | Fast | Per test | Never hit real OAuth providers in CI |
|
||||
| MFA is required | TOTP generation or bypass | Moderate | Per test | Generate real TOTP codes or use a test-mode bypass |
|
||||
| Token expires mid-suite | Session refresh fixture | Fast | Per check | Fixture validates the session before use |
|
||||
| Single test needs different user | `loginAs(role)` fixture | Moderate | Per call | Rare: prefer per-project roles |
|
||||
| API-first app (no login UI) | API login via `request.post()` | Fastest | Per test | No browser needed for auth |
|
||||
|
||||
### UI Login vs API Login vs Storage State
|
||||
|
||||
```text
|
||||
Need to test the login page itself?
|
||||
├── Yes → UI login with LoginPage POM, no storageState
|
||||
└── No → Do you have a login API endpoint?
|
||||
├── Yes → API login in global setup, save storageState (fastest)
|
||||
└── No → UI login in global setup, save storageState
|
||||
└── Tokens expire quickly?
|
||||
├── Yes → Add session refresh fixture
|
||||
└── No → Standard storageState reuse is fine
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Log in via UI before every test | Adds 2-5 seconds per test | Use `storageState` to skip login entirely |
|
||||
| Share a single auth state file across parallel workers that mutate state | Race conditions | Use per-worker fixtures with `{ scope: 'worker' }` |
|
||||
| Hardcode credentials in test files | Security risk | Use environment variables and `.env` files |
|
||||
| Ignore token expiration | Tests fail intermittently with 401 errors | Add a session validity check in your auth fixture |
|
||||
| Hit real OAuth providers in CI | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection |
|
||||
| Use `page.waitForTimeout(2000)` after login | Arbitrary delay | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` |
|
||||
| Store `.auth/*.json` files in git | Tokens in version control | Add `.auth/` to `.gitignore` |
|
||||
| Create one "god" test account with all permissions | Cannot test role-based access control | Create separate accounts per role |
|
||||
| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated | Pass `storageState` when creating the context |
|
||||
| Test MFA by disabling it everywhere | You never test the MFA flow | Use TOTP generation for at least one test |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Global setup fails with "Target page, context or browser has been closed"
|
||||
|
||||
**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Add `await page.waitForURL()` after the login action
|
||||
- Check that `baseURL` in your config matches the actual server URL and protocol
|
||||
- Add error handling to global setup:
|
||||
|
||||
```typescript
|
||||
const response = await page.waitForResponse("**/api/auth/**");
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`Login failed in global setup: ${response.status()} ${await response.text()}`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Tests fail with 401 Unauthorized after running for a while
|
||||
|
||||
**Cause**: The session token saved in `storageState` has expired.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Use the session refresh fixture pattern
|
||||
- Increase token expiry in test environment configuration
|
||||
- Switch to API-based login in a worker-scoped fixture
|
||||
|
||||
### `storageState` file is empty or contains no cookies
|
||||
|
||||
**Cause**: `storageState()` was called before the login response set cookies.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Wait for the post-login page to load: `await page.waitForURL('/home')`
|
||||
- Verify cookies exist before saving:
|
||||
|
||||
```typescript
|
||||
const cookies = await context.cookies();
|
||||
if (cookies.length === 0) {
|
||||
throw new Error("No cookies found after login");
|
||||
}
|
||||
await context.storageState({ path: ".auth/session.json" });
|
||||
```
|
||||
|
||||
### Different browsers get different cookies
|
||||
|
||||
**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Generate separate auth state files per browser project
|
||||
- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS:
|
||||
|
||||
```typescript
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
### Parallel tests interfere with each other's sessions
|
||||
|
||||
**Cause**: Multiple workers share the same test account and one worker's actions affect others.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com`
|
||||
- Use the per-worker authentication fixture pattern
|
||||
- Make tests idempotent
|
||||
|
||||
### OAuth mock does not work — still redirects to real provider
|
||||
|
||||
**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Register route handlers before any navigation: call `page.route()` before `page.goto()`
|
||||
- Log the actual redirect URL to verify the pattern:
|
||||
|
||||
```typescript
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("oauth") || req.url().includes("accounts.provider")) {
|
||||
console.log("OAuth request:", req.url());
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown
|
||||
- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration
|
||||
- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies
|
||||
- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking
|
||||
- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login
|
||||
- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness
|
||||
364
.cursor/skills/playwright-testing/advanced/clock-mocking.md
Normal file
364
.cursor/skills/playwright-testing/advanced/clock-mocking.md
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
# Date, Time & Clock Mocking
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Clock API Basics](#clock-api-basics)
|
||||
2. [Fixed Time Testing](#fixed-time-testing)
|
||||
3. [Time Advancement](#time-advancement)
|
||||
4. [Timezone Testing](#timezone-testing)
|
||||
5. [Timer Mocking](#timer-mocking)
|
||||
|
||||
## Clock API Basics
|
||||
|
||||
### Install Clock
|
||||
|
||||
```typescript
|
||||
test("mock current time", async ({ page }) => {
|
||||
// Install clock before navigating
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Page sees January 15, 2025 as current date
|
||||
await expect(page.getByText("January 15, 2025")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Clock with Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/clock.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
type ClockFixtures = {
|
||||
mockTime: (date: Date | string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<ClockFixtures>({
|
||||
mockTime: async ({ page }, use) => {
|
||||
await use(async (date) => {
|
||||
const time = typeof date === "string" ? new Date(date) : date;
|
||||
await page.clock.install({ time });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("subscription expiry", async ({ page, mockTime }) => {
|
||||
await mockTime("2025-12-31T23:59:00");
|
||||
await page.goto("/subscription");
|
||||
|
||||
await expect(page.getByText("Expires today")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Fixed Time Testing
|
||||
|
||||
### Test Date-Dependent Features
|
||||
|
||||
```typescript
|
||||
test("show holiday banner in December", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-12-20T10:00:00") });
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByRole("banner", { name: /holiday/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("no holiday banner in January", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T10:00:00") });
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByRole("banner", { name: /holiday/i })).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Relative Time Display
|
||||
|
||||
```typescript
|
||||
test("shows relative time correctly", async ({ page }) => {
|
||||
// Fix time to control "posted 2 hours ago" text
|
||||
await page.clock.install({ time: new Date("2025-06-15T14:00:00") });
|
||||
|
||||
// Mock API to return post with known timestamp
|
||||
await page.route("**/api/posts/1", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
id: 1,
|
||||
title: "Test Post",
|
||||
createdAt: "2025-06-15T12:00:00Z", // 2 hours before mock time
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/posts/1");
|
||||
|
||||
await expect(page.getByText("2 hours ago")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Date Boundaries
|
||||
|
||||
```typescript
|
||||
test.describe("end of month billing", () => {
|
||||
test("shows billing on last day of month", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-31T10:00:00") });
|
||||
await page.goto("/billing");
|
||||
|
||||
await expect(page.getByText("Payment due today")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows days remaining mid-month", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T10:00:00") });
|
||||
await page.goto("/billing");
|
||||
|
||||
await expect(page.getByText("16 days until payment")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Time Advancement
|
||||
|
||||
### Advance Time Manually
|
||||
|
||||
```typescript
|
||||
test("session timeout warning", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Advance 25 minutes (session timeout at 30 min)
|
||||
await page.clock.fastForward("25:00");
|
||||
|
||||
await expect(page.getByText("Session expires in 5 minutes")).toBeVisible();
|
||||
|
||||
// Advance 5 more minutes
|
||||
await page.clock.fastForward("05:00");
|
||||
|
||||
await expect(page.getByText("Session expired")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Pause and Resume Time
|
||||
|
||||
```typescript
|
||||
test("countdown timer", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
await page.goto("/sale");
|
||||
|
||||
// Initial state
|
||||
await expect(page.getByText("Sale ends in 2:00:00")).toBeVisible();
|
||||
|
||||
// Advance 1 hour
|
||||
await page.clock.fastForward("01:00:00");
|
||||
|
||||
await expect(page.getByText("Sale ends in 1:00:00")).toBeVisible();
|
||||
|
||||
// Advance past end
|
||||
await page.clock.fastForward("01:00:01");
|
||||
|
||||
await expect(page.getByText("Sale ended")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Run Pending Timers
|
||||
|
||||
```typescript
|
||||
test("debounced search", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
await page.goto("/search");
|
||||
|
||||
await page.getByLabel("Search").fill("playwright");
|
||||
|
||||
// Search is debounced by 300ms, won't fire yet
|
||||
await expect(page.getByTestId("search-results")).toBeHidden();
|
||||
|
||||
// Fast forward past debounce
|
||||
await page.clock.fastForward(300);
|
||||
|
||||
// Now search should execute
|
||||
await expect(page.getByTestId("search-results")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Timezone Testing
|
||||
|
||||
### Test Different Timezones
|
||||
|
||||
```typescript
|
||||
test.describe("timezone display", () => {
|
||||
test("shows correct time in PST", async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
timezoneId: "America/Los_Angeles",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC
|
||||
|
||||
await page.goto("/schedule");
|
||||
|
||||
// Should show 9 AM PST
|
||||
await expect(page.getByText("9:00 AM")).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("shows correct time in JST", async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
timezoneId: "Asia/Tokyo",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC
|
||||
|
||||
await page.goto("/schedule");
|
||||
|
||||
// Should show 2 AM next day JST
|
||||
await expect(page.getByText("2:00 AM")).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Timezone Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/timezone.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
type TimezoneFixtures = {
|
||||
pageInTimezone: (timezone: string) => Promise<Page>;
|
||||
};
|
||||
|
||||
export const test = base.extend<TimezoneFixtures>({
|
||||
pageInTimezone: async ({ browser }, use) => {
|
||||
const pages: Page[] = [];
|
||||
|
||||
await use(async (timezone) => {
|
||||
const context = await browser.newContext({ timezoneId: timezone });
|
||||
const page = await context.newPage();
|
||||
pages.push(page);
|
||||
return page;
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
for (const page of pages) {
|
||||
await page.context().close();
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Timer Mocking
|
||||
|
||||
### Mock setInterval
|
||||
|
||||
```typescript
|
||||
test("auto-refresh data", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
|
||||
let apiCalls = 0;
|
||||
await page.route("**/api/data", (route) => {
|
||||
apiCalls++;
|
||||
route.fulfill({ json: { value: apiCalls } });
|
||||
});
|
||||
|
||||
await page.goto("/live-data"); // Sets up 30s refresh interval
|
||||
|
||||
expect(apiCalls).toBe(1); // Initial load
|
||||
|
||||
// Advance 30 seconds
|
||||
await page.clock.fastForward("00:30");
|
||||
expect(apiCalls).toBe(2); // First refresh
|
||||
|
||||
// Advance another 30 seconds
|
||||
await page.clock.fastForward("00:30");
|
||||
expect(apiCalls).toBe(3); // Second refresh
|
||||
});
|
||||
```
|
||||
|
||||
### Mock setTimeout Chains
|
||||
|
||||
```typescript
|
||||
test("notification queue", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
await page.goto("/notifications");
|
||||
|
||||
// Trigger 3 notifications that show sequentially
|
||||
await page.getByRole("button", { name: "Show All" }).click();
|
||||
|
||||
// First notification appears immediately
|
||||
await expect(page.getByText("Notification 1")).toBeVisible();
|
||||
|
||||
// Second appears after 2 seconds
|
||||
await page.clock.fastForward("00:02");
|
||||
await expect(page.getByText("Notification 2")).toBeVisible();
|
||||
|
||||
// Third appears after 2 more seconds
|
||||
await page.clock.fastForward("00:02");
|
||||
await expect(page.getByText("Notification 3")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Animation Frames
|
||||
|
||||
```typescript
|
||||
test("animation completes", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
await page.goto("/animation-demo");
|
||||
|
||||
await page.getByRole("button", { name: "Animate" }).click();
|
||||
|
||||
// Animation runs for 500ms
|
||||
const element = page.getByTestId("animated-box");
|
||||
await expect(element).toHaveCSS("opacity", "0");
|
||||
|
||||
// Fast forward through animation
|
||||
await page.clock.fastForward(500);
|
||||
|
||||
await expect(element).toHaveCSS("opacity", "1");
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Always Install Clock Before Navigation
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
test("date test", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15") });
|
||||
await page.goto("/"); // Page loads with mocked time
|
||||
});
|
||||
|
||||
// Bad - time already captured by page
|
||||
test("date test", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.clock.install({ time: new Date("2025-01-15") }); // Too late!
|
||||
});
|
||||
```
|
||||
|
||||
### Use ISO Strings for Clarity
|
||||
|
||||
```typescript
|
||||
// Good - explicit timezone
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00Z") });
|
||||
|
||||
// Ambiguous - uses local timezone
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ---------------------------------------- | ------------------------------- | -------------------------------------- |
|
||||
| Installing clock after navigation | Page already captured real time | Install clock before `goto()` |
|
||||
| Hardcoded relative dates | Tests break over time | Use fixed dates with clock mock |
|
||||
| Not accounting for timezone | Tests fail in different regions | Use explicit UTC times or set timezone |
|
||||
| Using `waitForTimeout` with mocked clock | Conflicts with mocked timers | Use `fastForward` instead |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for time-based assertions
|
||||
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for clock fixtures
|
||||
409
.cursor/skills/playwright-testing/advanced/mobile-testing.md
Normal file
409
.cursor/skills/playwright-testing/advanced/mobile-testing.md
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
# Mobile & Responsive Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Device Emulation](#device-emulation)
|
||||
2. [Touch Gestures](#touch-gestures)
|
||||
3. [Viewport Testing](#viewport-testing)
|
||||
4. [Mobile-Specific UI](#mobile-specific-ui)
|
||||
5. [Responsive Breakpoints](#responsive-breakpoints)
|
||||
|
||||
## Device Emulation
|
||||
|
||||
### Use Built-in Devices
|
||||
|
||||
```typescript
|
||||
import { test, devices } from "@playwright/test";
|
||||
|
||||
// Configure in playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
|
||||
{ name: "Mobile Chrome", use: { ...devices["Pixel 7"] } },
|
||||
{ name: "Tablet", use: { ...devices["iPad Pro 11"] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Device Configuration
|
||||
|
||||
```typescript
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 3,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
|
||||
});
|
||||
|
||||
test("custom mobile device", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Test runs with custom device settings
|
||||
});
|
||||
```
|
||||
|
||||
### Test Across Multiple Devices
|
||||
|
||||
```typescript
|
||||
const mobileDevices = ["iPhone 14", "Pixel 7", "Galaxy S21"];
|
||||
|
||||
for (const deviceName of mobileDevices) {
|
||||
test(`checkout on ${deviceName}`, async ({ browser }) => {
|
||||
const device = devices[deviceName];
|
||||
const context = await browser.newContext({ ...device });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/checkout");
|
||||
await expect(page.getByRole("button", { name: "Pay" })).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Touch Gestures
|
||||
|
||||
### Tap
|
||||
|
||||
```typescript
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("tap to interact", async ({ page }) => {
|
||||
await page.goto("/gallery");
|
||||
|
||||
// Tap is like click but for touch devices
|
||||
await page.getByRole("img", { name: "Photo 1" }).tap();
|
||||
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Swipe
|
||||
|
||||
```typescript
|
||||
test("swipe carousel", async ({ page }) => {
|
||||
await page.goto("/carousel");
|
||||
|
||||
const carousel = page.getByTestId("carousel");
|
||||
const box = await carousel.boundingBox();
|
||||
|
||||
if (box) {
|
||||
// Swipe left
|
||||
await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2);
|
||||
await page.mouse.move(box.x + 50, box.y + box.height / 2);
|
||||
|
||||
// Or use drag
|
||||
await carousel.dragTo(carousel, {
|
||||
sourcePosition: { x: box.width - 50, y: box.height / 2 },
|
||||
targetPosition: { x: 50, y: box.height / 2 },
|
||||
});
|
||||
}
|
||||
|
||||
await expect(page.getByText("Slide 2")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Swipe Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/touch.fixture.ts
|
||||
import { test as base, Page } from "@playwright/test";
|
||||
|
||||
type TouchFixtures = {
|
||||
swipe: (
|
||||
element: Locator,
|
||||
direction: "left" | "right" | "up" | "down",
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<TouchFixtures>({
|
||||
swipe: async ({ page }, use) => {
|
||||
await use(async (element, direction) => {
|
||||
const box = await element.boundingBox();
|
||||
if (!box) throw new Error("Element not visible");
|
||||
|
||||
const centerX = box.x + box.width / 2;
|
||||
const centerY = box.y + box.height / 2;
|
||||
const distance = 100;
|
||||
|
||||
const moves = {
|
||||
left: {
|
||||
startX: centerX + distance,
|
||||
endX: centerX - distance,
|
||||
y: centerY,
|
||||
},
|
||||
right: {
|
||||
startX: centerX - distance,
|
||||
endX: centerX + distance,
|
||||
y: centerY,
|
||||
},
|
||||
up: {
|
||||
startX: centerX,
|
||||
endX: centerX,
|
||||
startY: centerY + distance,
|
||||
endY: centerY - distance,
|
||||
},
|
||||
down: {
|
||||
startX: centerX,
|
||||
endX: centerX,
|
||||
startY: centerY - distance,
|
||||
endY: centerY + distance,
|
||||
},
|
||||
};
|
||||
|
||||
const move = moves[direction];
|
||||
await page.touchscreen.tap(move.startX, move.startY ?? move.y);
|
||||
await page.mouse.move(move.endX, move.endY ?? move.y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("swipe to delete", async ({ page, swipe }) => {
|
||||
await page.goto("/inbox");
|
||||
|
||||
const message = page.getByTestId("message-1");
|
||||
await swipe(message, "left");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Delete" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Long Press
|
||||
|
||||
```typescript
|
||||
test("long press for context menu", async ({ page }) => {
|
||||
await page.goto("/files");
|
||||
|
||||
const file = page.getByText("document.pdf");
|
||||
const box = await file.boundingBox();
|
||||
|
||||
if (box) {
|
||||
// Touch down
|
||||
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
|
||||
|
||||
// Hold for 500ms
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Context menu should appear
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Pinch Zoom
|
||||
|
||||
```typescript
|
||||
test("pinch to zoom image", async ({ page }) => {
|
||||
await page.goto("/map");
|
||||
|
||||
// Pinch zoom requires two touch points
|
||||
// Playwright doesn't have native pinch support, so we simulate via evaluate
|
||||
await page.evaluate(() => {
|
||||
const element = document.querySelector("#map");
|
||||
if (element) {
|
||||
// Simulate wheel event as fallback for zoom
|
||||
element.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -100, // Negative = zoom in
|
||||
ctrlKey: true, // Ctrl+wheel = pinch on many apps
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Or trigger the app's zoom function directly
|
||||
await page.evaluate(() => {
|
||||
(window as any).mapInstance?.setZoom(15);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Viewport Testing
|
||||
|
||||
### Test Different Sizes
|
||||
|
||||
```typescript
|
||||
const viewports = [
|
||||
{ name: "mobile", width: 375, height: 667 },
|
||||
{ name: "tablet", width: 768, height: 1024 },
|
||||
{ name: "desktop", width: 1920, height: 1080 },
|
||||
];
|
||||
|
||||
for (const { name, width, height } of viewports) {
|
||||
test(`navigation on ${name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto("/");
|
||||
|
||||
if (width < 768) {
|
||||
// Mobile: should have hamburger menu
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
} else {
|
||||
// Desktop: should have visible nav links
|
||||
await expect(page.getByRole("link", { name: "Products" })).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Viewport Changes
|
||||
|
||||
```typescript
|
||||
test("responsive layout change", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Desktop: sidebar visible
|
||||
await expect(page.getByRole("complementary")).toBeVisible();
|
||||
|
||||
// Resize to mobile
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Mobile: sidebar hidden, hamburger visible
|
||||
await expect(page.getByRole("complementary")).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Mobile-Specific UI
|
||||
|
||||
### Hamburger Menu
|
||||
|
||||
```typescript
|
||||
test("mobile navigation", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto("/");
|
||||
|
||||
// Open hamburger menu
|
||||
await page.getByRole("button", { name: "Menu" }).click();
|
||||
|
||||
// Navigation drawer should appear
|
||||
const nav = page.getByRole("navigation");
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
// Navigate via mobile menu
|
||||
await nav.getByRole("link", { name: "Products" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/products");
|
||||
// Menu should close after navigation
|
||||
await expect(nav).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Bottom Sheet
|
||||
|
||||
```typescript
|
||||
test("bottom sheet interaction", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto("/product/123");
|
||||
|
||||
await page.getByRole("button", { name: "Add to Cart" }).click();
|
||||
|
||||
// Bottom sheet appears
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// Select options
|
||||
await sheet.getByRole("combobox", { name: "Size" }).selectOption("Large");
|
||||
await sheet.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
await expect(page.getByText("Added to cart")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Pull to Refresh
|
||||
|
||||
```typescript
|
||||
test("pull to refresh", async ({ page }) => {
|
||||
await page.goto("/feed");
|
||||
|
||||
const feed = page.getByTestId("feed");
|
||||
const initialFirstItem = await feed.locator("> *").first().textContent();
|
||||
|
||||
// Simulate pull down
|
||||
const box = await feed.boundingBox();
|
||||
if (box) {
|
||||
await page.touchscreen.tap(box.x + box.width / 2, box.y + 50);
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + 200, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
// Wait for refresh
|
||||
await expect(page.getByTestId("loading")).toBeVisible();
|
||||
await expect(page.getByTestId("loading")).toBeHidden();
|
||||
|
||||
// Content should be updated (in a real app)
|
||||
});
|
||||
```
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
### Test All Breakpoints
|
||||
|
||||
```typescript
|
||||
const breakpoints = {
|
||||
xs: 320,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
"2xl": 1536,
|
||||
};
|
||||
|
||||
test.describe("responsive header", () => {
|
||||
for (const [name, width] of Object.entries(breakpoints)) {
|
||||
test(`header at ${name} (${width}px)`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto("/");
|
||||
|
||||
if (width < 768) {
|
||||
await expect(page.getByTestId("mobile-menu-button")).toBeVisible();
|
||||
await expect(page.getByTestId("desktop-nav")).toBeHidden();
|
||||
} else {
|
||||
await expect(page.getByTestId("mobile-menu-button")).toBeHidden();
|
||||
await expect(page.getByTestId("desktop-nav")).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Visual Regression at Breakpoints
|
||||
|
||||
```typescript
|
||||
test.describe("visual regression", () => {
|
||||
const sizes = [
|
||||
{ width: 375, height: 667, name: "mobile" },
|
||||
{ width: 768, height: 1024, name: "tablet" },
|
||||
{ width: 1440, height: 900, name: "desktop" },
|
||||
];
|
||||
|
||||
for (const { width, height, name } of sizes) {
|
||||
test(`homepage at ${name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page).toHaveScreenshot(`homepage-${name}.png`);
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------- | ------------------------- | -------------------------------- |
|
||||
| Only testing one viewport | Misses responsive bugs | Test multiple breakpoints |
|
||||
| Ignoring touch events | Features broken on mobile | Test tap, swipe, long press |
|
||||
| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()` |
|
||||
| Not testing orientation | Landscape bugs missed | Test both portrait and landscape |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing
|
||||
- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors
|
||||
- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications)
|
||||
- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements
|
||||
288
.cursor/skills/playwright-testing/advanced/multi-context.md
Normal file
288
.cursor/skills/playwright-testing/advanced/multi-context.md
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
# Multi-Tab, Window & Popup Testing
|
||||
|
||||
This file covers **single-user scenarios** with multiple browser tabs, windows, and popups. For **multi-user collaboration testing** (multiple users interacting simultaneously), see [multi-user.md](multi-user.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Popup Handling](#popup-handling)
|
||||
2. [New Tab Navigation](#new-tab-navigation)
|
||||
3. [OAuth Flows](#oauth-flows)
|
||||
4. [Multiple Windows](#multiple-windows)
|
||||
5. [Tab Coordination](#tab-coordination)
|
||||
|
||||
## Popup Handling
|
||||
|
||||
### Basic Popup
|
||||
|
||||
```typescript
|
||||
test("handle popup window", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Start waiting for popup before triggering it
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.getByRole("button", { name: "Open Support Chat" }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
// Wait for popup to load
|
||||
await popup.waitForLoadState();
|
||||
|
||||
// Interact with popup
|
||||
await popup.getByLabel("Message").fill("Need help");
|
||||
await popup.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
await expect(popup.getByText("Message sent")).toBeVisible();
|
||||
|
||||
// Close popup
|
||||
await popup.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Popup with Authentication
|
||||
|
||||
```typescript
|
||||
test("popup login flow", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.getByRole("button", { name: "Connect Account" }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
await popup.waitForLoadState();
|
||||
|
||||
// Complete login in popup
|
||||
await popup.getByLabel("Email").fill("user@example.com");
|
||||
await popup.getByLabel("Password").fill("password123");
|
||||
await popup.getByRole("button", { name: "Log In" }).click();
|
||||
|
||||
// Popup should close automatically after auth
|
||||
await popup.waitForEvent("close");
|
||||
|
||||
// Main page should reflect connected state
|
||||
await expect(page.getByText("Account connected")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Handle Blocked Popups
|
||||
|
||||
```typescript
|
||||
test("handle popup blocker", async ({ page }) => {
|
||||
await page.goto("/share");
|
||||
|
||||
// Listen for console messages about blocked popup
|
||||
page.on("console", (msg) => {
|
||||
if (msg.text().includes("popup blocked")) {
|
||||
console.log("Popup was blocked");
|
||||
}
|
||||
});
|
||||
|
||||
const popupPromise = page.waitForEvent("popup").catch(() => null);
|
||||
await page.getByRole("button", { name: "Share to Twitter" }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
if (!popup) {
|
||||
// Popup blocked - app should show fallback
|
||||
await expect(page.getByText("Copy share link instead")).toBeVisible();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## New Tab Navigation
|
||||
|
||||
### Link Opens in New Tab
|
||||
|
||||
```typescript
|
||||
test("external link opens in new tab", async ({ page, context }) => {
|
||||
await page.goto("/resources");
|
||||
|
||||
// Wait for new page in context
|
||||
const pagePromise = context.waitForEvent("page");
|
||||
await page.getByRole("link", { name: "Documentation" }).click();
|
||||
const newPage = await pagePromise;
|
||||
|
||||
await newPage.waitForLoadState();
|
||||
|
||||
expect(newPage.url()).toContain("docs.example.com");
|
||||
await expect(newPage.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
|
||||
// Original page still there
|
||||
expect(page.url()).toContain("/resources");
|
||||
|
||||
await newPage.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Intercept New Tab
|
||||
|
||||
```typescript
|
||||
test("prevent new tab for testing", async ({ page }) => {
|
||||
await page.goto("/links");
|
||||
|
||||
// Remove target="_blank" to keep navigation in same tab
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('a[target="_blank"]').forEach((a) => {
|
||||
a.removeAttribute("target");
|
||||
});
|
||||
});
|
||||
|
||||
// Now link opens in same tab
|
||||
await page.getByRole("link", { name: "External Site" }).click();
|
||||
|
||||
// Can test the destination page
|
||||
await expect(page).toHaveURL(/external-site\.com/);
|
||||
});
|
||||
```
|
||||
|
||||
## OAuth Flows
|
||||
|
||||
### Google OAuth Popup
|
||||
|
||||
```typescript
|
||||
test("Google OAuth login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.getByRole("button", { name: "Sign in with Google" }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
await popup.waitForLoadState();
|
||||
|
||||
// Handle Google's OAuth flow
|
||||
await popup.getByLabel("Email or phone").fill("test@gmail.com");
|
||||
await popup.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await popup.getByLabel("Enter your password").fill("password");
|
||||
await popup.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Wait for redirect back and popup close
|
||||
await popup.waitForEvent("close");
|
||||
|
||||
// Verify logged in on main page
|
||||
await expect(page.getByText("Welcome, Test User")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock OAuth (Recommended)
|
||||
|
||||
```typescript
|
||||
test("mock OAuth flow", async ({ page, context }) => {
|
||||
// Mock the OAuth callback instead of real flow
|
||||
await page.route("**/auth/callback**", async (route) => {
|
||||
// Simulate successful OAuth
|
||||
const url = new URL(route.request().url());
|
||||
url.searchParams.set("code", "mock-auth-code");
|
||||
await route.fulfill({
|
||||
status: 302,
|
||||
headers: { Location: "/dashboard" },
|
||||
});
|
||||
});
|
||||
|
||||
// Mock token exchange
|
||||
await page.route("**/api/auth/token", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
access_token: "mock-token",
|
||||
user: { name: "Test User", email: "test@example.com" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Sign in with Google" }).click();
|
||||
|
||||
// Should redirect to dashboard without actual OAuth
|
||||
await expect(page).toHaveURL("/dashboard");
|
||||
await expect(page.getByText("Welcome, Test User")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### OAuth Fixture
|
||||
|
||||
> **For comprehensive OAuth mocking patterns** (fixtures, multiple providers, SAML SSO), see [third-party.md](third-party.md#oauthsso-mocking). This section focuses on popup window handling mechanics for OAuth flows.
|
||||
|
||||
## Multiple Windows
|
||||
|
||||
### Test Across Multiple Windows
|
||||
|
||||
```typescript
|
||||
test("sync between windows", async ({ context }) => {
|
||||
// Open two pages
|
||||
const page1 = await context.newPage();
|
||||
const page2 = await context.newPage();
|
||||
|
||||
await page1.goto("/dashboard");
|
||||
await page2.goto("/dashboard");
|
||||
|
||||
// Make change in first window
|
||||
await page1.getByRole("button", { name: "Add Item" }).click();
|
||||
await page1.getByLabel("Name").fill("New Item");
|
||||
await page1.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// Should sync to second window (if app supports real-time sync)
|
||||
await expect(page2.getByText("New Item")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
```
|
||||
|
||||
### Different Users in Different Windows
|
||||
|
||||
> **For multi-user collaboration patterns** (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see [multi-user.md](multi-user.md). This file focuses on single-user scenarios with multiple tabs/windows/popups.
|
||||
|
||||
## Tab Coordination
|
||||
|
||||
### Switch Between Tabs
|
||||
|
||||
```typescript
|
||||
test("manage multiple tabs", async ({ context }) => {
|
||||
const page1 = await context.newPage();
|
||||
await page1.goto("/editor");
|
||||
|
||||
const page2 = await context.newPage();
|
||||
await page2.goto("/preview");
|
||||
|
||||
// Edit in first tab
|
||||
await page1.bringToFront();
|
||||
await page1.getByLabel("Content").fill("Hello World");
|
||||
|
||||
// Check preview in second tab
|
||||
await page2.bringToFront();
|
||||
await page2.reload(); // If preview needs refresh
|
||||
await expect(page2.getByText("Hello World")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Close All Tabs Except One
|
||||
|
||||
```typescript
|
||||
test("cleanup tabs after test", async ({ context }) => {
|
||||
const mainPage = await context.newPage();
|
||||
await mainPage.goto("/");
|
||||
|
||||
// Open several popups during test
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const popup = await context.newPage();
|
||||
await popup.goto(`/popup/${i}`);
|
||||
}
|
||||
|
||||
// Close all except main page
|
||||
for (const page of context.pages()) {
|
||||
if (page !== mainPage) {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
expect(context.pages()).toHaveLength(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ----------------------- | ------------------------------ | ------------------------------------------ |
|
||||
| Not waiting for popup | Race condition | Use `waitForEvent("popup")` before trigger |
|
||||
| Testing real OAuth | Slow, flaky, needs credentials | Mock OAuth endpoints |
|
||||
| Assuming popup opens | May be blocked | Handle both open and blocked cases |
|
||||
| Not closing extra pages | Resource leak | Close pages in cleanup |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns
|
||||
- **Network**: See [network-advanced.md](network-advanced.md) for mocking OAuth
|
||||
393
.cursor/skills/playwright-testing/advanced/multi-user.md
Normal file
393
.cursor/skills/playwright-testing/advanced/multi-user.md
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
# Multi-User & Collaboration Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Multiple Browser Contexts](#multiple-browser-contexts)
|
||||
2. [Real-Time Collaboration](#real-time-collaboration)
|
||||
3. [Role-Based Testing](#role-based-testing)
|
||||
4. [Concurrent Actions](#concurrent-actions)
|
||||
5. [Chat & Messaging](#chat--messaging)
|
||||
|
||||
## Multiple Browser Contexts
|
||||
|
||||
### Two Users in Same Test
|
||||
|
||||
```typescript
|
||||
test("two users see each other's changes", async ({ browser }) => {
|
||||
// Create two isolated contexts (like two browsers)
|
||||
const userAContext = await browser.newContext();
|
||||
const userBContext = await browser.newContext();
|
||||
|
||||
const userAPage = await userAContext.newPage();
|
||||
const userBPage = await userBContext.newPage();
|
||||
|
||||
// Both users go to the same document
|
||||
await userAPage.goto("/doc/shared-123");
|
||||
await userBPage.goto("/doc/shared-123");
|
||||
|
||||
// User A types
|
||||
await userAPage.getByLabel("Content").fill("Hello from User A");
|
||||
|
||||
// User B should see the change
|
||||
await expect(userBPage.getByText("Hello from User A")).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await userAContext.close();
|
||||
await userBContext.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Users with Auth States
|
||||
|
||||
```typescript
|
||||
test("admin and user interaction", async ({ browser }) => {
|
||||
// Load different auth states
|
||||
const adminContext = await browser.newContext({
|
||||
storageState: ".auth/admin.json",
|
||||
});
|
||||
const userContext = await browser.newContext({
|
||||
storageState: ".auth/user.json",
|
||||
});
|
||||
|
||||
const adminPage = await adminContext.newPage();
|
||||
const userPage = await userContext.newPage();
|
||||
|
||||
// User submits request
|
||||
await userPage.goto("/support");
|
||||
await userPage.getByLabel("Message").fill("Need help!");
|
||||
await userPage.getByRole("button", { name: "Submit" }).click();
|
||||
|
||||
// Admin sees and responds
|
||||
await adminPage.goto("/admin/tickets");
|
||||
await expect(adminPage.getByText("Need help!")).toBeVisible();
|
||||
await adminPage.getByRole("button", { name: "Reply" }).click();
|
||||
await adminPage.getByLabel("Response").fill("How can I help?");
|
||||
await adminPage.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// User sees response
|
||||
await expect(userPage.getByText("How can I help?")).toBeVisible();
|
||||
|
||||
await adminContext.close();
|
||||
await userContext.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-User Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/multi-user.fixture.ts
|
||||
import { test as base, Browser, BrowserContext, Page } from "@playwright/test";
|
||||
|
||||
type UserSession = {
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
};
|
||||
|
||||
type MultiUserFixtures = {
|
||||
createUser: (authState?: string) => Promise<UserSession>;
|
||||
};
|
||||
|
||||
export const test = base.extend<MultiUserFixtures>({
|
||||
createUser: async ({ browser }, use) => {
|
||||
const sessions: UserSession[] = [];
|
||||
|
||||
await use(async (authState) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: authState,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
sessions.push({ context, page });
|
||||
return { context, page };
|
||||
});
|
||||
|
||||
// Cleanup all sessions
|
||||
for (const session of sessions) {
|
||||
await session.context.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("3 users collaborate", async ({ createUser }) => {
|
||||
const alice = await createUser(".auth/alice.json");
|
||||
const bob = await createUser(".auth/bob.json");
|
||||
const charlie = await createUser(".auth/charlie.json");
|
||||
|
||||
// All navigate to same room
|
||||
await alice.page.goto("/room/123");
|
||||
await bob.page.goto("/room/123");
|
||||
await charlie.page.goto("/room/123");
|
||||
|
||||
// Test interactions...
|
||||
});
|
||||
```
|
||||
|
||||
## Real-Time Collaboration
|
||||
|
||||
### Collaborative Document
|
||||
|
||||
```typescript
|
||||
test("real-time collaborative editing", async ({ browser }) => {
|
||||
const user1 = await browser.newContext();
|
||||
const user2 = await browser.newContext();
|
||||
|
||||
const page1 = await user1.newPage();
|
||||
const page2 = await user2.newPage();
|
||||
|
||||
await page1.goto("/docs/shared");
|
||||
await page2.goto("/docs/shared");
|
||||
|
||||
// User 1 types at the beginning
|
||||
const editor1 = page1.getByRole("textbox");
|
||||
await editor1.click();
|
||||
await editor1.press("Home");
|
||||
await editor1.type("User 1: ");
|
||||
|
||||
// User 2 types at the end
|
||||
const editor2 = page2.getByRole("textbox");
|
||||
await editor2.click();
|
||||
await editor2.press("End");
|
||||
await editor2.type(" - User 2");
|
||||
|
||||
// Both should see combined result
|
||||
await expect(page1.getByRole("textbox")).toContainText("User 1:");
|
||||
await expect(page1.getByRole("textbox")).toContainText("- User 2");
|
||||
await expect(page2.getByRole("textbox")).toContainText("User 1:");
|
||||
await expect(page2.getByRole("textbox")).toContainText("- User 2");
|
||||
|
||||
await user1.close();
|
||||
await user2.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Cursor Presence
|
||||
|
||||
```typescript
|
||||
test("shows other user cursors", async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext();
|
||||
const ctx2 = await browser.newContext();
|
||||
|
||||
const page1 = await ctx1.newPage();
|
||||
const page2 = await ctx2.newPage();
|
||||
|
||||
// Mock to identify users
|
||||
await page1.route("**/api/me", (route) =>
|
||||
route.fulfill({ json: { id: "user-1", name: "Alice" } }),
|
||||
);
|
||||
await page2.route("**/api/me", (route) =>
|
||||
route.fulfill({ json: { id: "user-2", name: "Bob" } }),
|
||||
);
|
||||
|
||||
await page1.goto("/whiteboard/123");
|
||||
await page2.goto("/whiteboard/123");
|
||||
|
||||
// Move cursor on page1
|
||||
await page1.mouse.move(200, 200);
|
||||
|
||||
// Page2 should see Alice's cursor
|
||||
await expect(page2.getByTestId("cursor-user-1")).toBeVisible();
|
||||
await expect(page2.getByText("Alice")).toBeVisible();
|
||||
|
||||
await ctx1.close();
|
||||
await ctx2.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Role-Based Testing
|
||||
|
||||
### Test RBAC
|
||||
|
||||
```typescript
|
||||
const roles = [
|
||||
{ role: "admin", canDelete: true, canEdit: true, canView: true },
|
||||
{ role: "editor", canDelete: false, canEdit: true, canView: true },
|
||||
{ role: "viewer", canDelete: false, canEdit: false, canView: true },
|
||||
];
|
||||
|
||||
for (const { role, canDelete, canEdit, canView } of roles) {
|
||||
test(`${role} permissions`, async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: `.auth/${role}.json`,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/document/123");
|
||||
|
||||
// Check view permission
|
||||
if (canView) {
|
||||
await expect(page.getByTestId("content")).toBeVisible();
|
||||
} else {
|
||||
await expect(page.getByText("Access denied")).toBeVisible();
|
||||
}
|
||||
|
||||
// Check edit permission
|
||||
const editButton = page.getByRole("button", { name: "Edit" });
|
||||
if (canEdit) {
|
||||
await expect(editButton).toBeEnabled();
|
||||
} else {
|
||||
await expect(editButton).toBeDisabled();
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const deleteButton = page.getByRole("button", { name: "Delete" });
|
||||
if (canDelete) {
|
||||
await expect(deleteButton).toBeVisible();
|
||||
} else {
|
||||
await expect(deleteButton).toBeHidden();
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Escalation Test
|
||||
|
||||
```typescript
|
||||
test("cannot access admin routes as user", async ({ browser }) => {
|
||||
const userContext = await browser.newContext({
|
||||
storageState: ".auth/user.json",
|
||||
});
|
||||
const page = await userContext.newPage();
|
||||
|
||||
// Try to access admin page directly
|
||||
await page.goto("/admin/users");
|
||||
|
||||
// Should redirect or show error
|
||||
await expect(page).not.toHaveURL("/admin/users");
|
||||
await expect(page.getByText("Access denied")).toBeVisible();
|
||||
|
||||
await userContext.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Concurrent Actions
|
||||
|
||||
### Race Condition Testing
|
||||
|
||||
```typescript
|
||||
test("handles concurrent edits", async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext();
|
||||
const ctx2 = await browser.newContext();
|
||||
|
||||
const page1 = await ctx1.newPage();
|
||||
const page2 = await ctx2.newPage();
|
||||
|
||||
await page1.goto("/item/123");
|
||||
await page2.goto("/item/123");
|
||||
|
||||
// Both click edit at the same time
|
||||
await Promise.all([
|
||||
page1.getByRole("button", { name: "Edit" }).click(),
|
||||
page2.getByRole("button", { name: "Edit" }).click(),
|
||||
]);
|
||||
|
||||
// Both try to save different values
|
||||
await page1.getByLabel("Name").fill("Value from User 1");
|
||||
await page2.getByLabel("Name").fill("Value from User 2");
|
||||
|
||||
await Promise.all([
|
||||
page1.getByRole("button", { name: "Save" }).click(),
|
||||
page2.getByRole("button", { name: "Save" }).click(),
|
||||
]);
|
||||
|
||||
// One should succeed, one should get conflict error
|
||||
const page1HasConflict = await page1.getByText("Conflict").isVisible();
|
||||
const page2HasConflict = await page2.getByText("Conflict").isVisible();
|
||||
|
||||
// Exactly one should have conflict
|
||||
expect(page1HasConflict || page2HasConflict).toBe(true);
|
||||
expect(page1HasConflict && page2HasConflict).toBe(false);
|
||||
|
||||
await ctx1.close();
|
||||
await ctx2.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Optimistic Locking Test
|
||||
|
||||
```typescript
|
||||
test("optimistic locking prevents overwrites", async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext();
|
||||
const ctx2 = await browser.newContext();
|
||||
|
||||
const page1 = await ctx1.newPage();
|
||||
const page2 = await ctx2.newPage();
|
||||
|
||||
// Both load the same version
|
||||
await page1.goto("/record/123");
|
||||
await page2.goto("/record/123");
|
||||
|
||||
// User 1 edits and saves first
|
||||
await page1.getByRole("button", { name: "Edit" }).click();
|
||||
await page1.getByLabel("Value").fill("Updated by User 1");
|
||||
await page1.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page1.getByText("Saved")).toBeVisible();
|
||||
|
||||
// User 2 tries to save with stale version
|
||||
await page2.getByRole("button", { name: "Edit" }).click();
|
||||
await page2.getByLabel("Value").fill("Updated by User 2");
|
||||
await page2.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// Should fail with version conflict
|
||||
await expect(page2.getByText("Someone else modified this")).toBeVisible();
|
||||
await expect(page2.getByRole("button", { name: "Reload" })).toBeVisible();
|
||||
|
||||
await ctx1.close();
|
||||
await ctx2.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Chat & Messaging
|
||||
|
||||
### Real-Time Chat
|
||||
|
||||
```typescript
|
||||
test("chat messages sync between users", async ({ browser }) => {
|
||||
const aliceCtx = await browser.newContext();
|
||||
const bobCtx = await browser.newContext();
|
||||
|
||||
const alicePage = await aliceCtx.newPage();
|
||||
const bobPage = await bobCtx.newPage();
|
||||
|
||||
// Setup user identities
|
||||
await alicePage.route("**/api/me", (r) =>
|
||||
r.fulfill({ json: { name: "Alice" } }),
|
||||
);
|
||||
await bobPage.route("**/api/me", (r) => r.fulfill({ json: { name: "Bob" } }));
|
||||
|
||||
await alicePage.goto("/chat/room-1");
|
||||
await bobPage.goto("/chat/room-1");
|
||||
|
||||
// Alice sends message
|
||||
await alicePage.getByLabel("Message").fill("Hi Bob!");
|
||||
await alicePage.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// Bob sees it
|
||||
await expect(bobPage.getByText("Alice: Hi Bob!")).toBeVisible();
|
||||
|
||||
// Bob replies
|
||||
await bobPage.getByLabel("Message").fill("Hey Alice!");
|
||||
await bobPage.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// Alice sees it
|
||||
await expect(alicePage.getByText("Bob: Hey Alice!")).toBeVisible();
|
||||
|
||||
await aliceCtx.close();
|
||||
await bobCtx.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ----------------------------- | ----------------------------- | ---------------------------- |
|
||||
| Sharing context between users | State leaks, not isolated | Create separate contexts |
|
||||
| Not closing contexts | Memory leak, browser overload | Always close in cleanup |
|
||||
| Hardcoded timing for sync | Flaky tests | Use `expect().toBeVisible()` |
|
||||
| Testing only single user | Misses collaboration bugs | Test multi-user scenarios |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup
|
||||
- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking
|
||||
452
.cursor/skills/playwright-testing/advanced/network-advanced.md
Normal file
452
.cursor/skills/playwright-testing/advanced/network-advanced.md
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
# Advanced Network Interception
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Request Modification](#request-modification)
|
||||
2. [GraphQL Mocking](#graphql-mocking)
|
||||
3. [HAR Recording & Playback](#har-recording--playback)
|
||||
4. [Conditional Mocking](#conditional-mocking)
|
||||
5. [Network Throttling](#network-throttling)
|
||||
|
||||
## Request Modification
|
||||
|
||||
### Modify Request Headers
|
||||
|
||||
```typescript
|
||||
test("add auth header to requests", async ({ page }) => {
|
||||
await page.route("**/api/**", (route) => {
|
||||
const headers = {
|
||||
...route.request().headers(),
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Test-Header": "test-value",
|
||||
};
|
||||
route.continue({ headers });
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
});
|
||||
```
|
||||
|
||||
### Modify Request Body
|
||||
|
||||
```typescript
|
||||
test("modify POST body", async ({ page }) => {
|
||||
await page.route("**/api/orders", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
const postData = route.request().postDataJSON();
|
||||
|
||||
// Add test metadata
|
||||
const modifiedData = {
|
||||
...postData,
|
||||
testMode: true,
|
||||
testTimestamp: Date.now(),
|
||||
};
|
||||
|
||||
await route.continue({
|
||||
postData: JSON.stringify(modifiedData),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Place Order" }).click();
|
||||
});
|
||||
```
|
||||
|
||||
### Transform Response
|
||||
|
||||
```typescript
|
||||
test("modify API response", async ({ page }) => {
|
||||
await page.route("**/api/products", async (route) => {
|
||||
// Fetch real response
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
|
||||
// Modify response
|
||||
const modified = json.map((product: any) => ({
|
||||
...product,
|
||||
price: product.price * 0.9, // 10% discount
|
||||
testMode: true,
|
||||
}));
|
||||
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: modified,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/products");
|
||||
});
|
||||
```
|
||||
|
||||
## GraphQL Mocking
|
||||
|
||||
### Mock by Operation Name
|
||||
|
||||
```typescript
|
||||
test("mock GraphQL query", async ({ page }) => {
|
||||
await page.route("**/graphql", async (route) => {
|
||||
const postData = route.request().postDataJSON();
|
||||
|
||||
if (postData.operationName === "GetUser") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
user: {
|
||||
id: "1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (postData.operationName === "GetProducts") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
products: [
|
||||
{ id: "1", name: "Product A", price: 29.99 },
|
||||
{ id: "2", name: "Product B", price: 49.99 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Pass through unmocked operations
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
});
|
||||
```
|
||||
|
||||
### GraphQL Mock Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/graphql.fixture.ts
|
||||
type GraphQLMock = {
|
||||
operation: string;
|
||||
variables?: Record<string, any>;
|
||||
response: { data?: any; errors?: any[] };
|
||||
};
|
||||
|
||||
type GraphQLFixtures = {
|
||||
mockGraphQL: (mocks: GraphQLMock[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<GraphQLFixtures>({
|
||||
mockGraphQL: async ({ page }, use) => {
|
||||
await use(async (mocks) => {
|
||||
await page.route("**/graphql", async (route) => {
|
||||
const postData = route.request().postDataJSON();
|
||||
|
||||
const mock = mocks.find((m) => {
|
||||
if (m.operation !== postData.operationName) return false;
|
||||
|
||||
// Optionally match variables
|
||||
if (m.variables) {
|
||||
return (
|
||||
JSON.stringify(m.variables) === JSON.stringify(postData.variables)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (mock) {
|
||||
return route.fulfill({ json: mock.response });
|
||||
}
|
||||
|
||||
return route.continue();
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("dashboard with mocked GraphQL", async ({ page, mockGraphQL }) => {
|
||||
await mockGraphQL([
|
||||
{
|
||||
operation: "GetDashboardStats",
|
||||
response: {
|
||||
data: { stats: { users: 100, revenue: 50000 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: "GetUser",
|
||||
variables: { id: "1" },
|
||||
response: {
|
||||
data: { user: { id: "1", name: "John" } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByText("100 users")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock GraphQL Mutations
|
||||
|
||||
```typescript
|
||||
test("mock GraphQL mutation", async ({ page }) => {
|
||||
await page.route("**/graphql", async (route) => {
|
||||
const postData = route.request().postDataJSON();
|
||||
|
||||
if (postData.operationName === "CreateOrder") {
|
||||
const { input } = postData.variables;
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
createOrder: {
|
||||
id: "order-123",
|
||||
status: "PENDING",
|
||||
items: input.items,
|
||||
total: input.items.reduce(
|
||||
(sum: number, item: any) => sum + item.price * item.quantity,
|
||||
0,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Place Order" }).click();
|
||||
|
||||
await expect(page.getByText("Order #order-123")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## HAR Recording & Playback
|
||||
|
||||
### Record HAR File
|
||||
|
||||
```typescript
|
||||
// Record network traffic
|
||||
test("record HAR", async ({ page, context }) => {
|
||||
// Start recording
|
||||
await context.routeFromHAR("./recordings/checkout.har", {
|
||||
update: true, // Create/update HAR file
|
||||
url: "**/api/**",
|
||||
});
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Place Order" }).click();
|
||||
|
||||
// HAR file is saved automatically
|
||||
});
|
||||
```
|
||||
|
||||
### Playback HAR File
|
||||
|
||||
```typescript
|
||||
// Use recorded HAR for offline testing
|
||||
test("playback HAR", async ({ page, context }) => {
|
||||
await context.routeFromHAR("./recordings/checkout.har", {
|
||||
url: "**/api/**",
|
||||
update: false, // Don't update, just playback
|
||||
});
|
||||
|
||||
await page.goto("/checkout");
|
||||
|
||||
// All API calls served from HAR file
|
||||
await expect(page.getByText("Order confirmed")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### HAR with Fallback
|
||||
|
||||
```typescript
|
||||
test("HAR with live fallback", async ({ page, context }) => {
|
||||
await context.routeFromHAR("./recordings/api.har", {
|
||||
url: "**/api/**",
|
||||
update: false,
|
||||
notFound: "fallback", // Use real network if not in HAR
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
});
|
||||
```
|
||||
|
||||
## Conditional Mocking
|
||||
|
||||
### Mock Based on Request Body
|
||||
|
||||
```typescript
|
||||
test("conditional mock by body", async ({ page }) => {
|
||||
await page.route("**/api/search", async (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
|
||||
if (body.query === "error") {
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { error: "Search failed" },
|
||||
});
|
||||
}
|
||||
|
||||
if (body.query === "empty") {
|
||||
return route.fulfill({
|
||||
json: { results: [] },
|
||||
});
|
||||
}
|
||||
|
||||
// Default response
|
||||
return route.fulfill({
|
||||
json: {
|
||||
results: [{ id: 1, title: `Result for: ${body.query}` }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/search");
|
||||
|
||||
// Test different scenarios
|
||||
await page.getByLabel("Search").fill("error");
|
||||
await page.getByLabel("Search").press("Enter");
|
||||
await expect(page.getByText("Search failed")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Nth Request
|
||||
|
||||
```typescript
|
||||
test("different response on retry", async ({ page }) => {
|
||||
let callCount = 0;
|
||||
|
||||
await page.route("**/api/status", (route) => {
|
||||
callCount++;
|
||||
|
||||
if (callCount < 3) {
|
||||
return route.fulfill({
|
||||
status: 503,
|
||||
json: { error: "Service unavailable" },
|
||||
});
|
||||
}
|
||||
|
||||
// Succeed on 3rd attempt
|
||||
return route.fulfill({
|
||||
json: { status: "ok" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// App should retry and eventually succeed
|
||||
await expect(page.getByText("Connected")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock with Delay
|
||||
|
||||
```typescript
|
||||
test("slow network simulation", async ({ page }) => {
|
||||
await page.route("**/api/data", async (route) => {
|
||||
// Simulate 2 second delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
return route.fulfill({
|
||||
json: { data: "loaded" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Loading state should appear
|
||||
await expect(page.getByText("Loading...")).toBeVisible();
|
||||
|
||||
// Then data appears
|
||||
await expect(page.getByText("loaded")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Network Throttling
|
||||
|
||||
### Slow 3G Simulation
|
||||
|
||||
```typescript
|
||||
test("slow network experience", async ({ page, context }) => {
|
||||
// Create CDP session for network throttling
|
||||
const client = await context.newCDPSession(page);
|
||||
|
||||
await client.send("Network.emulateNetworkConditions", {
|
||||
offline: false,
|
||||
downloadThroughput: (500 * 1024) / 8, // 500 Kbps
|
||||
uploadThroughput: (500 * 1024) / 8,
|
||||
latency: 400, // 400ms
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Test loading states appear
|
||||
await expect(page.getByTestId("skeleton-loader")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Offline Mode
|
||||
|
||||
Use `context.setOffline(true/false)` to simulate network connectivity changes.
|
||||
|
||||
> **For comprehensive offline testing patterns:**
|
||||
>
|
||||
> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing)
|
||||
> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing)
|
||||
|
||||
### Network Throttling Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/network.fixture.ts
|
||||
type NetworkCondition = "slow3g" | "fast3g" | "offline";
|
||||
|
||||
const conditions = {
|
||||
slow3g: { downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000 },
|
||||
fast3g: { downloadThroughput: 180000, uploadThroughput: 75000, latency: 150 },
|
||||
};
|
||||
|
||||
type NetworkFixtures = {
|
||||
setNetworkCondition: (condition: NetworkCondition) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<NetworkFixtures>({
|
||||
setNetworkCondition: async ({ page, context }, use) => {
|
||||
const client = await context.newCDPSession(page);
|
||||
|
||||
await use(async (condition) => {
|
||||
if (condition === "offline") {
|
||||
await context.setOffline(true);
|
||||
} else {
|
||||
await client.send("Network.emulateNetworkConditions", {
|
||||
offline: false,
|
||||
...conditions[condition],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reset
|
||||
await context.setOffline(false);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------ | ------------------------------ | -------------------------------- |
|
||||
| Mocking all requests | Tests don't reflect reality | Mock only what's necessary |
|
||||
| No cleanup of routes | Routes persist across tests | Use fixtures with cleanup |
|
||||
| Ignoring request method | Mock applies to wrong requests | Check `route.request().method()` |
|
||||
| Hardcoded mock responses | Brittle, hard to maintain | Use factories for mock data |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Basic Mocking**: See [test-suite-structure.md](../core/test-suite-structure.md) for simple mocking
|
||||
- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking
|
||||
464
.cursor/skills/playwright-testing/advanced/third-party.md
Normal file
464
.cursor/skills/playwright-testing/advanced/third-party.md
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
# Third-Party Service Mocking
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [OAuth/SSO Mocking](#oauthsso-mocking)
|
||||
2. [Payment Gateway Mocking](#payment-gateway-mocking)
|
||||
3. [Email Verification](#email-verification)
|
||||
4. [SMS Verification](#sms-verification)
|
||||
5. [Analytics & Tracking](#analytics--tracking)
|
||||
|
||||
## OAuth/SSO Mocking
|
||||
|
||||
### Mock Google OAuth
|
||||
|
||||
```typescript
|
||||
test("Google OAuth login", async ({ page }) => {
|
||||
// Mock the OAuth callback
|
||||
await page.route("**/auth/google/callback**", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
// Simulate successful OAuth by redirecting with token
|
||||
route.fulfill({
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: "/dashboard?token=mock-jwt-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock the token verification endpoint
|
||||
await page.route("**/api/auth/verify", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
valid: true,
|
||||
user: {
|
||||
id: "123",
|
||||
email: "test@gmail.com",
|
||||
name: "Test User",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Sign in with Google" }).click();
|
||||
|
||||
await expect(page.getByText("Welcome, Test User")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### OAuth Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/oauth.fixture.ts
|
||||
type OAuthProvider = "google" | "github" | "microsoft";
|
||||
|
||||
type OAuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
type OAuthFixtures = {
|
||||
mockOAuth: (provider: OAuthProvider, user: OAuthUser) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<OAuthFixtures>({
|
||||
mockOAuth: async ({ page }, use) => {
|
||||
await use(async (provider, user) => {
|
||||
// Mock callback redirect
|
||||
await page.route(`**/auth/${provider}/callback**`, (route) =>
|
||||
route.fulfill({
|
||||
status: 302,
|
||||
headers: { Location: `/auth/success?provider=${provider}` },
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock session/user endpoint
|
||||
await page.route("**/api/auth/session", (route) =>
|
||||
route.fulfill({
|
||||
json: { user, provider, authenticated: true },
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock user info endpoint
|
||||
await page.route("**/api/me", (route) => route.fulfill({ json: user }));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("login with GitHub", async ({ page, mockOAuth }) => {
|
||||
await mockOAuth("github", {
|
||||
id: "gh-123",
|
||||
email: "dev@github.com",
|
||||
name: "GitHub User",
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Sign in with GitHub" }).click();
|
||||
|
||||
await expect(page.getByText("Welcome, GitHub User")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock SAML SSO
|
||||
|
||||
```typescript
|
||||
test("SAML SSO login", async ({ page }) => {
|
||||
// Mock SAML assertion consumer service
|
||||
await page.route("**/saml/acs", async (route) => {
|
||||
route.fulfill({
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: "/dashboard",
|
||||
"Set-Cookie": "session=mock-saml-session; Path=/; HttpOnly",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock session validation
|
||||
await page.route("**/api/session", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
user: { email: "user@company.com", name: "SSO User" },
|
||||
provider: "saml",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "SSO Login" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/dashboard");
|
||||
});
|
||||
```
|
||||
|
||||
## Payment Gateway Mocking
|
||||
|
||||
### Mock Stripe
|
||||
|
||||
```typescript
|
||||
test("Stripe checkout", async ({ page }) => {
|
||||
// Mock Stripe.js
|
||||
await page.addInitScript(() => {
|
||||
(window as any).Stripe = () => ({
|
||||
elements: () => ({
|
||||
create: () => ({
|
||||
mount: () => {},
|
||||
on: () => {},
|
||||
destroy: () => {},
|
||||
}),
|
||||
}),
|
||||
confirmCardPayment: async () => ({
|
||||
paymentIntent: { status: "succeeded", id: "pi_mock_123" },
|
||||
}),
|
||||
createPaymentMethod: async () => ({
|
||||
paymentMethod: { id: "pm_mock_123" },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock backend payment endpoint
|
||||
await page.route("**/api/create-payment-intent", (route) =>
|
||||
route.fulfill({
|
||||
json: { clientSecret: "pi_mock_123_secret_mock" },
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/confirm-payment", (route) =>
|
||||
route.fulfill({
|
||||
json: { success: true, orderId: "order-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Pay $99.99" }).click();
|
||||
|
||||
await expect(page.getByText("Payment successful")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock PayPal
|
||||
|
||||
```typescript
|
||||
test("PayPal checkout", async ({ page }) => {
|
||||
// Mock PayPal SDK
|
||||
await page.addInitScript(() => {
|
||||
(window as any).paypal = {
|
||||
Buttons: () => ({
|
||||
render: () => Promise.resolve(),
|
||||
isEligible: () => true,
|
||||
}),
|
||||
FUNDING: { PAYPAL: "paypal", CARD: "card" },
|
||||
};
|
||||
});
|
||||
|
||||
// Mock PayPal order creation
|
||||
await page.route("**/api/paypal/create-order", (route) =>
|
||||
route.fulfill({
|
||||
json: { orderId: "PAYPAL-ORDER-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock PayPal capture
|
||||
await page.route("**/api/paypal/capture", (route) =>
|
||||
route.fulfill({
|
||||
json: { success: true, transactionId: "TXN-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/checkout");
|
||||
|
||||
// Simulate PayPal approval callback
|
||||
await page.evaluate(() => {
|
||||
(window as any).onPayPalApprove?.({ orderID: "PAYPAL-ORDER-123" });
|
||||
});
|
||||
|
||||
await expect(page.getByText("Order confirmed")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Payment Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/payment.fixture.ts
|
||||
type PaymentFixtures = {
|
||||
mockStripe: (options?: { failPayment?: boolean }) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<PaymentFixtures>({
|
||||
mockStripe: async ({ page }, use) => {
|
||||
await use(async (options = {}) => {
|
||||
await page.addInitScript(
|
||||
([shouldFail]) => {
|
||||
(window as any).Stripe = () => ({
|
||||
elements: () => ({
|
||||
create: () => ({
|
||||
mount: () => {},
|
||||
on: (event: string, handler: Function) => {
|
||||
if (event === "ready") setTimeout(handler, 100);
|
||||
},
|
||||
destroy: () => {},
|
||||
}),
|
||||
}),
|
||||
confirmCardPayment: async () => {
|
||||
if (shouldFail) {
|
||||
return { error: { message: "Card declined" } };
|
||||
}
|
||||
return { paymentIntent: { status: "succeeded" } };
|
||||
},
|
||||
});
|
||||
},
|
||||
[options.failPayment],
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("handles declined card", async ({ page, mockStripe }) => {
|
||||
await mockStripe({ failPayment: true });
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Pay" }).click();
|
||||
|
||||
await expect(page.getByText("Card declined")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Email Verification
|
||||
|
||||
### Mock Email API
|
||||
|
||||
```typescript
|
||||
test("email verification flow", async ({ page, request }) => {
|
||||
let verificationToken: string;
|
||||
|
||||
// Capture the verification email
|
||||
await page.route("**/api/send-verification", async (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
verificationToken = `mock-token-${Date.now()}`;
|
||||
|
||||
// Don't actually send email, just store token
|
||||
route.fulfill({
|
||||
json: { sent: true, messageId: "msg-123" },
|
||||
});
|
||||
});
|
||||
|
||||
// Mock token verification
|
||||
await page.route("**/api/verify-email**", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
if (token === verificationToken) {
|
||||
route.fulfill({ json: { verified: true } });
|
||||
} else {
|
||||
route.fulfill({ status: 400, json: { error: "Invalid token" } });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/signup");
|
||||
await page.getByLabel("Email").fill("test@example.com");
|
||||
await page.getByRole("button", { name: "Sign Up" }).click();
|
||||
|
||||
await expect(page.getByText("Check your email")).toBeVisible();
|
||||
|
||||
// Simulate clicking email link
|
||||
await page.goto(`/verify?token=${verificationToken}`);
|
||||
|
||||
await expect(page.getByText("Email verified")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Use Mailinator/Temp Mail
|
||||
|
||||
```typescript
|
||||
// fixtures/email.fixture.ts
|
||||
type EmailFixtures = {
|
||||
getVerificationEmail: (inbox: string) => Promise<{ link: string }>;
|
||||
};
|
||||
|
||||
export const test = base.extend<EmailFixtures>({
|
||||
getVerificationEmail: async ({ request }, use) => {
|
||||
await use(async (inbox) => {
|
||||
// Poll Mailinator API for new email
|
||||
const response = await request.get(
|
||||
`https://api.mailinator.com/v2/domains/public/inboxes/${inbox}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const messages = await response.json();
|
||||
const latest = messages.msgs[0];
|
||||
|
||||
// Get full message
|
||||
const msgResponse = await request.get(
|
||||
`https://api.mailinator.com/v2/domains/public/inboxes/${inbox}/messages/${latest.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const message = await msgResponse.json();
|
||||
|
||||
// Extract verification link from HTML
|
||||
const linkMatch = message.parts[0].body.match(
|
||||
/href="([^"]*verify[^"]*)"/,
|
||||
);
|
||||
return { link: linkMatch?.[1] || "" };
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## SMS Verification
|
||||
|
||||
### Mock SMS API
|
||||
|
||||
```typescript
|
||||
test("SMS verification", async ({ page }) => {
|
||||
let smsCode: string;
|
||||
|
||||
// Capture SMS send
|
||||
await page.route("**/api/send-sms", (route) => {
|
||||
smsCode = Math.random().toString().slice(2, 8); // 6-digit code
|
||||
|
||||
route.fulfill({
|
||||
json: { sent: true, messageId: "sms-123" },
|
||||
});
|
||||
});
|
||||
|
||||
// Mock code verification
|
||||
await page.route("**/api/verify-sms", (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
|
||||
if (body.code === smsCode) {
|
||||
route.fulfill({ json: { verified: true } });
|
||||
} else {
|
||||
route.fulfill({ status: 400, json: { error: "Invalid code" } });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/verify-phone");
|
||||
await page.getByLabel("Phone").fill("+1234567890");
|
||||
await page.getByRole("button", { name: "Send Code" }).click();
|
||||
|
||||
// Enter the code
|
||||
await page.getByLabel("Verification Code").fill(smsCode);
|
||||
await page.getByRole("button", { name: "Verify" }).click();
|
||||
|
||||
await expect(page.getByText("Phone verified")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Analytics & Tracking
|
||||
|
||||
### Block Analytics in Tests
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Block all analytics/tracking
|
||||
await page.route(
|
||||
/google-analytics|googletagmanager|facebook|hotjar|segment|mixpanel|amplitude/,
|
||||
(route) => route.abort(),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Analytics for Verification
|
||||
|
||||
```typescript
|
||||
test("tracks purchase event", async ({ page }) => {
|
||||
const analyticsEvents: any[] = [];
|
||||
|
||||
// Capture analytics calls
|
||||
await page.route("**/api/analytics/**", (route) => {
|
||||
analyticsEvents.push(route.request().postDataJSON());
|
||||
route.fulfill({ status: 200 });
|
||||
});
|
||||
|
||||
// Mock analytics SDK
|
||||
await page.addInitScript(() => {
|
||||
(window as any).analytics = {
|
||||
track: (event: string, props: any) => {
|
||||
fetch("/api/analytics/track", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ event, props }),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Complete Purchase" }).click();
|
||||
|
||||
// Verify analytics event was sent
|
||||
expect(analyticsEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: "Purchase Completed",
|
||||
props: expect.objectContaining({ amount: expect.any(Number) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------- | ------------------------------ | ----------------------- |
|
||||
| Using real OAuth in tests | Slow, needs credentials, flaky | Mock OAuth endpoints |
|
||||
| Real payment processing | Charges real money, slow | Use test mode or mock |
|
||||
| Waiting for real emails | Very slow, unreliable | Mock email API |
|
||||
| Not mocking analytics | Pollutes analytics data | Block or mock analytics |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Network Mocking**: See [network-advanced.md](network-advanced.md) for route patterns
|
||||
- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns
|
||||
Loading…
Add table
Add a link
Reference in a new issue