321 lines
13 KiB
Markdown
321 lines
13 KiB
Markdown
# 浏览器扩展
|
||
|
||
> **使用时机**:测试 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 配置
|