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

13 KiB
Raw Permalink Blame History

浏览器扩展

使用时机:测试 Chrome 扩展——弹出窗口、内容脚本、后台 Service Worker,或扩展注入的 UI。需要 Chromium 和持久的浏览器上下文。 前置条件core/configuration.mdcore/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 在同一文件中跨测试复用上下文

相关文档