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.

A stub that closes politely puts the client into its reconnect loop Two sequences are compared. In the first, the stub opens, delivers its recorded frames and then closes; the client's onclose handler schedules a reconnect, a fresh stub is constructed, the frames are delivered again, and the cycle repeats, so the layer re-renders continuously and the readiness gate never settles. In the second, the stub opens, delivers its frames and stays open indefinitely; the client has nothing to reconnect to, the layer reaches a stable state, and the readiness predicate latches. A caption notes that the same trap applies to any transport whose client treats disconnection as a recoverable event. Never close the stub — a close is an instruction to reconnect stub closes open frames close reconnect open the layer re-renders forever and the gate never settles stub stays open open frames idle, still open the layer settles and the readiness predicate latches

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.

Five parts of the socket contract, and what a stub that omits each one produces Five contract properties are listed with the observable failure that follows from omitting them. Missing readyState transitions leave the client queueing sends forever and never considering itself connected. A missing subprotocol echo makes strict clients close immediately and enter a retry loop. A missing heartbeat or pong lets the liveness timer fire and reconnect mid-capture. A frame encoding mismatch makes the decoder throw or return nothing, which renders as an empty layer rather than an error. And close semantics that trigger reconnection produce an endless cycle of stubs. Every omitted property has its own observable failure readyState transitions sends queue forever, never “connected” subprotocol echo strict clients close and retry heartbeat / pong liveness timer reconnects mid-capture frame encoding decoder throws → empty layer, no error close semantics endless reconnect cycle

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');
Both paths a telemetry client uses, and what stubbing only one leaves behind A telemetry client is shown fetching an initial snapshot over HTTP and then subscribing to a socket for deltas. Both feed the same application state and the same rendered layer. When only the socket is stubbed, the HTTP path is highlighted as still reaching the network, so the layer's baseline state is whatever the environment held at that moment while the deltas are deterministic — producing a capture that is partly stable and therefore harder to diagnose than one that is entirely unstable. Stub both paths, or the baseline state still comes from the network HTTP snapshot still on the network socket deltas stubbed, deterministic application state rendered layer a partly stable capture is harder to diagnose than a completely unstable one

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.