Extending Playwright: Mobile, Lighthouse, and Storybook

An experiment in extending the core Playwright runner to natively support Appium, inline Lighthouse audits, Storybook discovery, and duration-based test sharding.

I wanted to see what it would look like if Playwright's core runner natively handled the disjointed parts of a testing stack. Instead of tying separate tools together, I forked microsoft/playwright (keeping it synchronized with upstream v1.60) to experiment with building these capabilities directly into the engine. Here is a look at what it can do.

Native Appium support

Rather than running a separate Appium stack, I built a @playwright/mobile package that integrates AppiumConfig directly into the test options. It manages the Appium server natively and introduces typed selectors (IosSelector, AndroidSelector) alongside standard browser locators. Web and native mobile tests aggregate cleanly into a single HTML report.

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

    export default defineConfig({
      use: {
        appium: {
          serverUrl: 'http://127.0.0.1:4723',
          capabilities: {
            platformName: 'iOS',
            'appium:automationName': 'XCUITest',
            'appium:deviceName': 'iPhone 15 Pro',
          },
          autoStart: true,
        },
      },
    });

    // tests/mobile.spec.ts
    import { test, expect } from '@playwright/mobile';

    test('native iOS login', async ({ mobileApp }) => {
      await mobileApp.locator({ accessibilityId: 'username-input' }).fill('admin');
      await mobileApp.locator({ iosClassChain: '**/XCUIElementTypeButton[`label == "Login"`]' }).click();
      await expect(mobileApp.locator({ accessibilityId: 'welcome-banner' })).toBeVisible();
    });
    

Lighthouse audits

Running Lighthouse usually means stepping outside the test runner. I added a @playwright/lighthouse package that exports a custom lighthouseTest fixture. It provisions Chrome with remote debugging and exposes a lighthouse function in the test arguments to run performance assertions inline. When a threshold fails, the HTML report attaches straight to the Playwright trace viewer.

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

    test('performance meets standards', async ({ page, lighthouse }) => {
      await page.goto('https://example.com');
      
      await lighthouse({
        thresholds: {
          performance: 90,
          accessibility: 100,
          seo: 100,
        },
      });
    });
    

Storybook integration

I wanted to drive Storybook components without duplicating test logic. The @playwright/storybook package discovers components automatically and executes Storybook play functions natively inside Playwright test fixtures.

import { storybookTest as test, expect, fetchStoryIndex, filterStories, runPlayFunction } from '@playwright/storybook';

    const index = await fetchStoryIndex('http://localhost:6006');
    const stories = filterStories(index, { include: ['Form/*'] });

    for (const story of stories) {
      test(`renders ${story.title} / ${story.name}`, async ({ page, mountStory }) => {
        await mountStory(story.id);
        await runPlayFunction(page, story.id);
        await expect(page.locator('#storybook-root')).toBeVisible();
      });
    }
    

Smart sharding

Playwright's default alphabetical sharding often leads to uneven execution times. I added two new sharding algorithms to the configuration to balance the load:

  • round-robin: Distributes tests sequentially across workers to prevent clustering (lifted from this PR which didn't make it in).
  • duration-round-robin: Bins tests based on historical runtime duration to equalize worker completion times.
// playwright.config.ts
    import { defineConfig } from '@playwright/test';

    export default defineConfig({
      shardingMode: 'duration-round-robin',
      workers: 4,
    });
    

Bun compatibility and performance

Finally, I wanted to see how far the engine could be pushed with Bun. The runner is optimized for Bun compatibility, allowing you to use modern Bun APIs natively within your tests.

To improve execution performance, reporters are now strictly lazy-loaded via await import() (bypassing the fixed startup cost of the monolithic common bundle), and the SQLite cache backend has been rebuilt with batched writes and in-memory queues to dramatically speed up transform times.


Code: github.com/kidby/playwright
Images: mkidby/playwright