Files
Peter Hanssens eb8f7ae9d6 feat: comprehensive component updates and testing infrastructure
- Update multiple components with improved signal management and error handling
- Add integration tests for dialog, popover, dropdown-menu, command, and sheet components
- Enhance form validation with comprehensive type system
- Add visual testing infrastructure with Playwright
- Add analytics package for component tracking
- Improve lazy loading with new component browser
- Enhance error boundary with context and new_york variants
- Update tailwind-rs-core with improved responsive utilities
- Add extensive error handling utilities across packages

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-10 12:15:52 +00:00

12 KiB

Visual Testing API Reference

Complete API documentation for the visual testing framework.

Core Classes

VisualTestingFramework

Main entry point for visual testing.

Constructor

constructor(page: Page, screenshotDir?: string)

Parameters:

  • page - Playwright Page instance
  • screenshotDir - Directory for screenshots (default: "screenshots")

Example:

const framework = createVisualFramework(page, 'my-screenshots');

Methods

testStory(component, story, options)

Test a component story with visual regression.

async testStory(
  component: string,
  story: string,
  options: VisualTestOptions
): Promise<void>

Parameters:

  • component - Component name in Storybook (e.g., "components-button")
  • story - Story name (e.g., "default")
  • options - Test configuration options

VisualTestOptions:

interface VisualTestOptions {
  themes?: ThemeConfig[];      // Themes to test (default: 2 themes)
  viewports?: ViewportConfig[]; // Viewports to test (default: 2 viewports)
  threshold?: number;           // Max difference (default: 0.0001)
  hideSelectors?: string[];     // CSS selectors to hide
}

Example:

await framework.testStory('components-button', 'default', {
  themes: [THEMES[0]],
  viewports: [VIEWPORTS[0]],
  threshold: 0.0001,
  hideSelectors: ['.timestamp', '.random-id'],
});

Throws:

  • Error if visual regression detected
createBaselines(component, story, options)

Create baseline screenshots for a component.

async createBaselines(
  component: string,
  story: string,
  options?: {
    themes?: ThemeConfig[];
    viewports?: ViewportConfig[];
  }
): Promise<void>

Example:

await framework.createBaselines('components-button', 'default', {
  themes: THEMES,
  viewports: VIEWPORTS,
});
getFixture()

Get the underlying VisualTestFixture for advanced usage.

getFixture(): VisualTestFixture

VisualTestFixture

Lower-level fixture for fine-grained control.

Constructor

constructor(page: Page, screenshotDir?: string)

Methods

navigateToStory(component, story, theme?)

Navigate to a Storybook story.

async navigateToStory(
  component: string,
  story: string,
  theme?: ThemeConfig
): Promise<void>
takeScreenshot(config)

Take a screenshot with full configuration.

async takeScreenshot(config: ScreenshotConfig): Promise<Buffer>

ScreenshotConfig:

interface ScreenshotConfig {
  component: string;
  story: string;
  theme: ThemeConfig;
  viewport: ViewportConfig;
  variant?: string;
  animations?: 'allow' | 'disable';
  waitForSelector?: string;
  hideSelectors?: string[];
}
saveScreenshot(config, subdirectory?)

Take and save a screenshot to disk.

async saveScreenshot(
  config: ScreenshotConfig,
  subdirectory?: 'baseline' | 'actual' | 'diff'
): Promise<string>

Returns: Path to saved screenshot

compareWithBaseline(config, threshold?)

Compare current screenshot with baseline.

async compareWithBaseline(
  config: ScreenshotConfig,
  threshold?: ThresholdConfig
): Promise<{
  passed: boolean;
  diff?: VisualComparisonResult;
  diffPath?: string;
}>
disableAnimations()

Disable CSS animations for consistent screenshots.

async disableAnimations(): Promise<void>
hideElements(selectors)

Hide elements for cleaner screenshots.

async hideElements(selectors: string[]): Promise<void>
waitForStable(selector?, timeout?)

Wait for component to be stable.

async waitForStable(
  selector?: string,
  timeout?: number
): Promise<void>

Utility Functions

createVisualFramework(page, screenshotDir?)

Create a new VisualTestingFramework instance.

function createVisualFramework(
  page: Page,
  screenshotDir?: string
): VisualTestingFramework

Example:

import { createVisualFramework } from '@shadcn-ui/visual-testing';

const framework = createVisualFramework(page);

createVisualFixture(page, screenshotDir?)

Create a new VisualTestFixture instance.

function createVisualFixture(
  page: Page,
  screenshotDir?: string
): VisualTestFixture

testAcrossThemes(fixture, component, story, themes, testFn)

Run a test function across multiple themes.

async function testAcrossThemes(
  fixture: VisualTestFixture,
  component: string,
  story: string,
  themes: ThemeConfig[],
  testFn: (theme: ThemeConfig) => Promise<void>
): Promise<void>

Example:

await testAcrossThemes(fixture, 'components-button', 'default', THEMES, async (theme) => {
  await fixture.navigateToStory('components-button', 'default', theme);
  // Test with theme
});

testAcrossViewports(fixture, component, story, viewports, testFn)

Run a test function across multiple viewports.

async function testAcrossViewports(
  fixture: VisualTestFixture,
  component: string,
  story: string,
  viewports: ViewportConfig[],
  testFn: (viewport: ViewportConfig) => Promise<void>
): Promise<void>

comprehensiveVisualTest(fixture, component, story, themes, viewports, testFn)

Run tests across all themes and viewports.

async function comprehensiveVisualTest(
  fixture: VisualTestFixture,
  component: string,
  story: string,
  themes: ThemeConfig[],
  viewports: ViewportConfig[],
  testFn: (theme: ThemeConfig, viewport: ViewportConfig) => Promise<void>
): Promise<void>

Image Comparison

compareImages(image1Path, image2Path, diffImagePath, config?)

Compare two PNG images pixel by pixel.

function compareImages(
  image1Path: string,
  image2Path: string,
  diffImagePath: string,
  config?: ThresholdConfig
): Promise<VisualComparisonResult>

Parameters:

  • image1Path - Path to first image
  • image2Path - Path to second image
  • diffImagePath - Path to save diff image
  • config - Comparison threshold configuration

Returns: VisualComparisonResult

interface VisualComparisonResult {
  passed: boolean;           // Whether comparison passed
  diffPercentage: number;    // Difference percentage (0-1)
  mismatchedPixels: number;  // Number of different pixels
  totalPixels: number;       // Total pixels compared
  diffImagePath?: string;    // Path to diff image (if any)
}

Example:

const result = await compareImages(
  'baseline/button.png',
  'actual/button.png',
  'diff/button.png',
  { pixelDiffThreshold: 0.0001, maxMismatchedPixels: 100 }
);

if (!result.passed) {
  console.log(`Difference: ${(result.diffPercentage * 100).toFixed(2)}%`);
  console.log(`Mismatched pixels: ${result.mismatchedPixels}`);
}

generateScreenshotFilename(component, story, theme, viewport, variant?)

Generate a consistent filename for screenshots.

function generateScreenshotFilename(
  component: string,
  story: string,
  theme: string,
  viewport: string,
  variant?: string
): string

Example:

const filename = generateScreenshotFilename(
  'button',
  'default',
  'light',
  'desktop',
  'primary'
);
// Returns: "button_default_light_desktop_primary.png"

cropToContent(imagePath, outputPath)

Crop image to content bounds (removes empty space).

function cropToContent(
  imagePath: string,
  outputPath: string
): Promise<void>

normalizeImage(inputPath, outputPath)

Normalize image for consistent comparison.

function normalizeImage(
  inputPath: string,
  outputPath: string
): Promise<void>

Constants

THEMES

Predefined theme configurations.

const THEMES: ThemeConfig[] = [
  { name: 'Default Light', id: 'default-light', themeId: 'light' },
  { name: 'Default Dark', id: 'default-dark', themeId: 'dark' },
  { name: 'New York Light', id: 'new-york-light', themeId: 'new-york-light' },
  { name: 'New York Dark', id: 'new-york-dark', themeId: 'new-york-dark' },
];

VIEWPORTS

Predefined viewport configurations.

const VIEWPORTS: ViewportConfig[] = [
  { name: 'desktop', width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false },
  { name: 'laptop', width: 1366, height: 768, deviceScaleFactor: 1, isMobile: false },
  { name: 'tablet', width: 768, height: 1024, deviceScaleFactor: 2, isMobile: true },
  { name: 'mobile', width: 375, height: 667, deviceScaleFactor: 2, isMobile: true },
];

DEFAULT_THRESHOLD

Default threshold configuration.

const DEFAULT_THRESHOLD: ThresholdConfig = {
  pixelDiffThreshold: 0.0001,    // 0.01% difference
  maxMismatchedPixels: 100,
  ignoreAntiAliasing: true,
};

Type Definitions

ThemeConfig

interface ThemeConfig {
  name: string;      // Display name
  id: string;        // Unique identifier
  themeId: string;   // Storybook theme ID
}

ViewportConfig

interface ViewportConfig {
  name: string;             // Viewport name
  width: number;            // Width in pixels
  height: number;           // Height in pixels
  deviceScaleFactor?: number;  // Device pixel ratio
  isMobile?: boolean;       // Whether this is a mobile viewport
}

ThresholdConfig

interface ThresholdConfig {
  pixelDiffThreshold: number;      // Max pixel difference (0-1)
  maxMismatchedPixels: number;     // Max number of different pixels
  ignoreAntiAliasing: boolean;     // Ignore anti-aliasing differences
}

VisualTestCase

interface VisualTestCase {
  name: string;
  component: string;
  story: string;
  themes: ThemeConfig[];
  viewports: ViewportConfig[];
  variants?: Record<string, string>[];
}

Playwright Integration

Test Configuration

Extend your Playwright config for visual testing:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Use consistent screenshots
  use: {
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  // Configure projects for different browsers/viewports
  projects: [
    {
      name: 'chromium-desktop',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'chromium-mobile',
      use: { ...devices['iPhone 13 Pro'] },
    },
  ],
});

Custom Matchers

Extend Playwright's expect with visual matchers:

import { expect } from '@playwright/test';
import { compareImages } from '@shadcn-ui/visual-testing';

expect.extend({
  async toMatchVisualScreenshot(received: Buffer, baselinePath: string) {
    const actualPath = 'actual.png';
    const diffPath = 'diff.png';

    await fs.writeFile(actualPath, received);

    const result = await compareImages(baselinePath, actualPath, diffPath);

    return {
      pass: result.passed,
      message: () => `Visual comparison ${result.passed ? 'passed' : 'failed'}`,
    };
  },
});

Error Handling

Common Errors

VisualRegressionError

Thrown when visual regression is detected.

class VisualRegressionError extends Error {
  constructor(
    message: string,
    public diff: VisualComparisonResult,
    public diffPath?: string
  ) {
    super(message);
  }
}

BaselineNotFoundError

Thrown when baseline image doesn't exist.

class BaselineNotFoundError extends Error {
  constructor(public component: string, public story: string) {
    super(`Baseline not found for ${component}--${story}`);
  }
}

Error Handling Example

try {
  await framework.testStory('components-button', 'default');
} catch (error) {
  if (error instanceof VisualRegressionError) {
    console.error('Visual regression detected!');
    console.error(`Difference: ${error.diff.diffPercentage}`);
    console.error(`Diff image: ${error.diffPath}`);
  } else if (error instanceof BaselineNotFoundError) {
    console.log('Creating new baseline...');
    await framework.createBaselines(error.component, error.story);
  } else {
    throw error;
  }
}