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 withintimeoutMs - 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 observesmexactly once andserver.messagescontainsm
Verified by: src/transports/memory-transport/memory-transport.spec-conformance.test.ts
Scenario: Clock-driven latency
- WHEN
options.latencyMs = 50and the test advances the injectedFakeClockby less than 50 ms after asend - 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.sentMessagescontainsdataand the adapter does NOT auto-echo the payload back toonmessage
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
onmessagefires withd
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.5and a seededRng - THEN
stats.droppedMessages + stats.sentMessages === 1000andstats.droppedMessageslies within the seededRng'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: 100with no jitter and no loss - THEN each delivery is delayed exactly 100 ms on the injected
ClockANDstats.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.regionANDais 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
latencyMsdefaults to0
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 emitspeer:joinedwith payloadp - 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.tsruns - THEN no
@suaveplan/network-testingtest 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-testingruns - THEN statements / branches / functions / lines all report 100% on
provider: 'istanbul'
Verified by: bunx turbo test:coverage --filter=@suaveplan/network-testing