24 KiB
Browser APIs
When to use: When testing features that depend on browser-native APIs -- geolocation, permissions, clipboard, notifications, camera/microphone, localStorage, sessionStorage, IndexedDB. Prerequisites: core/configuration.md, core/fixtures-and-hooks.md
Quick Reference
// Geolocation — set via context options
const context = await browser.newContext({
geolocation: { latitude: 40.7128, longitude: -74.006 },
permissions: ["geolocation"],
})
// Permissions — grant at context level
const context = await browser.newContext({
permissions: ["clipboard-read", "clipboard-write", "notifications"],
})
// localStorage / sessionStorage — access via page.evaluate
await page.evaluate(() => localStorage.setItem("theme", "dark"))
const value = await page.evaluate(() => localStorage.getItem("theme"))
// Clipboard — read/write in page context
await page.evaluate(() => navigator.clipboard.writeText("copied text"))
Patterns
Geolocation
Use when: Your app uses navigator.geolocation for maps, store locators, delivery tracking, or location-based features.
Avoid when: Your app never reads the user's location.
Geolocation is set at the context level. You can also update it mid-test with context.setGeolocation().
TypeScript
import { test, expect } from "@playwright/test"
test("shows nearest store based on geolocation", async ({ browser }) => {
const context = await browser.newContext({
geolocation: { latitude: 40.7128, longitude: -74.006 }, // New York
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("update location mid-test for moving user", async ({ browser }) => {
const context = await browser.newContext({
geolocation: { latitude: 37.7749, longitude: -122.4194 }, // San Francisco
permissions: ["geolocation"],
})
const page = await context.newPage()
await page.goto("/delivery-tracker")
await expect(page.getByTestId("current-city")).toHaveText("San Francisco")
// Simulate user moving to a new location
await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 }) // Los Angeles
// Trigger location refresh
await page.getByRole("button", { name: "Update location" }).click()
await expect(page.getByTestId("current-city")).toHaveText("Los Angeles")
await context.close()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("shows nearest store based on geolocation", 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("update location mid-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()
})
You can also set geolocation globally in playwright.config:
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
use: {
geolocation: { latitude: 51.5074, longitude: -0.1278 }, // London
permissions: ["geolocation"],
},
})
Permissions
Use when: Your app requests browser permissions -- notifications, camera, microphone, geolocation, clipboard. Avoid when: Your app does not use the Permissions API.
Grant permissions at context creation. Playwright does not show permission dialogs; you pre-grant or deny them.
TypeScript
import { test, expect } from "@playwright/test"
test("notification permission granted shows notification UI", async ({ browser }) => {
const context = await browser.newContext({
permissions: ["notifications"],
})
const page = await context.newPage()
await page.goto("/settings/notifications")
// App checks Notification.permission and shows the toggle
await expect(page.getByRole("switch", { name: "Enable notifications" })).toBeEnabled()
await context.close()
})
test("notification permission denied shows upgrade prompt", async ({ browser }) => {
// No 'notifications' in permissions = denied
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("grant permissions mid-test", async ({ browser }) => {
const context = await browser.newContext()
const page = await context.newPage()
await page.goto("/camera-app")
// Initially no camera permission
await expect(page.getByText("Camera access needed")).toBeVisible()
// Grant permission dynamically
await context.grantPermissions(["camera"], { origin: "https://localhost:3000" })
await page.getByRole("button", { name: "Enable camera" }).click()
await expect(page.getByTestId("camera-preview")).toBeVisible()
// Revoke all permissions
await context.clearPermissions()
await context.close()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("notification permission granted shows notification 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("grant permissions mid-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
Use when: Testing copy/paste functionality, "Copy to clipboard" buttons, or paste-from-clipboard features. Avoid when: Your app does not interact with the clipboard.
TypeScript
import { test, expect } from "@playwright/test"
test("copy button puts text on clipboard", 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()
// Read clipboard content
const clipboardText = await page.evaluate(() => navigator.clipboard.readText())
expect(clipboardText).toContain("https://example.com/share/")
await context.close()
})
test("paste from clipboard into editor", async ({ browser }) => {
const context = await browser.newContext({
permissions: ["clipboard-read", "clipboard-write"],
})
const page = await context.newPage()
await page.goto("/editor")
// Write to clipboard programmatically
await page.evaluate(() => navigator.clipboard.writeText("Pasted content from clipboard"))
// Focus the editor and trigger paste
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
const { test, expect } = require("@playwright/test")
test("copy button puts text on clipboard", 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()
})
Camera and Microphone Mocking
Use when: Testing video calls, QR code scanners, voice recording, or any feature using getUserMedia.
Avoid when: Your app does not use camera or microphone.
Chromium can use fake media devices. This is not available in Firefox or WebKit.
TypeScript
import { test, expect, chromium } from "@playwright/test"
test("video call shows local preview with fake camera", async () => {
// Launch Chromium with fake media streams
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()
// Verify the video element is playing
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
const { test, expect, chromium } = require("@playwright/test")
test("video call shows local preview with fake camera", 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 and sessionStorage
Use when: Your app persists state, tokens, preferences, or feature flags in web storage. Avoid when: You can set the state through the UI or API instead. Prefer those approaches for realism.
TypeScript
import { test, expect } from "@playwright/test"
test("app loads dark theme from localStorage preference", async ({ page }) => {
// Set localStorage before navigating
await page.goto("/")
await page.evaluate(() => localStorage.setItem("theme", "dark"))
// Reload to pick up the stored preference
await page.reload()
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark")
})
test("clear localStorage between scenarios", async ({ page }) => {
await page.goto("/")
// Seed some data
await page.evaluate(() => {
localStorage.setItem("cart", JSON.stringify([{ id: 1, qty: 2 }]))
localStorage.setItem("user_prefs", JSON.stringify({ currency: "EUR" }))
})
// Read and verify
const cart = await page.evaluate(() => JSON.parse(localStorage.getItem("cart") || "[]"))
expect(cart).toHaveLength(1)
// Clear specific keys
await page.evaluate(() => localStorage.removeItem("cart"))
// Or clear everything
await page.evaluate(() => localStorage.clear())
})
test("sessionStorage survives navigations within the session", 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
const { test, expect } = require("@playwright/test")
test("app loads dark theme from localStorage preference", 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 survives navigations within the session", 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 Testing
Use when: Your app uses IndexedDB for offline storage, caching, or large datasets (progressive web apps, offline-first apps). Avoid when: Your app only uses localStorage or server-side storage.
TypeScript
import { test, expect } from "@playwright/test"
test("offline-first app stores data in IndexedDB", async ({ page }) => {
await page.goto("/notes")
// Create a note through the 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()
// Verify it is stored in 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("clear IndexedDB for a clean test state", async ({ page }) => {
await page.goto("/")
// Delete the entire database
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
const { test, expect } = require("@playwright/test")
test("offline-first app stores data in 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",
})
})
Notifications
Use when: Your app uses the browser Notification API to show desktop notifications. Avoid when: Notifications are purely server-side (push without Notification API).
Playwright cannot capture the actual system notification. Instead, mock the Notification constructor and verify the app calls it correctly.
TypeScript
import { test, expect } from "@playwright/test"
test("app triggers a browser notification on new message", async ({ browser }) => {
const context = await browser.newContext({
permissions: ["notifications"],
})
const page = await context.newPage()
// Intercept Notification constructor to capture calls
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")
// Simulate receiving a message that triggers a notification
await page.evaluate(() => {
window.dispatchEvent(
new CustomEvent("new-message", {
detail: { from: "Alice", text: "Hey there!" },
})
)
})
// Check the notification was created with correct content
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
const { test, expect } = require("@playwright/test")
test("app triggers a browser notification on new message", 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()
})
Decision Guide
| Browser API | How to Test | Key Configuration |
|---|---|---|
| Geolocation | geolocation context option + permissions: ['geolocation'] |
context.setGeolocation() for mid-test changes |
| Permissions (any) | permissions array in context options |
context.grantPermissions() / context.clearPermissions() |
| Clipboard | Grant clipboard-read/clipboard-write + page.evaluate(navigator.clipboard...) |
Requires secure context (HTTPS or localhost) |
| Notifications | Mock Notification constructor via page.evaluate |
Grant notifications permission; capture constructor calls |
| Camera / Microphone | Chromium --use-fake-device-for-media-stream launch arg |
Only works in Chromium; grant camera/microphone permissions |
| localStorage | page.evaluate(() => localStorage.getItem/setItem(...)) |
Set before navigation or reload to take effect |
| sessionStorage | page.evaluate(() => sessionStorage.getItem/setItem(...)) |
Scoped to the browsing session; cleared on context close |
| IndexedDB | page.evaluate with indexedDB.open() |
Wrap in Promises for async operations |
Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
Set geolocation without granting the permission |
getCurrentPosition returns permission error |
Always pair geolocation with permissions: ['geolocation'] |
| Test clipboard without secure context | navigator.clipboard throws in non-HTTPS contexts |
Use localhost or configure HTTPS in your test server |
| Access localStorage before navigating to the origin | page.evaluate runs in about:blank context initially |
page.goto('/') first, then set localStorage, then reload |
| Store test tokens in localStorage directly | Bypasses auth flow; may mask real login bugs | Use storageState or proper auth fixtures |
| Rely on IndexedDB state from a previous test | Tests must be independent | Clear or delete the database in test setup |
| Skip cleanup of injected mocks (Notification, etc.) | Mock leaks into subsequent tests if using the same context | Each test gets a fresh context by default; only a concern with shared contexts |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
geolocation returns undefined in the app |
Permission not granted | Add permissions: ['geolocation'] to context options |
Clipboard readText() throws DOMException |
Missing clipboard permissions or non-secure context | Grant clipboard-read; ensure HTTPS or localhost |
localStorage.getItem() returns null after setItem |
Set was done on a different origin or before navigation | Verify you navigated to the correct origin before calling setItem |
| Camera not working in Firefox/WebKit | Fake media devices are Chromium-only | Skip camera tests on non-Chromium browsers or mock getUserMedia via page.evaluate |
IndexedDB evaluate returns undefined |
Forgot to return the Promise | Ensure the page.evaluate callback returns new Promise(...) |
| Permissions change not reflected | App caches permission state on load | Reload the page after grantPermissions() or clearPermissions() |
Related
- core/configuration.md -- global geolocation/permissions in config
- core/service-workers-and-pwa.md -- offline and cache testing using IndexedDB and service workers
- core/fixtures-and-hooks.md -- wrap browser API setup in reusable fixtures
- core/debugging.md -- inspecting storage and permissions in Playwright traces