Files
2026-07-13 21:36:47 +08:00

321 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 浏览器扩展
> **使用时机**:测试 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<ExtensionFixtures>({
// 覆盖默认上下文以加载扩展
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://<id>/popup.html` | 无需点击扩展图标即可直接访问 |
| 测试内容脚本效果 | 加载真实页面或测试页面,断言注入的元素 | 内容脚本在匹配的 URL 上自动运行 |
| 测试后台逻辑 | 通过弹出窗口/内容脚本触发,验证副作用 | 无法直接从 Playwright 调用 Service Worker 函数 |
| 测试扩展存储 | 使用弹出窗口设置值,重新加载,验证持久化 | `chrome.storage` 只能从扩展页面访问 |
| 测试选项页 | 导航到 `chrome-extension://<id>/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://<id>/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 配置