Cross-Browser WebExtensions Testing: Chrome, Firefox, and Edge Compatibility

Cross-Browser WebExtensions Testing: Chrome, Firefox, and Edge Compatibility

WebExtensions work across Chrome, Firefox, and Edge via a common API standard, but there are meaningful compatibility differences in Manifest V3 support, background script models, and API availability. Use Playwright's multi-browser runner to test your extension in Chromium and Firefox. For Edge, test in Chromium (Edge uses the same engine). Use webextension-polyfill to normalize promise-based APIs. Test browser-specific features with conditional logic gated on browser.runtime.getBrowserInfo().

Key Takeaways

Edge is Chromium — test it via Chromium. Microsoft Edge uses the same Blink engine as Chrome. Extensions that work in Chrome/Chromium work in Edge. Run your Chromium tests and they cover Edge.

Firefox MV3 support is partial. Firefox added MV3 support in version 109, but some MV3 features (like declarativeNetRequest full rule set) lag behind Chrome. Test MV3 features explicitly in Firefox.

Use webextension-polyfill for consistent async. Chrome uses callbacks; Firefox supports promises. webextension-polyfill wraps the chrome.* API to return promises everywhere, making your code and tests more consistent.

Don't test browser-specific bugs, test your extension's behavior. If Firefox doesn't support a specific API, don't write a test that expects it to fail — feature-detect and handle it in code, then test the handled behavior.

Build separate bundles if needed. Some APIs require different code for Chrome vs. Firefox. Use your build tool to produce browser-specific bundles, then test each bundle against its target browser.

WebExtensions are designed for cross-browser compatibility, but in practice there are real differences between Chrome, Firefox, and Edge that can break your extension silently. This guide covers how to structure your cross-browser test suite, handle API differences, and run a CI pipeline that catches regressions in all three browsers.

Browser Compatibility Matrix

Feature Chrome Firefox Edge
Manifest V2 Deprecated Supported Deprecated
Manifest V3 Full Partial (v109+) Full
Background pages (MV2) Yes Yes Yes
Service workers (MV3) Yes Yes (v109+) Yes
declarativeNetRequest Full Partial Full
chrome.* namespace Yes Yes (polyfill) Yes
browser.* namespace No (native) Yes Partial
Side panel API Yes No Yes
Tab Groups API Yes No Yes
Offscreen API Yes No Yes

Setting Up Cross-Browser Testing with Playwright

npm install --save-dev @playwright/test
npx playwright install chromium firefox

Playwright Configuration for Extension Testing

// playwright.config.js
const { defineConfig } = require('@playwright/test');
const path = require('path');

const EXTENSION_PATH = path.resolve(__dirname, 'dist');

module.exports = defineConfig({
  projects: [
    {
      name: 'chromium-extension',
      use: {
        ...require('./test/fixtures/chromiumExtension'),
      },
      testMatch: '**/tests/chromium/**/*.spec.js',
    },
    {
      name: 'firefox-extension',
      use: {
        ...require('./test/fixtures/firefoxExtension'),
      },
      testMatch: '**/tests/firefox/**/*.spec.js',
    },
    {
      name: 'cross-browser',
      // Tests that should run in both browsers
      testMatch: '**/tests/cross-browser/**/*.spec.js',
    },
  ],
  reporter: [['html', { outputFolder: 'test-report' }]],
});

Chromium Extension Fixture

// test/fixtures/chromiumExtension.js
const { chromium } = require('@playwright/test');
const path = require('path');

const EXTENSION_PATH = path.resolve(__dirname, '../../dist');

async function setup() {
  const context = await chromium.launchPersistentContext('', {
    headless: false,
    args: [
      `--disable-extensions-except=${EXTENSION_PATH}`,
      `--load-extension=${EXTENSION_PATH}`,
    ],
  });

  // Get extension ID from service worker
  await context.waitForEvent('serviceworker');
  const [worker] = context.serviceWorkers();
  const extensionId = new URL(worker.url()).hostname;

  return { context, extensionId, browser: 'chromium' };
}

module.exports = { setup };

Firefox Extension Fixture

// test/fixtures/firefoxExtension.js
const { firefox } = require('@playwright/test');
const path = require('path');

const EXTENSION_PATH = path.resolve(__dirname, '../../dist-firefox');

async function setup() {
  const context = await firefox.launchPersistentContext('', {
    headless: false,
    firefoxUserPrefs: {
      'extensions.autoDisableScopes': 0,
      'extensions.enabledScopes': 15,
      'xpinstall.signatures.required': false,
    },
  });

  // Load extension via about:debugging
  const page = await context.newPage();
  await page.goto('about:debugging#/runtime/this-firefox');
  await page.click('[data-l10n-id="about-debugging-tmp-extension-install-button"]');

  const [fileChooser] = await Promise.all([
    page.waitForEvent('filechooser'),
    page.click('[data-l10n-id="about-debugging-tmp-extension-install-button"]'),
  ]);
  await fileChooser.setFiles(path.join(EXTENSION_PATH, 'manifest.json'));
  await page.close();

  return { context, browser: 'firefox' };
}

module.exports = { setup };

Writing Cross-Browser Compatible Tests

Shared Test Logic

Use a factory pattern to write tests once and run them in both browsers:

// tests/cross-browser/popup.spec.js
function createPopupTests(getContext) {
  return {
    'renders main toggle': async () => {
      const { context, extensionId } = await getContext();
      const page = await context.newPage();

      if (extensionId) {
        await page.goto(`chrome-extension://${extensionId}/popup.html`);
      } else {
        // Firefox: use moz-extension:// URL
        await page.goto(`moz-extension://${await getFirefoxExtensionId(context)}/popup.html`);
      }

      await expect(page.locator('#toggle')).toBeVisible();
      await page.close();
    },

    'toggle state persists across popup opens': async () => {
      const { context, extensionId } = await getContext();
      const page1 = await context.newPage();
      const popupUrl = extensionId
        ? `chrome-extension://${extensionId}/popup.html`
        : `moz-extension://${await getFirefoxExtensionId(context)}/popup.html`;

      await page1.goto(popupUrl);
      await page1.click('#toggle');
      await page1.close();

      const page2 = await context.newPage();
      await page2.goto(popupUrl);

      const isChecked = await page2.locator('#toggle').isChecked();
      expect(isChecked).toBe(false); // Was toggled off

      await page2.close();
    },
  };
}

Handling API Differences

Using webextension-polyfill

The webextension-polyfill library normalizes the async API across browsers:

npm install webextension-polyfill
// In your extension code
import browser from 'webextension-polyfill';

// Works the same in Chrome and Firefox
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
const storage = await browser.storage.local.get('settings');

In your tests, mock webextension-polyfill instead of chrome:

// test/unit/popup.test.js
jest.mock('webextension-polyfill', () => ({
  storage: {
    local: {
      get: jest.fn().mockResolvedValue({ settings: { enabled: true } }),
      set: jest.fn().mockResolvedValue(undefined),
    },
  },
  tabs: {
    query: jest.fn().mockResolvedValue([{ id: 1, url: 'https://example.com' }]),
  },
  runtime: {
    sendMessage: jest.fn().mockResolvedValue({ pong: true }),
    onMessage: { addListener: jest.fn() },
  },
}));

const browser = require('webextension-polyfill');
const { toggleExtension } = require('../../src/popup/toggle');

test('toggle disables extension in storage', async () => {
  await toggleExtension(false);

  expect(browser.storage.local.set).toHaveBeenCalledWith(
    expect.objectContaining({ enabled: false })
  );
});

Feature Detection in Tests

When an API is available in Chrome but not Firefox, detect it in your extension code and test the detection:

// src/features/sidePanel.js
export function isSidePanelSupported() {
  return typeof chrome !== 'undefined' &&
    typeof chrome.sidePanel !== 'undefined';
}

export async function openSidePanel(tabId) {
  if (!isSidePanelSupported()) {
    // Fallback: open in a tab
    await browser.tabs.create({ url: browser.runtime.getURL('panel.html') });
    return { method: 'tab' };
  }

  await chrome.sidePanel.open({ tabId });
  return { method: 'sidePanel' };
}
// test/unit/sidePanel.test.js
describe('Side Panel Feature Detection', () => {
  test('uses sidePanel API when available (Chrome)', async () => {
    global.chrome = {
      sidePanel: { open: jest.fn().mockResolvedValue(undefined) },
    };

    const { isSidePanelSupported, openSidePanel } = require('../../src/features/sidePanel');
    expect(isSidePanelSupported()).toBe(true);

    const result = await openSidePanel(1);
    expect(result.method).toBe('sidePanel');
    expect(chrome.sidePanel.open).toHaveBeenCalledWith({ tabId: 1 });
  });

  test('falls back to tab when sidePanel unavailable (Firefox)', async () => {
    global.chrome = {}; // No sidePanel

    const browser = require('webextension-polyfill');
    browser.tabs.create.mockResolvedValueOnce({ id: 99 });

    const { openSidePanel } = require('../../src/features/sidePanel');
    const result = await openSidePanel(1);

    expect(result.method).toBe('tab');
    expect(browser.tabs.create).toHaveBeenCalledWith({
      url: expect.stringContaining('panel.html'),
    });
  });
});

Building Browser-Specific Bundles

For extensions with significant browser-specific code, use conditional builds:

// build.js (esbuild)
const esbuild = require('esbuild');

const browsers = [
  {
    browser: 'chrome',
    define: { 'process.env.BROWSER': '"chrome"' },
    outdir: 'dist-chrome',
  },
  {
    browser: 'firefox',
    define: { 'process.env.BROWSER': '"firefox"' },
    outdir: 'dist-firefox',
  },
];

for (const config of browsers) {
  esbuild.buildSync({
    entryPoints: ['src/background.js', 'src/popup.js', 'src/content.js'],
    bundle: true,
    define: config.define,
    outdir: config.outdir,
  });
}
// In extension code
if (process.env.BROWSER === 'chrome') {
  // Chrome-specific API
  chrome.sidePanel.setOptions({ enabled: true });
}

Cross-Browser CI Pipeline

# .github/workflows/cross-browser-tests.yml
name: Cross-Browser Extension Tests

on: [push, pull_request]

jobs:
  chromium:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run build:chrome
      - run: npx playwright install --with-deps chromium
      - name: Run Chromium extension tests
        run: npx playwright test --project=chromium-extension
        env:
          DISPLAY: ':99'
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: chromium-test-results
          path: test-results/

  firefox:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run build:firefox
      - run: npx playwright install --with-deps firefox
      - name: Run Firefox extension tests
        run: npx playwright test --project=firefox-extension
        env:
          DISPLAY: ':99'
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: firefox-test-results
          path: test-results/

  compatibility-report:
    needs: [chromium, firefox]
    runs-on: ubuntu-latest
    steps:
      - name: Merge test reports
        run: echo "All browsers passed"

Common Compatibility Issues to Test

1. Manifest Fields

Some manifest fields are Chrome-specific. Test that your extension loads in Firefox without errors:

test('extension loads without errors in Firefox', async () => {
  // Check browser console for extension errors
  const errors = [];
  page.on('console', msg => {
    if (msg.type() === 'error') errors.push(msg.text());
  });

  await page.goto('about:blank');
  await page.waitForTimeout(2000);

  const extensionErrors = errors.filter(e => e.includes('Extension'));
  expect(extensionErrors).toHaveLength(0);
});

2. Content Security Policy

Firefox enforces CSP more strictly. Test that your extension's HTML doesn't violate CSP:

test('popup has no CSP violations', async ({ page }) => {
  const cspViolations = [];
  page.on('console', msg => {
    if (msg.text().includes('Content Security Policy')) {
      cspViolations.push(msg.text());
    }
  });

  await page.goto(`${extensionUrl}/popup.html`);
  await page.waitForTimeout(1000);

  expect(cspViolations).toHaveLength(0);
});

3. Storage API Differences

// Cross-browser storage test
test('settings persist in storage.local across browsers', async ({ page }) => {
  await page.evaluate(async () => {
    await browser.storage.local.set({ theme: 'dark', enabled: true });
  });

  const stored = await page.evaluate(async () => {
    return browser.storage.local.get(['theme', 'enabled']);
  });

  expect(stored).toEqual({ theme: 'dark', enabled: true });
});

Continuous Cross-Browser Monitoring

Browser API support changes with each browser release. Chrome's extension API evolves regularly, and Firefox's MV3 support is still catching up. HelpMeTest can run your cross-browser extension tests on a daily schedule, alerting you when a browser update breaks compatibility. At $100/month with no infrastructure to manage, it's the cheapest way to stay ahead of browser compatibility regressions.

Summary

Cross-browser WebExtension testing strategy:

  1. Edge = Chromium: don't maintain a separate Edge test suite — Chromium tests cover it
  2. Firefox MV3: run Firefox-specific tests for MV3 features with partial support
  3. webextension-polyfill: normalize async APIs and mock at the polyfill layer in unit tests
  4. Feature detection: test the detected fallback behavior, not browser-specific failure
  5. Browser-specific bundles: use esbuild or webpack to produce per-browser builds and test each
  6. CI matrix: run Chromium and Firefox in parallel; fail the whole pipeline if either fails

The most common source of cross-browser bugs: assuming chrome.* APIs are available in Firefox without the polyfill, and assuming MV3 APIs that are Chrome-only work everywhere. Test both browsers in CI from day one.

Read more