Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
testing-patterns/browser-extensions.md
1# Browser Extension Testing23## Table of Contents451. [Setup & Configuration](#setup--configuration)62. [Loading Extensions](#loading-extensions)73. [Popup Testing](#popup-testing)84. [Background Script Testing](#background-script-testing)95. [Content Script Testing](#content-script-testing)106. [Extension APIs](#extension-apis)117. [Cross-Browser Testing](#cross-browser-testing)1213## Setup & Configuration1415### Prerequisites1617```bash18npm install -D @playwright/test19npx playwright install chromium # Extensions only work in Chromium20```2122### Basic Configuration2324```typescript25// playwright.config.ts26import { defineConfig } from "@playwright/test";27import path from "path";2829export default defineConfig({30testDir: "./tests",31use: {32// Extensions require non-headless Chromium33headless: false,34},35projects: [36{37name: "chromium-extension",38use: {39browserName: "chromium",40},41},42],43});44```4546### Extension Fixture4748```typescript49// fixtures/extension.ts50import { test as base, chromium, BrowserContext, Page } from "@playwright/test";51import path from "path";5253type ExtensionFixtures = {54context: BrowserContext;55extensionId: string;56backgroundPage: Page;57};5859export const test = base.extend<ExtensionFixtures>({60context: async ({}, use) => {61const pathToExtension = path.join(__dirname, "../extension");6263const context = await chromium.launchPersistentContext("", {64headless: false,65args: [66`--disable-extensions-except=${pathToExtension}`,67`--load-extension=${pathToExtension}`,68],69});7071await use(context);72await context.close();73},7475extensionId: async ({ context }, use) => {76// Get extension ID from service worker URL77let extensionId = "";7879// Wait for service worker to be registered80const serviceWorker =81context.serviceWorkers()[0] ||82(await context.waitForEvent("serviceworker"));8384extensionId = serviceWorker.url().split("/")[2];8586await use(extensionId);87},8889backgroundPage: async ({ context }, use) => {90// For Manifest V2 extensions91const backgroundPage =92context.backgroundPages()[0] ||93(await context.waitForEvent("backgroundpage"));9495await use(backgroundPage);96},97});9899export { expect } from "@playwright/test";100```101102## Loading Extensions103104### Manifest V3 (Service Worker)105106```typescript107test("load MV3 extension", async () => {108const pathToExtension = path.join(__dirname, "../my-extension");109110const context = await chromium.launchPersistentContext("", {111headless: false,112args: [113`--disable-extensions-except=${pathToExtension}`,114`--load-extension=${pathToExtension}`,115],116});117118// Wait for service worker119const serviceWorker = await context.waitForEvent("serviceworker");120expect(serviceWorker.url()).toContain("chrome-extension://");121122await context.close();123});124```125126### Manifest V2 (Background Page)127128```typescript129test("load MV2 extension", async () => {130const pathToExtension = path.join(__dirname, "../my-extension-v2");131132const context = await chromium.launchPersistentContext("", {133headless: false,134args: [135`--disable-extensions-except=${pathToExtension}`,136`--load-extension=${pathToExtension}`,137],138});139140// Wait for background page141const backgroundPage = await context.waitForEvent("backgroundpage");142expect(backgroundPage.url()).toContain("chrome-extension://");143144await context.close();145});146```147148### Multiple Extensions149150```typescript151test("load multiple extensions", async () => {152const extension1 = path.join(__dirname, "../extension1");153const extension2 = path.join(__dirname, "../extension2");154155const context = await chromium.launchPersistentContext("", {156headless: false,157args: [158`--disable-extensions-except=${extension1},${extension2}`,159`--load-extension=${extension1},${extension2}`,160],161});162163// Both service workers should be available164await context.waitForEvent("serviceworker");165await context.waitForEvent("serviceworker");166167expect(context.serviceWorkers().length).toBe(2);168169await context.close();170});171```172173## Popup Testing174175### Opening Extension Popup176177```typescript178test("test popup UI", async ({ context, extensionId }) => {179// Open popup directly by URL180const popupPage = await context.newPage();181await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);182183// Test popup interactions184await expect(popupPage.getByRole("heading")).toHaveText("My Extension");185await popupPage.getByRole("button", { name: "Enable" }).click();186await expect(popupPage.getByText("Enabled")).toBeVisible();187});188```189190### Popup State Persistence191192```typescript193test("popup remembers state", async ({ context, extensionId }) => {194// First interaction195const popup1 = await context.newPage();196await popup1.goto(`chrome-extension://${extensionId}/popup.html`);197await popup1.getByRole("checkbox", { name: "Dark Mode" }).check();198await popup1.close();199200// Reopen popup201const popup2 = await context.newPage();202await popup2.goto(`chrome-extension://${extensionId}/popup.html`);203204// State should persist205await expect(206popup2.getByRole("checkbox", { name: "Dark Mode" }),207).toBeChecked();208});209```210211### Popup Communication with Background212213```typescript214test("popup sends message to background", async ({ context, extensionId }) => {215const popup = await context.newPage();216await popup.goto(`chrome-extension://${extensionId}/popup.html`);217218// Set up listener for response219const responsePromise = popup.evaluate(() => {220return new Promise((resolve) => {221chrome.runtime.onMessage.addListener((message) => {222if (message.type === "RESPONSE") resolve(message.data);223});224});225});226227// Click button that sends message228await popup.getByRole("button", { name: "Fetch Data" }).click();229230// Verify response231const response = await responsePromise;232expect(response).toBeDefined();233});234```235236## Background Script Testing237238### Manifest V3 Service Worker239240```typescript241test("service worker handles messages", async ({ context, extensionId }) => {242const page = await context.newPage();243await page.goto("https://example.com");244245// Send message to service worker from page246const response = await page.evaluate(async (extId) => {247return new Promise((resolve) => {248chrome.runtime.sendMessage(extId, { type: "GET_STATUS" }, resolve);249});250}, extensionId);251252expect(response).toEqual({ status: "active" });253});254```255256### Testing Background Logic257258```typescript259test("background script logic", async ({ context }) => {260const serviceWorker =261context.serviceWorkers()[0] ||262(await context.waitForEvent("serviceworker"));263264// Evaluate in service worker context265const result = await serviceWorker.evaluate(async () => {266// Access extension APIs267const storage = await chrome.storage.local.get("settings");268return storage;269});270271expect(result.settings).toBeDefined();272});273```274275### Alarms and Timers276277```typescript278test("alarm triggers correctly", async ({ context }) => {279const serviceWorker = await context.waitForEvent("serviceworker");280281// Create alarm282await serviceWorker.evaluate(async () => {283await chrome.alarms.create("test-alarm", { delayInMinutes: 0.01 });284});285286// Wait for alarm handler287await serviceWorker.evaluate(() => {288return new Promise<void>((resolve) => {289chrome.alarms.onAlarm.addListener((alarm) => {290if (alarm.name === "test-alarm") resolve();291});292});293});294295// Verify alarm was handled (check side effects)296const wasHandled = await serviceWorker.evaluate(async () => {297const { alarmTriggered } = await chrome.storage.local.get("alarmTriggered");298return alarmTriggered;299});300301expect(wasHandled).toBe(true);302});303```304305## Content Script Testing306307### Injected Content Script308309```typescript310test("content script injects UI", async ({ context }) => {311const page = await context.newPage();312await page.goto("https://example.com");313314// Wait for content script to inject elements315await expect(page.locator("#my-extension-widget")).toBeVisible();316317// Interact with injected UI318await page.locator("#my-extension-widget button").click();319await expect(page.locator("#my-extension-widget .result")).toHaveText(320"Success",321);322});323```324325### Content Script Communication326327```typescript328test("content script communicates with background", async ({329context,330extensionId,331}) => {332const page = await context.newPage();333await page.goto("https://example.com");334335// Trigger content script action336await page.locator("#my-extension-button").click();337338// Wait for background response reflected in UI339await expect(page.locator("#my-extension-status")).toHaveText("Connected");340});341```342343### Page Modification Testing344345```typescript346test("content script modifies page", async ({ context }) => {347const page = await context.newPage();348await page.goto("https://example.com");349350// Verify content script modifications351const hasModification = await page.evaluate(() => {352// Check for injected styles353const styles = document.querySelectorAll('style[data-extension="my-ext"]');354return styles.length > 0;355});356357expect(hasModification).toBe(true);358359// Check DOM modifications360const modifiedElements = await page361.locator("[data-modified-by-extension]")362.count();363expect(modifiedElements).toBeGreaterThan(0);364});365```366367## Extension APIs368369### Storage API370371```typescript372test("chrome.storage operations", async ({ context }) => {373const serviceWorker = await context.waitForEvent("serviceworker");374375// Set storage376await serviceWorker.evaluate(async () => {377await chrome.storage.local.set({ key: "value", count: 42 });378});379380// Get storage381const data = await serviceWorker.evaluate(async () => {382return await chrome.storage.local.get(["key", "count"]);383});384385expect(data).toEqual({ key: "value", count: 42 });386387// Test storage.sync388await serviceWorker.evaluate(async () => {389await chrome.storage.sync.set({ synced: true });390});391392const syncData = await serviceWorker.evaluate(async () => {393return await chrome.storage.sync.get("synced");394});395396expect(syncData.synced).toBe(true);397});398```399400### Tabs API401402```typescript403test("chrome.tabs operations", async ({ context }) => {404const serviceWorker = await context.waitForEvent("serviceworker");405406// Create a tab407const page = await context.newPage();408await page.goto("https://example.com");409410// Query tabs from service worker411const tabs = await serviceWorker.evaluate(async () => {412return await chrome.tabs.query({ url: "*://example.com/*" });413});414415expect(tabs.length).toBeGreaterThan(0);416expect(tabs[0].url).toContain("example.com");417418// Send message to tab419await serviceWorker.evaluate(async (tabId) => {420await chrome.tabs.sendMessage(tabId, { type: "PING" });421}, tabs[0].id);422});423```424425### Context Menus426427```typescript428test("context menu actions", async ({ context, extensionId }) => {429const serviceWorker = await context.waitForEvent("serviceworker");430431// Create context menu432await serviceWorker.evaluate(async () => {433await chrome.contextMenus.create({434id: "test-menu",435title: "Test Action",436contexts: ["selection"],437});438});439440// Simulate context menu click441const page = await context.newPage();442await page.goto("https://example.com");443444// Select text445await page.evaluate(() => {446const range = document.createRange();447range.selectNodeContents(document.body.firstChild!);448window.getSelection()?.addRange(range);449});450451// Trigger context menu action programmatically452await serviceWorker.evaluate(async () => {453// Simulate the click handler454chrome.contextMenus.onClicked.dispatch(455{ menuItemId: "test-menu", selectionText: "selected text" },456{ id: 1, url: "https://example.com" },457);458});459});460```461462### Permissions API463464```typescript465test("request permissions", async ({ context, extensionId }) => {466const popup = await context.newPage();467await popup.goto(`chrome-extension://${extensionId}/popup.html`);468469// Check current permissions470const hasPermission = await popup.evaluate(async () => {471return await chrome.permissions.contains({472origins: ["https://*.github.com/*"],473});474});475476// Request new permission (will show prompt in real scenario)477// For testing, we check the request is made correctly478const permissionRequest = popup.evaluate(async () => {479try {480return await chrome.permissions.request({481origins: ["https://*.github.com/*"],482});483} catch (e) {484return false;485}486});487488// In automated tests, permission prompts are typically auto-granted or mocked489});490```491492## Anti-Patterns to Avoid493494| Anti-Pattern | Problem | Solution |495| ------------------------------ | --------------------- | ---------------------------------------- |496| Testing in headless mode | Extensions don't load | Use `headless: false` |497| Not waiting for service worker | Race conditions | Wait for `serviceworker` event |498| Hardcoding extension ID | ID changes on reload | Extract ID from service worker URL |499| Testing packed extensions only | Slow iteration | Test unpacked during development |500| Ignoring MV3 differences | Breaking changes | Test both MV2 and MV3 if supporting both |501502## Related References503504- **Service Workers**: See [service-workers.md](../browser-apis/service-workers.md) for SW testing patterns505- **Multi-Context**: See [multi-context.md](../advanced/multi-context.md) for popup handling506- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions testing507