Stubbing WebSocket Map Telemetry in Visual Tests
Substituting WebSocket with a class that emits a few messages is the version of this that works in a demo and fails in a real application. Production map clients reconnect on close, send heartbeats and expect pongs, negotiate a subprotocol, often speak binary rather than JSON, and almost always hydrate over HTTP before subscribing. A stub that ignores any one of those leaves the client either stuck in a retry loop or quietly falling back to the network. This procedure builds a stub that satisfies the whole contract.
This is a task within Live Data Overlay Stabilization, under Dynamic Element Masking & UI Stability. The install point is the context init script described in Playwright Event Hooks for Map Capture.
Prerequisites
Step-by-step procedure
1. Establish what the client expects from the socket
Read the client, not the server. Five properties matter, and each has a failure mode if the stub omits it.
| Property | If the stub omits it |
|---|---|
readyState transitions |
The client queues sends forever and never considers itself connected |
| Subprotocol echo | Strict clients close immediately and enter their retry loop |
| Heartbeat / pong | The client’s liveness timer fires and it reconnects mid-capture |
| Binary vs text frames | JSON.parse throws on an ArrayBuffer, or the decoder receives a string |
| Close semantics | An onclose that triggers a reconnect produces an endless loop of stubs |
2. Implement the full surface, not just onmessage
await context.addInitScript(({ frames, protocol }) => {
const OPEN = 1, CLOSED = 3;
class StubSocket extends EventTarget {
constructor(url, protocols) {
super();
this.url = url;
this.protocol = Array.isArray(protocols) ? protocols[0] : (protocols ?? protocol);
this.readyState = OPEN;
this.binaryType = 'blob';
this.bufferedAmount = 0;
queueMicrotask(() => this.#flush());
}
#flush() {
this.#emit(new Event('open'));
for (const frame of frames) {
this.#emit(new MessageEvent('message', { data: JSON.stringify(frame) }));
}
globalThis.__telemetryDrained = frames.length;
// deliberately NOT closing: a close would trigger the client's reconnect
}
#emit(event) {
this.dispatchEvent(event);
const handler = this['on' + event.type];
if (typeof handler === 'function') handler.call(this, event);
}
send() { /* heartbeats and subscriptions are accepted and discarded */ }
close() { this.readyState = CLOSED; }
}
globalThis.WebSocket = StubSocket;
globalThis.WebSocket.OPEN = OPEN;
globalThis.WebSocket.CLOSED = CLOSED;
}, { frames: TELEMETRY_FRAMES, protocol: 'telemetry.v2' });
Two details do most of the work. Dispatching to both addEventListener subscribers and the onmessage property covers clients that use either, and many use both in different code paths. And never closing the socket is what keeps the client out of its reconnect path — a stub that closes politely after delivering its frames is the single most common cause of an endless reconnect loop during a capture.
3. Match the frame encoding
A client that expects binary will call arrayBuffer() or read .byteLength on the payload, and a string breaks it in a way that surfaces as an empty layer rather than an error.
function emitFrame(socket, frame, binary) {
const encoder = new TextEncoder();
const payload = binary
? encoder.encode(JSON.stringify(frame)).buffer
: JSON.stringify(frame);
socket.dispatchEvent(new MessageEvent('message', { data: payload }));
}
If the real protocol is a compact binary format rather than encoded JSON, record the raw frames as base64 in the fixture and decode them in the stub, so the client’s own decoder stays under test — which is usually the point.
4. Stub the HTTP hydration too
Most telemetry clients fetch a snapshot before subscribing, and stubbing only the socket leaves that snapshot coming from the network. It is the most common reason a “stubbed” layer still varies between runs.
await context.route('**/api/telemetry/snapshot*', (route) =>
route.fulfill({ json: SNAPSHOT_FIXTURE })
);
5. Neutralise the liveness timer
Clients commonly reconnect if no message has arrived within an interval. With a frozen clock the interval may never elapse — which is convenient — but clients that use setTimeout rather than a clock comparison will still fire. Emitting a heartbeat frame at the end of the recorded window, matching whatever the client treats as liveness, avoids relying on that.
6. Expose a drain signal the readiness gate can read
globalThis.__telemetryDrained = frames.length;
and in the predicate:
const settled = () =>
globalThis.__telemetryDrained === EXPECTED &&
map.areTilesLoaded() && !map.isMoving() && quiet >= 2;
7. Assert the stub was actually used
A page that constructed a real WebSocket produces a plausible capture. One check removes the whole class of doubt.
expect(await page.evaluate(() => globalThis.WebSocket.name)).toBe('StubSocket');
Verification
Confirm the procedure worked before wiring it into a blocking gate:
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The layer flickers and the readiness gate never latches | The stub closes after delivering its frames and the client reconnects, restarting the cycle | Leave the stub open indefinitely; the test’s lifetime is the socket’s lifetime |
| The layer is empty and no error appears anywhere | The client expects binary frames and received strings, so its decoder returned nothing | Match the encoding, or record raw frames as base64 and decode them in the stub so the client’s decoder still runs |
| Deltas are deterministic but the starting state varies | Only the socket was stubbed; the client hydrates over HTTP first | Intercept the snapshot endpoint as in step 4 — this is the single most common omission |
Frequently asked questions
Should the stub replace the global, or should the application take an injected transport?
Injection is cleaner if the application already supports it, because the test then exercises the real client with a fake transport rather than a fake of the whole API. Replacing the global is the pragmatic option when it does not, and it has one advantage: it covers transports created by code the test does not know about, including a third-party widget that opens its own connection.
How faithful does the stub need to be?
As faithful as the client’s contract, and no more. The test for whether it is faithful enough is behavioural: the client connects once, considers itself connected, receives every frame, decodes them, and never reconnects. Anything beyond that — backpressure, protocol negotiation subtleties, close codes — is only worth implementing if the client observably depends on it.
Can the same stub drive several tests with different data?
Yes, and it should: keep the class in one shared init script and pass the frames as the script argument, which is what the examples here do. One implementation with per-test data avoids the situation where four specs each carry their own slightly different stub and only three of them handle binary frames.
What about server-sent events?
The same pattern with EventSource replaced instead, and one extra consideration: EventSource reconnects automatically by specification, so a stub that ends its stream will be reconnected by the browser even if the client does nothing. Keeping the stub open matters even more there than it does for sockets.
Related
- Up to Live Data Overlay Stabilization, and the section Dynamic Element Masking & UI Stability.
- Freezing Real-Time Vehicle Position Layers for Tests — what to do with the frames once they are delivered deterministically.
- Playwright Event Hooks for Map Capture — where the substitution has to be installed.
- Intercepting Vector Tile Requests with Playwright route — the HTTP interception the hydration step reuses.