Awesome-omni-skill playwright-visual-regression

Playwright browser automation patterns for E2E testing, visual regression, screenshot capture, and accessibility validation. Use when implementing or debugging browser-based tests.

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/testing-security/playwright-visual-regression" ~/.claude/skills/diegosouzapw-awesome-omni-skill-playwright-visual-regression && rm -rf "$T"
manifest: skills/testing-security/playwright-visual-regression/SKILL.md
source content

Playwright Visual Regression Testing

This skill documents patterns for E2E testing with Playwright, including visual regression, cross-browser testing, and accessibility automation for OSCAR Export Analyzer.

Setup and Configuration

Installation

npm install -D @playwright/test
npx playwright install  # Install browsers

Configuration (
playwright.config.js
)

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

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});

Basic E2E Test

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

test.describe('CSV Upload Flow', () => {
  test('uploads CSV and displays charts', async ({ page }) => {
    await page.goto('/');

    // Upload CSV file
    const fileInput = page.locator('input[type="file"]');
    await fileInput.setInputFiles('tests/fixtures/sample-cpap-data.csv');

    // Wait for parsing to complete
    await expect(page.locator('text=Parsing complete')).toBeVisible();

    // Verify charts are rendered
    await expect(page.locator('[data-testid="usage-chart"]')).toBeVisible();
    await expect(page.locator('[data-testid="ahi-chart"]')).toBeVisible();
  });
});

Visual Regression Testing

Capture Visual Baseline

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

test.describe('Visual Regression', () => {
  test('chart layout matches baseline', async ({ page }) => {
    // Load test data
    await page.goto('/');
    await page
      .locator('input[type="file"]')
      .setInputFiles('tests/fixtures/test-data.csv');
    await page.waitForLoadState('networkidle');

    // Take screenshot and compare with baseline
    await expect(page).toHaveScreenshot('usage-chart.png', {
      maxDiffPixels: 100, // Allow small differences
    });
  });

  test('dark mode appearance', async ({ page }) => {
    await page.goto('/');

    // Enable dark mode
    await page.locator('[aria-label="Toggle dark mode"]').click();

    // Wait for theme transition
    await page.waitForTimeout(300);

    // Compare with dark mode baseline
    await expect(page).toHaveScreenshot('dark-mode.png');
  });
});

Update Baselines

# Update all baselines
npx playwright test --update-snapshots

# Update specific test baselines
npx playwright test visual-regression --update-snapshots

Baseline Management

  • Store baselines in git:
    tests/e2e/*.spec.js-snapshots/
  • Review visual diffs: Check Playwright HTML report
  • Approve changes: Commit updated baselines after verifying correctness

Responsive Testing

test.describe('Responsive Design', () => {
  test('mobile layout', async ({ page }) => {
    // Set mobile viewport
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');

    // Verify mobile layout
    await expect(page.locator('[data-testid="mobile-menu"]')).toBeVisible();
    await expect(page).toHaveScreenshot('mobile-layout.png');
  });

  test('tablet layout', async ({ page }) => {
    await page.setViewportSize({ width: 768, height: 1024 });
    await page.goto('/');

    await expect(page).toHaveScreenshot('tablet-layout.png');
  });

  test('desktop layout', async ({ page }) => {
    await page.setViewportSize({ width: 1920, height: 1080 });
    await page.goto('/');

    await expect(page).toHaveScreenshot('desktop-layout.png');
  });
});

Chart Interaction Testing

test.describe('Chart Interactions', () => {
  test('zoom chart with mouse wheel', async ({ page }) => {
    await page.goto('/');
    await loadTestData(page);

    const chart = page.locator('[data-testid="ahi-chart"]');

    // Get initial axis range
    const initialRange = await page.evaluate(() => {
      const plotly = window.Plotly;
      const chartDiv = document.querySelector('[data-testid="ahi-chart"]');
      return chartDiv.layout.xaxis.range;
    });

    // Zoom in with mouse wheel
    await chart.hover();
    await page.mouse.wheel(0, -100);

    // Wait for zoom animation
    await page.waitForTimeout(500);

    // Verify axis range changed
    const newRange = await page.evaluate(() => {
      const chartDiv = document.querySelector('[data-testid="ahi-chart"]');
      return chartDiv.layout.xaxis.range;
    });

    expect(newRange).not.toEqual(initialRange);
  });

  test('toggle legend series', async ({ page }) => {
    await page.goto('/');
    await loadTestData(page);

    // Click legend item to hide series
    await page.locator('.legend .traces:has-text("AHI")').click();

    // Verify series hidden
    const isVisible = await page.evaluate(() => {
      const trace = document.querySelector('.trace.scatter');
      return trace.style.opacity === '0.5';
    });

    expect(isVisible).toBe(true);
  });
});

Accessibility Testing

Built-in Accessibility Checks

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Accessibility', () => {
  test('page has no accessibility violations', async ({ page }) => {
    await page.goto('/');
    await loadTestData(page);

    // Run axe accessibility scan
    const accessibilityScanResults = await new AxeBuilder({ page }).analyze();

    expect(accessibilityScanResults.violations).toEqual([]);
  });
});

Keyboard Navigation

test('keyboard navigation works', async ({ page }) => {
  await page.goto('/');

  // Tab to file input
  await page.keyboard.press('Tab');
  await expect(page.locator('input[type="file"]')).toBeFocused();

  // Tab to upload button
  await page.keyboard.press('Tab');
  await expect(page.locator('button:has-text("Upload")')).toBeFocused();

  // Activate with Enter
  await page.keyboard.press('Enter');
});

Screen Reader Testing

test('has proper ARIA labels', async ({ page }) => {
  await page.goto('/');
  await loadTestData(page);

  // Check chart has accessible name
  const chartLabel = await page
    .locator('[data-testid="ahi-chart"]')
    .getAttribute('aria-label');

  expect(chartLabel).toContain('AHI Trends');

  // Check form labels
  await expect(page.locator('label[for="start-date"]')).toHaveText(
    'Start Date',
  );
});

Print Layout Testing

test.describe('Print Layout', () => {
  test('print stylesheet applied', async ({ page }) => {
    await page.goto('/');
    await loadTestData(page);

    // Emulate print media
    await page.emulateMedia({ media: 'print' });

    // Verify print-specific styles
    await expect(page).toHaveScreenshot('print-layout.png');

    // Check elements hidden in print
    const navVisible = await page.locator('nav').isVisible();
    expect(navVisible).toBe(false);
  });

  test('PDF export quality', async ({ page }) => {
    await page.goto('/');
    await loadTestData(page);

    // Trigger print dialog
    await page.locator('button:has-text("Export PDF")').click();

    // Generate PDF
    const pdf = await page.pdf({
      format: 'A4',
      printBackground: true,
    });

    // Verify PDF generated
    expect(pdf.length).toBeGreaterThan(0);
  });
});

Cross-Browser Testing

// Run same test across browsers
test.describe('Cross-Browser Compatibility', () => {
  test('works in all browsers', async ({ page, browserName }) => {
    test.skip(browserName === 'webkit', 'Known webkit issue #123');

    await page.goto('/');
    await loadTestData(page);

    // Verify core functionality works
    await expect(page.locator('[data-testid="usage-chart"]')).toBeVisible();
  });
});

Large File Stress Testing

test.describe('Performance', () => {
  test('handles large CSV upload', async ({ page }) => {
    // Increase timeout for large file
    test.setTimeout(60000);

    await page.goto('/');

    // Upload 30MB CSV
    await page
      .locator('input[type="file"]')
      .setInputFiles('tests/fixtures/large-cpap-data.csv');

    // Wait for parsing (with progress updates)
    await expect(page.locator('text=Parsing')).toBeVisible();
    await expect(page.locator('text=Parsing complete')).toBeVisible({
      timeout: 30000,
    });

    // Verify app still responsive
    await page.locator('button:has-text("Filter")').click();
    await expect(page.locator('[data-testid="date-filter"]')).toBeVisible();
  });

  test('memory usage stays reasonable', async ({ page }) => {
    await page.goto('/');
    await loadTestData(page);

    // Measure memory usage
    const metrics = await page.evaluate(() => {
      return (performance as any).memory
        ? {
            usedJSHeapSize: (performance as any).memory.usedJSHeapSize,
            totalJSHeapSize: (performance as any).memory.totalJSHeapSize,
          }
        : null;
    });

    if (metrics) {
      // Ensure memory usage under 200MB
      expect(metrics.usedJSHeapSize).toBeLessThan(200 * 1024 * 1024);
    }
  });
});

Fixture Management

// tests/e2e/fixtures/index.ts
import { test as base } from '@playwright/test';

type Fixtures = {
  loadTestData: () => Promise<void>;
};

export const test = base.extend<Fixtures>({
  loadTestData: async ({ page }, use) => {
    const loader = async () => {
      await page.goto('/');
      await page
        .locator('input[type="file"]')
        .setInputFiles('tests/fixtures/sample-cpap-data.csv');
      await page.waitForLoadState('networkidle');
    };

    await use(loader);
  },
});

Usage:

import { test, expect } from './fixtures';

test('uses fixture', async ({ page, loadTestData }) => {
  await loadTestData();

  // Test with data already loaded
  await expect(page.locator('[data-testid="usage-chart"]')).toBeVisible();
});

Resilient Selectors

Good Selectors (Stable)

// ✅ Data test IDs (most stable)
page.locator('[data-testid="usage-chart"]');

// ✅ Accessible roles and names
page.getByRole('button', { name: 'Upload' });
page.getByRole('textbox', { name: 'Start Date' });

// ✅ Text content (user-facing)
page.locator('text=Usage Patterns');

Bad Selectors (Brittle)

// ❌ CSS classes (change frequently)
page.locator('.chart-container-wrapper-inner');

// ❌ Deeply nested selectors
page.locator('div > div > div:nth-child(3) > span.label');

// ❌ Positional selectors
page.locator('.chart').nth(2);

Documentation Automation

Screenshot Generation for README

test.describe('Documentation Screenshots', () => {
  test('generate README screenshots', async ({ page }) => {
    await page.goto('/');
    await loadTestData(page);

    // Full page screenshot for README hero
    await page.screenshot({
      path: 'screenshots/app-overview.png',
      fullPage: true,
    });

    // Specific chart screenshots
    await page.locator('[data-testid="usage-chart"]').screenshot({
      path: 'screenshots/usage-chart.png',
    });

    await page.locator('[data-testid="ahi-chart"]').screenshot({
      path: 'screenshots/ahi-chart.png',
    });

    // Dark mode screenshot
    await page.locator('[aria-label="Toggle dark mode"]').click();
    await page.waitForTimeout(300);
    await page.screenshot({
      path: 'screenshots/dark-mode.png',
    });
  });
});

CI Integration

GitHub Actions

# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 20
      - name: Install dependencies
        run: npm ci
      - name: Install Playwright Browsers
        run: npx playwright install --with-deps
      - name: Run Playwright tests
        run: npx playwright test
      - uses: actions/upload-artifact@v3
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Debugging Tests

Interactive Mode

# Open Playwright Inspector
npx playwright test --debug

# Run in headed mode (see browser)
npx playwright test --headed

# Run specific test
npx playwright test tests/e2e/upload.spec.js --headed

Trace Viewer

# Generate trace on failure (configured in playwright.config.js)
# View trace after test failure:
npx playwright show-trace trace.zip

Pause Execution

test('debug test', async ({ page }) => {
  await page.goto('/');

  // Pause execution, open inspector
  await page.pause();

  // Continue manually in inspector
});

Resources