# 浏览器扩展 > **使用时机**:测试 Chrome 扩展——弹出窗口、内容脚本、后台 Service Worker,或扩展注入的 UI。需要 Chromium 和持久的浏览器上下文。 > **前置条件**:[core/configuration.md](configuration.md)、[core/fixtures-and-hooks.md](fixtures-and-hooks.md) ## 快速参考 ```typescript // 使用持久上下文加载解压扩展(仅限 Chromium) const context = await chromium.launchPersistentContext(userDataDir, { headless: false, // 扩展需要 headed 模式 args: [`--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`], }) ``` **硬性约束**:扩展仅在 Chromium 中生效。它们需要使用 `launchPersistentContext`(而非 `browser.newContext`)。必须使用 headed 模式——设置 `headless: false`,或使用 `--headless=new` Chromium 标志以启用支持扩展的"新 headless"模式。 ## 模式 ### 加载扩展 **使用时机**:你需要测试任何 Chrome 扩展功能时。 **避免时机**:你只需要测试扩展与之交互的 Web 应用时——应改为模拟扩展的效果。 **TypeScript** ```typescript import { test as base, expect, chromium, type BrowserContext } from "@playwright/test" import path from "path" // 创建一个提供加载了扩展的上下文的 fixture type ExtensionFixtures = { context: BrowserContext extensionId: string } export 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() }, // 从 Service Worker URL 中提取扩展 ID 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** ```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 } ``` ### 测试扩展弹出窗口 **使用时机**:你的扩展有浏览器动作弹出窗口(即点击扩展图标时显示的 UI)。 **避免时机**:弹出窗口功能很简单时——应改为测试内容脚本或后台逻辑。 **TypeScript** ```typescript import { test, expect } from "./extension-fixture" test("扩展弹出窗口显示已保存的书签", async ({ page, extensionId }) => { // 直接导航到弹出窗口 HTML await page.goto(`chrome-extension://${extensionId}/popup.html`) // 使用标准定位器与弹出窗口 UI 交互 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("扩展弹出窗口设置开关生效", 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** ```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) }) ``` ### 测试内容脚本 **使用时机**:你的扩展向网页注入脚本或 UI 时。 **避免时机**:内容脚本仅修改数据而不产生可见效果时——应通过后台 Worker 或存储进行测试。 **TypeScript** ```typescript import { test, expect } from "./extension-fixture" test("内容脚本注入比价小部件", async ({ context }) => { const page = await context.newPage() await page.goto("https://example-shop.com/product/123") // 等待内容脚本注入其 UI // 扩展添加了一个 shadow DOM 元素——Playwright 会自动穿透它 await expect(page.getByTestId("price-compare-widget")).toBeVisible({ timeout: 10000 }) await expect(page.getByText("Best price: $29.99")).toBeVisible() }) test("内容脚本高亮搜索词", async ({ context }) => { const page = await context.newPage() await page.goto("https://example.com/article") // 验证内容脚本添加了高亮 span const highlights = page.locator(".ext-highlight") await expect(highlights).toHaveCount(5) await expect(highlights.first()).toHaveCSS("background-color", "rgb(255, 255, 0)") }) ``` **JavaScript** ```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() }) ``` ### 测试后台 Service Worker **使用时机**:你的扩展使用 Manifest V3 Service Worker 进行后台处理、定时任务或消息传递时。 **避免时机**:后台逻辑很简单且已被弹出窗口或内容脚本测试覆盖时。 **TypeScript** ```typescript import { test, expect } from "./extension-fixture" test("后台 Worker 正确处理消息", async ({ context, extensionId }) => { const page = await context.newPage() await page.goto(`chrome-extension://${extensionId}/popup.html`) // 触发一个向后台 Worker 发送消息的操作 await page.getByRole("button", { name: "Sync data" }).click() // 验证来自后台 Worker 的响应更新了弹出窗口 await expect(page.getByText("Last synced: just now")).toBeVisible() }) test("Service Worker 处理扩展存储", async ({ context, extensionId }) => { const page = await context.newPage() await page.goto(`chrome-extension://${extensionId}/popup.html`) // 通过弹出窗口设置值 await page.getByLabel("API Key").fill("test-key-123") await page.getByRole("button", { name: "Save" }).click() // 重新加载弹出窗口并通过 Service Worker 验证持久化 await page.reload() await expect(page.getByLabel("API Key")).toHaveValue("test-key-123") }) ``` **JavaScript** ```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() }) ``` ### 测试扩展选项页 **使用时机**:你的扩展有专用的选项/设置页面时。 **避免时机**:设置已完全被弹出窗口测试覆盖时。 **TypeScript** ```typescript import { test, expect } from "./extension-fixture" test("选项页保存偏好设置", 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() }) ``` **JavaScript** ```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() }) ``` ## 决策指南 | 场景 | 方式 | 原因 | | ------------ | ------------------------------------------ | ------------------------------------------------------------ | | 测试弹出窗口 UI | 导航到 `chrome-extension:///popup.html` | 无需点击扩展图标即可直接访问 | | 测试内容脚本效果 | 加载真实页面或测试页面,断言注入的元素 | 内容脚本在匹配的 URL 上自动运行 | | 测试后台逻辑 | 通过弹出窗口/内容脚本触发,验证副作用 | 无法直接从 Playwright 调用 Service Worker 函数 | | 测试扩展存储 | 使用弹出窗口设置值,重新加载,验证持久化 | `chrome.storage` 只能从扩展页面访问 | | 测试选项页 | 导航到 `chrome-extension:///options.html` | 与弹出窗口测试方式相同 | | 测试跨页行为 | 在同一个上下文中打开多个页面 | 持久上下文在标签页间共享扩展状态 | | 在 CI 中运行(headless) | 使用 `--headless=new` Chromium 标志 | 新的 headless 模式支持扩展,旧的不支持 | | 测试多个扩展 | 向 `--load-extension` 添加多个路径 | 在标志值中用逗号分隔路径 | ## 反模式 | 不要这样做 | 问题 | 应这样做 | | ---------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------ | | 为扩展使用 `browser.newContext()` | 扩展需要持久上下文 | `chromium.launchPersistentContext()` | | 不加 `--headless=new` 就设置 `headless: true` | 旧的 headless 模式不支持扩展 | 设置 `headless: false` 或使用 `args: ['--headless=new']` | | 在 Firefox 或 WebKit 上测试 | 扩展仅适用于 Chromium | 对非 Chromium 项目跳过扩展测试 | | 通过坐标点击扩展图标 | 脆弱,工具栏布局不固定 | 直接导航到 `chrome-extension:///popup.html` | | 硬编码扩展 ID | ID 在不同构建和机器之间会变化 | 从 Service Worker URL 动态提取 | | 直接测试打包的 `.crx` 文件 | 难以调试,需要先解包 | 测试解压后的扩展源码目录 | | 共享持久上下文用户数据目录 | 状态在测试运行之间泄露 | 使用空字符串 `''` 获取临时目录 | | 内容脚本断言不设超时 | 内容脚本可能在页面加载后加载 | 对内容脚本元素断言使用 `{ timeout: 10000 }` | ## 故障排除 | 症状 | 可能的原因 | 修复方法 | | -------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------ | | 扩展未加载 | `--load-extension` 中的路径错误 | 使用 `path.resolve()` 获取扩展目录的绝对路径 | | `context.serviceWorkers()` 返回空 | Service Worker 尚未注册 | 在提取 ID 之前使用 `context.waitForEvent('serviceworker')` | | 弹出窗口页面为空白 | 弹出窗口 HTML 路径错误 | 检查 `manifest.json` 中正确的 `default_popup` 路径 | | 内容脚本未注入 | 页面 URL 与 manifest 中的 `matches` 不匹配 | 验证 `content_scripts[].matches` 中的 URL 模式 | | 扩展本地可工作但在 CI 中不行 | CI 使用了旧的 headless 模式 | 为 CI 在启动参数中添加 `--headless=new` | | `chrome.storage` 调用失败 | 在非扩展上下文中访问存储 | 只能通过扩展页面(弹出窗口、选项页、后台)访问存储 | | 多个扩展冲突 | 两个扩展修改了相同的页面元素 | 在每个扩展自己的持久上下文中单独测试 | | 测试启动缓慢 | 持久上下文初始化开销 | 使用 `test.describe` 在同一文件中跨测试复用上下文 | ## 相关文档 - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) —— 构建扩展上下文的自定义 fixture - [core/service-workers-and-pwa.md](service-workers-and-pwa.md) —— Service Worker 测试模式(非扩展) - [core/iframes-and-shadow-dom.md](iframes-and-shadow-dom.md) —— 内容脚本经常注入 Shadow DOM 元素 - [core/configuration.md](configuration.md) —— 仅限 Chromium 测试套件的项目配置 - [ci/ci-github-actions.md](../ci/ci-github-actions.md) —— headed/扩展测试的 CI 配置