SurfSense/.cursor/skills/playwright-testing/browser-extensions.md
2026-05-04 13:54:13 +05:30

13 KiB
Executable file

Browser Extensions

When to use: Testing Chrome extensions — popups, content scripts, background service workers, or extension-injected UI. Requires Chromium and a persistent browser context. Prerequisites: core/configuration.md, core/fixtures-and-hooks.md

Quick Reference

// Load an unpacked extension with a persistent context (Chromium only)
const context = await chromium.launchPersistentContext(userDataDir, {
  headless: false, // Extensions require headed mode
  args: [
    `--disable-extensions-except=${pathToExtension}`,
    `--load-extension=${pathToExtension}`,
  ],
});

Hard constraints: Extensions only work in Chromium. They require launchPersistentContext (not browser.newContext). Headed mode is mandatory — headless: false or use the --headless=new Chromium flag for "new headless" mode which supports extensions.

Patterns

Loading an Extension

Use when: You need to test any Chrome extension functionality. Avoid when: You only need to test the web app that an extension interacts with — mock the extension's effects instead.

TypeScript

import { test as base, expect, chromium, type BrowserContext } from '@playwright/test';
import path from 'path';

// Create a fixture that provides a context with the extension loaded
type ExtensionFixtures = {
  context: BrowserContext;
  extensionId: string;
};

export const test = base.extend<ExtensionFixtures>({
  // Override the default context to load the extension
  context: async ({}, use) => {
    const extensionPath = path.resolve(__dirname, '../my-extension');
    const context = await chromium.launchPersistentContext('', {
      headless: false,
      args: [
        `--disable-extensions-except=${extensionPath}`,
        `--load-extension=${extensionPath}`,
      ],
    });
    await use(context);
    await context.close();
  },

  // Extract the extension ID from the service worker URL
  extensionId: async ({ context }, use) => {
    let [background] = context.serviceWorkers();
    if (!background) {
      background = await context.waitForEvent('serviceworker');
    }
    const extensionId = background.url().split('/')[2];
    await use(extensionId);
  },
});

export { expect };

JavaScript

const { test: base, expect, chromium } = require('@playwright/test');
const path = require('path');

const test = base.extend({
  context: async ({}, use) => {
    const extensionPath = path.resolve(__dirname, '../my-extension');
    const context = await chromium.launchPersistentContext('', {
      headless: false,
      args: [
        `--disable-extensions-except=${extensionPath}`,
        `--load-extension=${extensionPath}`,
      ],
    });
    await use(context);
    await context.close();
  },

  extensionId: async ({ context }, use) => {
    let [background] = context.serviceWorkers();
    if (!background) {
      background = await context.waitForEvent('serviceworker');
    }
    const extensionId = background.url().split('/')[2];
    await use(extensionId);
  },
});

module.exports = { test, expect };

Testing Extension Popups

Use when: Your extension has a browser action popup (the UI that appears when clicking the extension icon). Avoid when: The popup is trivial — test the content script or background logic instead.

TypeScript

import { test, expect } from './extension-fixture';

test('extension popup displays saved bookmarks', async ({ page, extensionId }) => {
  // Navigate directly to the popup HTML
  await page.goto(`chrome-extension://${extensionId}/popup.html`);

  // Interact with popup UI using standard locators
  await expect(page.getByRole('heading', { name: 'My Bookmarks' })).toBeVisible();
  await page.getByRole('button', { name: 'Add current page' }).click();
  await expect(page.getByRole('listitem')).toHaveCount(1);
});

test('extension popup settings toggle works', async ({ page, extensionId }) => {
  await page.goto(`chrome-extension://${extensionId}/popup.html`);

  await page.getByRole('checkbox', { name: 'Enable notifications' }).check();
  await expect(page.getByText('Notifications enabled')).toBeVisible();
});

JavaScript

const { test, expect } = require('./extension-fixture');

test('extension popup displays saved bookmarks', async ({ page, extensionId }) => {
  await page.goto(`chrome-extension://${extensionId}/popup.html`);

  await expect(page.getByRole('heading', { name: 'My Bookmarks' })).toBeVisible();
  await page.getByRole('button', { name: 'Add current page' }).click();
  await expect(page.getByRole('listitem')).toHaveCount(1);
});

Testing Content Scripts

Use when: Your extension injects scripts or UI into web pages. Avoid when: The content script only modifies data without visible effects — test via the background worker or storage.

TypeScript

import { test, expect } from './extension-fixture';

test('content script injects price comparison widget', async ({ context }) => {
  const page = await context.newPage();
  await page.goto('https://example-shop.com/product/123');

  // Wait for the content script to inject its UI
  // The extension adds a shadow DOM element — Playwright pierces it automatically
  await expect(page.getByTestId('price-compare-widget')).toBeVisible({ timeout: 10000 });
  await expect(page.getByText('Best price: $29.99')).toBeVisible();
});

test('content script highlights search terms', async ({ context }) => {
  const page = await context.newPage();
  await page.goto('https://example.com/article');

  // Verify the content script added highlight spans
  const highlights = page.locator('.ext-highlight');
  await expect(highlights).toHaveCount(5);
  await expect(highlights.first()).toHaveCSS('background-color', 'rgb(255, 255, 0)');
});

JavaScript

const { test, expect } = require('./extension-fixture');

test('content script injects price comparison widget', async ({ context }) => {
  const page = await context.newPage();
  await page.goto('https://example-shop.com/product/123');

  await expect(page.getByTestId('price-compare-widget')).toBeVisible({ timeout: 10000 });
  await expect(page.getByText('Best price: $29.99')).toBeVisible();
});

Testing Background Service Workers

Use when: Your extension uses Manifest V3 service workers for background processing, alarms, or message passing. Avoid when: The background logic is simple and already covered by popup or content script tests.

TypeScript

import { test, expect } from './extension-fixture';

test('background worker processes messages correctly', async ({ context, extensionId }) => {
  const page = await context.newPage();
  await page.goto(`chrome-extension://${extensionId}/popup.html`);

  // Trigger an action that sends a message to the background worker
  await page.getByRole('button', { name: 'Sync data' }).click();

  // Verify the response from the background worker updates the popup
  await expect(page.getByText('Last synced: just now')).toBeVisible();
});

test('service worker handles extension storage', async ({ context, extensionId }) => {
  const page = await context.newPage();
  await page.goto(`chrome-extension://${extensionId}/popup.html`);

  // Set a value through the popup
  await page.getByLabel('API Key').fill('test-key-123');
  await page.getByRole('button', { name: 'Save' }).click();

  // Reload popup and verify persistence through the service worker
  await page.reload();
  await expect(page.getByLabel('API Key')).toHaveValue('test-key-123');
});

JavaScript

const { test, expect } = require('./extension-fixture');

test('background worker processes messages correctly', async ({ context, extensionId }) => {
  const page = await context.newPage();
  await page.goto(`chrome-extension://${extensionId}/popup.html`);

  await page.getByRole('button', { name: 'Sync data' }).click();
  await expect(page.getByText('Last synced: just now')).toBeVisible();
});

Testing Extension Options Page

Use when: Your extension has a dedicated options/settings page. Avoid when: Settings are fully covered by popup tests.

TypeScript

import { test, expect } from './extension-fixture';

test('options page saves preferences', async ({ page, extensionId }) => {
  await page.goto(`chrome-extension://${extensionId}/options.html`);

  await page.getByRole('combobox', { name: 'Theme' }).selectOption('dark');
  await page.getByRole('checkbox', { name: 'Auto-update' }).check();
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByText('Settings saved')).toBeVisible();

  // Verify persistence after reload
  await page.reload();
  await expect(page.getByRole('combobox', { name: 'Theme' })).toHaveValue('dark');
  await expect(page.getByRole('checkbox', { name: 'Auto-update' })).toBeChecked();
});

JavaScript

const { test, expect } = require('./extension-fixture');

test('options page saves preferences', async ({ page, extensionId }) => {
  await page.goto(`chrome-extension://${extensionId}/options.html`);

  await page.getByRole('combobox', { name: 'Theme' }).selectOption('dark');
  await page.getByRole('checkbox', { name: 'Auto-update' }).check();
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByText('Settings saved')).toBeVisible();

  await page.reload();
  await expect(page.getByRole('combobox', { name: 'Theme' })).toHaveValue('dark');
  await expect(page.getByRole('checkbox', { name: 'Auto-update' })).toBeChecked();
});

Decision Guide

Scenario Approach Why
Test popup UI Navigate to chrome-extension://<id>/popup.html Direct access without needing to click the extension icon
Test content script effects Load a real or test page, assert injected elements Content scripts run automatically on matching URLs
Test background logic Trigger via popup/content script, verify side effects Cannot directly call service worker functions from Playwright
Test extension storage Use popup to set values, reload, verify persistence chrome.storage is only accessible from extension pages
Test options page Navigate to chrome-extension://<id>/options.html Same approach as popup testing
Test cross-page behavior Open multiple pages in the same context Persistent context shares extension state across tabs
Run in CI (headless) Use --headless=new Chromium flag New headless mode supports extensions unlike old headless
Test with multiple extensions Add multiple paths to --load-extension Comma-separate paths in the flag value

Anti-Patterns

Don't Do This Problem Do This Instead
browser.newContext() for extensions Extensions require persistent context chromium.launchPersistentContext()
headless: true without --headless=new Old headless mode does not support extensions Set headless: false or use args: ['--headless=new']
Testing on Firefox or WebKit Extensions only work in Chromium Skip extension tests for non-Chromium projects
Clicking the extension icon via coordinates Fragile, toolbar layout varies Navigate directly to chrome-extension://<id>/popup.html
Hardcoding the extension ID IDs change between builds and machines Extract dynamically from the service worker URL
Testing packed .crx files directly Harder to debug, need to unpack first Test the unpacked extension source directory
Sharing persistent context user data dir State leaks between test runs Use an empty string '' for a temp directory
No timeout on content script assertions Content scripts may load after page load Use { timeout: 10000 } on content script element assertions

Troubleshooting

Symptom Likely Cause Fix
Extension does not load Wrong path in --load-extension Use path.resolve() to get the absolute path to the extension directory
context.serviceWorkers() returns empty Service worker not yet registered Use context.waitForEvent('serviceworker') before extracting the ID
Popup page is blank Popup HTML path is wrong Check manifest.json for the correct default_popup path
Content script not injecting Page URL does not match matches in manifest Verify the URL pattern in content_scripts[].matches
Extension works locally but not in CI CI uses old headless mode Add --headless=new to launch args for CI
chrome.storage calls fail Accessing storage from non-extension context Only access storage through extension pages (popup, options, background)
Multiple extensions conflict Both extensions modify the same page elements Test each extension in its own persistent context
Tests are slow to start Persistent context initialization overhead Reuse context across tests in the same file with test.describe