mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
chore: add playwright cursor skill
This commit is contained in:
parent
25aad38ca4
commit
d52225c18d
57 changed files with 25244 additions and 0 deletions
391
.cursor/skills/playwright-testing/browser-apis/browser-apis.md
Normal file
391
.cursor/skills/playwright-testing/browser-apis/browser-apis.md
Normal 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
|
||||
403
.cursor/skills/playwright-testing/browser-apis/iframes.md
Normal file
403
.cursor/skills/playwright-testing/browser-apis/iframes.md
Normal 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
|
||||
|
|
@ -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
|
||||
403
.cursor/skills/playwright-testing/browser-apis/websockets.md
Normal file
403
.cursor/skills/playwright-testing/browser-apis/websockets.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue