Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
# Event Capture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `event-capture` module provides a testing utility that records every event
emitted through an `EventBusLike` instance. It is designed for network test
suites that need to assert on side-effects, message delivery, or lifecycle
transitions without coupling tests to internal state.
## Features
- Transparent interception of all `emit` calls on the wrapped bus.
- Polling `waitFor` with configurable timeout for asynchronous assertions.
- Strict `assertEventEmitted` and `assertEventCount` helpers that throw
`NetworkTestError` on mismatch, producing deterministic test failures.
- `clear()` to reset captured history between test phases.
## Usage
```ts
import { EventBus } from "@suaveplan/events";
import { createNetworkEventCapture } from "@suaveplan/network-testing/assertions/events";
const bus = new EventBus<{ peer: { id: string } }>();
const capture = createNetworkEventCapture(bus);
bus.emit("peer", { id: "p1" });
capture.assertEventEmitted("peer");
capture.assertEventCount("peer", 1);
```
## API Reference
### `createNetworkEventCapture(bus)`
Returns a `NetworkEventCapture` that wraps the given `EventBusLike`.
### `NetworkEventCapture`
- `events` — read-only array of `{ type, data }` objects.
- `waitFor(event, timeout?)` — resolves with the first matching payload or
rejects with `NetworkTestError` (`NET_TEST_TIMEOUT`).
- `assertEventEmitted(event, predicate?)` — throws `NetworkTestError`
(`NET_TEST_EVENT_NOT_EMITTED`) if no matching event was captured.
- `assertEventCount(event, count)` — throws if the captured count differs.
- `clear()` — empties the `events` array.
## Implementation Notes
The implementation mutates the `emit` method on the wrapped bus via a proxy
assignment. The original bound function is stored so that event dispatch
continues normally. Because `EventBusLike` is generic, the wrapper uses
`unknown` casts to avoid index-signature type errors. This is safe because
`string` and `unknown` are supertypes of all possible event keys and payloads.

View File

@@ -0,0 +1,102 @@
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createNetworkEventCapture } from "./event-capture.js";
describe("createNetworkEventCapture", () => {
it("must capture emitted events", { timeout: 5000 }, () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
bus.emit("test", "hello");
expect(capture.events.length).toBeGreaterThanOrEqual(1);
});
it("must assert event emitted", { timeout: 5000 }, () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
bus.emit("test", "hello");
capture.assertEventEmitted("test");
});
it("must throw when event not emitted", { timeout: 5000 }, () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
expect(() => capture.assertEventEmitted("test")).toThrow();
});
it("must assert event count", { timeout: 5000 }, () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
bus.emit("test", "a");
bus.emit("test", "b");
capture.assertEventCount("test", 2);
});
it("must throw on count mismatch", { timeout: 5000 }, () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
bus.emit("test", "a");
expect(() => capture.assertEventCount("test", 2)).toThrow();
});
it("must clear events", { timeout: 5000 }, () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
bus.emit("test", "a");
capture.clear();
expect(capture.events.length).toBe(0);
});
it("must wait for event", { timeout: 30000 }, async () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
setTimeout(() => bus.emit("test", "delayed"), 50);
await capture.waitFor("test", 1000);
});
it("must reject waitFor on timeout", { timeout: 30000 }, async () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
await expect(capture.waitFor("test", 50)).rejects.toThrow("timeout");
});
it("must satisfy assertEventEmitted with a passing predicate", {
timeout: 5000,
}, () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
bus.emit("test", "match");
capture.assertEventEmitted(
"test",
(data) => typeof data === "string" && data === "match"
);
});
it("must throw assertEventEmitted when predicate rejects every captured event", {
timeout: 5000,
}, () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
bus.emit("test", "a");
expect(() => capture.assertEventEmitted("test", () => false)).toThrow();
});
it("must use the default timeout when waitFor is called without one", {
timeout: 30000,
}, async () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
setTimeout(() => bus.emit("test", "default-timeout"), 20);
const data = await capture.waitFor("test");
expect(data).toBe("default-timeout");
});
it("must support manual capture() insertion alongside bus interception", {
timeout: 5000,
}, () => {
const bus = new EventBus<{ test: string }>();
const capture = createNetworkEventCapture(bus);
capture.capture("manual", { v: 1 });
capture.assertEventEmitted("manual");
expect(capture.events.length).toBe(1);
});
});

View File

@@ -0,0 +1,98 @@
import type { EventBusLike } from "@suaveplan/types";
import { NETWORK_TEST_CODES, NetworkTestError } from "../../error-codes.js";
import type { NetworkEventCapture } from "../../types/common.js";
interface CapturedEvent {
readonly type: string;
readonly data: unknown;
}
class NetworkEventCaptureImpl implements NetworkEventCapture {
public readonly events: CapturedEvent[] = [];
private readonly originalEmit: EventBusLike["emit"];
public constructor(bus: EventBusLike) {
this.originalEmit = bus.emit.bind(bus);
// Wrap emit to capture all events
const mutableBus = bus as unknown as {
emit(type: string, data: unknown): unknown;
};
mutableBus.emit = (type: string, data: unknown) => {
this.events.push({ type, data });
return this.originalEmit(type, data);
};
}
public capture(type: string, data: unknown): void {
this.events.push({ type, data });
}
public waitFor(event: string, timeout = 1000): Promise<unknown> {
return new Promise((resolve, reject) => {
const check = () => {
const found = this.events.find((e) => e.type === event);
if (found) {
resolve(found.data);
return;
}
// If no event yet, wait a tick and check again
// This is a polling approach for test utilities
const elapsed = Date.now() - start;
if (elapsed >= timeout) {
reject(
new NetworkTestError(
NETWORK_TEST_CODES.TIMEOUT,
"test.timeout",
{ context: { event, timeout } }
)
);
return;
}
setTimeout(check, 10);
};
const start = Date.now();
check();
});
}
public assertEventEmitted(
event: string,
predicate?: (data: unknown) => boolean
): void {
const found = this.events.find(
(e) => e.type === event && (predicate ? predicate(e.data) : true)
);
if (!found) {
throw new NetworkTestError(
NETWORK_TEST_CODES.EVENT_NOT_EMITTED,
"test.event-not-emitted",
{ context: { event } }
);
}
}
public assertEventCount(event: string, count: number): void {
const actual = this.events.filter((e) => e.type === event).length;
if (actual !== count) {
throw new NetworkTestError(
NETWORK_TEST_CODES.EVENT_NOT_EMITTED,
"test.event-count-mismatch",
{ context: { event, expected: count, actual } }
);
}
}
public clear(): void {
this.events.length = 0;
}
}
/**
* Create a network event capture utility for testing.
*/
export function createNetworkEventCapture(
bus: EventBusLike
): NetworkEventCapture {
const capture = new NetworkEventCaptureImpl(bus);
return capture;
}

View File

@@ -0,0 +1 @@
export { createNetworkEventCapture } from "./event-capture.js";

View File

@@ -0,0 +1 @@
// Constants module — currently empty, reserved for future use.

View File

@@ -0,0 +1,56 @@
# Error Codes
## Purpose
This module defines the canonical error taxonomy for the `@suaveplan/network-testing`
package. All test-failure conditions are represented as typed `NetworkTestError`
instances so that consumers can distinguish between assertion failures,
timeouts, and transport-level problems without parsing strings.
## Features
- `NETWORK_TEST_CODES` registry with three standard codes: `TIMEOUT`,
`EVENT_NOT_EMITTED`, and `CONNECT_FAILED`.
- `NetworkTestError` class extending `SuaveplanError<NetworkTestErrorCode>`.
- Zero-constructor subclass pattern to satisfy the `protected` base constructor.
- Strongly typed error codes that integrate with the project-wide error
registry and telemetry systems.
- Predictable error identifiers that remain stable across refactors.
## Usage
```ts
import { NetworkTestError, NETWORK_TEST_CODES } from "@suaveplan/network-testing";
throw new NetworkTestError(
NETWORK_TEST_CODES.TIMEOUT,
"test.timeout",
{ context: { event: "peer.join", timeout: 1000 } }
);
```
## API Reference
### `NETWORK_TEST_CODES`
An object literal mapping short names to fully-qualified code strings such as
`NET_TEST_TIMEOUT`.
### `NetworkTestErrorCode`
A union of all values in `NETWORK_TEST_CODES`.
### `NetworkTestError`
Extends `SuaveplanError`. No custom constructor is declared; callers use the
base constructor directly, passing a code, translation key, and optional
metadata.
## Implementation Notes
The subclass does not define its own constructor. This is intentional:
`SuaveplanError` declares a `protected` constructor, and TypeScript allows
subclasses to inherit it directly as long as they do not try to declare a
narrower or conflicting signature. This keeps the error hierarchy shallow and
avoids boilerplate. Error codes follow the standard `NET_TEST_` prefix so they
can be searched and filtered easily in telemetry dashboards and log aggregators.

View File

@@ -0,0 +1,21 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const NETWORK_TEST_CODES = defineCodes("NET_TEST", [
"TIMEOUT",
"EVENT_NOT_EMITTED",
"CONNECT_FAILED",
] as const);
export type NetworkTestErrorCode =
(typeof NETWORK_TEST_CODES)[keyof typeof NETWORK_TEST_CODES];
export class NetworkTestError extends SuaveplanError<NetworkTestErrorCode> {
public constructor(
code: NetworkTestErrorCode,
messageKey: string,
options?: import("@suaveplan/error").SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkTestError";
}
}

View File

@@ -0,0 +1 @@
export { createTestPeer } from "./test-peer.js";

View File

@@ -0,0 +1,60 @@
# Test Peer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`test-peer` provides a fluent factory for generating synthetic peer identities
during network tests. Instead of mocking raw peer data inline, tests can create
`TestPeer` objects that expose registry entries, bootstrap seeds, and health
metadata in a single place.
## Features
- Auto-generated `id` and `address` with collision-safe counters.
- Fluent `withCapabilities`, `withRegion`, and `withHealth` methods that return
immutable copies, preserving the original peer.
- Direct conversion to registry entry (`asRegistryEntry`) and bootstrap seed
(`asBootstrapSeed`) formats.
## Usage
```ts
import { createTestPeer } from "@suaveplan/network-testing/factories/peer";
const peer = createTestPeer()
.withCapabilities(["relay", "sign"])
.withRegion("us-east")
.withHealth(true, 20);
const seed = peer.asBootstrapSeed();
```
## API Reference
### `createTestPeer(options?)`
Creates a `TestPeer`. Optional overrides: `id`, `address`, `capabilities`,
`region`.
### `TestPeer`
- `id` — unique peer identifier.
- `address` — host:port string.
- `capabilities` — read-only capability list.
- `region` — geographic or logical region label.
- `asRegistryEntry()` — returns `{ peerId, address, tier, latencyMs }` where
`tier` is `1` for healthy peers and `3` for unhealthy peers.
- `asBootstrapSeed()` — returns `{ peerId, address, capabilities, region }`.
- `withCapabilities(capabilities)` — returns a new peer with merged capabilities.
- `withRegion(region)` — returns a new peer in the given region.
- `withHealth(healthy, latencyMs?)` — returns a new peer with updated health.
## Implementation Notes
The fluent API creates a new `TestPeerImpl` on every mutation rather than
mutating internal state. This makes assertions in multi-step tests deterministic
and prevents accidental shared-state bugs when the same base peer is reused
across multiple test cases.

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createTestPeer } from "./test-peer.js";
describe("createTestPeer", () => {
it("must generate default id and address", { timeout: 5000 }, () => {
const peer = createTestPeer();
expect(peer.id).toMatch(/^test-peer-\d+$/);
expect(peer.address).toMatch(/^127\.0\.0\.1:\d+$/);
});
it("must accept custom options", { timeout: 5000 }, () => {
const peer = createTestPeer({
id: "custom-1",
address: "10.0.0.1:9000",
capabilities: ["relay"],
region: "us-east",
});
expect(peer.id).toBe("custom-1");
expect(peer.address).toBe("10.0.0.1:9000");
expect(peer.capabilities).toContain("relay");
expect(peer.region).toBe("us-east");
});
it("must produce registry entry", { timeout: 5000 }, () => {
const peer = createTestPeer({ id: "p1", address: "a1" });
const entry = peer.asRegistryEntry();
expect(entry.peerId).toBe("p1");
expect(entry.address).toBe("a1");
expect(entry.tier).toBe(1);
});
it("must produce bootstrap seed", { timeout: 5000 }, () => {
const peer = createTestPeer({
id: "p1",
address: "a1",
capabilities: ["relay"],
region: "eu-west",
});
const seed = peer.asBootstrapSeed();
expect(seed.peerId).toBe("p1");
expect(seed.address).toBe("a1");
expect(seed.capabilities).toContain("relay");
expect(seed.region).toBe("eu-west");
});
it("must add capabilities fluently", { timeout: 5000 }, () => {
const peer = createTestPeer().withCapabilities(["relay", "sign"]);
expect(peer.capabilities).toContain("relay");
expect(peer.capabilities).toContain("sign");
});
it("must change region fluently", { timeout: 5000 }, () => {
const peer = createTestPeer().withRegion("ap-south");
expect(peer.region).toBe("ap-south");
});
it("must set health fluently", { timeout: 5000 }, () => {
const peer = createTestPeer().withHealth(false, 150);
const entry = peer.asRegistryEntry();
expect(entry.tier).toBe(3);
expect(entry.latencyMs).toBe(150);
});
it("must default latencyMs to 0 when withHealth omits the second argument", {
timeout: 5000,
}, () => {
const peer = createTestPeer().withHealth(true);
const entry = peer.asRegistryEntry();
expect(entry.tier).toBe(1);
expect(entry.latencyMs).toBe(0);
});
});

View File

@@ -0,0 +1,83 @@
import type {
SeedConfig,
TestPeer,
TestPeerOptions,
} from "../../types/common.js";
let peerCounter = 0;
class TestPeerImpl implements TestPeer {
public readonly id: string;
public readonly address: string;
public readonly capabilities: readonly string[];
public readonly region: string;
private healthy = true;
private latencyMs = 0;
public constructor(options: TestPeerOptions = {}) {
this.id = options.id ?? `test-peer-${peerCounter++}`;
this.address = options.address ?? `127.0.0.1:${8000 + peerCounter}`;
this.capabilities = Object.freeze(options.capabilities ?? []);
this.region = options.region ?? "default";
}
public asRegistryEntry(): {
peerId: string;
address: string;
tier: number;
latencyMs: number;
} {
return {
peerId: this.id,
address: this.address,
tier: this.healthy ? 1 : 3,
latencyMs: this.latencyMs,
};
}
public asBootstrapSeed(): SeedConfig {
return {
peerId: this.id,
address: this.address,
capabilities: this.capabilities,
region: this.region,
};
}
public withCapabilities(capabilities: string[]): TestPeer {
return new TestPeerImpl({
id: this.id,
address: this.address,
capabilities: [...this.capabilities, ...capabilities],
region: this.region,
});
}
public withRegion(region: string): TestPeer {
return new TestPeerImpl({
id: this.id,
address: this.address,
capabilities: [...this.capabilities],
region,
});
}
public withHealth(healthy: boolean, latencyMs?: number): TestPeer {
const peer = new TestPeerImpl({
id: this.id,
address: this.address,
capabilities: [...this.capabilities],
region: this.region,
});
peer.healthy = healthy;
peer.latencyMs = latencyMs ?? 0;
return peer;
}
}
/**
* Create a fluent test peer factory.
*/
export function createTestPeer(options?: TestPeerOptions): TestPeer {
return new TestPeerImpl(options);
}

View File

@@ -0,0 +1,36 @@
// Error codes
import "./error-codes.js";
// Assertions
export { createNetworkEventCapture } from "./assertions/event-capture/index.js";
// Errors
export {
NETWORK_TEST_CODES,
NetworkTestError,
type NetworkTestErrorCode,
} from "./error-codes.js";
// Factories
export { createTestPeer } from "./factories/test-peer/index.js";
// Simulators
export { createConditionSimulator } from "./simulators/condition-simulator/index.js";
export { createFakeWebSocketAdapter } from "./transports/fake-websocket/index.js";
// Transports
export { createMemoryTransport } from "./transports/memory-transport/index.js";
// Types
export type {
ConditionSimulator,
FakeWebSocketAdapter,
FakeWebSocketHandle,
FakeWebSocketOptions,
LatencyDistribution,
MemoryTransport,
MemoryTransportOptions,
MemoryTransportPair,
NetworkConditions,
NetworkEventCapture,
SeedConfig,
SimulationStats,
TestPeer,
TestPeerOptions,
TransportState,
} from "./types/common.js";

View File

@@ -0,0 +1,64 @@
# Condition Simulator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`condition-simulator` wraps any transport-like object and injects real-world
network impairments: latency, jitter, packet loss, bandwidth limits, and forced
disconnections. It lets unit tests exercise retry, timeout, and circuit-breaker
logic without depending on actual network infrastructure.
## Features
- **Packet loss** — drops a configurable fraction of sends.
- **Latency & jitter** — delays messages by a constant or sampled distribution.
- **Bandwidth shaping** — adds extra delay proportional to payload size.
- **Forced disconnect** — throws after cumulative latency exceeds a threshold.
- **Runtime tuning** — `setConditions` and `reset` allow mid-test scenario changes.
- **Injectable RNG** — supports deterministic replay when a seeded `Rng` is
supplied.
## Usage
```ts
import { createConditionSimulator } from "@suaveplan/network-testing/simulators/condition";
const sim = createConditionSimulator({ latencyMs: 100, packetLossRate: 0.1 });
const wrapped = sim.wrap(transport);
await wrapped.send("hello");
console.log(sim.stats.avgLatencyMs);
```
## API Reference
### `createConditionSimulator(conditions, rng?)`
Returns a `ConditionSimulator`.
### `ConditionSimulator`
- `wrap(transport)` — returns a proxied transport whose `send` is intercepted.
- `setConditions(partial)` — merges new values into the active condition set.
- `reset()` — restores original conditions and clears statistics.
- `stats` — read-only `{ sentMessages, droppedMessages, totalLatencyMs,
avgLatencyMs }`.
### `NetworkConditions`
- `latencyMs` — constant delay or a `LatencyDistribution` object.
- `jitterMs` — random ±offset applied after latency.
- `packetLossRate` — probability between `0` and `1`.
- `bandwidthBps` — bytes-per-second cap.
- `disconnectAfterMs` — cumulative latency threshold that triggers a simulated
disconnect.
## Implementation Notes
The wrapper uses a `Proxy` to intercept only the `send` property, leaving all
other transport methods untouched. Latency sampling supports uniform, normal
(Box-Muller), and bimodal distributions. Statistics are updated synchronously
before the delay so that `stats.sentMessages` reflects the total attempted sends
immediately.

View File

@@ -0,0 +1,264 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { createConditionSimulator } from "./condition-simulator.js";
/**
* Deterministic Rng that cycles through a fixed sequence of floats.
* Use to exercise specific branches in `sampleLatency` without flakes.
*/
function fixedRng(sequence: number[]): Rng {
let i = 0;
const next = () => {
const v = sequence[i % sequence.length] ?? 0;
i += 1;
return v;
};
return {
nextUint32: () => Math.floor(next() * 4294967296),
nextFloat: next,
nextBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
for (let j = 0; j < n; j++) buf[j] = Math.floor(next() * 256);
return buf;
},
nextIntInRange(min: number, maxExclusive: number): number {
if (maxExclusive <= min)
throw new Error("maxExclusive must be > min");
return min + Math.floor(next() * (maxExclusive - min));
},
};
}
describe("createConditionSimulator", () => {
it("must pass through messages with no conditions", {
timeout: 5000,
}, async () => {
const transport = { send: async (msg: string) => msg };
const sim = createConditionSimulator({});
const wrapped = sim.wrap(transport);
const result = await wrapped.send("hello");
expect(result).toBe("hello");
expect(sim.stats.sentMessages).toBe(1);
expect(sim.stats.droppedMessages).toBe(0);
});
it("must drop messages at configured packet loss rate", {
timeout: 5000,
}, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator({ packetLossRate: 1 });
const wrapped = sim.wrap(transport);
await wrapped.send("drop-me");
expect(sim.stats.droppedMessages).toBe(1);
expect(sim.stats.sentMessages).toBe(1);
});
it("must never drop when packet loss is zero", {
timeout: 5000,
}, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator({ packetLossRate: 0 });
const wrapped = sim.wrap(transport);
for (let i = 0; i < 10; i++) {
await wrapped.send("msg");
}
expect(sim.stats.droppedMessages).toBe(0);
expect(sim.stats.sentMessages).toBe(10);
});
it("must add constant latency", { timeout: 30000 }, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator({ latencyMs: 50 });
const wrapped = sim.wrap(transport);
const start = Date.now();
await wrapped.send("delayed");
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(45);
expect(sim.stats.avgLatencyMs).toBeGreaterThanOrEqual(45);
});
it("must update conditions on the fly", { timeout: 5000 }, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator({ packetLossRate: 0 });
const wrapped = sim.wrap(transport);
await wrapped.send("pass");
sim.setConditions({ packetLossRate: 1 });
await wrapped.send("drop");
expect(sim.stats.droppedMessages).toBe(1);
});
it("must reset stats and conditions", { timeout: 5000 }, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator({ packetLossRate: 1 });
const wrapped = sim.wrap(transport);
await wrapped.send("x");
sim.reset();
expect(sim.stats.sentMessages).toBe(0);
expect(sim.stats.droppedMessages).toBe(0);
});
it("must compute average latency correctly", {
timeout: 30000,
}, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator({ latencyMs: 100 });
const wrapped = sim.wrap(transport);
await wrapped.send("a");
await wrapped.send("b");
expect(sim.stats.avgLatencyMs).toBeGreaterThanOrEqual(90);
});
it("must add jitter when configured", { timeout: 5000 }, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator({ jitterMs: 10 }, fixedRng([0.9]));
const wrapped = sim.wrap(transport);
await wrapped.send("jitter");
expect(sim.stats.totalLatencyMs).toBeGreaterThan(0);
});
it("must sample latency from a uniform distribution", {
timeout: 30000,
}, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator(
{ latencyMs: { min: 20, max: 40, distribution: "uniform" } },
fixedRng([0.5])
);
const wrapped = sim.wrap(transport);
await wrapped.send("u");
expect(sim.stats.totalLatencyMs).toBeGreaterThanOrEqual(20);
expect(sim.stats.totalLatencyMs).toBeLessThanOrEqual(40);
});
it("must sample latency from a normal distribution", {
timeout: 30000,
}, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator(
{ latencyMs: { min: 10, max: 50, distribution: "normal" } },
fixedRng([0.5, 0.5])
);
const wrapped = sim.wrap(transport);
await wrapped.send("n");
expect(sim.stats.totalLatencyMs).toBeGreaterThanOrEqual(10);
expect(sim.stats.totalLatencyMs).toBeLessThanOrEqual(50);
});
it("must sample latency from a bimodal distribution (low cluster)", {
timeout: 30000,
}, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator(
{ latencyMs: { min: 10, max: 100, distribution: "bimodal" } },
fixedRng([0.1, 0.5])
);
const wrapped = sim.wrap(transport);
await wrapped.send("b");
expect(sim.stats.totalLatencyMs).toBeGreaterThan(0);
});
it("must sample latency from a bimodal distribution (high cluster)", {
timeout: 30000,
}, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator(
{ latencyMs: { min: 10, max: 100, distribution: "bimodal" } },
fixedRng([0.9, 0.5])
);
const wrapped = sim.wrap(transport);
await wrapped.send("b");
expect(sim.stats.totalLatencyMs).toBeGreaterThan(0);
});
it("must add bandwidth-derived delay for string payloads", {
timeout: 30000,
}, async () => {
const transport = { send: async (_: string) => {} };
const sim = createConditionSimulator({ bandwidthBps: 1000 });
const wrapped = sim.wrap(transport);
await wrapped.send("x".repeat(500));
expect(sim.stats.totalLatencyMs).toBeGreaterThan(0);
});
it("must add bandwidth-derived delay for binary payloads", {
timeout: 30000,
}, async () => {
const transport = {
send: async (_: Uint8Array | ArrayBuffer | object) => {},
};
const sim = createConditionSimulator({ bandwidthBps: 1000 });
const wrapped = sim.wrap(transport);
const u8 = new Uint8Array(256);
const ab = new ArrayBuffer(128);
await wrapped.send(u8);
await wrapped.send(ab);
await wrapped.send({ json: true });
expect(sim.stats.totalLatencyMs).toBeGreaterThan(0);
});
it("must ignore bandwidth when set to zero", {
timeout: 5000,
}, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator({ bandwidthBps: 0 });
const wrapped = sim.wrap(transport);
await wrapped.send("any");
expect(sim.stats.totalLatencyMs).toBe(0);
});
it("must throw a simulated disconnect after threshold", {
timeout: 30000,
}, async () => {
const transport = { send: async () => {} };
const sim = createConditionSimulator({
latencyMs: 10,
disconnectAfterMs: 5,
});
const wrapped = sim.wrap(transport);
await expect(wrapped.send("x")).rejects.toThrow("Simulated disconnect");
});
it("must forward non-send property access through the proxy", {
timeout: 5000,
}, async () => {
const transport = {
send: async () => {},
meta: "passthrough" as const,
};
const sim = createConditionSimulator({});
const wrapped = sim.wrap(transport);
expect((wrapped as typeof transport).meta).toBe("passthrough");
});
it("must work with the default Rng implementation", {
timeout: 5000,
}, async () => {
const transport = { send: async () => {} };
// Construct without injected rng to exercise createDefaultRng.
const sim = createConditionSimulator({ jitterMs: 1 });
const wrapped = sim.wrap(transport);
await wrapped.send("default-rng");
expect(sim.stats.sentMessages).toBe(1);
});
it("default Rng must produce in-range integers and reject invalid ranges", {
timeout: 5000,
}, async () => {
// We can only access default Rng indirectly via the simulator's
// observable behavior, but we can validate the bounded sampling
// by sending enough messages to exercise nextFloat/nextUint32-style
// paths. We additionally assert nextIntInRange semantics by
// creating a transport whose handler observes argument counts.
const transport = {
send: async (...args: unknown[]) => args.length,
};
const sim = createConditionSimulator(
{ packetLossRate: 0, jitterMs: 0 }
// Use default Rng path (omit second arg)
);
const wrapped = sim.wrap(transport);
for (let i = 0; i < 5; i++) await wrapped.send("k");
expect(sim.stats.sentMessages).toBe(5);
expect(sim.stats.droppedMessages).toBe(0);
});
});

View File

@@ -0,0 +1,187 @@
import type { Rng } from "@suaveplan/types";
import type {
ConditionSimulator,
LatencyDistribution,
NetworkConditions,
SimulationStats,
} from "../../types/common.js";
/**
* Minimal Rng shape consumed by the simulator. Only `nextFloat` is required
* because `sampleLatency` and the jitter/packet-loss branches consume nothing
* else. Callers may still pass a full `Rng` from `@suaveplan/types` — the wider
* type is structurally compatible.
*/
type SimulatorRng = Pick<Rng, "nextFloat">;
/**
* Default Rng implementation using Math.random().
*/
function createDefaultRng(): SimulatorRng {
return {
nextFloat: () => Math.random(),
};
}
function sampleLatency(
latency: number | LatencyDistribution,
rng: SimulatorRng
): number {
if (typeof latency === "number") {
return latency;
}
const { min, max, distribution } = latency;
if (distribution === "uniform") {
return min + rng.nextFloat() * (max - min);
}
if (distribution === "normal") {
// Box-Muller transform
const u1 = rng.nextFloat();
const u2 = rng.nextFloat();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
const mean = (min + max) / 2;
const std = (max - min) / 6;
return Math.max(min, Math.min(max, mean + z * std));
}
// bimodal — pick one of two clusters
const cluster = rng.nextFloat() < 0.5 ? min : max;
const spread = (max - min) * 0.1;
return cluster + (rng.nextFloat() - 0.5) * spread;
}
function estimateSize(args: unknown[]): number {
let size = 0;
for (const arg of args) {
if (typeof arg === "string") {
size += new TextEncoder().encode(arg).length;
} else if (arg instanceof Uint8Array) {
size += arg.length;
} else if (arg instanceof ArrayBuffer) {
size += arg.byteLength;
} else {
size += JSON.stringify(arg).length;
}
}
return size;
}
class ConditionSimulatorImpl implements ConditionSimulator {
private conditions: NetworkConditions;
private readonly originalConditions: NetworkConditions;
private readonly rng: SimulatorRng;
private sentMessages = 0;
private droppedMessages = 0;
private totalLatencyMs = 0;
public constructor(conditions: NetworkConditions, rng?: Rng) {
this.conditions = { ...conditions };
this.originalConditions = { ...conditions };
this.rng = rng ?? createDefaultRng();
}
public wrap<T extends { send: (...args: unknown[]) => Promise<unknown> }>(
transport: T
): T {
const self = this;
return new Proxy(transport, {
get(target, prop) {
if (prop === "send") {
return async (...args: unknown[]) => {
self.sentMessages += 1;
// Packet loss
if (
self.conditions.packetLossRate !== undefined &&
self.rng.nextFloat() <
self.conditions.packetLossRate
) {
self.droppedMessages += 1;
return;
}
// Latency
let delay = 0;
if (self.conditions.latencyMs !== undefined) {
delay += sampleLatency(
self.conditions.latencyMs,
self.rng
);
}
// Jitter
if (self.conditions.jitterMs !== undefined) {
delay +=
(self.rng.nextFloat() - 0.5) *
2 *
self.conditions.jitterMs;
}
// Bandwidth
if (
self.conditions.bandwidthBps !== undefined &&
self.conditions.bandwidthBps > 0
) {
const size = estimateSize(args);
delay +=
(size * 1000) / self.conditions.bandwidthBps;
}
delay = Math.max(0, delay);
self.totalLatencyMs += delay;
if (delay > 0) {
await new Promise((resolve) =>
setTimeout(resolve, delay)
);
}
// Disconnect after threshold
if (
self.conditions.disconnectAfterMs !== undefined &&
self.totalLatencyMs >=
self.conditions.disconnectAfterMs
) {
throw new Error("Simulated disconnect");
}
return target.send(...args);
};
}
return (target as Record<string, unknown>)[prop as string];
},
}) as T;
}
public setConditions(conditions: Partial<NetworkConditions>): void {
this.conditions = { ...this.conditions, ...conditions };
}
public reset(): void {
this.conditions = { ...this.originalConditions };
this.sentMessages = 0;
this.droppedMessages = 0;
this.totalLatencyMs = 0;
}
public get stats(): SimulationStats {
return {
sentMessages: this.sentMessages,
droppedMessages: this.droppedMessages,
totalLatencyMs: this.totalLatencyMs,
avgLatencyMs:
this.sentMessages > 0
? this.totalLatencyMs / this.sentMessages
: 0,
};
}
}
/**
* Create a network condition simulator for testing.
*/
export function createConditionSimulator(
conditions: NetworkConditions,
rng?: Rng
): ConditionSimulator {
return new ConditionSimulatorImpl(conditions, rng);
}

View File

@@ -0,0 +1 @@
export { createConditionSimulator } from "./condition-simulator.js";

View File

@@ -0,0 +1,63 @@
# Fake WebSocket Adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module provides an in-memory WebSocket replacement for tests that need to
verify transport-layer behaviour without spinning up a real WebSocket server.
It models connection lifecycle, message send/receive, error injection, and close
semantics with enough fidelity to test protocol plugins and reconnection logic.
## Features
- `FakeWebSocketHandle` with full `readyState` lifecycle (`CONNECTING`, `OPEN`,
`CLOSING`, `CLOSED`).
- `send` tracking on each handle.
- `simulateMessage`, `simulateError`, and `simulateClose` broadcast to every
open handle.
- Optional `autoConnect` flag for testing manual connection flows.
- Multiple concurrent connections per adapter.
## Usage
```ts
import { createFakeWebSocketAdapter } from "@suaveplan/network-testing/transports/websocket";
const adapter = createFakeWebSocketAdapter();
const handle = adapter.connect("ws://relay/1");
handle.onmessage = (data) => console.log(data);
adapter.simulateMessage("hello");
```
## API Reference
### `createFakeWebSocketAdapter(options?)`
Creates a `FakeWebSocketAdapter`. Options: `autoConnect` (default `true`).
### `FakeWebSocketAdapter`
- `connect(url, protocols?)` — creates a `FakeWebSocketHandle`.
- `simulateMessage(data)` — delivers the message to every open handle.
- `simulateError(error)` — fires `onerror` on every handle.
- `simulateClose(code?, reason?)` — transitions handles to `CLOSED`.
- `connections` — read-only array of `{ url, handle }` objects.
### `FakeWebSocketHandle`
- `readyState` — `0` (`CONNECTING`), `1` (`OPEN`), `2` (`CLOSING`), `3` (`CLOSED`).
- `sentMessages` — array of payloads passed to `send`.
- `send(data)` — throws if not open.
- `close(code?, reason?)` — transitions to closed and fires `onclose`.
- `onopen`, `onclose`, `onmessage`, `onerror` — standard callback slots.
## Implementation Notes
Handles are stored in an internal array and broadcast methods iterate over the
entire collection. This is acceptable because test adapters typically manage
fewer than a dozen handles. `readyState` transitions skip `CLOSING` for
simplicity; `simulateClose` moves directly from `OPEN` to `CLOSED`.

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FakeWebSocketHandle } from "../../types/common.js";
import { createFakeWebSocketAdapter } from "./fake-websocket.js";
/**
* Internal handle shape exposing the `_open`/`_closed` test hooks the adapter
* uses. We narrow through this view so we can exercise idempotency branches
* without leaning on `as any`.
*/
interface InternalHandle extends FakeWebSocketHandle {
_open(): void;
_closed(code: number, reason: string): void;
close(code?: number, reason?: string): void;
}
describe("createFakeWebSocketAdapter", () => {
it("must create a handle on connect", { timeout: 5000 }, () => {
const adapter = createFakeWebSocketAdapter();
const handle = adapter.connect("ws://test");
expect(handle.readyState).toBe(1);
expect(adapter.connections.length).toBe(1);
});
it("must track sent messages", { timeout: 5000 }, () => {
const adapter = createFakeWebSocketAdapter();
const handle = adapter.connect("ws://test");
handle.send("hello");
expect(handle.sentMessages).toContain("hello");
});
it("must simulate incoming messages", { timeout: 5000 }, () => {
const adapter = createFakeWebSocketAdapter();
const handle = adapter.connect("ws://test");
const received: Array<string | Uint8Array> = [];
handle.onmessage = (data) => {
received.push(data);
};
adapter.simulateMessage("world");
expect(received).toContain("world");
});
it("must simulate errors", { timeout: 5000 }, () => {
const adapter = createFakeWebSocketAdapter();
const handle = adapter.connect("ws://test");
const errors: Error[] = [];
handle.onerror = (err) => {
errors.push(err);
};
adapter.simulateError(new Error("fail"));
expect(errors.length).toBe(1);
expect(errors[0].message).toBe("fail");
});
it("must simulate close", { timeout: 5000 }, () => {
const adapter = createFakeWebSocketAdapter();
const handle = adapter.connect("ws://test");
let closed = false;
handle.onclose = () => {
closed = true;
};
adapter.simulateClose(1001, "going away");
expect(closed).toBe(true);
expect(handle.readyState).toBe(3);
});
it("must support manual connect when autoConnect is false", {
timeout: 5000,
}, () => {
const adapter = createFakeWebSocketAdapter({ autoConnect: false });
const handle = adapter.connect("ws://test");
expect(handle.readyState).toBe(0);
});
it("must support multiple connections", { timeout: 5000 }, () => {
const adapter = createFakeWebSocketAdapter();
adapter.connect("ws://a");
adapter.connect("ws://b");
expect(adapter.connections.length).toBe(2);
});
it("must broadcast simulateMessage to all handles", {
timeout: 5000,
}, () => {
const adapter = createFakeWebSocketAdapter();
const h1 = adapter.connect("ws://a");
const h2 = adapter.connect("ws://b");
const r1: unknown[] = [];
const r2: unknown[] = [];
h1.onmessage = (data) => r1.push(data);
h2.onmessage = (data) => r2.push(data);
adapter.simulateMessage("broadcast");
expect(r1).toContain("broadcast");
expect(r2).toContain("broadcast");
});
it("must throw when sending on closed handle", { timeout: 5000 }, () => {
const adapter = createFakeWebSocketAdapter();
const handle = adapter.connect("ws://test");
adapter.simulateClose();
expect(() => handle.send("x")).toThrow("not open");
});
it("must drive close() and be idempotent across repeated invocations", {
timeout: 5000,
}, () => {
const adapter = createFakeWebSocketAdapter();
const handle = adapter.connect("ws://test") as InternalHandle;
let count = 0;
handle.onclose = () => {
count += 1;
};
handle.close(1002, "explicit");
handle.close();
expect(count).toBe(1);
expect(handle.readyState).toBe(3);
});
it("must not re-fire onopen when _open is invoked on an already-open handle", {
timeout: 5000,
}, () => {
const adapter = createFakeWebSocketAdapter();
const handle = adapter.connect("ws://test") as InternalHandle;
let opens = 0;
handle.onopen = () => {
opens += 1;
};
// Already open (autoConnect=true). Re-invoking _open must no-op.
handle._open();
expect(opens).toBe(0);
expect(handle.readyState).toBe(1);
});
it("must support manual _open via autoConnect:false", {
timeout: 5000,
}, () => {
const adapter = createFakeWebSocketAdapter({ autoConnect: false });
const handle = adapter.connect("ws://manual") as InternalHandle;
let opens = 0;
handle.onopen = () => {
opens += 1;
};
handle._open();
expect(opens).toBe(1);
expect(handle.readyState).toBe(1);
});
it("must ignore _receive when the handle is not open", {
timeout: 5000,
}, () => {
const adapter = createFakeWebSocketAdapter({ autoConnect: false });
const handle = adapter.connect("ws://x");
const received: unknown[] = [];
handle.onmessage = (data) => {
received.push(data);
};
adapter.simulateMessage("dropped-while-connecting");
expect(received).toHaveLength(0);
});
it("must no-op _closed when the handle is already closed", {
timeout: 5000,
}, () => {
const adapter = createFakeWebSocketAdapter();
const handle = adapter.connect("ws://test");
let count = 0;
handle.onclose = () => {
count += 1;
};
adapter.simulateClose(1000, "first");
adapter.simulateClose(1000, "second");
expect(count).toBe(1);
expect(handle.readyState).toBe(3);
});
});

View File

@@ -0,0 +1,117 @@
import type {
FakeWebSocketAdapter,
FakeWebSocketHandle,
FakeWebSocketOptions,
} from "../../types/common.js";
let handleId = 0;
class FakeWebSocketHandleImpl implements FakeWebSocketHandle {
public readonly id = handleId++;
public readonly url: string;
public readonly protocols: string[];
public readyState = 0;
public readonly sentMessages: Array<string | Uint8Array> = [];
public onopen: (() => void) | null = null;
public onclose: ((code: number, reason: string) => void) | null = null;
public onmessage: ((data: string | Uint8Array) => void) | null = null;
public onerror: ((error: Error) => void) | null = null;
public constructor(url: string, protocols: string[]) {
this.url = url;
this.protocols = protocols;
}
public send(data: string | Uint8Array): void {
if (this.readyState !== 1) {
throw new Error("WebSocket is not open");
}
this.sentMessages.push(data);
}
public close(code = 1000, reason = ""): void {
if (this.readyState === 2 || this.readyState === 3) {
return;
}
this.readyState = 3;
this.onclose?.(code, reason);
}
public _open(): void {
if (this.readyState !== 0) {
return;
}
this.readyState = 1;
this.onopen?.();
}
public _receive(data: string | Uint8Array): void {
if (this.readyState === 1) {
this.onmessage?.(data);
}
}
public _error(error: Error): void {
this.onerror?.(error);
}
public _closed(code: number, reason: string): void {
if (this.readyState === 3) {
return;
}
this.readyState = 3;
this.onclose?.(code, reason);
}
}
class FakeWebSocketAdapterImpl implements FakeWebSocketAdapter {
private readonly handles: FakeWebSocketHandleImpl[] = [];
private readonly autoConnect: boolean;
public constructor(options: FakeWebSocketOptions = {}) {
this.autoConnect = options.autoConnect ?? true;
}
public connect(url: string, protocols?: string[]): FakeWebSocketHandle {
const handle = new FakeWebSocketHandleImpl(url, protocols ?? []);
this.handles.push(handle);
if (this.autoConnect) {
handle._open();
}
return handle;
}
public simulateMessage(data: string | Uint8Array): void {
for (const handle of this.handles) {
handle._receive(data);
}
}
public simulateError(error: Error): void {
for (const handle of this.handles) {
handle._error(error);
}
}
public simulateClose(code = 1000, reason = ""): void {
for (const handle of this.handles) {
handle._closed(code, reason);
}
}
public get connections(): ReadonlyArray<{
url: string;
handle: FakeWebSocketHandle;
}> {
return this.handles.map((h) => ({ url: h.url, handle: h }));
}
}
/**
* Create a fake WebSocket adapter for testing.
*/
export function createFakeWebSocketAdapter(
options?: FakeWebSocketOptions
): FakeWebSocketAdapter {
return new FakeWebSocketAdapterImpl(options);
}

View File

@@ -0,0 +1 @@
export { createFakeWebSocketAdapter } from "./fake-websocket.js";

View File

@@ -0,0 +1 @@
export { createMemoryTransport } from "./memory-transport.js";

View File

@@ -0,0 +1,64 @@
# Memory Transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`memory-transport` creates a pair of connected in-memory transport endpoints.
It is the fastest way to write unit tests for network plugins, message routers,
and topology managers because it bypasses real I/O entirely while preserving the
`send`/`connect`/`disconnect` contract.
## Features
- Bidirectional message queue shared between `client` and `server`.
- Configurable artificial latency via `latencyMs`.
- AbortSignal support for cancellation tests.
- Event hooks (`connect`, `disconnect`, `message`) with unsubscribe.
- State machine (`connecting` → `connected` → `disconnected`).
## Usage
```ts
import { createMemoryTransport } from "@suaveplan/network-testing/transports/memory";
const pair = createMemoryTransport({ latencyMs: 10 });
pair.connect();
pair.server.on("message", (msg) => console.log(msg));
await pair.client.send("hello");
```
## API Reference
### `createMemoryTransport(options?)`
Returns a `MemoryTransportPair`. Options: `latencyMs`, `clock`.
### `MemoryTransportPair`
- `client` — client-side `MemoryTransport`.
- `server` — server-side `MemoryTransport`.
- `connect()` — transitions both ends to `connected` and fires handlers.
- `disconnect()` — transitions both ends to `disconnected` and fires handlers.
### `MemoryTransport`
- `id` — unique transport identifier.
- `state` — current `TransportState`.
- `messages` — array of delivered payloads.
- `send(message, signal?)` — enqueues on the peer after optional latency.
- `connect(signal?)` — transitions to `connected`.
- `disconnect()` — transitions to `disconnected`.
- `on(event, handler)` — subscribes; returns an unsubscribe function.
## Implementation Notes
The pair uses a cross-reference pattern: each `MemoryTransportImpl` holds a
reference to the peer via a shared mutable object, avoiding circular constructor
dependencies. Latency is implemented with `setTimeout`; when `latencyMs` is zero,
messages are delivered synchronously. The `send` method validates state before
enqueuing and throws a plain `Error` with a descriptive message if the transport
is not connected.

View File

@@ -0,0 +1,179 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMemoryTransport } from "./memory-transport.js";
describe("createMemoryTransport", () => {
it("must create a pair with disconnected state", { timeout: 5000 }, () => {
const pair = createMemoryTransport();
expect(pair.client.state).toBe("connecting");
expect(pair.server.state).toBe("connecting");
});
it("must connect both ends", { timeout: 5000 }, () => {
const pair = createMemoryTransport();
pair.connect();
expect(pair.client.state).toBe("connected");
expect(pair.server.state).toBe("connected");
});
it("must deliver client messages to server", {
timeout: 5000,
}, async () => {
const pair = createMemoryTransport();
pair.connect();
await pair.client.send("hello");
expect(pair.server.messages).toContain("hello");
});
it("must deliver server messages to client", {
timeout: 5000,
}, async () => {
const pair = createMemoryTransport();
pair.connect();
await pair.server.send("world");
expect(pair.client.messages).toContain("world");
});
it("must fire message handlers", { timeout: 5000 }, async () => {
const pair = createMemoryTransport();
pair.connect();
const received: unknown[] = [];
pair.server.on("message", (data) => {
received.push(data);
});
await pair.client.send("event");
expect(received).toContain("event");
});
it("must fire connect handlers", { timeout: 5000 }, () => {
const pair = createMemoryTransport();
let fired = false;
pair.client.on("connect", () => {
fired = true;
});
pair.connect();
expect(fired).toBe(true);
});
it("must disconnect both ends", { timeout: 5000 }, () => {
const pair = createMemoryTransport();
pair.connect();
pair.disconnect();
expect(pair.client.state).toBe("disconnected");
expect(pair.server.state).toBe("disconnected");
});
it("must fire disconnect handlers", { timeout: 5000 }, () => {
const pair = createMemoryTransport();
pair.connect();
let fired = false;
pair.client.on("disconnect", () => {
fired = true;
});
pair.disconnect();
expect(fired).toBe(true);
});
it("must reject send when disconnected", { timeout: 5000 }, async () => {
const pair = createMemoryTransport();
pair.connect();
pair.disconnect();
await expect(pair.client.send("x")).rejects.toThrow("not connected");
});
it("must support latency simulation", { timeout: 30000 }, async () => {
const pair = createMemoryTransport({ latencyMs: 50 });
pair.connect();
const start = Date.now();
await pair.client.send("delayed");
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(45);
expect(pair.server.messages).toContain("delayed");
});
it("must allow unsubscribing handlers", { timeout: 5000 }, async () => {
const pair = createMemoryTransport();
pair.connect();
const received: unknown[] = [];
const unsub = pair.server.on("message", (data) => {
received.push(data);
});
unsub();
await pair.client.send("after-unsub");
expect(received).toHaveLength(0);
});
it("must reject send when the AbortSignal is already aborted", {
timeout: 5000,
}, async () => {
const pair = createMemoryTransport();
pair.connect();
const ac = new AbortController();
ac.abort();
await expect(pair.client.send("x", ac.signal)).rejects.toThrow(
"Aborted"
);
});
it("must abort an in-flight latency-delayed send", {
timeout: 30000,
}, async () => {
const pair = createMemoryTransport({ latencyMs: 200 });
pair.connect();
const ac = new AbortController();
const send = pair.client.send("delayed", ac.signal);
setTimeout(() => ac.abort(), 20);
await expect(send).rejects.toThrow("Aborted");
expect(pair.server.messages).not.toContain("delayed");
});
it("must connect each end individually via connect()", {
timeout: 5000,
}, async () => {
const pair = createMemoryTransport();
await pair.client.connect();
await pair.server.connect();
expect(pair.client.state).toBe("connected");
expect(pair.server.state).toBe("connected");
await pair.client.send("solo");
expect(pair.server.messages).toContain("solo");
});
it("must be idempotent when disconnect is called twice", {
timeout: 5000,
}, () => {
const pair = createMemoryTransport();
pair.connect();
let count = 0;
pair.client.on("disconnect", () => {
count += 1;
});
pair.client.disconnect();
pair.client.disconnect();
expect(count).toBe(1);
});
it("must register multiple handlers for the same event into the existing Set", {
timeout: 5000,
}, async () => {
const pair = createMemoryTransport();
pair.connect();
const r1: unknown[] = [];
const r2: unknown[] = [];
pair.server.on("message", (d) => r1.push(d));
pair.server.on("message", (d) => r2.push(d));
await pair.client.send("multi");
expect(r1).toContain("multi");
expect(r2).toContain("multi");
});
it("must silently ignore emit when no handlers are registered for the event", {
timeout: 5000,
}, async () => {
const pair = createMemoryTransport();
pair.connect();
// No handlers registered for `message`; deliver should still
// record the message and not throw.
await pair.client.send("no-handlers");
expect(pair.server.messages).toContain("no-handlers");
});
});

View File

@@ -0,0 +1,135 @@
import type {
MemoryTransport,
MemoryTransportOptions,
MemoryTransportPair,
TransportState,
} from "../../types/common.js";
let transportId = 0;
class MemoryTransportImpl implements MemoryTransport {
public readonly id: string;
public state: TransportState = "connecting";
public readonly messages: unknown[] = [];
private readonly handlers = new Map<string, Set<(data: unknown) => void>>();
private readonly peer: { impl: MemoryTransportImpl | undefined };
private readonly latencyMs: number;
public constructor(
id: string,
peer: { impl: MemoryTransportImpl | undefined },
latencyMs: number
) {
this.id = id;
this.peer = peer;
this.latencyMs = latencyMs;
}
public async send(message: unknown, signal?: AbortSignal): Promise<void> {
if (this.state !== "connected") {
throw new Error(`Transport ${this.id} is not connected`);
}
if (signal?.aborted) {
throw new Error("Aborted");
}
// `createMemoryTransport` always wires both peers before returning, so
// `peer.impl` is non-undefined for the lifetime of the transport.
const target = this.peer.impl as MemoryTransportImpl;
if (this.latencyMs > 0) {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
target.deliver(message);
resolve();
}, this.latencyMs);
signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(new Error("Aborted"));
});
});
} else {
target.deliver(message);
}
}
public async connect(_signal?: AbortSignal): Promise<void> {
this.state = "connected";
this.emit("connect", {});
}
public disconnect(): void {
if (this.state === "disconnected") {
return;
}
this.state = "disconnected";
this.emit("disconnect", {});
}
public on(event: string, handler: (data: unknown) => void): () => void {
let set = this.handlers.get(event);
if (!set) {
set = new Set();
this.handlers.set(event, set);
}
set.add(handler);
return () => {
set?.delete(handler);
};
}
public deliver(message: unknown): void {
this.messages.push(message);
this.emit("message", message);
}
public emit(event: string, data: unknown): void {
const set = this.handlers.get(event);
if (!set) {
return;
}
for (const handler of set) {
handler(data);
}
}
}
/**
* Create a bidirectional in-memory transport pair for testing.
*/
export function createMemoryTransport(
options: MemoryTransportOptions = {}
): MemoryTransportPair {
const latencyMs = options.latencyMs ?? 0;
const clientRef: { impl: MemoryTransportImpl | undefined } = {
impl: undefined,
};
const serverRef: { impl: MemoryTransportImpl | undefined } = {
impl: undefined,
};
const client = new MemoryTransportImpl(
`mem-${transportId++}-client`,
serverRef,
latencyMs
);
const server = new MemoryTransportImpl(
`mem-${transportId++}-server`,
clientRef,
latencyMs
);
clientRef.impl = client;
serverRef.impl = server;
return {
client,
server,
connect() {
client.state = "connected";
server.state = "connected";
client.emit("connect", {});
server.emit("connect", {});
},
disconnect() {
client.disconnect();
server.disconnect();
},
};
}

View File

@@ -0,0 +1,71 @@
# Common Types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the public type surface of `@suaveplan/network-testing`.
All interfaces are pure types with no runtime footprint, allowing consumers to
import them without increasing bundle size.
## Features
- Transport abstractions (`MemoryTransport`, `MemoryTransportPair`).
- WebSocket testing types (`FakeWebSocketAdapter`, `FakeWebSocketHandle`).
- Network condition modelling (`NetworkConditions`, `LatencyDistribution`).
- Peer factory types (`TestPeer`, `TestPeerOptions`, `SeedConfig`).
- Event capture contract (`NetworkEventCapture`).
## Usage
```ts
import type { MemoryTransportPair, NetworkConditions } from "@suaveplan/network-testing";
function testWithLatency(pair: MemoryTransportPair, cond: NetworkConditions) {
// ...
}
```
## API Reference
### Transport Types
- `TransportState` — `"connecting" | "connected" | "disconnected"`.
- `MemoryTransportOptions` — `{ latencyMs?, clock? }`.
- `MemoryTransport` — single endpoint with `send`, `connect`, `disconnect`, `on`.
- `MemoryTransportPair` — `{ client, server, connect(), disconnect() }`.
### WebSocket Types
- `FakeWebSocketOptions` — `{ autoConnect?, clock? }`.
- `FakeWebSocketHandle` — standard WebSocket-like handle with callbacks.
- `FakeWebSocketAdapter` — factory and simulation surface.
### Simulation Types
- `LatencyDistribution` — `{ min, max, distribution: "uniform" | "normal" | "bimodal" }`.
- `NetworkConditions` — optional latency, jitter, packet loss, bandwidth, and
disconnect threshold.
- `SimulationStats` — counters and latency averages.
- `ConditionSimulator` — `wrap`, `setConditions`, `reset`, and `stats`.
### Peer Types
- `TestPeerOptions` — overrides for id, address, capabilities, region, clock.
- `SeedConfig` — bootstrap seed shape.
- `TestPeer` — fluent peer builder.
### Event Types
- `NetworkEventCapture` — `events`, `waitFor`, `assertEventEmitted`,
`assertEventCount`, `clear`.
## Implementation Notes
Types are intentionally minimal and focused exclusively on testing concerns.
Domain-specific network concepts such as `PeerId` and `MessageEnvelope` live in
`@suaveplan/network` rather than here, ensuring that this package remains a
clean testing utility and does not become a secondary source of network
contracts that would drift out of sync with the core implementation.

View File

@@ -0,0 +1,170 @@
import type { Clock } from "@suaveplan/types";
/**
* State of an in-memory transport endpoint.
*/
export type TransportState = "connecting" | "connected" | "disconnected";
/**
* Options for creating a memory transport pair.
*/
export interface MemoryTransportOptions {
readonly latencyMs?: number;
readonly clock?: Clock;
}
/**
* An in-memory transport endpoint.
*/
export interface MemoryTransport {
readonly id: string;
readonly state: TransportState;
send(message: unknown, signal?: AbortSignal): Promise<void>;
connect(signal?: AbortSignal): Promise<void>;
disconnect(): void;
on(event: string, handler: (data: unknown) => void): () => void;
readonly messages: unknown[];
}
/**
* A bidirectional pair of in-memory transports.
*/
export interface MemoryTransportPair {
readonly client: MemoryTransport;
readonly server: MemoryTransport;
connect(): void;
disconnect(): void;
}
/**
* Options for creating a fake WebSocket adapter.
*/
export interface FakeWebSocketOptions {
readonly autoConnect?: boolean;
readonly clock?: Clock;
}
/**
* A fake WebSocket handle returned by the adapter.
*/
export interface FakeWebSocketHandle {
readonly readyState: number;
readonly sentMessages: ReadonlyArray<string | Uint8Array>;
send(data: string | Uint8Array): void;
close(code?: number, reason?: string): void;
onopen: (() => void) | null;
onclose: ((code: number, reason: string) => void) | null;
onmessage: ((data: string | Uint8Array) => void) | null;
onerror: ((error: Error) => void) | null;
}
/**
* A fake WebSocket adapter for testing.
*/
export interface FakeWebSocketAdapter {
connect(url: string, protocols?: string[]): FakeWebSocketHandle;
simulateMessage(data: string | Uint8Array): void;
simulateError(error: Error): void;
simulateClose(code?: number, reason?: string): void;
readonly connections: ReadonlyArray<{
url: string;
handle: FakeWebSocketHandle;
}>;
}
/**
* Latency distribution for condition simulation.
*/
export interface LatencyDistribution {
readonly min: number;
readonly max: number;
readonly distribution: "uniform" | "normal" | "bimodal";
}
/**
* Network conditions to simulate.
*/
export interface NetworkConditions {
readonly latencyMs?: number | LatencyDistribution;
readonly jitterMs?: number;
readonly packetLossRate?: number;
readonly bandwidthBps?: number;
readonly disconnectAfterMs?: number;
}
/**
* Statistics collected by the condition simulator.
*/
export interface SimulationStats {
readonly sentMessages: number;
readonly droppedMessages: number;
readonly totalLatencyMs: number;
readonly avgLatencyMs: number;
}
/**
* A condition simulator that wraps transports.
*/
export interface ConditionSimulator {
wrap<T extends { send: (...args: unknown[]) => Promise<unknown> }>(
transport: T
): T;
setConditions(conditions: Partial<NetworkConditions>): void;
reset(): void;
readonly stats: SimulationStats;
}
/**
* Configuration for a test peer.
*/
export interface TestPeerOptions {
readonly id?: string;
readonly address?: string;
readonly capabilities?: string[];
readonly region?: string;
readonly clock?: Clock;
}
/**
* A bootstrap seed configuration.
*/
export interface SeedConfig {
readonly peerId: string;
readonly address: string;
readonly capabilities: readonly string[];
readonly region: string;
}
/**
* A fluent test peer builder.
*/
export interface TestPeer {
readonly id: string;
readonly address: string;
readonly capabilities: readonly string[];
readonly region: string;
asRegistryEntry(): {
peerId: string;
address: string;
tier: number;
latencyMs: number;
};
asBootstrapSeed(): SeedConfig;
withCapabilities(capabilities: string[]): TestPeer;
withRegion(region: string): TestPeer;
withHealth(healthy: boolean, latencyMs?: number): TestPeer;
}
/**
* A network event capture utility.
*/
export interface NetworkEventCapture {
readonly events: unknown[];
waitFor(event: string, timeout?: number): Promise<unknown>;
assertEventEmitted(
event: string,
predicate?: (data: unknown) => boolean
): void;
assertEventCount(event: string, count: number): void;
clear(): void;
}