In Playwright, how do you observe the frames a page sends and receives over a WebSocket?
answer
- Watching, not steering
- One page event per socket opened
- Two frame events, one payload field
- Attach before the navigation
- String or Buffer, never parsed for you
basics
~10 sPlaywright fires page.on('websocket') with a WebSocket object for each socket the page opens. Listen to that object's framesent and framereceived events and read frame.payload. The listener only watches; it cannot change or block traffic.
solid answer
~40 sAttach `page.on('websocket')`; Playwright calls it once per socket the page creates, handing you a `WebSocket` object. That object exposes `url()`, `isClosed()`, `waitForEvent()` and the events `framesent`, `framereceived`, `close` and `socketerror`. Each frame handler receives an object with a `payload` that is a `string` for text frames and a `Buffer` for binary ones. On a weather dashboard this is how you prove the page subscribed to the forecast channel and what the feed pushed back. Two rules matter in practice: attach the listener **before** the navigation that opens the socket, because the event is not retroactive, and register the frame handlers synchronously inside the callback. It is observation only — to answer or replace frames you need `page.routeWebSocket()` instead.
code
typescript · 18 linesimport { test, expect } from '@playwright/test';
test('forecast frames reach the dashboard', async ({ page }) => {
const received: string[] = [];
page.on('websocket', ws => {
if (!ws.url().includes('/forecast')) return;
ws.on('framesent', frame => console.log('subscribe frame:', frame.payload));
ws.on('framereceived', frame => {
if (typeof frame.payload === 'string') received.push(frame.payload);
});
});
await page.goto('/dashboard');
await expect(page.getByTestId('temperature')).toHaveText('21°C');
expect(received.some(p => p.includes('"temperature":21'))).toBe(true);
});go deeper
Remember the shape: page.on('websocket') gives you a WebSocket object, and its framesent and framereceived events carry the data in frame.payload. Attach it before the page navigates.
Explain why the event is not retroactive and why frame handlers must be registered synchronously inside the callback, and know that payload can be a string or a Buffer.
Show how you use it to diagnose a socket-driven UI: log ws.url() to find the real endpoint, harvest real payloads as fixtures, and assert on content rather than frame counts.
Decide where socket observation belongs in the strategy — a cheap contract check on the subscribe frame versus a full mock — and who owns keeping recorded fixtures honest as the feed evolves.
## What the event hands you `page.on('websocket')` is Playwright's read-only window onto a page's socket traffic. Each time the page constructs a `WebSocket`, the listener fires once with a `WebSocket` object standing for that one connection. On a weather dashboard whose temperature tiles only ever change because a third-party forecast feed pushed a frame, this is the difference between a test that can explain a stale tile and one that can only report that the number is wrong. The object is deliberately small: - `ws.url()` — the URL the page opened, so a dashboard running one socket for forecasts and another for severe-weather alerts can tell them apart. - `ws.on('framesent')` and `ws.on('framereceived')` — fired once per frame, the handler receiving an object with a `payload` field. - `ws.on('close')` — the connection ended. - `ws.on('socketerror')` — the connection errored, with the error text. - `ws.isClosed()` — a synchronous check of the current state. - `ws.waitForEvent(name, optionsOrPredicate)` — wait for one matching frame instead of collecting every frame yourself. `payload` is a `string` for text frames and a `Buffer` for binary frames, so code that JSON-parses blindly breaks the day a channel switches to binary. ## Subscription is not retroactive Events already emitted are gone. If the dashboard opens its socket during the first navigation and the listener is attached afterwards, the test sees nothing for that socket — not a truncated stream, an empty one. The same applies one level down: register `framereceived` inside the `websocket` callback synchronously, before any `await`, or the first frames slip past while the test is suspended. ```ts const frames: string[] = []; page.on('websocket', ws => { ws.on('framereceived', f => { if (typeof f.payload === 'string') frames.push(f.payload); }); }); await page.goto('/dashboard'); // the socket is created here ``` ## A collection pattern that holds up 1. Attach the `page.on('websocket')` listener before the navigation that opens the socket. 2. Filter on `ws.url()` so an unrelated analytics socket does not pollute the collection. 3. Push each `framereceived` payload into an array the test owns, then assert against that array with a polling assertion, so the check waits for the frame rather than guessing when it lands. ## What the listener cannot do Observation is the whole contract. There is no way to drop, delay, rewrite or answer a frame from here — the page talks to the real server and the test watches over its shoulder. | Question | `page.on('websocket')` | `page.routeWebSocket()` | |---|---|---| | Reaches the real server | Yes, always | Only after `connectToServer()` | | Can change the traffic | No | Yes — `send`, `onMessage`, `close` | | Typical use | Proving what the app exchanged | Making the app see a chosen frame | | Standing risk | Depends on a live feed | The mock drifts from the real feed | So the listener is the right tool for two jobs — verifying that the app subscribed with the payload you expect, and capturing real frames to turn into fixtures for a later mocked run — and the wrong tool for making the dashboard render a heatwave on demand. ## Where it fits in a suite - **Debugging.** Log `ws.url()` and every payload while writing a test; the socket host is frequently not the page's host, and seeing it saves an hour of guessing at patterns. - **Contract checks.** Assert on the subscribe frame the dashboard sends: the channel name, the units, the city id. That frame is the app's side of the bargain and it is cheap to pin. - **Fixture harvesting.** Record a real session's `framereceived` payloads to a file, then replay them from a route in the fast UI suite. ## Reading payloads without flake - Guard the type before parsing: `typeof frame.payload === 'string' ? JSON.parse(frame.payload) : frame.payload`. - Assert on content, not on frame counts; a feed is free to batch two updates into one frame or split one across two. - Treat payloads as raw application data — Playwright does not decode, buffer or replay them for you; parsing and matching are the test's job. - Keep the handler cheap. It runs on every frame, and a chatty feed can deliver hundreds during one assertion.
- What type is frame.payload, and how should a test handle it?It is a `string` for text frames and a `Buffer` for binary frames. Check the type before parsing: `typeof payload === 'string' ? JSON.parse(payload) : payload`. A feed that switches a channel to binary will otherwise throw inside the handler, which surfaces as an unrelated-looking test failure rather than a parse error at the assertion.
- The listener is attached but records nothing. What is the first thing you check?Whether the socket already existed when the listener was attached — the event is not retroactive, so a socket opened during an earlier `page.goto()` never fires it. Next, check that the frame handlers were registered synchronously inside the callback, and that any `ws.url()` filter actually matches the URL the page used.
It is a wiretap, not a switchboard: you hear every word both sides say, but you cannot cut in, hang up, or put words in anyone's mouth.
saying these in an interview costs you the question
- Thinks page.on('websocket') can block or rewrite frames
- Attaches the listener after the navigation and expects earlier frames
- Awaits inside the callback before registering frame handlers
- Calls JSON.parse on every payload without checking for a Buffer
- Asserts on an exact frame count from a live feed