# 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 追踪中检查存储和权限