mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
14 KiB
14 KiB
Browser Extension Testing
Table of Contents
- Setup & Configuration
- Loading Extensions
- Popup Testing
- Background Script Testing
- Content Script Testing
- Extension APIs
- Cross-Browser Testing
Setup & Configuration
Prerequisites
npm install -D @playwright/test
npx playwright install chromium # Extensions only work in Chromium
Basic Configuration
// playwright.config.ts
import { defineConfig } from "@playwright/test";
import path from "path";
export default defineConfig({
testDir: "./tests",
use: {
// Extensions require non-headless Chromium
headless: false,
},
projects: [
{
name: "chromium-extension",
use: {
browserName: "chromium",
},
},
],
});
Extension Fixture
// fixtures/extension.ts
import { test as base, chromium, BrowserContext, Page } from "@playwright/test";
import path from "path";
type ExtensionFixtures = {
context: BrowserContext;
extensionId: string;
backgroundPage: Page;
};
export const test = base.extend<ExtensionFixtures>({
context: async ({}, use) => {
const pathToExtension = path.join(__dirname, "../extension");
const context = await chromium.launchPersistentContext("", {
headless: false,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
await use(context);
await context.close();
},
extensionId: async ({ context }, use) => {
// Get extension ID from service worker URL
let extensionId = "";
// Wait for service worker to be registered
const serviceWorker =
context.serviceWorkers()[0] ||
(await context.waitForEvent("serviceworker"));
extensionId = serviceWorker.url().split("/")[2];
await use(extensionId);
},
backgroundPage: async ({ context }, use) => {
// For Manifest V2 extensions
const backgroundPage =
context.backgroundPages()[0] ||
(await context.waitForEvent("backgroundpage"));
await use(backgroundPage);
},
});
export { expect } from "@playwright/test";
Loading Extensions
Manifest V3 (Service Worker)
test("load MV3 extension", async () => {
const pathToExtension = path.join(__dirname, "../my-extension");
const context = await chromium.launchPersistentContext("", {
headless: false,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
// Wait for service worker
const serviceWorker = await context.waitForEvent("serviceworker");
expect(serviceWorker.url()).toContain("chrome-extension://");
await context.close();
});
Manifest V2 (Background Page)
test("load MV2 extension", async () => {
const pathToExtension = path.join(__dirname, "../my-extension-v2");
const context = await chromium.launchPersistentContext("", {
headless: false,
args: [
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
// Wait for background page
const backgroundPage = await context.waitForEvent("backgroundpage");
expect(backgroundPage.url()).toContain("chrome-extension://");
await context.close();
});
Multiple Extensions
test("load multiple extensions", async () => {
const extension1 = path.join(__dirname, "../extension1");
const extension2 = path.join(__dirname, "../extension2");
const context = await chromium.launchPersistentContext("", {
headless: false,
args: [
`--disable-extensions-except=${extension1},${extension2}`,
`--load-extension=${extension1},${extension2}`,
],
});
// Both service workers should be available
await context.waitForEvent("serviceworker");
await context.waitForEvent("serviceworker");
expect(context.serviceWorkers().length).toBe(2);
await context.close();
});
Popup Testing
Opening Extension Popup
test("test popup UI", async ({ context, extensionId }) => {
// Open popup directly by URL
const popupPage = await context.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
// Test popup interactions
await expect(popupPage.getByRole("heading")).toHaveText("My Extension");
await popupPage.getByRole("button", { name: "Enable" }).click();
await expect(popupPage.getByText("Enabled")).toBeVisible();
});
Popup State Persistence
test("popup remembers state", async ({ context, extensionId }) => {
// First interaction
const popup1 = await context.newPage();
await popup1.goto(`chrome-extension://${extensionId}/popup.html`);
await popup1.getByRole("checkbox", { name: "Dark Mode" }).check();
await popup1.close();
// Reopen popup
const popup2 = await context.newPage();
await popup2.goto(`chrome-extension://${extensionId}/popup.html`);
// State should persist
await expect(
popup2.getByRole("checkbox", { name: "Dark Mode" }),
).toBeChecked();
});
Popup Communication with Background
test("popup sends message to background", async ({ context, extensionId }) => {
const popup = await context.newPage();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
// Set up listener for response
const responsePromise = popup.evaluate(() => {
return new Promise((resolve) => {
chrome.runtime.onMessage.addListener((message) => {
if (message.type === "RESPONSE") resolve(message.data);
});
});
});
// Click button that sends message
await popup.getByRole("button", { name: "Fetch Data" }).click();
// Verify response
const response = await responsePromise;
expect(response).toBeDefined();
});
Background Script Testing
Manifest V3 Service Worker
test("service worker handles messages", async ({ context, extensionId }) => {
const page = await context.newPage();
await page.goto("https://example.com");
// Send message to service worker from page
const response = await page.evaluate(async (extId) => {
return new Promise((resolve) => {
chrome.runtime.sendMessage(extId, { type: "GET_STATUS" }, resolve);
});
}, extensionId);
expect(response).toEqual({ status: "active" });
});
Testing Background Logic
test("background script logic", async ({ context }) => {
const serviceWorker =
context.serviceWorkers()[0] ||
(await context.waitForEvent("serviceworker"));
// Evaluate in service worker context
const result = await serviceWorker.evaluate(async () => {
// Access extension APIs
const storage = await chrome.storage.local.get("settings");
return storage;
});
expect(result.settings).toBeDefined();
});
Alarms and Timers
test("alarm triggers correctly", async ({ context }) => {
const serviceWorker = await context.waitForEvent("serviceworker");
// Create alarm
await serviceWorker.evaluate(async () => {
await chrome.alarms.create("test-alarm", { delayInMinutes: 0.01 });
});
// Wait for alarm handler
await serviceWorker.evaluate(() => {
return new Promise<void>((resolve) => {
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "test-alarm") resolve();
});
});
});
// Verify alarm was handled (check side effects)
const wasHandled = await serviceWorker.evaluate(async () => {
const { alarmTriggered } = await chrome.storage.local.get("alarmTriggered");
return alarmTriggered;
});
expect(wasHandled).toBe(true);
});
Content Script Testing
Injected Content Script
test("content script injects UI", async ({ context }) => {
const page = await context.newPage();
await page.goto("https://example.com");
// Wait for content script to inject elements
await expect(page.locator("#my-extension-widget")).toBeVisible();
// Interact with injected UI
await page.locator("#my-extension-widget button").click();
await expect(page.locator("#my-extension-widget .result")).toHaveText(
"Success",
);
});
Content Script Communication
test("content script communicates with background", async ({
context,
extensionId,
}) => {
const page = await context.newPage();
await page.goto("https://example.com");
// Trigger content script action
await page.locator("#my-extension-button").click();
// Wait for background response reflected in UI
await expect(page.locator("#my-extension-status")).toHaveText("Connected");
});
Page Modification Testing
test("content script modifies page", async ({ context }) => {
const page = await context.newPage();
await page.goto("https://example.com");
// Verify content script modifications
const hasModification = await page.evaluate(() => {
// Check for injected styles
const styles = document.querySelectorAll('style[data-extension="my-ext"]');
return styles.length > 0;
});
expect(hasModification).toBe(true);
// Check DOM modifications
const modifiedElements = await page
.locator("[data-modified-by-extension]")
.count();
expect(modifiedElements).toBeGreaterThan(0);
});
Extension APIs
Storage API
test("chrome.storage operations", async ({ context }) => {
const serviceWorker = await context.waitForEvent("serviceworker");
// Set storage
await serviceWorker.evaluate(async () => {
await chrome.storage.local.set({ key: "value", count: 42 });
});
// Get storage
const data = await serviceWorker.evaluate(async () => {
return await chrome.storage.local.get(["key", "count"]);
});
expect(data).toEqual({ key: "value", count: 42 });
// Test storage.sync
await serviceWorker.evaluate(async () => {
await chrome.storage.sync.set({ synced: true });
});
const syncData = await serviceWorker.evaluate(async () => {
return await chrome.storage.sync.get("synced");
});
expect(syncData.synced).toBe(true);
});
Tabs API
test("chrome.tabs operations", async ({ context }) => {
const serviceWorker = await context.waitForEvent("serviceworker");
// Create a tab
const page = await context.newPage();
await page.goto("https://example.com");
// Query tabs from service worker
const tabs = await serviceWorker.evaluate(async () => {
return await chrome.tabs.query({ url: "*://example.com/*" });
});
expect(tabs.length).toBeGreaterThan(0);
expect(tabs[0].url).toContain("example.com");
// Send message to tab
await serviceWorker.evaluate(async (tabId) => {
await chrome.tabs.sendMessage(tabId, { type: "PING" });
}, tabs[0].id);
});
Context Menus
test("context menu actions", async ({ context, extensionId }) => {
const serviceWorker = await context.waitForEvent("serviceworker");
// Create context menu
await serviceWorker.evaluate(async () => {
await chrome.contextMenus.create({
id: "test-menu",
title: "Test Action",
contexts: ["selection"],
});
});
// Simulate context menu click
const page = await context.newPage();
await page.goto("https://example.com");
// Select text
await page.evaluate(() => {
const range = document.createRange();
range.selectNodeContents(document.body.firstChild!);
window.getSelection()?.addRange(range);
});
// Trigger context menu action programmatically
await serviceWorker.evaluate(async () => {
// Simulate the click handler
chrome.contextMenus.onClicked.dispatch(
{ menuItemId: "test-menu", selectionText: "selected text" },
{ id: 1, url: "https://example.com" },
);
});
});
Permissions API
test("request permissions", async ({ context, extensionId }) => {
const popup = await context.newPage();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
// Check current permissions
const hasPermission = await popup.evaluate(async () => {
return await chrome.permissions.contains({
origins: ["https://*.github.com/*"],
});
});
// Request new permission (will show prompt in real scenario)
// For testing, we check the request is made correctly
const permissionRequest = popup.evaluate(async () => {
try {
return await chrome.permissions.request({
origins: ["https://*.github.com/*"],
});
} catch (e) {
return false;
}
});
// In automated tests, permission prompts are typically auto-granted or mocked
});
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Testing in headless mode | Extensions don't load | Use headless: false |
| Not waiting for service worker | Race conditions | Wait for serviceworker event |
| Hardcoding extension ID | ID changes on reload | Extract ID from service worker URL |
| Testing packed extensions only | Slow iteration | Test unpacked during development |
| Ignoring MV3 differences | Breaking changes | Test both MV2 and MV3 if supporting both |
Related References
- Service Workers: See service-workers.md for SW testing patterns
- Multi-Context: See multi-context.md for popup handling
- Browser APIs: See browser-apis.md for permissions testing