661 lines
25 KiB
Markdown
661 lines
25 KiB
Markdown
# 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 追踪中检查存储和权限
|