138 lines
8.8 KiB
Markdown
138 lines
8.8 KiB
Markdown
# 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`
|