13 KiB
13 KiB
浏览器扩展
使用时机:测试 Chrome 扩展——弹出窗口、内容脚本、后台 Service Worker,或扩展注入的 UI。需要 Chromium 和持久的浏览器上下文。 前置条件:core/configuration.md、core/fixtures-and-hooks.md
快速参考
// 使用持久上下文加载解压扩展(仅限 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
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
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
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
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
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
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
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
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
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
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 —— 构建扩展上下文的自定义 fixture
- core/service-workers-and-pwa.md —— Service Worker 测试模式(非扩展)
- core/iframes-and-shadow-dom.md —— 内容脚本经常注入 Shadow DOM 元素
- core/configuration.md —— 仅限 Chromium 测试套件的项目配置
- ci/ci-github-actions.md —— headed/扩展测试的 CI 配置