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

661 lines
25 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.
# Browser APIs
> **适用场景**:当测试的功能依赖浏览器原生 API 时使用——地理定位(geolocation)、权限(permissions)、剪贴板(clipboard)、通知(notifications)、摄像头/麦克风、localStorage、sessionStorage、IndexedDB。
> **前置条件**[core/configuration.md](configuration.md)、[core/fixtures-and-hooks.md](fixtures-and-hooks.md)
## 快速参考
```typescript
// 地理定位 —— 通过上下文选项设置
const context = await browser.newContext({
geolocation: { latitude: 40.7128, longitude: -74.006 },
permissions: ["geolocation"],
})
// 权限 —— 在上下文层面授予
const context = await browser.newContext({
permissions: ["clipboard-read", "clipboard-write", "notifications"],
})
// localStorage / sessionStorage —— 通过 page.evaluate 访问
await page.evaluate(() => localStorage.setItem("theme", "dark"))
const value = await page.evaluate(() => localStorage.getItem("theme"))
// 剪贴板 —— 在页面上下文中读写
await page.evaluate(() => navigator.clipboard.writeText("copied text"))
```
## 模式
### 地理定位
**适用场景**:你的应用使用 `navigator.geolocation` 来实现地图、门店定位器、配送跟踪或基于位置的功能。
**避免场景**:你的应用从不读取用户位置。
地理定位在上下文级别设置。你也可以在测试过程中使用 `context.setGeolocation()` 更新它。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("根据地理定位显示最近的门店", async ({ browser }) => {
const context = await browser.newContext({
geolocation: { latitude: 40.7128, longitude: -74.006 }, // 纽约
permissions: ["geolocation"],
})
const page = await context.newPage()
await page.goto("/store-locator")
await page.getByRole("button", { name: "Find nearby stores" }).click()
await expect(page.getByText("Manhattan Store")).toBeVisible()
await context.close()
})
test("在测试中途更新用户位置", async ({ browser }) => {
const context = await browser.newContext({
geolocation: { latitude: 37.7749, longitude: -122.4194 }, // 旧金山
permissions: ["geolocation"],
})
const page = await context.newPage()
await page.goto("/delivery-tracker")
await expect(page.getByTestId("current-city")).toHaveText("San Francisco")
// 模拟用户移动到新位置
await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 }) // 洛杉矶
// 触发位置刷新
await page.getByRole("button", { name: "Update location" }).click()
await expect(page.getByTestId("current-city")).toHaveText("Los Angeles")
await context.close()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("根据地理定位显示最近的门店", async ({ browser }) => {
const context = await browser.newContext({
geolocation: { latitude: 40.7128, longitude: -74.006 },
permissions: ["geolocation"],
})
const page = await context.newPage()
await page.goto("/store-locator")
await page.getByRole("button", { name: "Find nearby stores" }).click()
await expect(page.getByText("Manhattan Store")).toBeVisible()
await context.close()
})
test("在测试中途更新位置", async ({ browser }) => {
const context = await browser.newContext({
geolocation: { latitude: 37.7749, longitude: -122.4194 },
permissions: ["geolocation"],
})
const page = await context.newPage()
await page.goto("/delivery-tracker")
await expect(page.getByTestId("current-city")).toHaveText("San Francisco")
await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 })
await page.getByRole("button", { name: "Update location" }).click()
await expect(page.getByTestId("current-city")).toHaveText("Los Angeles")
await context.close()
})
```
你也可以在 `playwright.config` 中全局设置地理定位:
```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
use: {
geolocation: { latitude: 51.5074, longitude: -0.1278 }, // 伦敦
permissions: ["geolocation"],
},
})
```
### 权限
**适用场景**:你的应用请求浏览器权限——通知、摄像头、麦克风、地理定位、剪贴板。
**避免场景**:你的应用不使用 Permissions API。
在创建上下文时授予权限。Playwright 不显示权限对话框;你可以预先授予或拒绝它们。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("授予通知权限后显示通知 UI", async ({ browser }) => {
const context = await browser.newContext({
permissions: ["notifications"],
})
const page = await context.newPage()
await page.goto("/settings/notifications")
// 应用检查 Notification.permission 并显示开关
await expect(page.getByRole("switch", { name: "Enable notifications" })).toBeEnabled()
await context.close()
})
test("拒绝通知权限后显示升级提示", async ({ browser }) => {
// permissions 中不含 'notifications' = 已拒绝
const context = await browser.newContext({
permissions: [],
})
const page = await context.newPage()
await page.goto("/settings/notifications")
await expect(page.getByText("Notifications are blocked")).toBeVisible()
await context.close()
})
test("在测试中途授予权限", async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto("/camera-app")
// 初始状态下没有摄像头权限
await expect(page.getByText("Camera access needed")).toBeVisible()
// 动态授予权限
await context.grantPermissions(["camera"], { origin: "https://localhost:3000" })
await page.getByRole("button", { name: "Enable camera" }).click()
await expect(page.getByTestId("camera-preview")).toBeVisible()
// 撤销所有权限
await context.clearPermissions()
await context.close()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("授予通知权限后显示通知 UI", async ({ browser }) => {
const context = await browser.newContext({
permissions: ["notifications"],
})
const page = await context.newPage()
await page.goto("/settings/notifications")
await expect(page.getByRole("switch", { name: "Enable notifications" })).toBeEnabled()
await context.close()
})
test("在测试中途授予权限", async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto("/camera-app")
await context.grantPermissions(["camera"], { origin: "https://localhost:3000" })
await page.getByRole("button", { name: "Enable camera" }).click()
await expect(page.getByTestId("camera-preview")).toBeVisible()
await context.clearPermissions()
await context.close()
})
```
### Clipboard API
**适用场景**:测试复制/粘贴功能、"复制到剪贴板"按钮或从剪贴板粘贴的功能。
**避免场景**:你的应用不操作剪贴板。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("复制按钮将文本放入剪贴板", async ({ browser }) => {
const context = await browser.newContext({
permissions: ["clipboard-read", "clipboard-write"],
})
const page = await context.newPage()
await page.goto("/share")
await page.getByRole("button", { name: "Copy link" }).click()
// 读取剪贴板内容
const clipboardText = await page.evaluate(() => navigator.clipboard.readText())
expect(clipboardText).toContain("https://example.com/share/")
await context.close()
})
test("从剪贴板粘贴到编辑器", async ({ browser }) => {
const context = await browser.newContext({
permissions: ["clipboard-read", "clipboard-write"],
})
const page = await context.newPage()
await page.goto("/editor")
// 以编程方式写入剪贴板
await page.evaluate(() => navigator.clipboard.writeText("Pasted content from clipboard"))
// 聚焦编辑器并触发粘贴
const editor = page.getByRole("textbox", { name: "Editor" })
await editor.focus()
await page.keyboard.press("ControlOrMeta+v")
await expect(editor).toContainText("Pasted content from clipboard")
await context.close()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("复制按钮将文本放入剪贴板", async ({ browser }) => {
const context = await browser.newContext({
permissions: ["clipboard-read", "clipboard-write"],
})
const page = await context.newPage()
await page.goto("/share")
await page.getByRole("button", { name: "Copy link" }).click()
const clipboardText = await page.evaluate(() => navigator.clipboard.readText())
expect(clipboardText).toContain("https://example.com/share/")
await context.close()
})
```
### 摄像头与麦克风模拟
**适用场景**:测试视频通话、二维码扫描器、语音录制,或任何使用 `getUserMedia` 的功能。
**避免场景**:你的应用不使用摄像头或麦克风。
Chromium 可以使用虚拟媒体设备。此功能在 Firefox 或 WebKit 中不可用。
**TypeScript**
```typescript
import { test, expect, chromium } from "@playwright/test"
test("视频通话使用虚拟摄像头显示本地预览", async () => {
// 使用虚拟媒体流启动 Chromium
const browser = await chromium.launch({
args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
})
const context = await browser.newContext({
permissions: ["camera", "microphone"],
})
const page = await context.newPage()
await page.goto("/video-call")
await page.getByRole("button", { name: "Start camera" }).click()
// 验证视频元素正在播放
const isPlaying = await page.evaluate(() => {
const video = document.querySelector("video#local-preview") as HTMLVideoElement
return video && !video.paused && video.readyState >= 2
})
expect(isPlaying).toBe(true)
await context.close()
await browser.close()
})
```
**JavaScript**
```javascript
const { test, expect, chromium } = require("@playwright/test")
test("视频通话使用虚拟摄像头显示本地预览", async () => {
const browser = await chromium.launch({
args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
})
const context = await browser.newContext({
permissions: ["camera", "microphone"],
})
const page = await context.newPage()
await page.goto("/video-call")
await page.getByRole("button", { name: "Start camera" }).click()
const isPlaying = await page.evaluate(() => {
const video = document.querySelector("video#local-preview")
return video && !video.paused && video.readyState >= 2
})
expect(isPlaying).toBe(true)
await context.close()
await browser.close()
})
```
### localStorage 与 sessionStorage
**适用场景**:你的应用在 Web 存储中持久化状态、令牌、偏好设置或功能开关。
**避免场景**:你可以通过 UI 或 API 设置状态。为真实性考虑,优先采用这些方式。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("应用从 localStorage 偏好设置加载深色主题", async ({ page }) => {
// 在导航前设置 localStorage
await page.goto("/")
await page.evaluate(() => localStorage.setItem("theme", "dark"))
// 重新加载以应用存储的偏好
await page.reload()
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark")
})
test("在场景之间清除 localStorage", async ({ page }) => {
await page.goto("/")
// 写入一些数据
await page.evaluate(() => {
localStorage.setItem("cart", JSON.stringify([{ id: 1, qty: 2 }]))
localStorage.setItem("user_prefs", JSON.stringify({ currency: "EUR" }))
})
// 读取并验证
const cart = await page.evaluate(() => JSON.parse(localStorage.getItem("cart") || "[]"))
expect(cart).toHaveLength(1)
// 清除特定键
await page.evaluate(() => localStorage.removeItem("cart"))
// 或者清除所有内容
await page.evaluate(() => localStorage.clear())
})
test("sessionStorage 在会话内的导航中保持不变", async ({ page }) => {
await page.goto("/step-1")
await page.evaluate(() => sessionStorage.setItem("wizard_step", "1"))
await page.goto("/step-2")
const step = await page.evaluate(() => sessionStorage.getItem("wizard_step"))
expect(step).toBe("1")
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("应用从 localStorage 偏好设置加载深色主题", async ({ page }) => {
await page.goto("/")
await page.evaluate(() => localStorage.setItem("theme", "dark"))
await page.reload()
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark")
})
test("sessionStorage 在会话内的导航中保持不变", async ({ page }) => {
await page.goto("/step-1")
await page.evaluate(() => sessionStorage.setItem("wizard_step", "1"))
await page.goto("/step-2")
const step = await page.evaluate(() => sessionStorage.getItem("wizard_step"))
expect(step).toBe("1")
})
```
### IndexedDB 测试
**适用场景**:你的应用使用 IndexedDB 进行离线存储、缓存或大数据集(渐进式 Web 应用、离线优先应用)。
**避免场景**:你的应用仅使用 localStorage 或服务端存储。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("离线优先应用将数据存储在 IndexedDB 中", async ({ page }) => {
await page.goto("/notes")
// 通过 UI 创建笔记
await page.getByRole("button", { name: "New note" }).click()
await page.getByRole("textbox", { name: "Title" }).fill("Test Note")
await page.getByRole("textbox", { name: "Content" }).fill("This is stored in IndexedDB")
await page.getByRole("button", { name: "Save" }).click()
// 验证它已存储在 IndexedDB 中
const storedNotes = await page.evaluate(() => {
return new Promise((resolve, reject) => {
const request = indexedDB.open("NotesDB", 1)
request.onsuccess = () => {
const db = request.result
const tx = db.transaction("notes", "readonly")
const store = tx.objectStore("notes")
const getAll = store.getAll()
getAll.onsuccess = () => resolve(getAll.result)
getAll.onerror = () => reject(getAll.error)
}
request.onerror = () => reject(request.error)
})
})
expect(storedNotes).toHaveLength(1)
expect(storedNotes[0]).toMatchObject({
title: "Test Note",
content: "This is stored in IndexedDB",
})
})
test("清除 IndexedDB 以获取干净的测试状态", async ({ page }) => {
await page.goto("/")
// 删除整个数据库
await page.evaluate(() => {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase("NotesDB")
request.onsuccess = () => resolve(undefined)
request.onerror = () => reject(request.error)
})
})
await page.reload()
await expect(page.getByText("No notes yet")).toBeVisible()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("离线优先应用将数据存储在 IndexedDB 中", async ({ page }) => {
await page.goto("/notes")
await page.getByRole("button", { name: "New note" }).click()
await page.getByRole("textbox", { name: "Title" }).fill("Test Note")
await page.getByRole("textbox", { name: "Content" }).fill("This is stored in IndexedDB")
await page.getByRole("button", { name: "Save" }).click()
const storedNotes = await page.evaluate(() => {
return new Promise((resolve, reject) => {
const request = indexedDB.open("NotesDB", 1)
request.onsuccess = () => {
const db = request.result
const tx = db.transaction("notes", "readonly")
const store = tx.objectStore("notes")
const getAll = store.getAll()
getAll.onsuccess = () => resolve(getAll.result)
getAll.onerror = () => reject(getAll.error)
}
request.onerror = () => reject(request.error)
})
})
expect(storedNotes).toHaveLength(1)
expect(storedNotes[0]).toMatchObject({
title: "Test Note",
content: "This is stored in IndexedDB",
})
})
```
### 通知
**适用场景**:你的应用使用浏览器 Notification API 显示桌面通知。
**避免场景**:通知纯粹是服务端触发的(不涉及 Notification API 的推送)。
Playwright 无法捕获实际的系统通知。取而代之的是,模拟 Notification 构造函数并验证应用是否正确调用了它。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("应用在收到新消息时触发浏览器通知", async ({ browser }) => {
const context = await browser.newContext({
permissions: ["notifications"],
})
const page = await context.newPage()
// 拦截 Notification 构造函数以捕获调用
await page.evaluate(() => {
;(window as any).__notifications = []
const OriginalNotification = window.Notification
;(window as any).Notification = class MockNotification {
constructor(title: string, options?: NotificationOptions) {
;(window as any).__notifications.push({ title, ...options })
}
static get permission() {
return "granted"
}
static requestPermission() {
return Promise.resolve("granted" as NotificationPermission)
}
}
})
await page.goto("/chat")
// 模拟收到一条会触发通知的消息
await page.evaluate(() => {
window.dispatchEvent(
new CustomEvent("new-message", {
detail: { from: "Alice", text: "Hey there!" },
})
)
})
// 检查通知是否以正确的内容创建
const notifications = await page.evaluate(() => (window as any).__notifications)
expect(notifications).toHaveLength(1)
expect(notifications[0].title).toBe("New message from Alice")
expect(notifications[0].body).toBe("Hey there!")
await context.close()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("应用在收到新消息时触发浏览器通知", async ({ browser }) => {
const context = await browser.newContext({
permissions: ["notifications"],
})
const page = await context.newPage()
await page.evaluate(() => {
window.__notifications = []
window.Notification = class MockNotification {
constructor(title, options) {
window.__notifications.push({ title, ...options })
}
static get permission() {
return "granted"
}
static requestPermission() {
return Promise.resolve("granted")
}
}
})
await page.goto("/chat")
await page.evaluate(() => {
window.dispatchEvent(
new CustomEvent("new-message", {
detail: { from: "Alice", text: "Hey there!" },
})
)
})
const notifications = await page.evaluate(() => window.__notifications)
expect(notifications).toHaveLength(1)
expect(notifications[0].title).toBe("New message from Alice")
await context.close()
})
```
## 决策指南
| 浏览器 API | 测试方法 | 关键配置 |
| ------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| 地理定位(Geolocation | `geolocation` 上下文选项 + `permissions: ['geolocation']` | `context.setGeolocation()` 用于测试中途变更 |
| 权限(任意) | 上下文选项中的 `permissions` 数组 | `context.grantPermissions()` / `context.clearPermissions()` |
| 剪贴板(Clipboard | 授予 `clipboard-read`/`clipboard-write` + `page.evaluate(navigator.clipboard...)` | 需要安全上下文(HTTPS 或 localhost |
| 通知(Notifications | 通过 `page.evaluate` 模拟 `Notification` 构造函数 | 授予 `notifications` 权限;捕获构造函数调用 |
| 摄像头/麦克风 | Chromium 启动参数 `--use-fake-device-for-media-stream` | 仅在 Chromium 中有效;授予 `camera`/`microphone` 权限 |
| localStorage | `page.evaluate(() => localStorage.getItem/setItem(...))` | 在导航或重新加载前设置才能生效 |
| sessionStorage | `page.evaluate(() => sessionStorage.getItem/setItem(...))` | 作用域限定于当前浏览会话;上下文关闭时清除 |
| IndexedDB | 使用 `indexedDB.open()``page.evaluate` | 对异步操作需用 Promise 包裹 |
## 反模式
| 不要这样做 | 问题 | 应这样做 |
| ------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------ |
| 设置 `geolocation` 但不授予权限 | `getCurrentPosition` 返回权限错误 | 始终将 `geolocation``permissions: ['geolocation']` 配对 |
| 在非安全上下文中测试剪贴板 | `navigator.clipboard` 在非 HTTPS 上下文中抛出异常 | 使用 `localhost` 或在测试服务器中配置 HTTPS |
| 在导航到目标源之前访问 localStorage | `page.evaluate` 最初在 `about:blank` 上下文中执行 | 先执行 `page.goto('/')`,然后设置 localStorage,再重新加载 |
| 直接在 localStorage 中存储测试令牌 | 绕过认证流程;可能掩盖真实的登录缺陷 | 使用 `storageState` 或合适的认证 fixture |
| 依赖上一个测试的 IndexedDB 状态 | 测试必须相互独立 | 在测试设置中清除或删除数据库 |
| 不清除注入的模拟(Notification 等) | 如果使用同一个上下文,模拟会泄漏到后续测试中 | 默认每个测试使用新的上下文;仅在使用共享上下文时需要注意 |
## 故障排查
| 症状 | 原因 | 修复方法 |
| --------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- |
| 应用中 `geolocation` 返回 `undefined` | 未授予权限 | 在上下文选项中添加 `permissions: ['geolocation']` |
| 剪贴板 `readText()` 抛出 `DOMException` | 缺少剪贴板权限或非安全上下文 | 授予 `clipboard-read`;确保使用 HTTPS 或 localhost |
| `localStorage.getItem()``setItem` 后返回 `null` | 设置操作在不同的源上执行,或在导航之前执行 | 验证你已在调用 `setItem` 之前导航到正确的源 |
| 摄像头在 Firefox/WebKit 中无法工作 | 虚拟媒体设备仅 Chromium 支持 | 在非 Chromium 浏览器上跳过摄像头测试,或通过 `page.evaluate` 模拟 `getUserMedia` |
| IndexedDB 的 `evaluate` 返回 `undefined` | 忘记返回 Promise | 确保 `page.evaluate` 回调返回 `new Promise(...)` |
| 权限变更未生效 | 应用在加载时缓存了权限状态 | 在 `grantPermissions()``clearPermissions()` 之后重新加载页面 |
## 相关文档
- [core/configuration.md](configuration.md) —— 配置中的全局地理定位/权限设置
- [core/service-workers-and-pwa.md](service-workers-and-pwa.md) —— 使用 IndexedDB 和服务工作线程进行离线与缓存测试
- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) —— 将浏览器 API 设置封装为可复用的 fixture
- [core/debugging.md](debugging.md) —— 在 Playwright 追踪中检查存储和权限