Testland
Browse all skills & agents

server-sent-events-tests

Test Server-Sent Events (SSE) flows, one-way server-to-client push only (not bidirectional, use websocket-tests for client-to-server messaging): `EventSource` API on the browser side (`onmessage`, `onerror`, `readyState` 0/1/2), event stream format (`data:`, `event:`, `id:`, `retry:`), `Last-Event-ID` reconnect-with-replay header, content-type `text/event-stream`, and HTTP/1.1 connection-pool limits. Use Playwright for browser-side, raw HTTP client for server-side stream tests. Use when a feature pushes updates over `text/event-stream` and the reconnect interval, `Last-Event-ID` replay, or per-origin connection ceiling has no coverage.

Install with skills.sh (any agent)

npx skills add testland/qa --skill server-sent-events-tests
View source

server-sent-events-tests

Tests the SSE surfaces per the WHATWG SSE spec (opens in new window): stream format, readyState lifecycle, Last-Event-ID reconnect-with-replay, and the HTTP/1.1 connection-pool ceiling.

When to use

  • Real-time UI updates that don't need bidirectional communication (notifications, log tailers, build status, score tickers).
  • Pre-deploy gate: SSE retry interval, replay via Last-Event-ID, and HTTP/1.1 connection limits all behave as designed.

Step 1 - Server-side event stream format

Per the WHATWG SSE spec (opens in new window), response must use Content-Type: text/event-stream (UTF-8) and stream lines:

FieldMeaning
data:Appends to message payload (multiple data: lines join with newlines)
event:Custom event type (default = message)
id:Sets last event ID for reconnect replay
retry:Reconnect interval (ms)
:Comment line (kept-alive heartbeat)

Empty line ends a message. Example:

event: order_update
id: 142
data: {"orderId":"o123","status":"shipped"}

event: order_update
id: 143
data: {"orderId":"o124","status":"shipped"}

Step 2 - Browser test (Playwright)

import { test, expect } from '@playwright/test';

test('client receives server-pushed events', async ({ page }) => {
  await page.goto('https://localhost:3000/dashboard');

  const events = await page.evaluate(() => {
    return new Promise<any[]>((resolve) => {
      const collected: any[] = [];
      const es = new EventSource('/api/orders/stream');
      es.addEventListener('order_update', (e: any) => {
        collected.push(JSON.parse(e.data));
        if (collected.length === 2) {
          es.close();
          resolve(collected);
        }
      });
    });
  });

  expect(events).toHaveLength(2);
  expect(events[0].orderId).toBe('o123');
});

Step 3 - readyState lifecycle

Per the WHATWG SSE spec (opens in new window), readyState values:

ValueState
0CONNECTING
1OPEN
2CLOSED
test('readyState transitions through CONNECTING → OPEN', async ({ page }) => {
  await page.goto('https://localhost:3000/dashboard');

  const transitions = await page.evaluate(() => {
    return new Promise<number[]>((resolve) => {
      const states: number[] = [];
      const es = new EventSource('/api/stream');
      states.push(es.readyState); // 0
      es.onopen = () => {
        states.push(es.readyState); // 1
        es.close();
        states.push(es.readyState); // 2
        resolve(states);
      };
    });
  });

  expect(transitions).toEqual([0, 1, 2]);
});

Deeper recipes

Reconnect-with-replay via Last-Event-ID, the retry: interval, 204 No Content disable, and the HTTP/1.1 connection-pool ceiling are in references/sse-test-recipes.md.

Anti-patterns

Anti-patternWhy it failsFix
Wrong content-typeBrowser doesn't recognize as SSEContent-Type: text/event-stream (Step 1)
Skip newline-newline message terminatorBrowser buffers indefinitelyAlways end messages with \n\n (Step 1)
No id: on eventsLast-Event-ID replay impossibleAlways emit id: (see references)
Multiple EventSource per page on HTTP/1.1Connection pool starvationOne stream + multiplex via event: (see references)
Use SSE for two-way commsOne-way only; need WebSocket for client→serverUse websocket-tests skill instead

Limitations

  • SSE is HTTP-only; some intermediaries (legacy proxies) buffer responses, breaking real-time push.
  • No native binary support; SSE is text-only (use WebSocket if binary needed).
  • Reconnect uses Last-Event-ID only - server must persist event IDs (or generate from timestamp) for replay to work.

References

SSE test recipes

Deeper recipes for reconnect-with-replay, retry interval, 204 disable, and the HTTP/1.1 connection-pool ceiling. All behavior is per the WHATWG SSE spec (opens in new window).

Reconnect-with-replay via Last-Event-ID

On disconnect the client automatically reconnects with Last-Event-ID: <last-id-seen>. The server uses it to replay missed events.

Server pseudocode:

def stream(request):
    last_id = int(request.headers.get("Last-Event-ID", "0"))
    for evt in fetch_events_since(last_id):
        yield f"id: {evt.id}\nevent: {evt.type}\ndata: {evt.json()}\n\n"

Test (raw HTTP client, simulates reconnect):

import requests

# parse_until_count(r, n): read the stream until n complete events parse, return them
def test_replay_via_last_event_id():
    # First connection - read 5 events, then close
    with requests.get("http://localhost:8080/stream", stream=True) as r:
        events = parse_until_count(r, 5)
        last_id = events[-1]["id"]

    # Reconnect with Last-Event-ID
    headers = {"Last-Event-ID": last_id}
    with requests.get("http://localhost:8080/stream", stream=True, headers=headers) as r:
        replay = parse_until_count(r, 1)
        assert int(replay[0]["id"]) > int(last_id)

Verify: assert the reconnect request carries Last-Event-ID and the first replayed id is greater than the last one seen; if it is not, the server is not persisting event IDs - fix the store before relying on replay.

Reconnect interval (retry:)

Server hints at the reconnect interval:

retry: 10000

The browser waits >= 10s before reconnecting. Test that it honors the hint:

test('client honors retry: 10000 on disconnect', async ({ page }) => {
  // Server emits retry: 10000, then closes
  const reconnectMs = await page.evaluate(() => {
    return new Promise<number>((resolve) => {
      const es = new EventSource('/api/stream-with-retry');
      let openTime = 0;
      es.onopen = () => {
        if (openTime === 0) {
          openTime = performance.now();
        } else {
          es.close();
          resolve(performance.now() - openTime);
        }
      };
    });
  });
  // Allow +/-20% slack
  expect(reconnectMs).toBeGreaterThanOrEqual(8000);
  expect(reconnectMs).toBeLessThanOrEqual(12000);
});

Disable reconnect via 204 No Content

A server responding 204 No Content disables further reconnection. Useful for "subscription ended" scenarios:

def stream(request):
    if user_unsubscribed(request):
        return Response(status=204)
    # ... event stream ...

Test the client gives up:

test('client stops reconnecting after server returns 204', async ({ page }) => {
  // Server returns 204 immediately
  const states = await page.evaluate(() => {
    return new Promise<number[]>((resolve) => {
      const es = new EventSource('/api/stream-204');
      const seen: number[] = [];
      const interval = setInterval(() => seen.push(es.readyState), 100);
      setTimeout(() => {
        clearInterval(interval);
        resolve(seen);
      }, 2000);
    });
  });
  expect(states[states.length - 1]).toBe(2); // CLOSED
});

HTTP/1.1 connection-pool ceiling

Browsers cap concurrent HTTP/1.1 connections per origin (~6 in Chrome). SSE consumes one persistently - apps with many EventSource connections starve.

test('app uses single EventSource for fan-out', async ({ page }) => {
  await page.goto('https://localhost:3000/dashboard');

  const eventSourceCount = await page.evaluate(() =>
    performance.getEntriesByType('resource')
      .filter((r) => r.name.includes('/api/stream'))
      .length
  );
  expect(eventSourceCount).toBe(1);
});

HTTP/2 / HTTP/3 lift this limit but verify your CDN supports it end-to-end.

Related skills

grpc-streaming-tests

Test gRPC streaming RPCs - Server-streaming (server returns sequence), Client-streaming (client sends sequence), Bidirectional (both sides stream independently). Cover deadline + cancellation + flow control + status codes (CANCELLED, DEADLINE_EXCEEDED) + metadata. Use ghz for load, grpcurl for ad-hoc, language-native test stubs for unit/integration. Use when a service exposes server-, client-, or bidirectional-streaming RPCs and deadline, cancellation, or partial-stream status-code behavior is unverified.

mqtt-tests

Test MQTT v5.0 with Mosquitto broker in CI + paho-mqtt clients - QoS 0 / 1 / 2 delivery semantics, retained messages, Last Will and Testament (LWT), shared subscriptions ($share/group/topic), $SYS topic introspection. Critical for IoT, embedded, and M2M systems where wire-level guarantees matter. Use when a product speaks MQTT on the wire and QoS 1 / 2 redelivery, retained-message state, or LWT behavior needs a broker-backed test - including smoke-testing a new broker auth / ACL / persistence config.

sse-load-tests

Load-tests SSE endpoints at scale with k6 - measures concurrent-stream capacity, connection churn, and server memory pressure. Covers the HTTP/1.1 6-connection-per-origin browser ceiling vs HTTP/2 multiplexing, a custom k6 SSE client built on ReadableStream, and threshold gates for TTFB and data throughput. Use when validating whether a server can sustain N concurrent EventSource connections without connection starvation or memory growth.

stomp-amqp-tests

Tests STOMP over WebSocket (Spring, ActiveMQ, RabbitMQ Web STOMP) and AMQP 0-9-1 (RabbitMQ Java client) - frame connect/subscribe/send/ack sequences, ack modes (auto/client/client-individual), exchange and queue declarations, binding routing, Testcontainers RabbitMQ broker, and delivery assertion. Use when validating enterprise Spring or RabbitMQ messaging stacks before deploy.

webhook-replay-tests

Tests inbound webhook receivers for replay-attack resistance: capture incoming webhook payloads + headers, replay against the receiver under test, validate the Standard Webhooks signature scheme (svix-id + svix-timestamp + svix-signature, HMAC-SHA256 over `{id}.{timestamp}.{payload}`), svix-id idempotency dedup, and 5-minute timestamp-window enforcement by signing fixtures at runtime. Does NOT cover outbound delivery, retry-on-5xx, or failure-event exhaustion - those belong to an outbound webhook delivery harness. Use when testing the receiving side of a webhook integration.

websocket-tests

Test WebSocket protocol behavior - opening handshake (HTTP Upgrade with Sec-WebSocket-Key + Sec-WebSocket-Version: 13), control frames (ping 0x9 / pong 0xA / close 0x8), close-frame status codes (1000 normal, 1001 going-away, 1006 abnormal, 1011 server error), subprotocol negotiation, backpressure, and reconnect with jitter. Works with ws (Node), websockets (Python), or Playwright frame inspection per language. Use when a feature holds a long-lived WebSocket open and reconnect, close-code, or backpressure behavior is unverified.