chore: add playwright cursor skill

This commit is contained in:
Anish Sarkar 2026-05-10 04:19:55 +05:30
parent 25aad38ca4
commit d52225c18d
57 changed files with 25244 additions and 0 deletions

View file

@ -0,0 +1,391 @@
# Browser APIs: Geolocation, Permissions & More
## Table of Contents
1. [Geolocation](#geolocation)
2. [Permissions](#permissions)
3. [Clipboard](#clipboard)
4. [Notifications](#notifications)
5. [Camera & Microphone](#camera--microphone)
## Geolocation
### Mock Location
```typescript
test("shows nearby stores", async ({ context }) => {
// Grant permission and set location
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); // San Francisco
const page = await context.newPage();
await page.goto("/store-finder");
await page.getByRole("button", { name: "Find Nearby" }).click();
await expect(page.getByText("San Francisco")).toBeVisible();
});
```
### Geolocation Fixture
```typescript
// fixtures/geolocation.fixture.ts
import { test as base } from "@playwright/test";
type Coordinates = { latitude: number; longitude: number; accuracy?: number };
type GeoFixtures = {
setLocation: (coords: Coordinates) => Promise<void>;
};
export const test = base.extend<GeoFixtures>({
setLocation: async ({ context }, use) => {
await context.grantPermissions(["geolocation"]);
await use(async (coords) => {
await context.setGeolocation({
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy ?? 100,
});
});
},
});
// Usage
test("delivery zone check", async ({ page, setLocation }) => {
await setLocation({ latitude: 40.7128, longitude: -74.006 }); // NYC
await page.goto("/delivery");
await expect(page.getByText("Delivery available")).toBeVisible();
});
```
### Test Location Changes
```typescript
test("tracks location updates", async ({ context }) => {
await context.grantPermissions(["geolocation"]);
const page = await context.newPage();
await page.goto("/tracking");
// Initial location
await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
await page.getByRole("button", { name: "Start Tracking" }).click();
await expect(page.getByTestId("location")).toContainText("37.7749");
// Move to new location
await context.setGeolocation({ latitude: 37.8044, longitude: -122.2712 });
// Trigger location update
await page.evaluate(() => {
navigator.geolocation.getCurrentPosition(() => {});
});
await expect(page.getByTestId("location")).toContainText("37.8044");
});
```
### Test Geolocation Denial
```typescript
test("handles location denied", async ({ browser }) => {
// Create context without geolocation permission
const context = await browser.newContext({
permissions: [], // No permissions
});
const page = await context.newPage();
await page.goto("/store-finder");
await page.getByRole("button", { name: "Find Nearby" }).click();
await expect(page.getByText("Location access denied")).toBeVisible();
await expect(page.getByLabel("Enter ZIP code")).toBeVisible();
await context.close();
});
```
## Permissions
### Grant Permissions
```typescript
test("notifications with permission", async ({ context }) => {
await context.grantPermissions(["notifications"]);
const page = await context.newPage();
await page.goto("/alerts");
// Notification API should work
const permission = await page.evaluate(() => Notification.permission);
expect(permission).toBe("granted");
});
```
### Test Permission Denied
```typescript
test("handles notification permission denied", async ({ browser }) => {
const context = await browser.newContext({
permissions: [], // Deny all
});
const page = await context.newPage();
await page.goto("/notifications");
await page.getByRole("button", { name: "Enable Notifications" }).click();
await expect(page.getByText("Please enable notifications")).toBeVisible();
await context.close();
});
```
### Multiple Permissions
```typescript
test("video call with permissions", async ({ context }) => {
await context.grantPermissions(["camera", "microphone", "notifications"]);
const page = await context.newPage();
await page.goto("/video-call");
// All permissions should be granted
const permissions = await page.evaluate(async () => ({
camera: await navigator.permissions.query({
name: "camera" as PermissionName,
}),
microphone: await navigator.permissions.query({
name: "microphone" as PermissionName,
}),
}));
expect(permissions.camera.state).toBe("granted");
expect(permissions.microphone.state).toBe("granted");
});
```
## Clipboard
### Test Copy to Clipboard
```typescript
test("copy button works", async ({ page, context }) => {
// Grant clipboard permissions
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.goto("/share");
await page.getByRole("button", { name: "Copy Link" }).click();
// Read clipboard content
const clipboardContent = await page.evaluate(() =>
navigator.clipboard.readText(),
);
expect(clipboardContent).toContain("https://example.com/share/");
});
```
### Test Paste from Clipboard
```typescript
test("paste from clipboard", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.goto("/editor");
// Write to clipboard
await page.evaluate(() => navigator.clipboard.writeText("Pasted content"));
// Trigger paste
await page.getByLabel("Content").focus();
await page.keyboard.press("Control+V");
await expect(page.getByLabel("Content")).toHaveValue("Pasted content");
});
```
### Clipboard Fixture
```typescript
// fixtures/clipboard.fixture.ts
import { test as base } from "@playwright/test";
type ClipboardFixtures = {
clipboard: {
write: (text: string) => Promise<void>;
read: () => Promise<string>;
};
};
export const test = base.extend<ClipboardFixtures>({
clipboard: async ({ page, context }, use) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await use({
write: async (text) => {
await page.evaluate((t) => navigator.clipboard.writeText(t), text);
},
read: async () => {
return page.evaluate(() => navigator.clipboard.readText());
},
});
},
});
```
## Notifications
### Mock Notification API
```typescript
test("shows browser notification", async ({ page }) => {
const notifications: any[] = [];
// Mock Notification constructor
await page.addInitScript(() => {
(window as any).__notifications = [];
(window as any).Notification = class {
constructor(title: string, options?: NotificationOptions) {
(window as any).__notifications.push({ title, ...options });
}
static permission = "granted";
static requestPermission = async () => "granted";
};
});
await page.goto("/alerts");
await page.getByRole("button", { name: "Notify Me" }).click();
// Check notification was created
const created = await page.evaluate(() => (window as any).__notifications);
expect(created).toHaveLength(1);
expect(created[0].title).toBe("New Alert");
});
```
### Test Notification Click
```typescript
test("notification click handler", async ({ page }) => {
await page.addInitScript(() => {
(window as any).Notification = class {
onclick: (() => void) | null = null;
constructor(title: string) {
// Simulate click after creation
setTimeout(() => this.onclick?.(), 100);
}
static permission = "granted";
static requestPermission = async () => "granted";
};
});
await page.goto("/messages");
await page.evaluate(() => {
new Notification("New Message");
});
// Should navigate to messages when notification clicked
await expect(page).toHaveURL(/\/messages/);
});
```
## Camera & Microphone
### Mock Media Devices
```typescript
test("video preview works", async ({ page, context }) => {
await context.grantPermissions(["camera"]);
// Mock getUserMedia
await page.addInitScript(() => {
navigator.mediaDevices.getUserMedia = async () => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 480;
return canvas.captureStream();
};
});
await page.goto("/video-settings");
await page.getByRole("button", { name: "Start Camera" }).click();
await expect(page.getByTestId("video-preview")).toBeVisible();
});
```
### Test Media Device Selection
```typescript
test("switch camera", async ({ page }) => {
await page.addInitScript(() => {
navigator.mediaDevices.enumerateDevices = async () =>
[
{
deviceId: "cam1",
kind: "videoinput",
label: "Front Camera",
groupId: "1",
},
{
deviceId: "cam2",
kind: "videoinput",
label: "Back Camera",
groupId: "2",
},
] as MediaDeviceInfo[];
navigator.mediaDevices.getUserMedia = async () => {
const canvas = document.createElement("canvas");
return canvas.captureStream();
};
});
await page.goto("/camera");
// Should show camera options
await expect(page.getByRole("combobox", { name: "Camera" })).toBeVisible();
await expect(page.getByText("Front Camera")).toBeVisible();
await expect(page.getByText("Back Camera")).toBeVisible();
});
```
### Test Media Errors
```typescript
test("handles camera access error", async ({ page }) => {
await page.addInitScript(() => {
navigator.mediaDevices.getUserMedia = async () => {
throw new DOMException("Permission denied", "NotAllowedError");
};
});
await page.goto("/video-call");
await page.getByRole("button", { name: "Join Call" }).click();
await expect(page.getByText("Camera access denied")).toBeVisible();
await expect(
page.getByRole("button", { name: "Join Audio Only" }),
).toBeVisible();
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ----------------------------- | --------------------------------- | ----------------------------------- |
| Not granting permissions | Tests fail with permission errors | Use `context.grantPermissions()` |
| Testing real geolocation | Flaky, environment-dependent | Mock with `setGeolocation()` |
| Not testing permission denial | Misses error handling | Test both granted and denied states |
| Using real camera/mic | CI has no devices | Mock `getUserMedia` |
## Related References
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for context fixtures
- **Mobile**: See [mobile-testing.md](../advanced/mobile-testing.md) for device emulation

View file

@ -0,0 +1,403 @@
# iFrame Testing
## Table of Contents
1. [Basic iFrame Access](#basic-iframe-access)
2. [Cross-Origin iFrames](#cross-origin-iframes)
3. [Nested iFrames](#nested-iframes)
4. [Dynamic iFrames](#dynamic-iframes)
5. [iFrame Navigation](#iframe-navigation)
6. [Common Patterns](#common-patterns)
## Basic iFrame Access
### Using frameLocator
```typescript
// Access iframe by selector
const frame = page.frameLocator("iframe#payment");
await frame.getByRole("button", { name: "Pay" }).click();
// Access by name attribute
const namedFrame = page.frameLocator('iframe[name="checkout"]');
await namedFrame.getByLabel("Card number").fill("4242424242424242");
// Access by title
const titledFrame = page.frameLocator('iframe[title="Payment Form"]');
// Access by src (partial match)
const srcFrame = page.frameLocator('iframe[src*="stripe.com"]');
```
### Frame vs FrameLocator
```typescript
// frameLocator - for locator-based operations (recommended)
const frameLocator = page.frameLocator("#my-iframe");
await frameLocator.getByRole("button").click();
// frame() - for Frame object operations (navigation, evaluation)
const frame = page.frame({ name: "my-frame" });
if (frame) {
await frame.goto("https://example.com");
const title = await frame.title();
}
// Get all frames
const frames = page.frames();
for (const f of frames) {
console.log("Frame URL:", f.url());
}
```
### Waiting for iFrame Content
```typescript
// Wait for iframe to load
const frame = page.frameLocator("#dynamic-iframe");
// Wait for element inside iframe
await expect(frame.getByRole("heading")).toBeVisible({ timeout: 10000 });
// Wait for iframe src to change
await page.waitForFunction(() => {
const iframe = document.querySelector("iframe#my-frame") as HTMLIFrameElement;
return iframe?.src.includes("loaded");
});
```
## Cross-Origin iFrames
### Accessing Cross-Origin Content
```typescript
// Cross-origin iframes work seamlessly with frameLocator
const thirdPartyFrame = page.frameLocator('iframe[src*="third-party.com"]');
// Interact with elements inside cross-origin iframe
await thirdPartyFrame.getByRole("textbox").fill("test@example.com");
await thirdPartyFrame.getByRole("button", { name: "Submit" }).click();
// Wait for cross-origin iframe to be ready
await expect(thirdPartyFrame.locator("body")).toBeVisible();
```
### Payment Provider iFrames (Stripe, PayPal)
```typescript
test("Stripe payment iframe", async ({ page }) => {
await page.goto("/checkout");
// Stripe uses multiple iframes for each field
const cardFrame = page
.frameLocator('iframe[name*="__privateStripeFrame"]')
.first();
// Wait for Stripe to initialize
await expect(cardFrame.locator('[placeholder="Card number"]')).toBeVisible({
timeout: 15000,
});
// Fill card details
await cardFrame
.locator('[placeholder="Card number"]')
.fill("4242424242424242");
await cardFrame.locator('[placeholder="MM / YY"]').fill("12/30");
await cardFrame.locator('[placeholder="CVC"]').fill("123");
});
```
### Handling OAuth in iFrames
```typescript
test("OAuth iframe flow", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Sign in with Google" }).click();
// If OAuth opens in iframe instead of popup
const oauthFrame = page.frameLocator('iframe[src*="accounts.google.com"]');
// Wait for OAuth form
await expect(oauthFrame.getByLabel("Email")).toBeVisible({ timeout: 10000 });
await oauthFrame.getByLabel("Email").fill("test@gmail.com");
});
```
## Nested iFrames
### Accessing Nested Frames
```typescript
// Parent iframe contains child iframe
const parentFrame = page.frameLocator("#outer-frame");
const childFrame = parentFrame.frameLocator("#inner-frame");
// Interact with deeply nested content
await childFrame.getByRole("button", { name: "Submit" }).click();
// Multiple levels of nesting
const level1 = page.frameLocator("#level1");
const level2 = level1.frameLocator("#level2");
const level3 = level2.frameLocator("#level3");
await level3.getByText("Deep content").click();
```
### Finding Elements Across Frame Hierarchy
```typescript
// Helper to search all frames for an element
async function findInAnyFrame(
page: Page,
selector: string,
): Promise<Locator | null> {
// Check main page first
const mainCount = await page.locator(selector).count();
if (mainCount > 0) return page.locator(selector);
// Check all frames
for (const frame of page.frames()) {
const count = await frame.locator(selector).count();
if (count > 0) {
return frame.locator(selector);
}
}
return null;
}
test("find element in any frame", async ({ page }) => {
await page.goto("/complex-page");
const element = await findInAnyFrame(page, '[data-testid="submit-btn"]');
if (element) await element.click();
});
```
## Dynamic iFrames
### iFrames Created at Runtime
```typescript
test("handle dynamically created iframe", async ({ page }) => {
await page.goto("/dashboard");
// Click button that creates iframe
await page.getByRole("button", { name: "Open Widget" }).click();
// Wait for iframe to appear in DOM
await page.waitForSelector("iframe#widget-frame");
// Now access the frame
const widgetFrame = page.frameLocator("#widget-frame");
await expect(widgetFrame.getByText("Widget Loaded")).toBeVisible();
});
```
### iFrames with Changing src
```typescript
test("iframe src changes", async ({ page }) => {
await page.goto("/multi-step");
const frame = page.frameLocator("#step-frame");
// Step 1
await expect(frame.getByText("Step 1")).toBeVisible();
await frame.getByRole("button", { name: "Next" }).click();
// Wait for iframe to reload with new content
await expect(frame.getByText("Step 2")).toBeVisible({ timeout: 10000 });
await frame.getByRole("button", { name: "Next" }).click();
// Step 3
await expect(frame.getByText("Step 3")).toBeVisible({ timeout: 10000 });
});
```
### Lazy-Loaded iFrames
```typescript
test("lazy loaded iframe", async ({ page }) => {
await page.goto("/page-with-lazy-iframe");
// Scroll to trigger lazy load
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
// Wait for iframe to load
const lazyFrame = page.frameLocator("#lazy-iframe");
await expect(lazyFrame.locator("body")).not.toBeEmpty({ timeout: 15000 });
// Interact with content
await lazyFrame.getByRole("button").click();
});
```
## iFrame Navigation
### Navigating Within iFrame
```typescript
test("iframe internal navigation", async ({ page }) => {
await page.goto("/app");
// Get frame object for navigation control
const frame = page.frame({ name: "content-frame" });
if (!frame) throw new Error("Frame not found");
// Navigate within iframe
await frame.goto("https://embedded-app.com/page2");
// Wait for navigation
await frame.waitForURL("**/page2");
// Verify content
await expect(frame.getByRole("heading")).toHaveText("Page 2");
});
```
### Handling Frame Navigation Events
```typescript
test("track iframe navigation", async ({ page }) => {
const navigations: string[] = [];
// Listen to frame navigation
page.on("framenavigated", (frame) => {
if (frame.parentFrame()) {
// This is an iframe navigation
navigations.push(frame.url());
}
});
await page.goto("/with-iframe");
await page
.frameLocator("#nav-frame")
.getByRole("link", { name: "Page 2" })
.click();
// Verify navigation occurred
expect(navigations.some((url) => url.includes("page2"))).toBe(true);
});
```
## Common Patterns
### iFrame Fixture
```typescript
// fixtures.ts
import { test as base, FrameLocator } from "@playwright/test";
export const test = base.extend<{ paymentFrame: FrameLocator }>({
paymentFrame: async ({ page }, use) => {
await page.goto("/checkout");
// Wait for payment iframe to be ready
const frame = page.frameLocator('iframe[src*="payment"]');
await expect(frame.locator("body")).toBeVisible({ timeout: 15000 });
await use(frame);
},
});
// test file
test("complete payment", async ({ paymentFrame }) => {
await paymentFrame.getByLabel("Card").fill("4242424242424242");
await paymentFrame.getByRole("button", { name: "Pay" }).click();
});
```
### Debugging iFrame Issues
```typescript
test("debug iframe content", async ({ page }) => {
await page.goto("/page-with-iframes");
// List all frames
console.log("All frames:");
for (const frame of page.frames()) {
console.log(` - ${frame.name() || "(unnamed)"}: ${frame.url()}`);
}
// Screenshot specific iframe content
const frame = page.frame({ name: "target-frame" });
if (frame) {
const body = frame.locator("body");
await body.screenshot({ path: "iframe-content.png" });
}
// Get iframe HTML for debugging
const frameContent = page.frameLocator("#my-frame");
const html = await frameContent.locator("body").innerHTML();
console.log("iFrame HTML:", html.substring(0, 500));
});
```
### Handling iFrame Load Failures
```typescript
test("handle iframe load failure", async ({ page }) => {
await page.goto("/page-with-unreliable-iframe");
const frame = page.frameLocator("#unreliable-frame");
try {
// Try to interact with iframe content
await expect(frame.getByRole("button")).toBeVisible({ timeout: 5000 });
await frame.getByRole("button").click();
} catch (error) {
// Fallback: refresh iframe
await page.evaluate(() => {
const iframe = document.querySelector(
"#unreliable-frame",
) as HTMLIFrameElement;
if (iframe) iframe.src = iframe.src;
});
// Retry
await expect(frame.getByRole("button")).toBeVisible({ timeout: 10000 });
await frame.getByRole("button").click();
}
});
```
### Mocking iFrame Content
```typescript
test("mock iframe response", async ({ page }) => {
// Intercept iframe src request
await page.route("**/embedded-widget**", (route) => {
route.fulfill({
contentType: "text/html",
body: `
<!DOCTYPE html>
<html>
<body>
<h1>Mocked Widget</h1>
<button>Mocked Button</button>
</body>
</html>
`,
});
});
await page.goto("/page-with-widget");
const frame = page.frameLocator("#widget-frame");
await expect(frame.getByRole("heading")).toHaveText("Mocked Widget");
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------------------- | --------------------------------- | -------------------------------------------------- |
| Using `page.frame()` for interactions | Less reliable than frameLocator | Use `page.frameLocator()` for element interactions |
| Hardcoding iframe index | Fragile if DOM order changes | Use name, id, or src attribute selectors |
| Not waiting for iframe load | Race conditions | Wait for element inside iframe to be visible |
| Assuming same-origin | Cross-origin has different timing | Always wait for iframe content explicitly |
| Ignoring nested iframes | Element not found | Chain frameLocator calls for nested frames |
## Related References
- **Locators**: See [locators.md](../core/locators.md) for selector strategies
- **Third-party services**: See [third-party.md](../advanced/third-party.md) for payment iframe patterns
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting iframe issues

View file

@ -0,0 +1,504 @@
# Service Worker Testing
## Table of Contents
1. [Service Worker Basics](#service-worker-basics)
2. [Registration & Lifecycle](#registration--lifecycle)
3. [Cache Testing](#cache-testing)
4. [Offline Testing](#offline-testing)
5. [Push Notifications](#push-notifications)
6. [Background Sync](#background-sync)
## Service Worker Basics
### Waiting for Service Worker Registration
```typescript
test("service worker registers", async ({ page }) => {
await page.goto("/pwa-app");
// Wait for SW to register
const swRegistered = await page.evaluate(async () => {
if (!("serviceWorker" in navigator)) return false;
const registration = await navigator.serviceWorker.ready;
return !!registration.active;
});
expect(swRegistered).toBe(true);
});
```
### Getting Service Worker State
```typescript
test("check SW state", async ({ page }) => {
await page.goto("/");
const swState = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
if (!registration) return null;
return {
installing: !!registration.installing,
waiting: !!registration.waiting,
active: !!registration.active,
scope: registration.scope,
};
});
expect(swState?.active).toBe(true);
expect(swState?.scope).toContain(page.url());
});
```
### Service Worker Context
```typescript
test("access service worker", async ({ context, page }) => {
await page.goto("/pwa-app");
// Get all service workers in context
const workers = context.serviceWorkers();
// Wait for service worker if not yet available
if (workers.length === 0) {
await context.waitForEvent("serviceworker");
}
const sw = context.serviceWorkers()[0];
expect(sw.url()).toContain("sw.js");
});
```
## Registration & Lifecycle
### Testing SW Update Flow
```typescript
test("service worker updates", async ({ page }) => {
await page.goto("/pwa-app");
// Check for update
const hasUpdate = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
await registration.update();
return new Promise<boolean>((resolve) => {
if (registration.waiting) {
resolve(true);
} else {
registration.addEventListener("updatefound", () => {
resolve(true);
});
// Timeout if no update
setTimeout(() => resolve(false), 5000);
}
});
});
// If update found, test skip waiting flow
if (hasUpdate) {
await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
registration.waiting?.postMessage({ type: "SKIP_WAITING" });
});
// Wait for controller change
await page.evaluate(() => {
return new Promise<void>((resolve) => {
navigator.serviceWorker.addEventListener("controllerchange", () => {
resolve();
});
});
});
}
});
```
### Testing SW Installation
```typescript
test("verify SW install event", async ({ context, page }) => {
// Listen for service worker before navigating
const swPromise = context.waitForEvent("serviceworker");
await page.goto("/pwa-app");
const sw = await swPromise;
// Evaluate in SW context
const swVersion = await sw.evaluate(() => {
// Access SW globals
return (self as any).SW_VERSION || "unknown";
});
expect(swVersion).toBe("1.0.0");
});
```
### Unregistering Service Workers
```typescript
test.beforeEach(async ({ page }) => {
await page.goto("/");
// Unregister all service workers for clean state
await page.evaluate(async () => {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((r) => r.unregister()));
});
// Clear caches
await page.evaluate(async () => {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
});
});
```
## Cache Testing
### Verifying Cached Resources
```typescript
test("assets are cached", async ({ page }) => {
await page.goto("/pwa-app");
// Wait for SW to cache assets
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
});
// Check cache contents
const cachedUrls = await page.evaluate(async () => {
const cache = await caches.open("app-cache-v1");
const requests = await cache.keys();
return requests.map((r) => r.url);
});
expect(cachedUrls).toContain(expect.stringContaining("/styles.css"));
expect(cachedUrls).toContain(expect.stringContaining("/app.js"));
});
```
### Testing Cache Strategies
```typescript
test("cache-first strategy", async ({ page }) => {
await page.goto("/pwa-app");
// Wait for initial cache
await page.waitForFunction(async () => {
const cache = await caches.open("app-cache-v1");
const keys = await cache.keys();
return keys.length > 0;
});
// Block network for cached resources
await page.route("**/styles.css", (route) => route.abort());
// Reload - should work from cache
await page.reload();
// Verify page still styled (CSS loaded from cache)
const hasStyles = await page.evaluate(() => {
const body = document.body;
const styles = window.getComputedStyle(body);
return styles.fontFamily !== ""; // Has custom font from CSS
});
expect(hasStyles).toBe(true);
});
```
### Testing Cache Updates
```typescript
test("cache updates on new version", async ({ page }) => {
await page.goto("/pwa-app");
// Get initial cache
const initialCacheKeys = await page.evaluate(async () => {
const cache = await caches.open("app-cache-v1");
const keys = await cache.keys();
return keys.map((r) => r.url);
});
// Simulate app update by mocking SW response
await page.route("**/sw.js", (route) => {
route.fulfill({
contentType: "application/javascript",
body: `
const VERSION = 'v2';
self.addEventListener('install', (e) => {
e.waitUntil(caches.open('app-cache-v2'));
self.skipWaiting();
});
`,
});
});
// Trigger update
await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
await reg.update();
});
// Verify new cache exists
await page.waitForFunction(async () => {
return await caches.has("app-cache-v2");
});
});
```
## Offline Testing
This section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing).
### Simulating Offline Mode
```typescript
test("app works offline", async ({ page, context }) => {
await page.goto("/pwa-app");
// Ensure SW is active and content cached
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
});
await page.waitForTimeout(1000); // Allow caching to complete
// Go offline
await context.setOffline(true);
// Navigate to cached page
await page.reload();
// Verify content loads
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
// Verify offline indicator
await expect(page.locator(".offline-badge")).toBeVisible();
// Go back online
await context.setOffline(false);
await expect(page.locator(".offline-badge")).not.toBeVisible();
});
```
### Testing Offline Fallback
```typescript
test("shows offline page for uncached routes", async ({ page, context }) => {
await page.goto("/pwa-app");
await page.evaluate(() => navigator.serviceWorker.ready);
// Go offline
await context.setOffline(true);
// Navigate to uncached page
await page.goto("/uncached-page");
// Should show offline fallback
await expect(page.getByText("You are offline")).toBeVisible();
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});
```
### Testing Offline Form Submission
```typescript
test("queues form submission offline", async ({ page, context }) => {
await page.goto("/pwa-app/form");
// Go offline
await context.setOffline(true);
// Submit form
await page.getByLabel("Message").fill("Offline message");
await page.getByRole("button", { name: "Send" }).click();
// Should show queued status
await expect(page.getByText("Queued for sync")).toBeVisible();
// Go online
await context.setOffline(false);
// Trigger sync (or wait for automatic)
await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
// Manually trigger sync for testing
await (reg as any).sync?.register("form-sync");
});
// Verify submission completed
await expect(page.getByText("Message sent")).toBeVisible({ timeout: 10000 });
});
```
## Push Notifications
### Mocking Push Subscription
```typescript
test("handles push subscription", async ({ page, context }) => {
// Grant notification permission
await context.grantPermissions(["notifications"]);
await page.goto("/pwa-app");
// Subscribe to push
const subscription = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: "test-key",
});
return sub.toJSON();
});
expect(subscription.endpoint).toBeDefined();
});
```
### Testing Push Message Handling
```typescript
test("handles push notification", async ({ context, page }) => {
await context.grantPermissions(["notifications"]);
await page.goto("/pwa-app");
// Wait for SW
const swPromise = context.waitForEvent("serviceworker");
const sw = await swPromise;
// Simulate push message to service worker
await sw.evaluate(async () => {
// Dispatch push event
const pushEvent = new PushEvent("push", {
data: new PushMessageData(
JSON.stringify({ title: "Test", body: "Push message" }),
),
});
self.dispatchEvent(pushEvent);
});
// Note: Actual notification display testing is limited in Playwright
// Focus on verifying the SW handles the push correctly
});
```
### Testing Notification Click
```typescript
test("notification click opens page", async ({ context, page }) => {
await context.grantPermissions(["notifications"]);
await page.goto("/pwa-app");
// Store notification URL target
let notificationUrl = "";
// Listen for new pages (notification click opens new page)
context.on("page", (newPage) => {
notificationUrl = newPage.url();
});
// Trigger notification via SW
await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
await reg.showNotification("Test", {
body: "Click me",
data: { url: "/notification-target" },
});
});
// Simulate clicking notification (via SW)
const sw = context.serviceWorkers()[0];
await sw.evaluate(() => {
self.dispatchEvent(
new NotificationEvent("notificationclick", {
notification: { data: { url: "/notification-target" } } as any,
}),
);
});
// Verify navigation occurred
await page.waitForTimeout(1000);
// Check if new page opened or current page navigated
});
```
## Background Sync
### Testing Background Sync Registration
```typescript
test("registers background sync", async ({ page }) => {
await page.goto("/pwa-app");
// Register sync
const syncRegistered = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
if (!("sync" in reg)) return false;
await (reg as any).sync.register("my-sync");
return true;
});
expect(syncRegistered).toBe(true);
});
```
### Testing Sync Event
```typescript
test("sync event fires when online", async ({ context, page }) => {
await page.goto("/pwa-app");
// Queue data while offline
await context.setOffline(true);
await page.evaluate(async () => {
// Store data in IndexedDB for sync
const db = await openDB();
await db.put("sync-queue", { id: 1, data: "test" });
// Register sync
const reg = await navigator.serviceWorker.ready;
await (reg as any).sync.register("data-sync");
});
// Track sync completion
await page.evaluate(() => {
window.syncCompleted = false;
navigator.serviceWorker.addEventListener("message", (e) => {
if (e.data.type === "SYNC_COMPLETE") {
window.syncCompleted = true;
}
});
});
// Go online
await context.setOffline(false);
// Wait for sync to complete
await page.waitForFunction(() => window.syncCompleted, { timeout: 10000 });
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------------ | ----------------------- | -------------------------------------------- |
| Not clearing SW between tests | Tests affect each other | Unregister SW in beforeEach |
| Not waiting for SW ready | Race conditions | Always await `navigator.serviceWorker.ready` |
| Testing in isolation only | Misses real SW behavior | Test with actual caching |
| Hardcoded timeouts for caching | Flaky tests | Wait for cache to populate |
| Ignoring SW update cycle | Missing update bugs | Test install, activate, update flows |
## Related References
- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns
- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions
- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception
- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns

View file

@ -0,0 +1,403 @@
# WebSocket & Real-Time Testing
## Table of Contents
1. [WebSocket Basics](#websocket-basics)
2. [Mocking WebSocket Messages](#mocking-websocket-messages)
3. [Testing Real-Time Features](#testing-real-time-features)
4. [Server-Sent Events](#server-sent-events)
5. [Reconnection Testing](#reconnection-testing)
## WebSocket Basics
### Wait for WebSocket Connection
```typescript
test("chat connects via websocket", async ({ page }) => {
// Listen for WebSocket connection
const wsPromise = page.waitForEvent("websocket");
await page.goto("/chat");
const ws = await wsPromise;
expect(ws.url()).toContain("/ws/chat");
// Wait for connection to be established
await ws.waitForEvent("framesent");
});
```
### Monitor WebSocket Messages
```typescript
test("receives real-time updates", async ({ page }) => {
const messages: string[] = [];
// Set up listener before navigation
page.on("websocket", (ws) => {
ws.on("framereceived", (frame) => {
messages.push(frame.payload as string);
});
});
await page.goto("/dashboard");
// Wait for some messages
await expect.poll(() => messages.length).toBeGreaterThan(0);
// Verify message format
const data = JSON.parse(messages[0]);
expect(data).toHaveProperty("type");
});
```
### Capture Sent Messages
```typescript
test("sends correct message format", async ({ page }) => {
const sentMessages: string[] = [];
page.on("websocket", (ws) => {
ws.on("framesent", (frame) => {
sentMessages.push(frame.payload as string);
});
});
await page.goto("/chat");
await page.getByLabel("Message").fill("Hello!");
await page.getByRole("button", { name: "Send" }).click();
// Verify sent message
await expect.poll(() => sentMessages.length).toBeGreaterThan(0);
const sent = JSON.parse(sentMessages[sentMessages.length - 1]);
expect(sent).toEqual({
type: "message",
content: "Hello!",
});
});
```
## Mocking WebSocket Messages
### Inject Messages via Page Evaluate
```typescript
test("displays incoming chat message", async ({ page }) => {
await page.goto("/chat");
// Wait for WebSocket to be ready
await page.waitForFunction(
() => (window as any).chatSocket?.readyState === 1,
);
// Simulate incoming message
await page.evaluate(() => {
const event = new MessageEvent("message", {
data: JSON.stringify({
type: "message",
from: "Alice",
content: "Hello there!",
}),
});
(window as any).chatSocket.dispatchEvent(event);
});
await expect(page.getByText("Alice: Hello there!")).toBeVisible();
});
```
### Mock WebSocket with Route Handler
```typescript
test("mock websocket entirely", async ({ page, context }) => {
// Intercept the WebSocket upgrade
await context.route("**/ws/**", async (route) => {
// For WebSocket routes, we can't fulfill directly
// Instead, use page.evaluate to mock the client-side
});
// Alternative: Mock at application level
await page.addInitScript(() => {
const OriginalWebSocket = window.WebSocket;
(window as any).WebSocket = function (url: string) {
const ws = {
readyState: 1,
send: (data: string) => {
console.log("WS Send:", data);
},
close: () => {},
addEventListener: () => {},
removeEventListener: () => {},
};
setTimeout(() => ws.onopen?.(), 100);
return ws;
};
});
await page.goto("/chat");
});
```
### WebSocket Mock Fixture
```typescript
// fixtures/websocket.fixture.ts
import { test as base, Page } from "@playwright/test";
type WsMessage = { type: string; [key: string]: any };
type WebSocketFixtures = {
mockWebSocket: {
injectMessage: (message: WsMessage) => Promise<void>;
getSentMessages: () => Promise<WsMessage[]>;
};
};
export const test = base.extend<WebSocketFixtures>({
mockWebSocket: async ({ page }, use) => {
const sentMessages: WsMessage[] = [];
// Capture sent messages
await page.addInitScript(() => {
(window as any).__wsSent = [];
const OriginalWebSocket = window.WebSocket;
window.WebSocket = function (url: string) {
const ws = new OriginalWebSocket(url);
const originalSend = ws.send.bind(ws);
ws.send = (data: string) => {
(window as any).__wsSent.push(JSON.parse(data));
originalSend(data);
};
(window as any).__ws = ws;
return ws;
} as any;
});
await use({
injectMessage: async (message) => {
await page.evaluate((msg) => {
const event = new MessageEvent("message", {
data: JSON.stringify(msg),
});
(window as any).__ws?.dispatchEvent(event);
}, message);
},
getSentMessages: async () => {
return page.evaluate(() => (window as any).__wsSent || []);
},
});
},
});
// Usage
test("chat with mocked websocket", async ({ page, mockWebSocket }) => {
await page.goto("/chat");
// Inject incoming message
await mockWebSocket.injectMessage({
type: "message",
from: "Bob",
content: "Hi!",
});
await expect(page.getByText("Bob: Hi!")).toBeVisible();
// Send a reply
await page.getByLabel("Message").fill("Hello Bob!");
await page.getByRole("button", { name: "Send" }).click();
// Verify sent message
const sent = await mockWebSocket.getSentMessages();
expect(sent).toContainEqual(
expect.objectContaining({ content: "Hello Bob!" }),
);
});
```
## Testing Real-Time Features
### Live Notifications
```typescript
test("displays live notification", async ({ page }) => {
await page.goto("/dashboard");
// Simulate notification via WebSocket
await page.evaluate(() => {
const event = new MessageEvent("message", {
data: JSON.stringify({
type: "notification",
title: "New Order",
message: "Order #123 received",
}),
});
(window as any).notificationSocket.dispatchEvent(event);
});
await expect(page.getByRole("alert")).toContainText("Order #123 received");
});
```
### Live Data Updates
```typescript
test("updates stock price in real-time", async ({ page }) => {
await page.goto("/stocks/AAPL");
const priceElement = page.getByTestId("stock-price");
const initialPrice = await priceElement.textContent();
// Simulate price update
await page.evaluate(() => {
const event = new MessageEvent("message", {
data: JSON.stringify({
type: "price_update",
symbol: "AAPL",
price: 150.25,
}),
});
(window as any).stockSocket.dispatchEvent(event);
});
await expect(priceElement).not.toHaveText(initialPrice!);
await expect(priceElement).toContainText("150.25");
});
```
### Collaborative Editing
```typescript
test("shows collaborator cursor", async ({ page }) => {
await page.goto("/document/123");
// Simulate another user's cursor position
await page.evaluate(() => {
const event = new MessageEvent("message", {
data: JSON.stringify({
type: "cursor",
userId: "user-456",
userName: "Alice",
position: { x: 100, y: 200 },
}),
});
(window as any).docSocket.dispatchEvent(event);
});
await expect(page.getByTestId("cursor-user-456")).toBeVisible();
await expect(page.getByText("Alice")).toBeVisible();
});
```
## Server-Sent Events
### Test SSE Updates
```typescript
test("receives SSE updates", async ({ page }) => {
// Mock SSE endpoint
await page.route("**/api/events", (route) => {
route.fulfill({
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
body: `data: {"type":"update","value":42}\n\n`,
});
});
await page.goto("/live-data");
await expect(page.getByTestId("value")).toHaveText("42");
});
```
### Simulate Multiple SSE Events
```typescript
test("handles multiple SSE events", async ({ page }) => {
await page.route("**/api/events", async (route) => {
const encoder = new TextEncoder();
const events = [
`data: {"count":1}\n\n`,
`data: {"count":2}\n\n`,
`data: {"count":3}\n\n`,
];
route.fulfill({
status: 200,
headers: { "Content-Type": "text/event-stream" },
body: events.join(""),
});
});
await page.goto("/counter");
// Should receive all events
await expect(page.getByTestId("count")).toHaveText("3");
});
```
## Reconnection Testing
### Test Connection Loss
```typescript
test("handles connection loss gracefully", async ({ page }) => {
await page.goto("/chat");
// Simulate connection close
await page.evaluate(() => {
(window as any).chatSocket.close();
});
// Should show disconnected state
await expect(page.getByText("Reconnecting...")).toBeVisible();
});
```
### Test Reconnection
```typescript
test("reconnects after connection loss", async ({ page }) => {
await page.goto("/chat");
// Simulate disconnect
await page.evaluate(() => {
(window as any).chatSocket.close();
});
await expect(page.getByText("Reconnecting...")).toBeVisible();
// Simulate reconnection
await page.evaluate(() => {
const event = new Event("open");
(window as any).chatSocket = { readyState: 1 };
(window as any).chatSocket.dispatchEvent?.(event);
});
// Force component to re-check connection
await page.evaluate(() => {
window.dispatchEvent(new Event("online"));
});
await expect(page.getByText("Connected")).toBeVisible();
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------------------- | ----------------------------- | ---------------------------------- |
| Not waiting for WebSocket ready | Messages sent too early | Wait for `readyState === 1` |
| Testing against real WebSocket server | Flaky, timing-dependent | Mock WebSocket messages |
| Ignoring connection state | Tests pass but feature broken | Test connected/disconnected states |
| No cleanup of listeners | Memory leaks in tests | Clean up event listeners |
## Related References
- **Network**: See [network-advanced.md](../advanced/network-advanced.md) for HTTP mocking patterns
- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for polling patterns
- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for real-time collaboration testing with multiple users