mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
572 lines
19 KiB
Markdown
Executable file
572 lines
19 KiB
Markdown
Executable file
# WebSockets and Real-Time Testing
|
|
|
|
> **When to use**: When your application uses WebSockets, Server-Sent Events (SSE), or polling for real-time features -- chat, live dashboards, notifications, collaborative editing, stock tickers, live sports scores.
|
|
> **Prerequisites**: [core/assertions-and-waiting.md](assertions-and-waiting.md), [core/fixtures-and-hooks.md](fixtures-and-hooks.md)
|
|
|
|
## Quick Reference
|
|
|
|
```typescript
|
|
// Listen for WebSocket connections
|
|
page.on('websocket', (ws) => {
|
|
console.log('WebSocket opened:', ws.url());
|
|
|
|
ws.on('framesent', (frame) => console.log('Sent:', frame.payload));
|
|
ws.on('framereceived', (frame) => console.log('Received:', frame.payload));
|
|
ws.on('close', () => console.log('WebSocket closed'));
|
|
});
|
|
|
|
// Mock a WebSocket via route (Playwright 1.48+)
|
|
await page.routeWebSocket('**/ws', (ws) => {
|
|
ws.onMessage((message) => {
|
|
ws.send(JSON.stringify({ echo: message }));
|
|
});
|
|
});
|
|
```
|
|
|
|
## Patterns
|
|
|
|
### Observing WebSocket Traffic
|
|
|
|
**Use when**: You need to verify that your app sends and receives the correct WebSocket messages without modifying them.
|
|
**Avoid when**: You need to intercept or mock the messages. Use `routeWebSocket` instead.
|
|
|
|
**TypeScript**
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('chat message is sent over WebSocket', async ({ page }) => {
|
|
const messages: { direction: string; payload: string }[] = [];
|
|
|
|
page.on('websocket', (ws) => {
|
|
ws.on('framesent', (frame) => {
|
|
messages.push({ direction: 'sent', payload: String(frame.payload) });
|
|
});
|
|
ws.on('framereceived', (frame) => {
|
|
messages.push({ direction: 'received', payload: String(frame.payload) });
|
|
});
|
|
});
|
|
|
|
await page.goto('/chat');
|
|
await page.getByRole('textbox', { name: 'Message' }).fill('Hello!');
|
|
await page.getByRole('button', { name: 'Send' }).click();
|
|
|
|
// Wait for the message to appear in UI (confirms round-trip)
|
|
await expect(page.getByText('Hello!')).toBeVisible();
|
|
|
|
// Verify WebSocket traffic
|
|
const sentMessage = messages.find(
|
|
(m) => m.direction === 'sent' && m.payload.includes('Hello!')
|
|
);
|
|
expect(sentMessage).toBeDefined();
|
|
});
|
|
```
|
|
|
|
**JavaScript**
|
|
```javascript
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
test('chat message is sent over WebSocket', async ({ page }) => {
|
|
const messages = [];
|
|
|
|
page.on('websocket', (ws) => {
|
|
ws.on('framesent', (frame) => {
|
|
messages.push({ direction: 'sent', payload: String(frame.payload) });
|
|
});
|
|
ws.on('framereceived', (frame) => {
|
|
messages.push({ direction: 'received', payload: String(frame.payload) });
|
|
});
|
|
});
|
|
|
|
await page.goto('/chat');
|
|
await page.getByRole('textbox', { name: 'Message' }).fill('Hello!');
|
|
await page.getByRole('button', { name: 'Send' }).click();
|
|
|
|
await expect(page.getByText('Hello!')).toBeVisible();
|
|
|
|
const sentMessage = messages.find(
|
|
(m) => m.direction === 'sent' && m.payload.includes('Hello!')
|
|
);
|
|
expect(sentMessage).toBeDefined();
|
|
});
|
|
```
|
|
|
|
### Waiting for a Specific WebSocket Message
|
|
|
|
**Use when**: Your test depends on a particular server-pushed message before proceeding.
|
|
**Avoid when**: The UI already reflects the message. Assert on the UI instead.
|
|
|
|
**TypeScript**
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('wait for server acknowledgment over WebSocket', async ({ page }) => {
|
|
// Create a promise that resolves when we get the specific message
|
|
const ackPromise = new Promise<void>((resolve) => {
|
|
page.on('websocket', (ws) => {
|
|
ws.on('framereceived', (frame) => {
|
|
const data = JSON.parse(String(frame.payload));
|
|
if (data.type === 'message_ack') {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
await page.goto('/chat');
|
|
await page.getByRole('textbox', { name: 'Message' }).fill('Important update');
|
|
await page.getByRole('button', { name: 'Send' }).click();
|
|
|
|
// Wait for server to acknowledge
|
|
await ackPromise;
|
|
|
|
// Now verify the message shows a "delivered" checkmark
|
|
await expect(page.getByTestId('message-status').last()).toHaveText('Delivered');
|
|
});
|
|
```
|
|
|
|
**JavaScript**
|
|
```javascript
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
test('wait for server acknowledgment over WebSocket', async ({ page }) => {
|
|
const ackPromise = new Promise((resolve) => {
|
|
page.on('websocket', (ws) => {
|
|
ws.on('framereceived', (frame) => {
|
|
const data = JSON.parse(String(frame.payload));
|
|
if (data.type === 'message_ack') {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
await page.goto('/chat');
|
|
await page.getByRole('textbox', { name: 'Message' }).fill('Important update');
|
|
await page.getByRole('button', { name: 'Send' }).click();
|
|
|
|
await ackPromise;
|
|
await expect(page.getByTestId('message-status').last()).toHaveText('Delivered');
|
|
});
|
|
```
|
|
|
|
### Mocking WebSocket Messages with `routeWebSocket`
|
|
|
|
**Use when**: You want to control what the server sends to test specific UI states -- error messages, edge cases, high-volume data -- without a real backend.
|
|
**Avoid when**: You need to test actual server behavior. Use a real backend.
|
|
|
|
**TypeScript**
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('display notification when server pushes an alert', async ({ page }) => {
|
|
const wsRoute = await page.routeWebSocket('**/ws/notifications', (ws) => {
|
|
// Let the app send its initial handshake
|
|
ws.onMessage((message) => {
|
|
const data = JSON.parse(message);
|
|
if (data.type === 'subscribe') {
|
|
ws.send(JSON.stringify({ type: 'subscribed', channel: data.channel }));
|
|
}
|
|
});
|
|
|
|
// Push a notification after a short delay
|
|
setTimeout(() => {
|
|
ws.send(JSON.stringify({
|
|
type: 'notification',
|
|
title: 'Server Alert',
|
|
body: 'Deployment completed successfully',
|
|
severity: 'info',
|
|
}));
|
|
}, 500);
|
|
});
|
|
|
|
await page.goto('/dashboard');
|
|
|
|
// Verify the notification appears in the UI
|
|
await expect(page.getByRole('alert')).toContainText('Deployment completed successfully');
|
|
});
|
|
|
|
test('handle WebSocket server error gracefully', async ({ page }) => {
|
|
await page.routeWebSocket('**/ws', (ws) => {
|
|
// Immediately close with an error code
|
|
ws.close({ code: 1011, reason: 'Internal server error' });
|
|
});
|
|
|
|
await page.goto('/chat');
|
|
|
|
// App should show a reconnection message, not crash
|
|
await expect(page.getByText('Connection lost. Reconnecting...')).toBeVisible();
|
|
});
|
|
```
|
|
|
|
**JavaScript**
|
|
```javascript
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
test('display notification when server pushes an alert', async ({ page }) => {
|
|
await page.routeWebSocket('**/ws/notifications', (ws) => {
|
|
ws.onMessage((message) => {
|
|
const data = JSON.parse(message);
|
|
if (data.type === 'subscribe') {
|
|
ws.send(JSON.stringify({ type: 'subscribed', channel: data.channel }));
|
|
}
|
|
});
|
|
|
|
setTimeout(() => {
|
|
ws.send(JSON.stringify({
|
|
type: 'notification',
|
|
title: 'Server Alert',
|
|
body: 'Deployment completed successfully',
|
|
severity: 'info',
|
|
}));
|
|
}, 500);
|
|
});
|
|
|
|
await page.goto('/dashboard');
|
|
await expect(page.getByRole('alert')).toContainText('Deployment completed successfully');
|
|
});
|
|
|
|
test('handle WebSocket server error gracefully', async ({ page }) => {
|
|
await page.routeWebSocket('**/ws', (ws) => {
|
|
ws.close({ code: 1011, reason: 'Internal server error' });
|
|
});
|
|
|
|
await page.goto('/chat');
|
|
await expect(page.getByText('Connection lost. Reconnecting...')).toBeVisible();
|
|
});
|
|
```
|
|
|
|
### Forwarding with Modification (Man-in-the-Middle)
|
|
|
|
**Use when**: You want to connect to the real server but intercept, modify, or inject messages.
|
|
**Avoid when**: Full mocking (`routeWebSocket` without `connectToServer`) is sufficient.
|
|
|
|
**TypeScript**
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('inject a fake high-priority message into real stream', async ({ page }) => {
|
|
await page.routeWebSocket('**/ws/feed', (ws) => {
|
|
const server = ws.connectToServer();
|
|
|
|
// Forward all messages from server to client, but inject extras
|
|
server.onMessage((message) => {
|
|
ws.send(message); // Forward the real message
|
|
});
|
|
|
|
// Forward all client messages to server
|
|
ws.onMessage((message) => {
|
|
server.send(message);
|
|
});
|
|
|
|
// Inject a synthetic message after 1 second
|
|
setTimeout(() => {
|
|
ws.send(JSON.stringify({
|
|
type: 'alert',
|
|
priority: 'high',
|
|
text: 'Injected test alert',
|
|
}));
|
|
}, 1000);
|
|
});
|
|
|
|
await page.goto('/live-feed');
|
|
await expect(page.getByText('Injected test alert')).toBeVisible();
|
|
});
|
|
```
|
|
|
|
**JavaScript**
|
|
```javascript
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
test('inject a fake high-priority message into real stream', async ({ page }) => {
|
|
await page.routeWebSocket('**/ws/feed', (ws) => {
|
|
const server = ws.connectToServer();
|
|
|
|
server.onMessage((message) => {
|
|
ws.send(message);
|
|
});
|
|
|
|
ws.onMessage((message) => {
|
|
server.send(message);
|
|
});
|
|
|
|
setTimeout(() => {
|
|
ws.send(JSON.stringify({
|
|
type: 'alert',
|
|
priority: 'high',
|
|
text: 'Injected test alert',
|
|
}));
|
|
}, 1000);
|
|
});
|
|
|
|
await page.goto('/live-feed');
|
|
await expect(page.getByText('Injected test alert')).toBeVisible();
|
|
});
|
|
```
|
|
|
|
### Server-Sent Events (SSE) Testing
|
|
|
|
**Use when**: Your app uses `EventSource` for server-to-client streaming (live logs, progress updates, news feeds).
|
|
**Avoid when**: The app uses WebSockets. SSE is HTTP-based and intercepted differently.
|
|
|
|
SSE responses are standard HTTP -- intercept them with `page.route()` and return a streaming response.
|
|
|
|
**TypeScript**
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('SSE live log stream displays entries', async ({ page }) => {
|
|
// Intercept the SSE endpoint and return controlled events
|
|
await page.route('**/api/logs/stream', async (route) => {
|
|
const events = [
|
|
'data: {"level":"info","message":"Server started"}\n\n',
|
|
'data: {"level":"warn","message":"High memory usage"}\n\n',
|
|
'data: {"level":"error","message":"Connection timeout"}\n\n',
|
|
];
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
},
|
|
body: events.join(''),
|
|
});
|
|
});
|
|
|
|
await page.goto('/admin/logs');
|
|
|
|
await expect(page.getByText('Server started')).toBeVisible();
|
|
await expect(page.getByText('High memory usage')).toBeVisible();
|
|
await expect(page.getByText('Connection timeout')).toBeVisible();
|
|
});
|
|
|
|
test('SSE reconnection on connection drop', async ({ page }) => {
|
|
let requestCount = 0;
|
|
|
|
await page.route('**/api/events', async (route) => {
|
|
requestCount++;
|
|
if (requestCount === 1) {
|
|
// First request: send one event then close abruptly
|
|
await route.fulfill({
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/event-stream' },
|
|
body: 'data: {"msg":"first"}\n\n',
|
|
});
|
|
} else {
|
|
// Reconnection: send the next event
|
|
await route.fulfill({
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/event-stream' },
|
|
body: 'data: {"msg":"reconnected"}\n\n',
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.goto('/live');
|
|
await expect(page.getByText('first')).toBeVisible();
|
|
// EventSource auto-reconnects; verify the app handles it
|
|
await expect(page.getByText('reconnected')).toBeVisible({ timeout: 10000 });
|
|
});
|
|
```
|
|
|
|
**JavaScript**
|
|
```javascript
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
test('SSE live log stream displays entries', async ({ page }) => {
|
|
await page.route('**/api/logs/stream', async (route) => {
|
|
const events = [
|
|
'data: {"level":"info","message":"Server started"}\n\n',
|
|
'data: {"level":"warn","message":"High memory usage"}\n\n',
|
|
'data: {"level":"error","message":"Connection timeout"}\n\n',
|
|
];
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
},
|
|
body: events.join(''),
|
|
});
|
|
});
|
|
|
|
await page.goto('/admin/logs');
|
|
|
|
await expect(page.getByText('Server started')).toBeVisible();
|
|
await expect(page.getByText('High memory usage')).toBeVisible();
|
|
await expect(page.getByText('Connection timeout')).toBeVisible();
|
|
});
|
|
```
|
|
|
|
### Polling-Based Real-Time Testing
|
|
|
|
**Use when**: Your app uses HTTP polling (setInterval + fetch) instead of WebSockets or SSE.
|
|
**Avoid when**: The app uses WebSockets or SSE -- use the patterns above.
|
|
|
|
**TypeScript**
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('polling updates dashboard data every interval', async ({ page }) => {
|
|
let callCount = 0;
|
|
|
|
await page.route('**/api/dashboard/stats', async (route) => {
|
|
callCount++;
|
|
const data = callCount === 1
|
|
? { activeUsers: 100, revenue: 5000 }
|
|
: { activeUsers: 142, revenue: 5250 };
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(data),
|
|
});
|
|
});
|
|
|
|
await page.goto('/dashboard');
|
|
|
|
// First poll result
|
|
await expect(page.getByTestId('active-users')).toHaveText('100');
|
|
|
|
// Wait for the second poll to update the UI
|
|
await expect(page.getByTestId('active-users')).toHaveText('142', { timeout: 15000 });
|
|
|
|
// Verify at least 2 requests were made
|
|
expect(callCount).toBeGreaterThanOrEqual(2);
|
|
});
|
|
```
|
|
|
|
**JavaScript**
|
|
```javascript
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
test('polling updates dashboard data every interval', async ({ page }) => {
|
|
let callCount = 0;
|
|
|
|
await page.route('**/api/dashboard/stats', async (route) => {
|
|
callCount++;
|
|
const data = callCount === 1
|
|
? { activeUsers: 100, revenue: 5000 }
|
|
: { activeUsers: 142, revenue: 5250 };
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(data),
|
|
});
|
|
});
|
|
|
|
await page.goto('/dashboard');
|
|
await expect(page.getByTestId('active-users')).toHaveText('100');
|
|
await expect(page.getByTestId('active-users')).toHaveText('142', { timeout: 15000 });
|
|
expect(callCount).toBeGreaterThanOrEqual(2);
|
|
});
|
|
```
|
|
|
|
### WebSocket Connection Lifecycle
|
|
|
|
**Use when**: You need to verify that your app handles connection, disconnection, and reconnection properly.
|
|
**Avoid when**: Connection lifecycle is not user-visible.
|
|
|
|
**TypeScript**
|
|
```typescript
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('app reconnects after WebSocket drops', async ({ page }) => {
|
|
let connectionCount = 0;
|
|
|
|
await page.routeWebSocket('**/ws', (ws) => {
|
|
connectionCount++;
|
|
|
|
if (connectionCount === 1) {
|
|
// First connection: close after a brief moment
|
|
setTimeout(() => ws.close({ code: 1006, reason: 'Abnormal closure' }), 500);
|
|
} else {
|
|
// Second connection (reconnect): stay open and respond
|
|
ws.onMessage((message) => {
|
|
ws.send(JSON.stringify({ type: 'pong' }));
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.goto('/app');
|
|
|
|
// App detects disconnect and shows status
|
|
await expect(page.getByText('Reconnecting...')).toBeVisible();
|
|
|
|
// App reconnects and status returns to normal
|
|
await expect(page.getByText('Connected')).toBeVisible({ timeout: 10000 });
|
|
|
|
expect(connectionCount).toBe(2);
|
|
});
|
|
```
|
|
|
|
**JavaScript**
|
|
```javascript
|
|
const { test, expect } = require('@playwright/test');
|
|
|
|
test('app reconnects after WebSocket drops', async ({ page }) => {
|
|
let connectionCount = 0;
|
|
|
|
await page.routeWebSocket('**/ws', (ws) => {
|
|
connectionCount++;
|
|
|
|
if (connectionCount === 1) {
|
|
setTimeout(() => ws.close({ code: 1006, reason: 'Abnormal closure' }), 500);
|
|
} else {
|
|
ws.onMessage((message) => {
|
|
ws.send(JSON.stringify({ type: 'pong' }));
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.goto('/app');
|
|
await expect(page.getByText('Reconnecting...')).toBeVisible();
|
|
await expect(page.getByText('Connected')).toBeVisible({ timeout: 10000 });
|
|
expect(connectionCount).toBe(2);
|
|
});
|
|
```
|
|
|
|
## Decision Guide
|
|
|
|
| Scenario | Approach | Why |
|
|
|---|---|---|
|
|
| Verify app sends correct WS message | `page.on('websocket')` + `ws.on('framesent')` | Observe without intercepting |
|
|
| Verify app handles server push | `page.routeWebSocket()` with mock server | Full control over what the "server" sends |
|
|
| Test with real server but inject messages | `routeWebSocket` + `connectToServer()` | Man-in-the-middle: forward real traffic plus inject extras |
|
|
| Test SSE endpoint | `page.route()` with `text/event-stream` content type | SSE is HTTP -- standard route interception works |
|
|
| Test HTTP polling | `page.route()` with changing responses per call | Increment a counter; return different data each call |
|
|
| Verify reconnection logic | `routeWebSocket` that closes the first connection | Simulate server failure, verify the app retries |
|
|
| Test binary WebSocket data | `ws.on('framereceived')`, check `frame.payload` as Buffer | Binary frames arrive as `Buffer` in Node.js |
|
|
|
|
## Anti-Patterns
|
|
|
|
| Don't Do This | Problem | Do This Instead |
|
|
|---|---|---|
|
|
| `page.waitForTimeout(3000)` to wait for WS message | Arbitrary delay; flaky and slow | `await expect(page.getByText('msg')).toBeVisible()` or wait on a Promise |
|
|
| Directly construct `WebSocket` in `page.evaluate` | You lose Playwright's observation and routing capabilities | Let the app create its own WebSocket; intercept via `routeWebSocket` |
|
|
| Ignore WebSocket close codes in mocks | App may behave differently for 1000 (normal) vs 1006 (abnormal) | Use the correct close code: `ws.close({ code: 1000 })` |
|
|
| Test real-time features against live third-party servers | Flaky, slow, and may incur costs | Mock the WebSocket or SSE endpoint |
|
|
| Assert on raw WebSocket frame content in every test | Couples tests to wire protocol; breaks on payload format changes | Assert on the UI -- that is what users see |
|
|
| Forget to handle binary vs text frames | `frame.payload` can be `string` or `Buffer` | Check frame type or use `String(frame.payload)` consistently |
|
|
|
|
## Troubleshooting
|
|
|
|
| Symptom | Cause | Fix |
|
|
|---|---|---|
|
|
| `page.on('websocket')` never fires | WebSocket connects before the listener is attached | Register the listener before `page.goto()` |
|
|
| `routeWebSocket` does not intercept | URL pattern does not match the actual WebSocket URL | Check the URL in DevTools Network tab; update the glob pattern |
|
|
| SSE mock returns all events at once | `route.fulfill` sends the body synchronously | For true streaming, use the real server or chunk the response with pauses via `page.evaluate` |
|
|
| WebSocket messages arrive but UI does not update | App processes messages asynchronously; assertion runs too early | Use `await expect(...).toBeVisible()` which auto-retries |
|
|
| Binary frames show as garbled text | `String(frame.payload)` on binary data produces garbage | Treat `frame.payload` as `Buffer` and decode appropriately |
|
|
| Reconnection test is flaky | App has exponential backoff; timeout too short | Increase assertion timeout: `toBeVisible({ timeout: 15000 })` |
|
|
|
|
## Related
|
|
|
|
- [core/multi-user-and-collaboration.md](multi-user-and-collaboration.md) -- multi-user tests that rely on WebSocket for real-time sync
|
|
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- auto-retrying assertions for async UI updates
|
|
- [core/when-to-mock.md](when-to-mock.md) -- deciding when to mock WebSocket vs use real server
|
|
- [core/debugging.md](debugging.md) -- tracing WebSocket frames in Playwright traces
|