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 firefoxPlaywright 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:
- Edge = Chromium: don't maintain a separate Edge test suite — Chromium tests cover it
- Firefox MV3: run Firefox-specific tests for MV3 features with partial support
- webextension-polyfill: normalize async APIs and mock at the polyfill layer in unit tests
- Feature detection: test the detected fallback behavior, not browser-specific failure
- Browser-specific bundles: use esbuild or webpack to produce per-browser builds and test each
- 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.