npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

webrun-testing

v1.0.1

Published

A web component testing runtime for Playwright

Readme

Component Testing Runtime for Playwright

A modern, type-safe testing runtime for web components with Playwright. Test your components in real browsers using a familiar Testing Library-like API, with full support for JSX, Lit, Stencil, and vanilla Custom Elements.

TypeScript Playwright License

Key Features

  • Familiar Testing Library-like test syntax - Write component tests with the render(<component attribute={value} />) API you already know
  • Automatic visual regression testing - Capture screenshots after every assertion with autoVrt mode for zero-config and zero-code visual testing
  • Mocking and component testing utilities - Set properties/functions, emit events, spy on handlers
  • Support for JSX and Lit HTML components - Write tests in your preferred format
  • Hot reload and Tooling - Watch mode for rapid test iteration and Playwright Plugin VSCode and IntelliJ Test Integration support
  • Automatic component type detection - JSX and Lit HTML detected automatically
  • Works with any web component library - Stencil, Lit, vanilla Custom Elements, etc.
  • Example projects for Lit and Stencil - Get started quickly with working examples
  • Easy configurable testbeds - Import maps, custom styles, and startup scripts
  • Automatic visibility detection - Components are automatically ready when you need them

Table of Contents

Prerequisites

Before installing, ensure you have:

  • Node.js 20 or higher (24 recommended)
  • Playwright 1.40 or higher
  • TypeScript 5.0+ (recommended for JSX support)

Installation

# Using pnpm (recommended)
pnpm add -D @wc-tools/webrun @playwright/test

# Using npm
npm install --save-dev @wc-tools/webrun @playwright/test

# Using yarn
yarn add -D @wc-tools/webrun @playwright/test

Quick Start

Step 1: Configure Playwright

Create or update playwright.config.ts:

import { defineConfig } from '@playwright/test';
import { withComponentTesting } from '@wc-tools/webrun';

export default withComponentTesting({
  port: 3000,
  host: 'localhost',
  autoStart: true, // Auto-start http-server
})(defineConfig({
  testDir: './test',
  fullyParallel: true,
  use: {
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
    { name: 'webkit', use: { browserName: 'webkit' } },
  ],
}));

Step 2: Configure TypeScript (for JSX)

If using JSX, update tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@wc-tools/webrun",
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler"
  }
}

Step 3: Write Your First Test

Create test/button.spec.tsx:

import { test, expect, spyOn } from '@wc-tools/webrun';

test('button handles clicks', async ({ render }) => {
  // Render component
  const { container } = await render(
    <button id="my-button">Click me</button>
  );

  // Spy on click events
  const getClickEvents = await container.spyOn('click');

  // Interact with component
  await container.click();
  await container.click();

  // Assert behavior
  const events = await getClickEvents();
  expect(events).toHaveLength(2);
});

Step 4: Run Tests

npx playwright test

Core Concepts

The render() Fixture

The render() function is the primary way to render components in tests. It accepts HTML strings, JSX, or Lit templates:

// JSX
const { container } = await render(<my-component value="test" />);

// Lit
const { container } = await render(html`<my-component value="test"></my-component>`);

// HTML string
const { container } = await render('<my-component value="test"></my-component>');

Understanding container

The container is a Playwright Locator that points to your rendered component's root element. It extends Playwright's Locator with web component-specific methods:

const { container } = await render(<my-component />);

// ✅ All standard Playwright Locator methods
await container.click();
await expect(container).toBeVisible();
const button = container.getByRole('button');

// ✅ Plus web component helpers
await container.setProperty('value', 'test');
const value = await container.getProperty<string>('value');
await container.callMethod('reset');

Key Points:

  • container represents the first child of the render container (your component's root)
  • Use container methods for the rendered component
  • Use container.locator() or container.getByRole() for child elements

Automatic Retry with getProperty()

The getProperty() method automatically retries until a property is set, perfect for async component initialization:

test('waits for async properties', async ({ render }) => {
  const { container } = await render(<my-async-component />);

  // Automatically retries for up to 5 seconds
  const data = await container.getProperty<string>('loadedData');

  expect(data).toBe('loaded');
});

Component Lifecycle

The render() function returns an unmount() helper for cleanup:

test('unmounts component', async ({ render }) => {
  const { container, unmount } = await render(<my-component />);

  await expect(container).toBeVisible();

  // Clean up
  await unmount();

  await expect(container).not.toBeVisible();
});

Common Use Cases

Testing Forms

import { test, expect, spyOn } from '@wc-tools/webrun';

test('handles form submission', async ({ render }) => {
  const { container } = await render(
    <form>
      <label htmlFor="email">Email</label>
      <input type="email" id="email" name="email" />

      <label htmlFor="password">Password</label>
      <input type="password" id="password" name="password" />

      <button type="submit">Login</button>
    </form>
  );

  // Spy on submit events
  const getSubmitEvents = await container.spyOn('submit');

  // Fill form using accessible queries
  await container.getByLabel('Email').fill('[email protected]');
  await container.getByLabel('Password').fill('secret123');
  await container.getByRole('button', { name: 'Login' }).click();

  // Verify submission
  const events = await getSubmitEvents();
  expect(events).toHaveLength(1);
});

Testing Event Handlers

import { test, expect, spyOn } from '@wc-tools/webrun';

test('tracks button clicks', async ({ render }) => {
  const { container } = await render(
    <button id="counter">Click count: 0</button>
  );

  // Set up event spy
  const getClickEvents = await container.spyOn('click');

  // Trigger multiple clicks
  await container.click();
  await container.click();
  await container.click();

  // Verify all clicks were captured
  const events = await getClickEvents();
  expect(events).toHaveLength(3);
  expect(events[0]?.type).toBe('click');
});

Testing Accessibility

import { test, expect } from '@wc-tools/webrun';

test('has proper ARIA attributes', async ({ render }) => {
  const { container } = await render(
    <button
      aria-label="Open menu"
      aria-expanded="false"
      aria-controls="main-menu"
    >
      Menu
    </button>
  );

  // Verify ARIA attributes
  await expect(container).toHaveAttribute('aria-label', 'Open menu');
  await expect(container).toHaveAttribute('aria-expanded', 'false');
  await expect(container).toHaveAttribute('aria-controls', 'main-menu');

  // Verify accessible name
  await expect(container).toHaveAccessibleName('Open menu');
});

Testing Async Operations

import { test, expect } from '@wc-tools/webrun';

test('handles async data loading', async ({ render }) => {
  const { container } = await render(
    <data-loader id="loader" />
  );

  // Initial state
  await expect(container).toHaveText('Loading...');

  // Wait for async property with custom predicate
  const data = await container.getProperty<{ loaded: boolean }>('dataset', {
    predicate: (data) => data?.loaded === true,
    timeout: 5000
  });

  expect(data.loaded).toBe(true);
  await expect(container).toHaveText('Loaded!');
});

Testing Custom Web Components

import { test, expect, waitForComponent } from '@wc-tools/webrun';

test('tests custom element with Shadow DOM', async ({ render, page }) => {
  const { container } = await render(
    <my-custom-button variant="primary">
      Click Me
    </my-custom-button>
  );

  // Wait for custom element to be defined
  await waitForComponent(page, 'my-custom-button');

  // Set component properties
  await container.setProperty('disabled', true);

  // Call component methods
  const result = await container.callMethod<string>('getText');
  expect(result).toBe('Click Me');

  // Access Shadow DOM elements
  const shadowButton = container.locator('button');
  await expect(shadowButton).toBeDisabled();
});

Configuration

Configuration Options Reference

The withComponentTesting() function accepts these options:

interface ComponentTestingPresetOptions {
  /** Port for the dev server (default: 3000) */
  port?: number;

  /** Host for the dev server (default: 'localhost') */
  host?: string;

  /** Static files directory (default: './public')
   *
   * Examples:
   * - Stencil: './dist' (contains loader + ESM components)
   * - Lit: './dist' (contains built components)
   * - Vanilla: './public' (static assets)
   */
  staticDir?: string;

  /** Auto-start web server (default: true) */
  autoStart?: boolean;

  /** Additional http-server CLI options */
  serverOptions?: string;

  /** Global CSS files to include in test pages */
  stylesheets?: string[];

  /** Global JavaScript files to include
   *
   * Examples:
   * - Stencil: ['/build/my-library.esm.js']
   * - Lit: ['/my-components.js']
   */
  scripts?: string[];

  /** Inline global styles */
  globalStyles?: string;

  /** ES module import map for CDN dependencies */
  importMap?: {
    imports?: Record<string, string>;
    scopes?: Record<string, Record<string, string>>;
  };

  /** Enable automatic visual regression testing (default: false)
   * Captures screenshots after every assertion for visual testing
   */
  autoVrt?: boolean;

  /** Custom web server configuration
   * Overrides the default http-server settings
   */
  webServer?: Partial<PlaywrightTestConfig['webServer']>;
}

Framework-Specific Configuration

Stencil Components

import { defineConfig } from '@playwright/test';
import { withComponentTesting } from '@wc-tools/webrun';

export default withComponentTesting({
  port: 3000,
  staticDir: './dist', // Stencil build output
  scripts: [
    '/build/my-component-library.esm.js' // Stencil loader
  ],
  autoStart: true
})(defineConfig({
  testDir: './test',
  fullyParallel: true,
}));

See examples/playwright.config.stencil.ts for complete example.

Lit Components

import { defineConfig } from '@playwright/test';
import { withComponentTesting } from '@wc-tools/webrun';

export default withComponentTesting({
  port: 3000,
  staticDir: './dist',
  scripts: ['/my-components.js'],
  importMap: {
    imports: {
      'lit': 'https://cdn.jsdelivr.net/npm/lit@3/+esm',
      'lit/': 'https://cdn.jsdelivr.net/npm/lit@3/',
      '@lit/reactive-element': 'https://cdn.jsdelivr.net/npm/@lit/reactive-element@2/+esm',
    }
  }
})(defineConfig({
  testDir: './test',
  fullyParallel: true,
}));

See examples/playwright.config.lit.ts for complete example.

Vanilla Custom Elements

export default withComponentTesting({
  port: 3000,
  staticDir: './public',
  scripts: ['/components.js'],
  stylesheets: ['/styles/components.css']
})(defineConfig({
  testDir: './test',
}));

Global Styles and Scripts

Add global CSS and JavaScript to all test pages:

export default withComponentTesting({
  stylesheets: [
    '/styles/reset.css',
    '/styles/theme.css',
  ],
  scripts: [
    '/scripts/polyfills.js',
  ],
  globalStyles: `
    * {
      box-sizing: border-box;
    }
    body {
      font-family: system-ui, sans-serif;
      margin: 0;
      padding: 0;
    }
  `,
})(defineConfig({ /* ... */ }));

Import Maps for ES Modules

Use import maps to load libraries from CDNs without bundling:

export default withComponentTesting({
  importMap: {
    imports: {
      'lit': 'https://cdn.jsdelivr.net/npm/lit@3/+esm',
      'lit/': 'https://cdn.jsdelivr.net/npm/lit@3/',
      'react': 'https://esm.sh/react@18',
      'react-dom': 'https://esm.sh/react-dom@18',
    },
  },
})(defineConfig({ /* ... */ }));

Then use in your tests:

import { html } from 'lit';

test('uses import map', async ({ render }) => {
  const { container } = await render(html`
    <div>
      <h1>Hello from Lit!</h1>
    </div>
  `);

  await expect(container.getByRole('heading')).toHaveText('Hello from Lit!');
});

Using a Custom Dev Server

Use Vite, Webpack, or other dev servers instead of http-server:

export default withComponentTesting({
  autoStart: false, // Don't auto-start http-server
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
})(defineConfig({ /* ... */ }));

See examples/playwright.config.custom-server.ts for complete example.

API Reference

Test Fixtures

render(component): Promise<RenderResult>

Renders a component to the test page.

Parameters:

  • component: HTML string, JSX element, or Lit template

Returns: Promise<RenderResult>

interface RenderResult {
  /** Locator for the rendered component (first child element) */
  container: ExtendedLocator;

  /** Remove the component from the DOM */
  unmount: () => Promise<void>;
}

Example:

const { container, unmount } = await render(<button>Click me</button>);
await expect(container).toBeVisible();
await container.click();
await unmount();

Container Methods

The container extends Playwright's Locator with these web component-specific methods:

container.setProperty(propertyName, value)

Set a property on the component.

await container.setProperty('value', 'Hello');
await container.setProperty('disabled', true);
await container.setProperty('data', { items: [1, 2, 3] });

container.getProperty<T>(propertyName, options?)

Get a property value with automatic retry.

// Simple usage
const value = await container.getProperty<string>('value');

// With custom retry options
const data = await container.getProperty<object>('data', {
  timeout: 2000,        // Wait up to 2 seconds
  interval: 100,        // Check every 100ms
  predicate: (v) => v !== undefined  // Custom validation
});

Options:

  • timeout: Maximum wait time in ms (default: 5000)
  • interval: Retry interval in ms (default: 100)
  • predicate: Custom validation function

container.callMethod<T>(methodName, ...args)

Call a method on the component.

await container.callMethod('reset');
const result = await container.callMethod<string>('getData', 'param1', 'param2');

Standalone Helper Functions

⚠️ Legacy API - Prefer container methods when working with rendered components. These are provided for backward compatibility and advanced use cases.

setProperty(page, selector, propertyName, value)

Set a property using a CSS selector.

import { setProperty } from '@wc-tools/webrun';

await setProperty(page, '#my-component', 'value', 'Hello');

getProperty(page, selector, propertyName, options?)

Get a property using a CSS selector with retry.

import { getProperty } from '@wc-tools/webrun';

const value = await getProperty(page, '#my-component', 'value', {
  timeout: 2000,
  predicate: (v) => v !== undefined
});

call(page, selector, methodName, ...args)

Call a method using a CSS selector.

import { call } from '@wc-tools/webrun';

const result = await call(page, '#my-component', 'reset');

getAttributes(page, selector)

Get all attributes from an element.

import { getAttributes } from '@wc-tools/webrun';

const attrs = await getAttributes(page, '#my-component');
expect(attrs['data-value']).toBe('test');

Event Handling

spyOn(locator, eventName)

Spy on events emitted by a component. Returns a getter function to retrieve captured events.

import { spyOn } from '@wc-tools/webrun';

// Recommended: Use with container
const { container } = await render(<button>Click</button>);
const getClickEvents = await container.spyOn('click');
await container.click();

const events = await getClickEvents();
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe('click');

⚠️ Legacy signature: spyOn(page, selector, eventName) is also supported.

emit(page, selector, eventName, detail?, options?)

Emit a custom event on a component.

import { emit } from '@wc-tools/webrun';

await emit(page, '#my-component', 'customEvent', { key: 'value' }, {
  bubbles: true,
  composed: true,
  cancelable: true
});

waitForEvent(page, selector, eventName, timeout?)

Wait for a specific event to be emitted.

import { waitForEvent } from '@wc-tools/webrun';

const event = await waitForEvent(page, '#my-component', 'loaded', 5000);
expect(event.detail).toBeDefined();

getFunctionCalls(page, selector, propertyName)

Get the call history for a function property set via setProperty.

import { setProperty, getFunctionCalls } from '@wc-tools/webrun';

await setProperty(page, '#btn', 'onClick', () => {});
await page.locator('#btn').click();

const calls = await getFunctionCalls(page, '#btn', 'onClick');
expect(calls).toHaveLength(1);

Component Lifecycle Helpers

waitForComponent(page, tagName, timeout?)

Wait for a custom element to be defined.

import { waitForComponent } from '@wc-tools/webrun';

await waitForComponent(page, 'my-custom-element', 5000);

isComponentDefined(page, tagName)

Check if a custom element is defined.

import { isComponentDefined } from '@wc-tools/webrun';

const isDefined = await isComponentDefined(page, 'my-custom-element');
expect(isDefined).toBe(true);

Configuration Functions

withComponentTesting(options?)

Higher-order function that enhances Playwright configuration with component testing capabilities.

import { withComponentTesting } from '@wc-tools/webrun';

export default withComponentTesting({
  port: 3000,
  stylesheets: ['/global.css'],
})(defineConfig({ /* ... */ }));

getBaseURL(options?)

Get the base URL for the component testing server.

import { getBaseURL } from '@wc-tools/webrun';

const baseURL = getBaseURL({ port: 3000, host: 'localhost' });
// Returns: "http://localhost:3000"

Advanced Topics

Automatic Visual Regression Testing

Enable automatic screenshot capture after every assertion by setting autoVrt: true:

export default withComponentTesting({
  autoVrt: true  // Captures screenshots after every assertion
})(defineConfig({ /* ... */ }));

Usage:

import { test, expect } from '@wc-tools/webrun';

test('visual regression', async ({ render }) => {
  const { container } = await render(<button>Click</button>);
  await expect(container).toBeVisible(); // Screenshot captured automatically
});

The expect from @wc-tools/webrun automatically uses autoVrt when enabled - no code changes needed!

Testing with Lit Templates

import { test, expect } from '@wc-tools/webrun';
import { html } from 'lit';

test('renders Lit template', async ({ render }) => {
  const name = 'World';

  const { container } = await render(html`
    <div>
      <h1>Hello, ${name}!</h1>
      <button>Say Hello</button>
    </div>
  `);

  await expect(container.getByRole('heading', { level: 1 })).toHaveText('Hello, World!');
  await expect(container.getByRole('button', { name: 'Say Hello' })).toBeVisible();
});

Testing Shadow DOM

Access Shadow DOM elements using standard Playwright locators:

test('accesses shadow DOM', async ({ render }) => {
  const { container } = await render(<my-component />);

  // Shadow DOM elements are automatically accessible
  const shadowButton = container.locator('button');
  await expect(shadowButton).toBeVisible();
  await shadowButton.click();
});

Component Property Testing

test('sets and gets component properties', async ({ render }) => {
  const { container } = await render(<my-component />);

  // Set complex property
  await container.setProperty('config', {
    theme: 'dark',
    items: [1, 2, 3]
  });

  // Get with retry
  const config = await container.getProperty<{theme: string}>('config');
  expect(config.theme).toBe('dark');
});

Testing Component Methods

test('calls component methods', async ({ render }) => {
  const { container } = await render(<my-form />);

  await container.callMethod('reset');

  const isValid = await container.callMethod<boolean>('validate');
  expect(isValid).toBe(true);

  const data = await container.callMethod<FormData>('getFormData');
  expect(data).toBeDefined();
});

Troubleshooting

"Element not found" errors

Problem: Tests fail with "Element not found" or timeout errors.

Solutions:

  1. For Stencil components, configure the hydrated class to wait for hydration:

    export default withComponentTesting({
      hydratedClass: 'hydrated', // Wait for Stencil hydration
      // ... other options
    })(defineConfig({ /* ... */ }));

    Components automatically wait for the hydrated class before interactions.

  2. For custom elements, wait for definition:

    await waitForComponent(page, 'my-component');
  3. Check that staticDir points to the correct build output.

  4. As a last resort, wait for visibility (not recommended as primary solution):

    const { container } = await render(<my-component />);
    await expect(container).toBeVisible();

Properties not updating

Problem: Component properties don't update or getProperty() times out.

Solutions:

  1. Use the retry feature with custom predicate:

    const value = await container.getProperty('data', {
      predicate: (v) => v !== undefined,
      timeout: 5000
    });
  2. Ensure the property is actually set in the component.

  3. Check browser console for component errors:

    test('debug', async ({ page }) => {
      page.on('console', msg => console.log(msg.text()));
      // ... your test
    });

Import map not working

Problem: Module imports fail with import map configuration.

Solutions:

  1. Import maps must be defined before any <script type="module">:

    export default withComponentTesting({
      importMap: { /* ... */ },
      scripts: ['/components.js'] // Loaded after import map
    });
  2. Verify import map URLs are accessible.

  3. Check browser DevTools Network tab for 404s.

TypeScript JSX errors

Problem: TypeScript complains about JSX syntax.

Solution: Configure tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@wc-tools/webrun"
  }
}

Component not hydrating (Stencil)

Problem: Stencil components don't hydrate properly.

Solutions:

  1. Ensure Stencil loader is included:

    scripts: ['/build/my-library.esm.js']
  2. Wait for component to be ready:

    await waitForComponent(page, 'my-component');
    await page.waitForLoadState('networkidle');

Tests timing out

Problem: Tests timeout waiting for components.

Solutions:

  1. Increase timeout for slow operations:

    test('slow operation', async ({ render }) => {
      test.setTimeout(60000); // 60 seconds
      // ... test code
    });
  2. Check if autoStart web server is running:

    # Manually start server to debug
    npx http-server ./dist -p 3000
  3. Verify webServer.url is accessible.

Examples

See the examples/ directory for complete working examples:

Roadmap

We're actively working on exciting new features:

🔬 V8 Code Coverage

  • Native V8 coverage integration for accurate component coverage reports
  • Coverage visualization and reporting

🎭 Typed Component Harnesses

  • Type-safe component test harnesses
  • Autocomplete for component APIs
  • Better test refactoring support

Stencil Hydration Detection

  • Automatic detection of Stencil component hydration
  • Wait for components to be fully ready before testing
  • Improved test reliability for SSR/hydrated components

Want to contribute? See our Contributing Guide!

Contributing

Contributions are welcome! See CONTRIBUTING.md for:

  • Development setup and workflow
  • Project architecture and structure
  • Code style guidelines
  • Testing strategy
  • Pull request process

License

ISC

Resources

Acknowledgments

Built with:

  • Playwright - Cross-browser testing framework
  • TypeScript - Type safety and developer experience
  • Lit - Efficient web components library
  • oxlint - Fast and accurate linter

Need help? Open an issue on GitHub or check our Troubleshooting guide.