Firefox Extension Testing: Cross-Browser Compatibility Strategies

Firefox Extension Testing: Cross-Browser Compatibility Strategies

Building a browser extension that works in both Chrome and Firefox sounds straightforward — both use the WebExtensions API. In practice, there are enough API differences, manifest differences, and behavioral quirks to make cross-browser extension testing its own discipline.

This guide focuses on Firefox-specific testing: what's different from Chrome, what tools to use, and how to structure your tests for cross-browser coverage.

Chrome vs Firefox: Key API Differences

Before testing, you need to understand where Firefox and Chrome diverge.

Promise-based vs callback-based APIs: Firefox's WebExtensions API is natively Promise-based. Chrome's is callback-based (though Chrome has been adding Promise support). A Firefox extension written with await browser.storage.local.get() needs to be adapted for Chrome or run through a polyfill like webextension-polyfill.

browser vs chrome namespace: Firefox uses browser.*, Chrome uses chrome.*. Extensions targeting both should use browser.* with the polyfill, or check typeof browser !== 'undefined' at runtime.

Manifest differences:

  • Chrome MV3: "background": { "service_worker": "background.js" }
  • Firefox: "background": { "scripts": ["background.js"] } (Firefox supports MV3 but with some differences; MV2 is still widely used in Firefox)
  • Firefox requires "browser_specific_settings" for AMO submission

Content Security Policy: Firefox enforces CSP more strictly in some cases. Extensions that use eval or dynamic code execution may work in Chrome but fail in Firefox.

Container tab support: Firefox has a container tab concept (Multi-Account Containers) with a cookieStoreId on tabs. If your extension manages cookies or tabs, this is Firefox-only functionality to test.

Sidebar support: Firefox supports browser sidebars natively ("sidebar_action"). Chrome doesn't have this. If you support sidebars in Firefox, you need Firefox-specific tests.

Setting Up Firefox Testing with web-ext

Mozilla provides web-ext, the official CLI for Firefox extension development and testing:

npm install --save-dev web-ext

Running your extension in Firefox for manual testing:

web-ext run --source-dir ./src --firefox=firefox

Running the linting tool that catches Firefox-specific issues:

web-ext lint --source-dir ./src

web-ext lint checks for:

  • Deprecated manifest keys
  • APIs not supported in Firefox
  • Missing browser_specific_settings.gecko.id
  • Policy violations that would reject the extension from AMO

Run this in CI as a required check:

- name: Lint extension for Firefox
  run: npx web-ext lint --source-dir ./src

Testing with the Polyfill

If your extension targets both Chrome and Firefox, use webextension-polyfill:

// background.js
import browser from 'webextension-polyfill';

// Now you can use browser.* API with Promises in both Chrome and Firefox
const settings = await browser.storage.sync.get(['theme', 'language']);

Testing this setup — ensure the polyfill works correctly in your unit tests:

// jest.setup.js
// Mock the polyfill — in tests we use the polyfill's API
jest.mock('webextension-polyfill', () => ({
  storage: {
    sync: {
      get: jest.fn().mockResolvedValue({}),
      set: jest.fn().mockResolvedValue(undefined),
    },
    local: {
      get: jest.fn().mockResolvedValue({}),
      set: jest.fn().mockResolvedValue(undefined),
    },
  },
  runtime: {
    sendMessage: jest.fn().mockResolvedValue({}),
    onMessage: {
      addListener: jest.fn(),
      removeListener: jest.fn(),
    },
    getURL: jest.fn((path) => `moz-extension://test-id/${path}`),
    lastError: undefined,
  },
  tabs: {
    query: jest.fn().mockResolvedValue([]),
    create: jest.fn().mockResolvedValue({}),
    sendMessage: jest.fn().mockResolvedValue({}),
  },
}));

End-to-End Testing Firefox Extensions with Playwright

Playwright supports Firefox extension testing since version 1.18:

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

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

export default defineConfig({
  projects: [
    {
      name: 'chrome',
      use: {
        launchOptions: {
          args: [
            `--disable-extensions-except=${EXTENSION_PATH}`,
            `--load-extension=${EXTENSION_PATH}`,
          ],
        },
      },
    },
    {
      name: 'firefox',
      use: {
        browserName: 'firefox',
        launchOptions: {
          firefoxUserPrefs: {
            // Required for extension testing in Firefox
            'xpinstall.signatures.required': false,
          },
          args: ['-profile', '/tmp/firefox-test-profile'],
        },
      },
    },
  ],
});

Loading the extension in Firefox requires a different approach than Chrome:

// tests/e2e/firefox-setup.js
import { firefox } from '@playwright/test';
import path from 'path';

export async function launchFirefoxWithExtension() {
  const browser = await firefox.launch({
    firefoxUserPrefs: {
      'xpinstall.signatures.required': false,
    },
  });
  
  const context = await browser.newContext();
  
  // Firefox requires installing the extension via the addon manager
  const extensionPath = path.join(__dirname, '../../dist');
  await context.route('**/*', route => route.continue());
  
  // Use web-ext API to load the extension
  // Or use Firefox's about:debugging approach
  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"]');
  
  // File dialog handling is complex — see web-ext for alternatives
  
  return { browser, context };
}

Practical note: Firefox extension loading in Playwright is more complex than Chrome. For most teams, use web-ext run for Firefox-specific interactive testing and Playwright for Chrome E2E, then test Firefox manually or with selenium/geckodriver.

Using geckodriver and Selenium for Firefox

For more reliable Firefox extension testing in CI, Selenium with geckodriver is the established approach:

// tests/firefox/extension.test.js (using selenium-webdriver)
const { Builder, By, until } = require('selenium-webdriver');
const firefox = require('selenium-webdriver/firefox');
const path = require('path');

describe('Firefox extension', () => {
  let driver;
  
  beforeAll(async () => {
    const extensionPath = path.join(__dirname, '../../dist.xpi'); // Firefox requires .xpi
    
    const options = new firefox.Options();
    options.addExtensions(extensionPath);
    options.setPreference('xpinstall.signatures.required', false);
    options.headless(); // Headless Firefox works for extensions
    
    driver = await new Builder()
      .forBrowser('firefox')
      .setFirefoxOptions(options)
      .build();
  });
  
  afterAll(() => driver.quit());
  
  test('extension popup opens', async () => {
    await driver.get('about:blank');
    
    // Firefox extensions are accessible via moz-extension:// URL
    // Get the extension ID first
    const extensionId = await getFirefoxExtensionId(driver);
    
    await driver.get(`moz-extension://${extensionId}/popup.html`);
    
    const header = await driver.wait(
      until.elementLocated(By.css('[data-testid="popup-header"]')),
      5000
    );
    expect(await header.isDisplayed()).toBe(true);
  });
});

async function getFirefoxExtensionId(driver) {
  // Navigate to about:debugging and parse the extension ID
  await driver.get('about:debugging#/runtime/this-firefox');
  const idElement = await driver.wait(
    until.elementLocated(By.css('.debug-target-item__detail-target-full-id')),
    3000
  );
  return (await idElement.getText()).replace('ID: ', '');
}

To create the .xpi file:

web-ext build --source-dir ./src --artifacts-dir ./dist
# Creates dist/extension.xpi

Cross-Browser Test Structure

Organize tests to run the same scenarios across both browsers:

// tests/e2e/scenarios.js — browser-agnostic test scenarios
export const extensionScenarios = {
  'popup shows correct item count': async (page, extensionUrl) => {
    await page.goto(`${extensionUrl}/popup.html`);
    await expect(page.locator('[data-testid="item-count"]')).toBeVisible();
  },
  
  'settings are saved and retrieved': async (page, extensionUrl) => {
    await page.goto(`${extensionUrl}/options.html`);
    await page.selectOption('[data-testid="theme"]', 'dark');
    await page.click('[data-testid="save"]');
    await page.reload();
    await expect(page.locator('[data-testid="theme"]')).toHaveValue('dark');
  },
};

// tests/e2e/chrome.spec.js
import { extensionScenarios } from './scenarios';

for (const [name, scenario] of Object.entries(extensionScenarios)) {
  test(`Chrome: ${name}`, async ({ browser }) => {
    const extensionId = await getChromeExtensionId(browser);
    const page = await browser.newPage();
    await scenario(page, `chrome-extension://${extensionId}`);
  });
}

// tests/e2e/firefox.spec.js
for (const [name, scenario] of Object.entries(extensionScenarios)) {
  test(`Firefox: ${name}`, async ({ browser }) => {
    const extensionId = await getFirefoxExtensionId(browser);
    const page = await browser.newPage();
    await scenario(page, `moz-extension://${extensionId}`);
  });
}

Firefox-Specific Features to Test

Container tabs (if you use browser.contextualIdentities):

test('extension works with Firefox container tabs', async () => {
  // Create a container tab
  const container = await browser.contextualIdentities.create({
    name: 'Work',
    color: 'blue',
    icon: 'briefcase',
  });
  
  const tab = await browser.tabs.create({
    url: 'https://example.com',
    cookieStoreId: container.cookieStoreId,
  });
  
  // Verify extension handles container tabs correctly
  const result = await browser.runtime.sendMessage({
    type: 'GET_TAB_INFO',
    tabId: tab.id,
  });
  
  expect(result.cookieStoreId).toBe(container.cookieStoreId);
});

Sidebar (if you implement sidebar_action):

test('sidebar opens correctly in Firefox', async () => {
  await driver.get('https://example.com');
  
  // Click the sidebar button (extension action)
  const action = driver.wait(
    until.elementLocated(By.css('.sidebar-action')),
    3000
  );
  await action.click();
  
  // Switch to sidebar frame
  await driver.switchTo().frame('sidebar-frame');
  
  const sidebarContent = await driver.wait(
    until.elementLocated(By.css('[data-testid="sidebar-content"]')),
    3000
  );
  expect(await sidebarContent.isDisplayed()).toBe(true);
});

Firefox Manifest Validation

Firefox has stricter manifest validation than Chrome. Always lint your manifest:

// tests/manifest.test.js
const manifest = require('../src/manifest.json');

describe('manifest validation', () => {
  it('has required browser_specific_settings for Firefox', () => {
    expect(manifest.browser_specific_settings?.gecko?.id).toBeDefined();
    expect(manifest.browser_specific_settings.gecko.id).toMatch(
      /^[a-z0-9._-]+@[a-z0-9._-]+\.[a-z]+$/
    );
  });
  
  it('specifies minimum Firefox version', () => {
    expect(manifest.browser_specific_settings?.gecko?.strict_min_version).toBeDefined();
  });
  
  it('does not use Chrome-only APIs in permissions', () => {
    const chromeOnlyPermissions = ['desktopCapture', 'platformKeys'];
    const permissions = manifest.permissions || [];
    const chromeOnlyUsed = permissions.filter(p => chromeOnlyPermissions.includes(p));
    
    expect(chromeOnlyUsed).toHaveLength(0);
  });
});

Summary

Firefox extension testing requires:

  1. web-ext lint in CI to catch Firefox-specific manifest and API issues
  2. Promise-based API usage via webextension-polyfill
  3. Firefox-specific E2E setup using geckodriver + selenium or Playwright's Firefox support
  4. Separate test runs for Firefox-only features (containers, sidebar)
  5. Cross-browser test scenarios shared between Chrome and Firefox test files

The most common cross-browser bugs are callback vs Promise API usage, chrome.* vs browser.* namespace issues, and manifest configuration differences. Unit tests with proper mocks catch most of these; a Firefox E2E run catches the rest.

Read more

Start now free