Files
suaveplan/openspec/specs/network-testing/spec.md
2026-05-27 23:29:53 +01:00

8.8 KiB

network-testing

Purpose

The @suaveplan/network-testing package provides universal, runner-agnostic primitives for testing networked code without bringing up real sockets or peers: an in-memory bidirectional transport pair, a FakeWebSocketAdapter injectable into any globalThis.WebSocket-shaped client, a ConditionSimulator that wraps any transport and injects deterministic latency, jitter, and packet-loss via an injectable Clock + Rng, a fluent TestPeer factory producing shapes compatible with @suaveplan/network-registry and @suaveplan/network-bootstrap, and a NetworkEventCapture that wraps any EventBusLike for waitFor / assertEventEmitted / assertEventCount assertions. Every public surface is dependency-injected for determinism, Zod-validated at the boundary, threaded with AbortSignal, and instrumented with @opentelemetry/api no-op-safe spans/counters.

Requirements

Requirement: Structured error registry

The package SHALL export a NetworkTestError extending SuaveplanError and a frozen NETWORK_TEST_CODES registry built via defineCodes("NET_TEST", […]) containing exactly TIMEOUT, EVENT_NOT_EMITTED, and CONNECT_FAILED. Every failure path that escapes a public surface MUST throw a NetworkTestError carrying one of these codes — never a bare Error.

Scenario: Wait-for timeout

  • WHEN NetworkEventCapture.waitFor(event, timeoutMs) is called for an event that never fires within timeoutMs
  • THEN the returned promise rejects with NetworkTestError(NET_TEST.TIMEOUT)

Verified by: src/assertions/event-capture/event-capture.spec-conformance.test.ts

Scenario: Missing event assertion

  • WHEN assertEventEmitted(event, predicate?) is called and no captured event matches
  • THEN it throws NetworkTestError(NET_TEST.EVENT_NOT_EMITTED)

Verified by: src/assertions/event-capture/event-capture.spec-conformance.test.ts

Requirement: In-memory transport pair

createMemoryTransport(options?) SHALL return a MemoryTransportPair with two MemoryTransport ends (client / server) that share a single in-process queue: client.send(m) delivers m to handlers registered on server.on("message", …) and pushes the message into server.messages (and symmetrically server→client). connect() and disconnect() MUST toggle both ends' state atomically and fire registered lifecycle handlers exactly once per transition. When options.latencyMs > 0, delivery MUST be scheduled through the injected Clock so tests advance time deterministically.

Scenario: Bidirectional delivery

  • WHEN the pair is connected and client.send(m) resolves
  • THEN every server.on("message", h) subscriber observes m exactly once and server.messages contains m

Verified by: src/transports/memory-transport/memory-transport.spec-conformance.test.ts

Scenario: Clock-driven latency

  • WHEN options.latencyMs = 50 and the test advances the injected FakeClock by less than 50 ms after a send
  • THEN no handler observes the message; advancing past 50 ms releases delivery

Verified by: src/transports/memory-transport/memory-transport.spec-conformance.test.ts

Requirement: Fake WebSocket adapter

createFakeWebSocketAdapter(options?) SHALL return an adapter whose connect(url, protocols?) produces a FakeWebSocketHandle exposing readyState, sentMessages (a read-only capture of every send invocation), and the four lifecycle callbacks (onopen / onclose / onmessage / onerror). simulateMessage / simulateError / simulateClose MUST fan out to every live handle. With autoConnect: true (default), onopen MUST fire synchronously after connect.

Scenario: Send capture

  • WHEN a handle calls send(data)
  • THEN handle.sentMessages contains data and the adapter does NOT auto-echo the payload back to onmessage

Verified by: src/transports/fake-websocket/fake-websocket.spec-conformance.test.ts

Scenario: Multi-handle broadcast

  • WHEN two handles are open and adapter.simulateMessage(d) is called
  • THEN each handle's onmessage fires with d

Verified by: src/transports/fake-websocket/fake-websocket.spec-conformance.test.ts

Requirement: Condition simulator

createConditionSimulator(conditions) SHALL return a ConditionSimulator whose wrap(transport) returns a structurally-compatible proxy that intercepts send and applies — in order — packet-drop sampling via the injected Rng (skip when rng() < packetLossRate), latency sampling from the configured number | LatencyDistribution, and ±jitterMs offset, before delegating to the underlying transport.send on the injected Clock. setConditions(partial) MUST apply atomically to subsequent sends; reset() MUST restore the original conditions AND zero the stats. The exposed stats SHALL track sentMessages, droppedMessages, totalLatencyMs, and avgLatencyMs.

Scenario: Packet drop probability

  • WHEN 1000 messages are sent through a wrap with packetLossRate: 0.5 and a seeded Rng
  • THEN stats.droppedMessages + stats.sentMessages === 1000 and stats.droppedMessages lies within the seeded Rng's deterministic expected band

Verified by: src/simulators/condition-simulator/condition-simulator.spec-conformance.test.ts

Scenario: Latency accounting

  • WHEN messages are sent under latencyMs: 100 with no jitter and no loss
  • THEN each delivery is delayed exactly 100 ms on the injected Clock AND stats.avgLatencyMs === 100

Verified by: src/simulators/condition-simulator/condition-simulator.spec-conformance.test.ts

Requirement: Test peer factory

createTestPeer(options?) SHALL return an immutable-per-call fluent builder: withCapabilities, withRegion, and withHealth return new TestPeer instances rather than mutating the receiver. id and address MUST auto-generate (test-peer-${n} / 127.0.0.1:${port}) when not supplied. asRegistryEntry() and asBootstrapSeed() MUST return values structurally compatible with @suaveplan/network-registry RegistryEntry and @suaveplan/network-bootstrap SeedConfig.

Scenario: Fluent immutability

  • WHEN const a = createTestPeer(); const b = a.withRegion("eu-west")
  • THEN a.region !== b.region AND a is unmodified

Verified by: src/factories/test-peer/test-peer.spec-conformance.test.ts

Scenario: Default health latency

  • WHEN peer.withHealth(true) is called without a second argument
  • THEN the resulting peer's latencyMs defaults to 0

Verified by: src/factories/test-peer/test-peer.spec-conformance.test.ts

Requirement: Network event capture

createNetworkEventCapture(eventBus) SHALL subscribe to every topic on the supplied EventBusLike and push each event into a events array in arrival order. waitFor(event, timeoutMs?) MUST return a promise that resolves with the next matching event payload or rejects with NetworkTestError(NET_TEST.TIMEOUT). assertEventEmitted(event, predicate?) MUST throw NetworkTestError(NET_TEST.EVENT_NOT_EMITTED) when no captured event matches. assertEventCount(event, count) MUST throw when the captured count differs. clear() MUST empty events without unsubscribing.

Scenario: Wait resolves on subsequent emit

  • WHEN waitFor("peer:joined") is awaited and the bus later emits peer:joined with payload p
  • THEN the promise resolves to p

Verified by: src/assertions/event-capture/event-capture.spec-conformance.test.ts

Scenario: Clear preserves subscription

  • WHEN capture.clear() is called and the bus then emits a new event
  • THEN the new event appears in capture.events

Verified by: src/assertions/event-capture/event-capture.spec-conformance.test.ts

Requirement: Test runner neutrality

Every test file in the package SHALL import runner primitives (describe / it / expect / vi / lifecycle hooks) exclusively from @suaveplan/testing/runner. Direct imports from vitest or bun:test are forbidden.

Scenario: Repo-wide runner-import gate

  • WHEN bun scripts/lint/check-test-runner-imports.ts runs
  • THEN no @suaveplan/network-testing test file is reported

Verified by: scripts/lint/check-test-runner-imports.ts (repo-wide gate)

Requirement: Quality gates

The package SHALL pass bunx turbo lint typecheck test --filter=@suaveplan/network-testing and SHALL meet 100% Istanbul coverage under bunx turbo test:coverage --filter=@suaveplan/network-testing on every touched module. Every it/test MUST carry an explicit { timeout } option.

Scenario: Coverage gate

  • WHEN bunx turbo test:coverage --filter=@suaveplan/network-testing runs
  • THEN statements / branches / functions / lines all report 100% on provider: 'istanbul'

Verified by: bunx turbo test:coverage --filter=@suaveplan/network-testing