story-diff
v1.0.0
Published
Test-framework-agnostic visual regression testing for Storybook components using Puppeteer or Playwright
Downloads
158
Maintainers
Readme
Story Diff
The Completely Free, Open-Source Chromatic Alternative
Test-framework-agnostic visual regression and snapshot testing for Storybook components using Puppeteer or Playwright. Story Diff enables you to capture screenshots of your Storybook stories and compare them against baselines, regardless of whether you use Vitest, Jest, Playwright Test, or any other test runner. It uses either puppeteer or playwright to load the stories, and a combination of pixelmatch & pngjs for the image comparison.
Installation
npm install --save-dev story-diff puppeteernpm install --save-dev story-diff playwright @playwright/test
npx playwright install chromiumRequirements:
- Node.js 20 or higher
- Storybook 10.0.0 or higher
- Puppeteer 24.41.0 or higher
- Playwright 1.59.1 or higher when using the Playwright provider
Quick Start
Create a test file in your preferred testing framework (e.g., Vitest, Jest):
// visual.test.ts
import { StoryDiff } from 'story-diff';
describe('Storybook Visual Regression', () => {
let diff: StoryDiff;
beforeAll(async () => {
// 1. Initialize the StoryDiff instance
diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './visual-snapshots',
browser: {
provider: 'puppeteer',
},
});
// 2. Launch browser and ensure Storybook is ready
await diff.setup();
}, 60000);
afterAll(async () => {
// 3. Clean up browser instances
await diff.teardown();
});
// 4. Capture and assert single story
it('matches Button primary baseline', async () => {
const result = await diff.assertMatchesBaseline('components-button--primary', {
snapshotName: 'button-primary-desktop',
viewport: 'desktop',
});
expect(result.match).toBe(true);
});
// 5. Test globals/themes
it('matches dark mode', async () => {
const result = await diff.assertMatchesBaseline('components-button--primary', {
snapshotName: 'button-primary-dark',
globals: { theme: 'dark' },
});
expect(result.match).toBe(true);
});
// 6. Test multiple stories declaratively in batch
it('supports declarative batch execution', async () => {
const results = await diff.runAll([
{
componentName: 'Button',
storyPath: 'components-button',
stories: ['primary', 'secondary'],
viewports: ['mobile', 'desktop'],
},
]);
results.forEach((r) => {
expect(r.result.match).toBe(true);
});
}, 60000);
});Playwright Test Example
import { expect, test } from '@playwright/test';
import { StoryDiff } from 'story-diff';
test.describe('Storybook Visual Regression', () => {
let diff: StoryDiff;
test.beforeAll(async () => {
diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './visual-snapshots',
browser: {
provider: 'playwright',
browserName: 'chromium',
},
});
await diff.setup();
});
test.afterAll(async () => {
await diff.teardown();
});
test('matches Button primary baseline', async () => {
const result = await diff.assertMatchesBaseline('components-button--primary', {
snapshotName: 'button-primary-desktop',
viewport: 'desktop',
});
expect(result.match).toBe(true);
});
});Running Tests
Ensure your Storybook server is running:
npm run storybookTip: To prevent Storybook from opening a browser window, use the
--no-openflag:storybook dev -p 6006 --no-openIn a separate terminal, run your test framework:
# Vitest vitest run visual.test.ts # Jest NODE_OPTIONS=--experimental-vm-modules jest visual.test.ts # Playwright Test playwright test visual.spec.ts
Tip: For CI, use wait-on and concurrently to automate this. See CI/CD Integration.
Core Concepts
Story IDs
Story IDs follow Storybook's naming convention: {path}--{story-name}. For example:
components-button--primarypages-login--defaultui-card--with-image
You can find story IDs in your Storybook's URL or in the index.json file.
Snapshot Naming
Snapshot names are used to identify baseline images on disk. They should be:
- Descriptive and unique
- Lowercase with hyphens
- Include viewport if testing multiple sizes
Examples:
button-primary-desktopcard-with-image-mobilelogin-form-dark-mode
Viewports
Story Diff includes three default viewports:
- mobile: 393×852px
- tablet: 768×1024px
- desktop: 1440×900px
You can reference them by name or define custom viewports. See Custom Viewports.
Globals
Globals are Storybook's way of passing configuration to stories (e.g., theme, locale). Pass them via the globals option:
await diff.assertMatchesBaseline('button--primary', {
snapshotName: 'button-dark',
globals: { theme: 'dark', locale: 'en-US' },
});Automatic Lifecycle Management
Story Diff can automatically handle the beforeAll and afterAll hooks to manage the browser lifecycle.
Using the Constructor
Pass autoLifecycle: true to the constructor. This will attempt to detect beforeAll and afterAll in the global scope (supported by Vitest and Jest).
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
autoLifecycle: true,
});Using the hookLifecycle Helper
If you prefer explicit registration or need to support environments without global hooks (like Playwright Test), use the hookLifecycle helper:
import { test } from '@playwright/test';
import { StoryDiff, hookLifecycle } from 'story-diff';
const diff = new StoryDiff();
// Explicitly pass hooks for Playwright Test
hookLifecycle(diff, {
enabled: true,
beforeAll: test.beforeAll,
afterAll: test.afterAll,
timeout: 60000,
});Configuration Options
type LifecycleConfig = {
enabled?: boolean; // Enable automatic management
beforeAll?: HookFn; // Custom beforeAll implementation
afterAll?: HookFn; // Custom afterAll implementation
timeout?: number; // Timeout for hooks (default: 60000ms)
};API Reference
StoryDiff Class
The main class for visual regression testing.
Constructor
const diff = new StoryDiff(config?: StoryDiffConfig);If you omit config, Story Diff searches upward from process.cwd() for one of these files and loads it automatically:
story-diff.config.mjsstory-diff.config.jsstory-diff.config.cjsstory-diff.config.jsonstory-diff.mjsstory-diff.jsstory-diff.cjsstory-diff.json
Lifecycle Methods
setup()
Launches the browser and verifies Storybook is ready.
await diff.setup();Must be called before any capture or assertion methods.
teardown()
Closes the browser and cleans up resources.
await diff.teardown();Should be called in test cleanup (e.g., afterAll).
Configuration
StoryDiffConfig
type StoryDiffConfig = {
// Required: URL where Storybook is hosted
storybookUrl: string;
// Required: Directory path for baseline images
snapshotsDir: string;
// Optional: Custom viewport definitions
viewports?: Record<string, Viewport>;
// Optional: Browser automation configuration
browser?: BrowserConfig;
// Optional: Image comparison settings
comparison?: ComparisonConfig;
// Optional: Update mode (overwrites baselines)
update?: boolean;
// Optional: Fail when baseline is missing
failOnMissingBaseline?: boolean;
// Optional: Logger configuration
logger?: LoggerConfig;
// Optional: Default capture options applied to every call
defaults?: CaptureOptions;
// Optional: Batch definitions used when runAll() is called without arguments
tests?: readonly StoryVisualTest[];
};When loaded from a config file, snapshotsDir is resolved relative to that file.
BrowserConfig
type BrowserConfig = {
// Browser automation provider. Default: 'puppeteer'
provider?: 'puppeteer' | 'playwright';
// Playwright-only browser engine. Default: 'chromium'
browserName?: 'chromium' | 'firefox' | 'webkit';
// Playwright-only browser channel, e.g. 'chromium' or 'chrome'
channel?: string;
// Run browser in headless mode. Default: true
headless?: boolean;
// Extra browser launch args
args?: readonly string[];
// Launch timeout in ms
timeout?: number;
// Custom browser executable path
executablePath?: string;
};LoggerConfig
type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
type LoggerConfig = {
// Logging level. Default: 'silent'
level?: LogLevel;
// Custom logger function. If not provided, uses console
customLogger?: (level: LogLevel, message: string, ...args: unknown[]) => void;
};ComparisonConfig
type ComparisonConfig = {
// Pixelmatch threshold (0-1). Lower = stricter. Default: 0.1
threshold?: number;
// Acceptable diff as percentage (0-100) or pixel count. Default: 0
failureThreshold?: number;
// Whether failureThreshold is 'percent' or 'pixel'. Default: 'percent'
failureThresholdType?: 'percent' | 'pixel';
// Allow size mismatches between actual and baseline. Default: false
allowSizeMismatch?: boolean;
};Methods
captureStory()
Captures a screenshot of a story without comparison.
const screenshot: Buffer = await diff.captureStory(
storyId: string,
options?: CaptureOptions
);Options:
type CaptureOptions = {
viewport?: string | Viewport;
globals?: Record<string, string>;
waitForSelector?: string;
waitForTimeout?: number;
};compareWithBaseline()
Compares a screenshot buffer against a baseline.
const result: ComparisonResult = await diff.compareWithBaseline(
screenshot: Buffer,
snapshotName: string,
comparisonOverride?: ComparisonConfig
);assertMatchesBaseline()
Captures a story and asserts it matches the baseline. Throws on mismatch.
const result: ComparisonResult = await diff.assertMatchesBaseline(
storyId: string,
options: AssertOptions
);Options:
type AssertOptions = CaptureOptions & {
snapshotName: string;
comparison?: ComparisonConfig;
};Throws:
BaselineMissingError- When baseline doesn't exist andfailOnMissingBaselineis trueVisualRegressionError- When images differ beyond thresholdSizeMismatchError- When dimensions differ andallowSizeMismatchis false
updateBaseline()
Saves a screenshot as a new baseline.
const path: string = await diff.updateBaseline(
screenshot: Buffer,
snapshotName: string
);runAll()
Batch processes multiple stories declaratively.
const results: BatchResult[] = await diff.runAll(
tests?: StoryVisualTest[]
);Input:
type StoryVisualTest = {
componentName: string;
storyPath: string;
stories: readonly string[];
viewports?: readonly string[];
globals?: Record<string, string>;
};Output:
type BatchResult = {
storyId: string;
snapshotName: string;
viewport: string;
result: ComparisonResult;
};Type Definitions
ComparisonResult
type ComparisonResult = {
match: boolean;
diffPixels: number;
diffPercentage: number;
diffImage: Buffer | null;
baselineCreated: boolean;
baselineMissing: boolean;
snapshotPath: string;
diffPath: string | null;
};Viewport
type Viewport = {
name: string;
width: number;
height: number;
};Advanced Usage
Logging Configuration
Control output verbosity with the logger configuration:
// Default - silent mode (no output)
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
// logger is silent by default, no need to configure
});
// Info mode - recommended for development
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
logger: {
level: 'info',
},
});
// Error only
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
logger: {
level: 'error',
},
});
// Debug mode - verbose output
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
logger: {
level: 'debug',
},
});
// Custom logger (e.g., integrate with your logging framework)
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
logger: {
level: 'info',
customLogger: (level, message, ...args) => {
// Send to your logging service
myLogger.log({ level, message, args });
},
},
});Log Levels:
silent: No output (default)error: Only errorswarn: Errors and warningsinfo: Errors, warnings, and informational messagesdebug: All messages including detailed debugging information
Headless vs Headed Mode
By default, Story Diff runs the browser in headless mode. For debugging or E2E testing, you can run in headed mode:
// Headed mode - see the browser window
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
browser: {
headless: false,
},
});
// Headless mode (default)
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
browser: {
headless: true, // or omit this line
},
});When to use headed mode:
- Debugging test failures
- Developing new tests
- Demonstrating visual tests
- Internal E2E testing
Note: Headed mode is slower and requires a display. Always use headless mode in CI/CD environments.
Environment Variable Control:
You can control headless mode via environment variables:
# Run in headed mode for debugging
HEADLESS=false npm test
# Run in headless mode (default)
npm testconst diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
browser: {
headless: process.env.HEADLESS !== 'false',
},
logger: {
level: (process.env.LOG_LEVEL as LogLevel) || 'silent',
},
});This allows you to easily switch between modes without changing code:
# Debug with headed browser and verbose logging
HEADLESS=false LOG_LEVEL=debug npm test
# CI mode with default silent logging
npm test
# Enable info logging for CI troubleshooting
LOG_LEVEL=info npm testBatch Testing
Test multiple stories and viewports efficiently:
const results = await diff.runAll([
{
componentName: 'Button',
storyPath: 'components-button',
stories: ['primary', 'secondary', 'disabled'],
viewports: ['mobile', 'tablet', 'desktop'],
},
{
componentName: 'Card',
storyPath: 'components-card',
stories: ['default', 'with-image'],
viewports: ['desktop'],
globals: { theme: 'dark' },
},
]);
// Generates snapshots like:
// - button-primary-mobile.png
// - button-primary-tablet.png
// - button-primary-desktop.png
// - button-secondary-mobile.png
// ... etcCustom Viewports
Define custom viewport sizes:
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
viewports: {
mobile: { name: 'mobile', width: 375, height: 667 },
tablet: { name: 'tablet', width: 768, height: 1024 },
desktop: { name: 'desktop', width: 1920, height: 1080 },
'4k': { name: '4k', width: 3840, height: 2160 },
},
});Or pass inline viewports:
await diff.assertMatchesBaseline('button--primary', {
snapshotName: 'button-custom',
viewport: { name: 'custom', width: 1024, height: 768 },
});Root Config File
If you want one global config object for the whole project, create a root config file and instantiate StoryDiff without passing config in your test hooks:
// story-diff.config.mjs
export default {
storybookUrl: 'http://localhost:6006',
snapshotsDir: './visual-snapshots',
failOnMissingBaseline: false,
defaults: {
viewport: 'desktop',
globals: {
theme: 'dark',
locale: 'en-AU',
},
waitForSelector: '#storybook-root',
waitForTimeout: 300,
},
tests: [
{
componentName: 'Button',
storyPath: 'components-button',
stories: ['primary', 'secondary'],
viewports: ['mobile', 'desktop'],
},
],
};import { StoryDiff } from 'story-diff';
const diff = new StoryDiff();
beforeAll(async () => {
await diff.setup();
});
afterAll(async () => {
await diff.teardown();
});
it('uses discovered defaults automatically', async () => {
const result = await diff.assertMatchesBaseline('components-button--primary', {
snapshotName: 'button-primary-dark',
});
expect(result.match).toBe(true);
});
it('can use configured batch tests without redefining them', async () => {
const results = await diff.runAll();
expect(results).toHaveLength(4);
});Comparison Thresholds
Fine-tune sensitivity to visual changes:
// Global configuration
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
comparison: {
threshold: 0.05, // Stricter pixel matching
failureThreshold: 0.5, // Allow 0.5% difference
failureThresholdType: 'percent',
},
});
// Per-assertion override
await diff.assertMatchesBaseline('button--primary', {
snapshotName: 'button',
comparison: {
threshold: 0.2, // More lenient for this test
failureThreshold: 100, // Allow 100 pixels difference
failureThresholdType: 'pixel',
},
});Async Components
Wait for async content to load before capturing:
// Wait for a specific element
await diff.assertMatchesBaseline('async-component--default', {
snapshotName: 'async-loaded',
waitForSelector: '#data-loaded',
});
// Wait for a fixed duration
await diff.assertMatchesBaseline('animated-component--default', {
snapshotName: 'animation-complete',
waitForTimeout: 2000, // 2 seconds
});CI/CD Integration
Using concurrently and wait-on
For local development and CI, use these tools to automate Storybook startup:
{
"scripts": {
"test:visual": "concurrently -k -s first \"npm run storybook -- --ci\" \"wait-on http://localhost:6006 && vitest run visual.test.ts\""
}
}GitHub Actions Example
name: Visual Regression Tests
on: [push, pull_request]
jobs:
visual-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Run visual tests
run: npm run test:visual
- name: Upload diff images
if: failure()
uses: actions/upload-artifact@v7
with:
name: visual-diffs
path: ./visual-snapshots/*-diff.pngError Handling
Story Diff provides custom error classes for robust error handling:
import {
VisualRegressionError,
BaselineMissingError,
SizeMismatchError,
NotInitializedError,
StorybookConnectionError,
ViewportNotFoundError,
} from 'story-diff';
try {
await diff.assertMatchesBaseline('button--primary', {
snapshotName: 'btn',
});
} catch (e) {
if (e instanceof VisualRegressionError) {
console.log(`Visual diff detected: ${e.diffPercentage}%`);
console.log(`Diff image: ${e.diffPath}`);
} else if (e instanceof BaselineMissingError) {
console.log(`Baseline missing: ${e.snapshotPath}`);
} else if (e instanceof SizeMismatchError) {
console.error('Snapshot dimensions have changed!');
} else if (e instanceof NotInitializedError) {
console.error('Call setup() before using StoryDiff');
} else if (e instanceof StorybookConnectionError) {
console.error('Cannot connect to Storybook');
} else if (e instanceof ViewportNotFoundError) {
console.error('Unknown viewport name');
}
}Error Types
| Error | When Thrown | Properties |
| -------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------- |
| VisualRegressionError | Images differ beyond threshold | snapshotName, diffPercentage, diffPixels, snapshotPath, diffPath |
| BaselineMissingError | Baseline doesn't exist (when failOnMissingBaseline: true) | snapshotName, snapshotPath |
| SizeMismatchError | Image dimensions differ (when allowSizeMismatch: false) | - |
| NotInitializedError | Methods called before setup() | - |
| StorybookConnectionError | Cannot reach Storybook server | - |
| ViewportNotFoundError | Unknown viewport name used | - |
Baseline Management
Creating Baselines
On first run, baselines must be created. There are two approaches:
1. Explicit Update Mode (Recommended)
UPDATE_SNAPSHOTS=true npm testconst diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
update: process.env.UPDATE_SNAPSHOTS === 'true',
});2. Silent Creation Mode
const diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
failOnMissingBaseline: false, // Creates baselines silently
});Updating Baselines
When components change intentionally, update baselines:
UPDATE_SNAPSHOTS=true npm testOr update specific tests:
UPDATE_SNAPSHOTS=true npm test -- button.test.tsBaseline Storage
Baselines are stored as PNG files in your snapshotsDir:
visual-snapshots/
├── button-primary-desktop.png
├── button-primary-mobile.png
├── button-secondary-desktop.png
└── card-default-desktop.pngDiff images (on failure) are saved with -diff suffix:
visual-snapshots/
├── button-primary-desktop.png
└── button-primary-desktop-diff.png ← Generated on mismatchCommit baselines to version control to track visual changes over time.
Testing Frameworks
Vitest
Vitest is the recommended framework for its speed and ESM support.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
testTimeout: 60000,
hookTimeout: 60000,
},
});// visual.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { StoryDiff } from 'story-diff';
describe('Visual Regression', () => {
let diff: StoryDiff;
beforeAll(async () => {
diff = new StoryDiff({
storybookUrl: 'http://localhost:6006',
snapshotsDir: './snapshots',
});
await diff.setup();
}, 60000);
afterAll(async () => {
await diff.teardown();
});
it('matches baseline', async () => {
const result = await diff.assertMatchesBaseline('button--primary', {
snapshotName: 'button-primary',
});
expect(result.match).toBe(true);
});
});Jest
Jest requires experimental VM modules for ESM support.
// jest.config.ts
export default {
preset: 'ts-jest/presets/default-esm',
extensionsToTreatAsEsm: ['.ts'],
testTimeout: 60000,
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
};Run with:
NODE_OPTIONS=--experimental-vm-modules jestOther Frameworks
Story Diff works with any test framework that supports async/await:
- Mocha
- Ava
- Tape
- Node's built-in test runner
Troubleshooting
Storybook Connection Errors
Problem: StorybookConnectionError: Failed to connect to Storybook
Solutions:
- Ensure Storybook is running:
npm run storybook - Verify the URL is correct (default:
http://localhost:6006) - Check firewall/network settings
- Increase browser timeout:
browser: { timeout: 60000; }
Flaky Tests
Problem: Tests pass sometimes, fail other times
Solutions:
- Use
waitForSelectorfor async content - Increase
waitForTimeoutfor animations - Disable animations in Storybook preview:
// .storybook/preview.ts export const parameters = { chromatic: { disableSnapshot: false }, // Disable animations layout: 'fullscreen', }; - Adjust
failureThresholdto allow minor differences
Size Mismatch Errors
Problem: SizeMismatchError: Image size mismatch
Solutions:
- Component dimensions changed - update baseline
- Viewport changed - ensure consistent viewport usage
- Allow size mismatches temporarily:
comparison: { allowSizeMismatch: true; }
Memory Issues
Problem: Tests crash with out-of-memory errors
Solutions:
- Close browser between test suites
- Reduce concurrent tests
- Increase Node memory:
NODE_OPTIONS=--max-old-space-size=4096 npm test
Puppeteer Installation Issues
Problem: Puppeteer fails to install or launch
Solutions:
- Install system dependencies (Linux):
sudo apt-get install -y \ libnss3 libatk1.0-0 libatk-bridge2.0-0 \ libcups2 libdrm2 libxkbcommon0 libxcomposite1 \ libxdamage1 libxrandr2 libgbm1 libasound2 - Use custom Chrome path:
browser: { executablePath: '/usr/bin/google-chrome'; }
Contributing
Contributions are welcome! Please follow these guidelines:
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Make your changes with tests
- Run tests:
npm test - Run E2E tests:
npm run test:e2e - Commit with conventional commits:
feat: add new feature - Push and create a pull request
Development Setup
# Clone the repository
git clone https://github.com/yourusername/story-diff.git
cd story-diff
# Install dependencies
npm install
# Build the library
npm run build
# Run unit tests
npm test
# Run E2E tests (requires Storybook)
npm run test:e2e
# Type checking
npm run typecheckLicense
MIT License - Copyright (c) 2026 Shubham Sharma
See LICENSE for full details.
