alpharigel

web-e2e

0
0
# Install this skill:
npx skills add alpharigel/web-e2e-skill --skill "web-e2e"

Install specific skill from multi-skill repository

# Description

End-to-end web app testing with Playwright. Use when the user asks to set up e2e tests, write browser tests, test web UI flows, or verify web app behavior.

# SKILL.md


name: web-e2e
description: End-to-end web app testing with Playwright. Use when the user asks to set up e2e tests, write browser tests, test web UI flows, or verify web app behavior.


Web E2E Testing with Playwright

Set up and write end-to-end tests for web applications using Playwright.

Setup

Install

npm install -D @playwright/test
npx playwright install chromium

Config

Create playwright.config.ts at the project root:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  timeout: 15000,
  retries: 0,
  use: {
    baseURL: 'http://localhost:3000',  // adjust to your dev server port
    headless: true,
  },
  webServer: {
    command: 'npm run dev',
    port: 3000,                        // must match baseURL
    reuseExistingServer: true,
    timeout: 30000,
  },
  projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
});

Key points:
- webServer auto-starts your dev server and waits for it β€” no manual startup needed
- reuseExistingServer: true means if you already have npm run dev running, Playwright uses it
- Start with Chromium only β€” add Firefox/Safari later if needed

Package.json scripts

{
  "scripts": {
    "test:e2e": "npx playwright test",
    "test:e2e:ui": "npx playwright test --ui"
  }
}

Writing Tests

Basic structure

import { test, expect } from '@playwright/test';

test.describe('Feature name', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });

  test('does something', async ({ page }) => {
    await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
  });
});

Finding elements (prefer in this order)

  1. Role β€” most resilient: page.getByRole('button', { name: 'Submit' })
  2. Text β€” for visible text: page.getByText('Sign in')
  3. Label β€” for form fields: page.getByLabel('Email')
  4. Placeholder β€” fallback for inputs: page.getByPlaceholder('Enter email')
  5. CSS selector β€” last resort: page.locator('#username'), page.locator('button[aria-label="Send"]')

Forms

// Fill and submit
await page.locator('#email').fill('[email protected]');
await page.locator('#password').fill('secret');
await page.getByRole('button', { name: 'Sign In' }).click();

// Submit via Enter key
await page.locator('#password').press('Enter');

// Verify validation error
await expect(page.getByText('Invalid email')).toBeVisible();
// Wait for redirect
await page.goto('/protected');
await page.waitForURL('**/login**', { timeout: 5000 });
expect(page.url()).toContain('/login');

Assertions

await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toHaveText('exact text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveValue('input value');
await expect(locator).toBeDisabled();
await expect(locator).toBeEnabled();
await expect(locator).toHaveAttribute('href', '/settings');
await expect(locator).toHaveCount(3);  // number of matching elements

All assertions auto-retry until timeout β€” no need for manual waits.

Common Patterns

localStorage / sessionStorage setup

Apps that store auth tokens or config in localStorage need it seeded before navigating:

test.beforeEach(async ({ page }) => {
  // Navigate first (localStorage is per-origin)
  await page.goto('/');
  await page.evaluate(() => {
    localStorage.setItem('auth_token', 'test-token-123');
    localStorage.setItem('server_url', 'http://localhost:8000');
  });
  // Re-navigate so the app reads the seeded values
  await page.goto('/');
});

To clear state between tests:

await page.evaluate(() => localStorage.clear());

Injecting app store state (Zustand, Redux, etc.)

If the app exposes its store on window (common for testing), you can set state directly:

// In your app code (dev/test only):
if (process.env.NODE_ENV !== 'production') {
  (window as any).__APP_STORE__ = useStore;
}

// In your test:
await page.evaluate(() => {
  const store = (window as any).__APP_STORE__;
  store.setState({ authState: 'login_required' });
});

This lets you test UI states without simulating the full server interaction.

Auth flow testing

test.describe('with real server', () => {
  const API_URL = process.env.TEST_API_URL || 'http://localhost:8000';

  test.beforeEach(async ({ page }) => {
    await page.goto('/');
    await page.evaluate(() => localStorage.clear());
    await page.goto('/');
  });

  test('shows login form', async ({ page }) => {
    await expect(page.locator('#username')).toBeVisible({ timeout: 5000 });
    await expect(page.locator('#password')).toBeVisible();
  });

  test('login hides modal on success', async ({ page }) => {
    await page.locator('#username').fill('testuser');
    await page.locator('#password').fill('password123');
    await page.getByRole('button', { name: 'Sign In' }).click();
    await expect(page.getByText('Sign in')).toBeHidden({ timeout: 5000 });
  });
});

Environment-specific config

Use env vars so tests work against different backends:

const API_URL = process.env.TEST_API_URL || 'http://localhost:8000';
const WS_URL = process.env.TEST_WS_URL || 'ws://localhost:8765';

Testing without a server (UI-only tests)

Group tests that don't need a running backend separately β€” they're faster and more reliable:

test.describe('UI rendering (no server)', () => {
  test.beforeEach(async ({ page }) => {
    // Point at unreachable URL so no real connection
    await page.goto('/');
    await page.evaluate(() => {
      localStorage.setItem('server_url', 'http://localhost:19999');
    });
    await page.goto('/');
  });

  test('renders form fields', async ({ page }) => {
    await expect(page.locator('#email')).toBeVisible();
  });
});

Debugging

# Run with browser visible
npx playwright test --headed

# Run single test file
npx playwright test e2e/login.spec.ts

# Run single test by name
npx playwright test -g "shows login form"

# Interactive UI mode (best for debugging)
npx playwright test --ui

# Generate trace on failure (add to config)
# use: { trace: 'on-first-retry' }
# Then view: npx playwright show-trace trace.zip

# Screenshot on failure (add to config)
# use: { screenshot: 'only-on-failure' }

Gotchas

  1. localStorage is per-origin β€” you must page.goto() first before page.evaluate() can access localStorage. Navigate, seed, then navigate again.
  2. Assertions auto-retry β€” expect(locator).toBeVisible() polls until timeout. You don't need waitForSelector before asserting.
  3. Don't use page.waitForTimeout() β€” it's a hard sleep and makes tests slow/flaky. Use assertions or waitForURL/waitForSelector instead.
  4. reuseExistingServer: true β€” set this so Playwright doesn't fight with your already-running dev server. Without it, Playwright fails if the port is taken.
  5. Headless can behave differently β€” if a test passes in --headed but fails headless, check viewport size (headless defaults to 1280x720) and animations.
  6. Form submission β€” use .press('Enter') on the last field rather than finding a submit button, when testing keyboard flows.
  7. Multiple elements with same text β€” getByRole('button', { name: 'Save' }) is more specific than getByText('Save') which matches any element.
  8. Strict mode β€” Playwright errors if a locator matches multiple elements. Use .first(), .last(), or .nth(n) to disambiguate, or make the selector more specific.

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.