# 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](configuration.md), [core/fixtures-and-hooks.md](fixtures-and-hooks.md) ## Quick Reference ```typescript // Geolocation — set via context options const context = await browser.newContext({ geolocation: { latitude: 40.7128, longitude: -74.0060 }, 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** ```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.0060 }, // 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** ```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.0060 }, 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`: ```typescript // 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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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](configuration.md) -- global geolocation/permissions in config - [core/service-workers-and-pwa.md](service-workers-and-pwa.md) -- offline and cache testing using IndexedDB and service workers - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- wrap browser API setup in reusable fixtures - [core/debugging.md](debugging.md) -- inspecting storage and permissions in Playwright traces