Visual regression testing with Playwright and Percy. Use when implementing screenshot-based testing.
/plugin marketplace add IvanTorresEdge/molcajete.ai/plugin install ivantorresedge-react-tech-stacks-js-react@IvanTorresEdge/molcajete.aiThis skill inherits all available tools. When active, it can use any tool Claude has access to.
This skill covers visual regression testing patterns for React applications.
Use this skill when:
CATCH VISUAL BUGS - Visual regression tests detect unintended changes to UI appearance that functional tests miss.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/visual',
snapshotDir: './tests/visual/__snapshots__',
updateSnapshots: 'missing',
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
threshold: 0.2,
},
},
projects: [
{
name: 'Desktop Chrome',
use: {
browserName: 'chromium',
viewport: { width: 1280, height: 720 },
},
},
{
name: 'Mobile Safari',
use: {
browserName: 'webkit',
viewport: { width: 375, height: 667 },
},
},
],
});
import { test, expect } from '@playwright/test';
test.describe('Visual Regression', () => {
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('homepage.png');
});
test('dashboard matches snapshot', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('dashboard.png');
});
});
test('button component variants', async ({ page }) => {
await page.goto('/storybook/button');
// Screenshot specific element
const button = page.getByRole('button', { name: 'Primary' });
await expect(button).toHaveScreenshot('button-primary.png');
// Screenshot component container
const container = page.locator('[data-testid="button-variants"]');
await expect(container).toHaveScreenshot('button-variants.png');
});
test('page with dynamic content', async ({ page }) => {
await page.goto('/profile');
// Mask dynamic elements
await expect(page).toHaveScreenshot('profile.png', {
mask: [
page.locator('[data-testid="timestamp"]'),
page.locator('[data-testid="avatar"]'),
page.locator('.dynamic-ad'),
],
});
});
test('page with animations', async ({ page }) => {
await page.goto('/animated-page');
// Disable animations
await page.emulateMedia({ reducedMotion: 'reduce' });
// Or wait for animations to complete
await page.waitForFunction(() => {
const animations = document.getAnimations();
return animations.every((a) => a.playState === 'finished');
});
await expect(page).toHaveScreenshot('animated-page.png');
});
const viewports = [
{ width: 375, height: 667, name: 'mobile' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 1280, height: 720, name: 'desktop' },
{ width: 1920, height: 1080, name: 'wide' },
];
test.describe('Responsive layouts', () => {
for (const viewport of viewports) {
test(`homepage at ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
});
}
});
test('full page screenshot', async ({ page }) => {
await page.goto('/long-page');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('long-page-full.png', {
fullPage: true,
});
});
npm install -D @percy/cli @percy/playwright
# .percy.yml
version: 2
snapshot:
widths:
- 375
- 768
- 1280
minHeight: 1024
percyCSS: |
.hide-in-percy {
visibility: hidden;
}
discovery:
allowedHostnames:
- localhost
- cdn.example.com
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test.describe('Percy Visual Tests', () => {
test('homepage', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await percySnapshot(page, 'Homepage');
});
test('dashboard states', async ({ page }) => {
await page.goto('/dashboard');
// Empty state
await percySnapshot(page, 'Dashboard - Empty');
// With data
await page.evaluate(() => {
// Inject mock data
});
await percySnapshot(page, 'Dashboard - With Data');
// Loading state
await page.evaluate(() => {
document.body.classList.add('loading');
});
await percySnapshot(page, 'Dashboard - Loading');
});
});
npm install -D @percy/storybook
// .storybook/main.js
module.exports = {
addons: ['@percy/storybook'],
};
# Run Percy on Storybook
npx percy storybook ./storybook-static
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
chromatic: { disableSnapshot: false },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<Button variant="default">Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
</div>
<div className="flex gap-2">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
</div>
<div className="flex gap-2">
<Button disabled>Disabled</Button>
<Button isLoading>Loading</Button>
</div>
</div>
),
};
// Interaction states for visual testing
export const HoverState: Story = {
args: { children: 'Hover Me' },
parameters: {
pseudo: { hover: true },
},
};
export const FocusState: Story = {
args: { children: 'Focus Me' },
parameters: {
pseudo: { focus: true },
},
};
// tests/visual/fixtures.ts
import { test as base } from '@playwright/test';
interface VisualTestFixtures {
visualPage: typeof page;
}
export const test = base.extend<VisualTestFixtures>({
visualPage: async ({ page }, use) => {
// Disable animations globally
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});
// Set consistent date/time
await page.addInitScript(() => {
const fixedDate = new Date('2024-01-15T10:00:00Z');
// @ts-ignore
Date = class extends Date {
constructor(...args: []) {
if (args.length === 0) {
super(fixedDate);
} else {
// @ts-ignore
super(...args);
}
}
static now() {
return fixedDate.getTime();
}
};
});
await use(page);
},
});
export { expect } from '@playwright/test';
test.describe('Dark mode visual tests', () => {
test('homepage in light mode', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-light.png');
});
test('homepage in dark mode', async ({ page }) => {
await page.goto('/');
// Enable dark mode
await page.evaluate(() => {
document.documentElement.classList.add('dark');
});
await expect(page).toHaveScreenshot('homepage-dark.png');
});
test('component in both modes', async ({ page }) => {
await page.goto('/components/card');
const card = page.locator('[data-testid="card"]');
// Light mode
await expect(card).toHaveScreenshot('card-light.png');
// Dark mode
await page.evaluate(() => {
document.documentElement.classList.add('dark');
});
await expect(card).toHaveScreenshot('card-dark.png');
});
});
# .github/workflows/visual.yml
name: Visual Regression
on: [push, pull_request]
jobs:
visual-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run visual tests
run: npx playwright test tests/visual/
- name: Upload snapshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-snapshots
path: |
tests/visual/__snapshots__/
test-results/
# .github/workflows/percy.yml
name: Percy
on: [push, pull_request]
jobs:
percy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Percy snapshot
run: npx percy exec -- npx playwright test tests/visual/
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
# Run visual tests
npx playwright test tests/visual/
# Update snapshots
npx playwright test tests/visual/ --update-snapshots
# Run specific visual test
npx playwright test tests/visual/homepage.spec.ts
# Run with Percy
npx percy exec -- npx playwright test tests/visual/
# View report
npx playwright show-report
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.