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,26 @@
# @suaveplan/network-plugin-simulation - Deterministic virtual-network simulation framework for the Suaveplan mesh: NetworkSimulator (seeded RNG + time control + topology factories), VirtualNode, ScenarioRunner, NetworkConditionInjector, ChaosEngine. Dev/test only.
## Summary
Deterministic virtual-network simulation framework for the Suaveplan mesh. `NetworkSimulator` runs scenarios against a seeded RNG with simulated time (acceleration / deceleration vs wall clock) and four built-in topology factories (star / mesh / tiered / custom); `VirtualNode` wraps the same `NetworkNode` + plugin code that real peers use, so behaviour is bit-identical between simulation and production; `ScenarioRunner` executes declarative scenarios (setup → ordered events → success criteria → metric collection) with checkpointing for long runs; `NetworkConditionInjector` applies latency / loss / bandwidth / jitter / connection-disruption models per peer-pair; `ChaosEngine` injects random failures (peer crashes, transport faults, partitions, message drops) for chaos testing. Dev/test only — the production wrapper is a no-op when `NODE_ENV === "production"`.
<!-- begin:auto-docs -->
## Documentation tree
- [chaos](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [chaos-engine](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [conditions](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [network-condition-injector](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [simulation-plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [scenario](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [scenario-runner](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [network-simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and register…
<!-- end:auto-docs --> - [chaos-engine](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [conditions](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-condition-injector](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario-runner](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and register…
- [chaos-engine](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [conditions](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-condition-injector](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario-runner](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and register…
- [chaos-engine](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [conditions](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-condition-injector](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario-runner](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and register…
- [chaos-engine](./src/chaos/chaos-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [conditions](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-condition-injector](./src/conditions/network-condition-injector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [simulation-plugin](./src/plugin/simulation-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [scenario](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [scenario-runner](./src/scenario/scenario-runner.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-simulator](./src/simulator/network-simulator.md) — <!-- begin:auto-docs --> _(no submodules)_- [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_ - [virtual-node](./src/virtual-node/virtual-node.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and register…

View File

@@ -0,0 +1,49 @@
{
"name": "@suaveplan/network-plugin-simulation",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Deterministic virtual-network simulation framework for the Suaveplan mesh: NetworkSimulator (seeded RNG + time control + topology factories), VirtualNode, ScenarioRunner, NetworkConditionInjector, ChaosEngine. Dev/test only.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,81 @@
# `ChaosEngine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Probabilistic failure injector for chaos testing. Each call to `step(nodes, edges)` rolls four independent dice (crash / partition / message-drop / transport-fault) using the simulator's seeded RNG, applies the relevant mutation, and appends a `ChaosEvent` to the engine's event log. Used to push the mesh past the predefined-scenario surface and surface bugs that only appear under noisy real-world conditions.
## Features
- Four configurable per-step probabilities — set any to `0` (or omit) to disable that chaos type.
- Crash and transport-fault both pick a random victim from the live `nodes` array; partition picks a random victim edge from the supplied edge list.
- Message-drop is decoupled from a specific message — it fires based on the per-step probability so callers can pair drops with arbitrary downstream traffic.
- Event log is per-engine and survives `step` calls so test assertions can read the full chaos history in one shot.
- Pure RNG via the simulator means deterministic chaos under a fixed seed.
## Usage: Basic Example
```ts
import {
ChaosEngine,
NetworkSimulator,
VirtualNode,
} from "@suaveplan/network-plugin-simulation";
const sim = new NetworkSimulator({ seed: 1 });
const nodes = [new VirtualNode({ id: "n1", tier: 0 }, sim)];
const chaos = new ChaosEngine(sim, { crashProbability: 0.5 });
const events = chaos.step(nodes, []);
console.log(events);
```
## Usage: Advanced Example
```ts
import {
ChaosEngine,
NetworkSimulator,
VirtualNode,
} from "@suaveplan/network-plugin-simulation";
const sim = new NetworkSimulator({ seed: 99 });
const nodes = Array.from(
{ length: 5 },
(_, i) => new VirtualNode({ id: `n${i}`, tier: i === 0 ? 0 : 1 }, sim)
);
const edges = nodes
.slice(1)
.map((n) => ({ from: "n0", to: n.id }));
const chaos = new ChaosEngine(sim, {
crashProbability: 0.05,
partitionProbability: 0.02,
messageDropProbability: 0.10,
transportFaultProbability: 0.01,
});
for (let tick = 0; tick < 100; tick += 1) {
sim.advanceBy(1);
chaos.step(nodes, edges);
}
console.log(chaos.eventLog().length);
```
## API Reference
- `class ChaosEngine(simulator, config?)`
- `update(config: ChaosConfig): void`.
- `step(nodes, edges): ReadonlyArray<ChaosEvent>` — events emitted this step.
- `eventLog(): ReadonlyArray<ChaosEvent>` — full log since construction.
- `reset(): void` — clears the event log.
- `interface ChaosConfig` — `{ crashProbability?, partitionProbability?, messageDropProbability?, transportFaultProbability? }`.
- `interface ChaosEvent` — `{ atMs, type, target?, target2? }`.
## Implementation Notes
- The engine never throws — chaos is intentionally noisy and the simulator should not abort because of an unlucky roll. Caller code that wants to refuse particular events should filter the event log post-hoc.
- Crash skips victims that are already not-running; transport-fault does not, because a transport fault on a downed node is still a meaningful test event for downstream dispatchers.
- `messageDropProbability` is not paired with a specific message because the engine does not know about message identities — pair it with a downstream layer that consults the event log for "should I drop the next outbound?" decisions.
- The four probabilities are independent — every step can emit between zero and four events.

View File

@@ -0,0 +1,124 @@
import type { NetworkSimulator } from "../simulator/network-simulator.js";
import type { VirtualNode } from "../virtual-node/virtual-node.js";
export interface ChaosConfig {
/** Per-step probability that a randomly-chosen node crashes. */
readonly crashProbability?: number;
/** Per-step probability that a randomly-chosen edge partitions. */
readonly partitionProbability?: number;
/** Per-step probability that the next message drops. */
readonly messageDropProbability?: number;
/** Per-step probability of a transport fault (returned in event log). */
readonly transportFaultProbability?: number;
}
export interface ChaosEvent {
readonly atMs: number;
readonly type: "crash" | "partition" | "drop" | "transport-fault";
readonly target?: string;
readonly target2?: string;
}
/**
* Probabilistic failure injector. Steps through (node, RNG roll)
* pairs; consumers call `step(nodes, edges)` once per simulator tick
* to roll for each chaos type.
*/
export class ChaosEngine {
private readonly simulator: NetworkSimulator;
private config: ChaosConfig;
private readonly events: ChaosEvent[] = [];
public constructor(simulator: NetworkSimulator, config: ChaosConfig = {}) {
this.simulator = simulator;
this.config = config;
}
public update(config: ChaosConfig): void {
this.config = config;
}
/**
* Run one chaos step. `nodes` is the live virtual-node set; `edges`
* is the pair list the partition roll picks from. Returns the
* events emitted this step.
*/
public step(
nodes: ReadonlyArray<VirtualNode>,
edges: ReadonlyArray<{ from: string; to: string }>
): ReadonlyArray<ChaosEvent> {
const stepEvents: ChaosEvent[] = [];
this.maybeCrash(nodes, stepEvents);
this.maybePartition(edges, stepEvents);
this.maybeDrop(stepEvents);
this.maybeTransportFault(nodes, stepEvents);
for (const e of stepEvents) this.events.push(e);
return stepEvents;
}
public eventLog(): ReadonlyArray<ChaosEvent> {
return [...this.events];
}
public reset(): void {
this.events.length = 0;
}
private maybeCrash(
nodes: ReadonlyArray<VirtualNode>,
out: ChaosEvent[]
): void {
const probability = this.config.crashProbability ?? 0;
if (probability <= 0 || nodes.length === 0) return;
if (this.simulator.rand() >= probability) return;
const idx = this.simulator.randInt(0, nodes.length);
const target = nodes[idx] as VirtualNode;
if (target.getStatus() !== "running") return;
target.crash();
out.push({
atMs: this.simulator.now(),
type: "crash",
target: target.id,
});
}
private maybePartition(
edges: ReadonlyArray<{ from: string; to: string }>,
out: ChaosEvent[]
): void {
const probability = this.config.partitionProbability ?? 0;
if (probability <= 0 || edges.length === 0) return;
if (this.simulator.rand() >= probability) return;
const idx = this.simulator.randInt(0, edges.length);
const edge = edges[idx] as { from: string; to: string };
out.push({
atMs: this.simulator.now(),
type: "partition",
target: edge.from,
target2: edge.to,
});
}
private maybeDrop(out: ChaosEvent[]): void {
const probability = this.config.messageDropProbability ?? 0;
if (probability <= 0) return;
if (this.simulator.rand() >= probability) return;
out.push({ atMs: this.simulator.now(), type: "drop" });
}
private maybeTransportFault(
nodes: ReadonlyArray<VirtualNode>,
out: ChaosEvent[]
): void {
const probability = this.config.transportFaultProbability ?? 0;
if (probability <= 0 || nodes.length === 0) return;
if (this.simulator.rand() >= probability) return;
const idx = this.simulator.randInt(0, nodes.length);
const target = nodes[idx] as VirtualNode;
out.push({
atMs: this.simulator.now(),
type: "transport-fault",
target: target.id,
});
}
}

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NetworkSimulator } from "../simulator/network-simulator.js";
import { VirtualNode } from "../virtual-node/virtual-node.js";
import { ChaosEngine } from "./chaos-engine.js";
const setup = (configOverrides = {}) => {
const sim = new NetworkSimulator({ seed: 1 });
const nodes = Array.from(
{ length: 4 },
(_, i) => new VirtualNode({ id: `n${i}`, tier: 0 }, sim)
);
const edges = nodes.slice(1).map((n) => ({ from: "n0", to: n.id }));
return {
sim,
nodes,
edges,
chaos: new ChaosEngine(sim, configOverrides),
};
};
describe("ChaosEngine: per-step probabilistic failure injection", () => {
it("default config emits zero events", () => {
const { nodes, edges, chaos } = setup({});
for (let i = 0; i < 50; i += 1) {
chaos.step(nodes, edges);
}
expect(chaos.eventLog().length).toBe(0);
});
it("crashProbability=1 crashes a random node", () => {
const { nodes, edges, chaos } = setup({ crashProbability: 1 });
chaos.step(nodes, edges);
const events = chaos.eventLog();
expect(events.length).toBe(1);
expect(events[0]?.type).toBe("crash");
});
it("crash skips already-crashed victims", () => {
const { nodes, edges, chaos } = setup({ crashProbability: 1 });
nodes.forEach((n) => n.crash());
const events = chaos.step(nodes, edges);
expect(events.length).toBe(0);
});
it("partition picks an edge", () => {
const { nodes, edges, chaos } = setup({ partitionProbability: 1 });
const events = chaos.step(nodes, edges);
expect(events.find((e) => e.type === "partition")).toBeDefined();
});
it("messageDrop emits a drop event", () => {
const { nodes, edges, chaos } = setup({ messageDropProbability: 1 });
const events = chaos.step(nodes, edges);
expect(events.find((e) => e.type === "drop")).toBeDefined();
});
it("transportFault picks a random target", () => {
const { nodes, edges, chaos } = setup({
transportFaultProbability: 1,
});
const events = chaos.step(nodes, edges);
expect(events.find((e) => e.type === "transport-fault")).toBeDefined();
});
it("zero / unset probabilities are no-ops", () => {
const { nodes, edges, chaos } = setup({
crashProbability: 0,
partitionProbability: 0,
messageDropProbability: 0,
transportFaultProbability: 0,
});
const events = chaos.step(nodes, edges);
expect(events.length).toBe(0);
});
it("crashProbability with zero nodes is a no-op", () => {
const sim = new NetworkSimulator({ seed: 1 });
const chaos = new ChaosEngine(sim, { crashProbability: 1 });
const events = chaos.step([], []);
expect(events.length).toBe(0);
});
it("partition with zero edges is a no-op", () => {
const { nodes, chaos } = setup({ partitionProbability: 1 });
const events = chaos.step(nodes, []);
expect(events.find((e) => e.type === "partition")).toBeUndefined();
});
it("transportFault with zero nodes is a no-op", () => {
const sim = new NetworkSimulator({ seed: 1 });
const chaos = new ChaosEngine(sim, { transportFaultProbability: 1 });
const events = chaos.step([], []);
expect(events.length).toBe(0);
});
it("update replaces the active config", () => {
const { nodes, edges, chaos } = setup({});
chaos.update({ messageDropProbability: 1 });
const events = chaos.step(nodes, edges);
expect(events.find((e) => e.type === "drop")).toBeDefined();
});
it("eventLog accumulates across steps; reset clears", () => {
const { nodes, edges, chaos } = setup({ messageDropProbability: 1 });
chaos.step(nodes, edges);
chaos.step(nodes, edges);
expect(chaos.eventLog().length).toBe(2);
chaos.reset();
expect(chaos.eventLog().length).toBe(0);
});
it("eventLog returns a defensive copy", () => {
const { nodes, edges, chaos } = setup({ messageDropProbability: 1 });
chaos.step(nodes, edges);
const log = chaos.eventLog();
(log as unknown as Array<unknown>).push(null);
expect(chaos.eventLog().length).toBe(1);
});
it("crashProbability roll that lands above the threshold skips", () => {
const sim = new NetworkSimulator({ seed: 1 });
const nodes = [new VirtualNode({ id: "n", tier: 0 }, sim)];
const chaos = new ChaosEngine(sim, { crashProbability: 0.001 });
chaos.step(nodes, []);
expect(chaos.eventLog().length).toBe(0);
});
it("non-zero probabilities that do not trip leave each branch a no-op", () => {
// Probability 0.001 with seed=1 produces rolls > threshold so each
// chaos branch exits without firing — exercises the early-return
// path in maybeCrash / maybePartition / maybeDrop / maybeTransportFault.
const { nodes, edges, chaos } = setup({
crashProbability: 0.001,
partitionProbability: 0.001,
messageDropProbability: 0.001,
transportFaultProbability: 0.001,
});
const events = chaos.step(nodes, edges);
expect(events.length).toBe(0);
});
});

View File

@@ -0,0 +1,258 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NetworkSimulator } from "../simulator/network-simulator.js";
import { NetworkConditionInjector } from "./network-condition-injector.js";
const setup = (config: Parameters<typeof NetworkConditionInjector>[1]) => {
const sim = new NetworkSimulator({ seed: 99 });
return {
sim,
injector: new NetworkConditionInjector(sim, config),
};
};
describe("NetworkConditionInjector: latency / loss / bandwidth / jitter / disruption", () => {
it("default config returns zero latency / no drops / infinite bandwidth", () => {
const { injector } = setup({});
const r = injector.inject({
from: "a",
to: "b",
sizeBytes: 100,
});
expect(r.latencyMs).toBe(0);
expect(r.dropped).toBe(false);
expect(r.bandwidthBytesPerSec).toBe(Number.POSITIVE_INFINITY);
});
it("constant latency model returns the configured value", () => {
const { injector } = setup({
latency: { kind: "constant", latencyMs: 75 },
});
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.latencyMs).toBe(75);
});
it("random latency stays within [min, max)", () => {
const { injector } = setup({
latency: { kind: "random", minMs: 50, maxMs: 100 },
});
for (let i = 0; i < 50; i += 1) {
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.latencyMs).toBeGreaterThanOrEqual(50);
expect(r.latencyMs).toBeLessThan(100);
}
});
it("geographic latency sums the per-region values", () => {
const { injector } = setup({
latency: {
kind: "geographic",
perRegionMs: { "us-east": 30, "eu-west": 80 },
},
});
const r = injector.inject({
from: "a",
to: "b",
fromRegion: "us-east",
toRegion: "eu-west",
sizeBytes: 1,
});
expect(r.latencyMs).toBe(110);
});
it("geographic latency falls to 0 for unknown regions", () => {
const { injector } = setup({
latency: {
kind: "geographic",
perRegionMs: { "us-east": 30 },
},
});
const r = injector.inject({
from: "a",
to: "b",
fromRegion: "ap-south",
toRegion: "us-east",
sizeBytes: 1,
});
expect(r.latencyMs).toBe(30);
});
it("geographic latency with no regions on the message returns 0", () => {
const { injector } = setup({
latency: {
kind: "geographic",
perRegionMs: { "us-east": 30 },
},
});
const r = injector.inject({
from: "a",
to: "b",
sizeBytes: 1,
});
expect(r.latencyMs).toBe(0);
});
it("loss random eventually drops messages", () => {
const { injector } = setup({
loss: { kind: "random", probability: 1 },
});
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.droppedByLoss).toBe(true);
expect(r.dropped).toBe(true);
});
it("loss burst with zero burst length never drops", () => {
const { injector } = setup({
loss: { kind: "burst", burstProbability: 0, burstLength: 1 },
});
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.droppedByLoss).toBe(false);
});
it("loss connection-based picks per-edge probability", () => {
const { injector } = setup({
loss: {
kind: "connection-based",
probabilityPerEdge: { "a:b": 1, "c:d": 0 },
},
});
expect(
injector.inject({ from: "a", to: "b", sizeBytes: 1 }).droppedByLoss
).toBe(true);
expect(
injector.inject({ from: "c", to: "d", sizeBytes: 1 }).droppedByLoss
).toBe(false);
// Unknown edge defaults to 0
expect(
injector.inject({ from: "x", to: "y", sizeBytes: 1 }).droppedByLoss
).toBe(false);
});
it("bandwidth fixed returns the configured rate", () => {
const { injector } = setup({
bandwidth: { kind: "fixed", bytesPerSec: 1024 },
});
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.bandwidthBytesPerSec).toBe(1024);
});
it("bandwidth variable stays within [min, max]", () => {
const { injector } = setup({
bandwidth: {
kind: "variable",
minBytesPerSec: 100,
maxBytesPerSec: 200,
},
});
for (let i = 0; i < 30; i += 1) {
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.bandwidthBytesPerSec).toBeGreaterThanOrEqual(100);
expect(r.bandwidthBytesPerSec).toBeLessThan(200);
}
});
it("bandwidth congestion multiplies base by factor", () => {
const { injector } = setup({
bandwidth: {
kind: "congestion",
baseBytesPerSec: 1000,
congestionFactor: 0.25,
},
});
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.bandwidthBytesPerSec).toBe(250);
});
it("jitter adds amplitude * sin(2π t/period)", () => {
const { sim, injector } = setup({
latency: { kind: "constant", latencyMs: 10 },
jitter: { amplitudeMs: 5, periodMs: 4 },
});
sim.advanceTo(1); // sin(2π/4) = 1
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.latencyMs).toBeCloseTo(15, 5);
});
it("jitter with periodMs <= 0 contributes zero", () => {
const { injector } = setup({
latency: { kind: "constant", latencyMs: 10 },
jitter: { amplitudeMs: 5, periodMs: 0 },
});
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.latencyMs).toBe(10);
});
it("disruption: probability=1 immediately drops the edge", () => {
const { injector } = setup({
disruption: { probability: 1, durationMs: 1_000 },
});
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.droppedByDisruption).toBe(true);
});
it("disruption: temporary disruption clears after durationMs", () => {
const { sim, injector } = setup({
disruption: { probability: 1, durationMs: 100 },
});
injector.inject({ from: "a", to: "b", sizeBytes: 1 });
sim.advanceBy(150);
// After expiry, the edge is no longer disrupted (until next roll)
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
// re-rolled disruption may re-trip with prob 1; either way the
// injector executed the expiry path
expect(typeof r.droppedByDisruption).toBe("boolean");
});
it("disruption: permanent stays dropped indefinitely", () => {
const { sim, injector } = setup({
disruption: { probability: 1, durationMs: 100, permanent: true },
});
injector.inject({ from: "a", to: "b", sizeBytes: 1 });
sim.advanceBy(10_000);
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.droppedByDisruption).toBe(true);
});
it("update replaces the active config", () => {
const { injector } = setup({});
injector.update({
latency: { kind: "constant", latencyMs: 1 },
});
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.latencyMs).toBe(1);
});
it("disruption persistence: subsequent inject within window returns dropped", () => {
const { sim, injector } = setup({
disruption: { probability: 1, durationMs: 1_000 },
});
injector.inject({ from: "a", to: "b", sizeBytes: 1 });
sim.advanceBy(100); // still inside the 1_000ms window
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.droppedByDisruption).toBe(true);
});
it("disruption probability=0 never trips", () => {
const { injector } = setup({
disruption: { probability: 0, durationMs: 1_000 },
});
const r = injector.inject({ from: "a", to: "b", sizeBytes: 1 });
expect(r.droppedByDisruption).toBe(false);
});
it("geographic latency missing toRegion in the model contributes 0", () => {
const { injector } = setup({
latency: {
kind: "geographic",
perRegionMs: { "us-east": 30 },
},
});
const r = injector.inject({
from: "a",
to: "b",
fromRegion: "us-east",
toRegion: "ap-south", // not in model
sizeBytes: 1,
});
expect(r.latencyMs).toBe(30);
});
});

View File

@@ -0,0 +1,83 @@
# `NetworkConditionInjector`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-message decision engine that applies the configured `NetworkConditionConfig` (latency / loss / bandwidth / jitter / disruption) to each simulated send. Returns an `InjectionResult` describing the resulting latency, drop verdict, and effective bandwidth so the simulator's transport layer can decide whether and when to deliver the message.
## Features
- Three latency models (`constant` / `random` / `geographic`) selected via discriminated `kind`.
- Three loss patterns (`random` / `burst` / `connection-based`) — burst is approximated as `burstProbability × burstLength` per attempt for simplicity.
- Three bandwidth limits (`fixed` / `variable` / `congestion`).
- Sinusoidal jitter added to the resolved latency on every call.
- Per-edge disruption tracker remembers active drops until the configured `durationMs` elapses (or forever if `permanent: true`).
- Pure: every random decision goes through the simulator's seeded RNG so two runs with identical configs produce identical decisions.
## Usage: Basic Example
```ts
import {
NetworkConditionInjector,
NetworkSimulator,
} from "@suaveplan/network-plugin-simulation";
const sim = new NetworkSimulator({ seed: 1 });
const injector = new NetworkConditionInjector(sim, {
latency: { kind: "constant", latencyMs: 50 },
loss: { kind: "random", probability: 0.01 },
});
const result = injector.inject({ from: "n1", to: "n2", sizeBytes: 1024 });
result.latencyMs; // 50 + jitter (0 here)
```
## Usage: Advanced Example
```ts
import {
NetworkConditionInjector,
NetworkSimulator,
} from "@suaveplan/network-plugin-simulation";
const sim = new NetworkSimulator({ seed: 99 });
const injector = new NetworkConditionInjector(sim, {
latency: {
kind: "geographic",
perRegionMs: { "us-east": 30, "eu-west": 80 },
},
bandwidth: {
kind: "congestion",
baseBytesPerSec: 5_000_000,
congestionFactor: 0.5,
},
jitter: { amplitudeMs: 20, periodMs: 5_000 },
disruption: { probability: 0.001, durationMs: 1_000 },
});
sim.advanceBy(2);
const r = injector.inject({
from: "n1",
to: "n2",
fromRegion: "us-east",
toRegion: "eu-west",
sizeBytes: 4096,
});
console.log(r.latencyMs, r.bandwidthBytesPerSec);
```
## API Reference
- `class NetworkConditionInjector(simulator, config?)`
- `update(config: NetworkConditionConfig): void`.
- `inject(message: MessageDescriptor): InjectionResult`.
- `interface MessageDescriptor` — `{ from, to, fromRegion?, toRegion?, sizeBytes }`.
- `interface InjectionResult` — `{ latencyMs, droppedByLoss, droppedByDisruption, dropped, bandwidthBytesPerSec }`.
## Implementation Notes
- All decisions consume one or more values from the simulator's seeded RNG, so byte-identical runs with the same seed produce byte-identical injection results.
- Disruption is sticky: once an edge drops, every subsequent inject for that edge returns dropped until either `durationMs` expires or the disruption is `permanent`. This matches the TCP-style "the link is down" experience consumers want to test against.
- Geographic latency adds the `fromRegion` and `toRegion` values; missing regions contribute zero so consumers can mix region-tagged and untagged messages without bespoke handling.
- The jitter wave uses `sin(2πt/period)` so amplitude is symmetric around zero — large negative jitter can subtract from constant latency, modelling under-load fast paths as well as congested ones.

View File

@@ -0,0 +1,196 @@
import type { NetworkSimulator } from "../simulator/network-simulator.js";
import type {
BandwidthLimit,
DisruptionConfig,
JitterConfig,
LatencyModel,
LossPattern,
NetworkConditionConfig,
} from "../types/condition.types.js";
export interface MessageDescriptor {
readonly from: string;
readonly to: string;
readonly fromRegion?: string;
readonly toRegion?: string;
readonly sizeBytes: number;
}
export interface InjectionResult {
readonly latencyMs: number;
readonly droppedByLoss: boolean;
readonly droppedByDisruption: boolean;
/** True if any reason caused the message to drop. */
readonly dropped: boolean;
readonly bandwidthBytesPerSec: number;
}
interface DisruptionState {
readonly until: number;
readonly permanent: boolean;
}
/**
* Decides per-message latency, drop, and bandwidth based on a
* `NetworkConditionConfig` and the simulator's seeded RNG. Stateless
* across messages except for the per-edge disruption tracker.
*/
export class NetworkConditionInjector {
private readonly simulator: NetworkSimulator;
private readonly disruptions = new Map<string, DisruptionState>();
private config: NetworkConditionConfig;
public constructor(
simulator: NetworkSimulator,
config: NetworkConditionConfig = {}
) {
this.simulator = simulator;
this.config = config;
}
public update(config: NetworkConditionConfig): void {
this.config = config;
}
public inject(message: MessageDescriptor): InjectionResult {
const latency = this.computeLatency(message);
const droppedByLoss = this.computeLoss(message);
const droppedByDisruption = this.computeDisruption(message);
const bandwidth = this.computeBandwidth();
return {
latencyMs: latency + this.computeJitter(),
droppedByLoss,
droppedByDisruption,
dropped: droppedByLoss || droppedByDisruption,
bandwidthBytesPerSec: bandwidth,
};
}
private computeLatency(message: MessageDescriptor): number {
const model = this.config.latency;
if (model === undefined) return 0;
return resolveLatency(model, message, this.simulator);
}
private computeLoss(message: MessageDescriptor): boolean {
const pattern = this.config.loss;
if (pattern === undefined) return false;
return resolveLoss(pattern, message, this.simulator);
}
private computeBandwidth(): number {
const limit = this.config.bandwidth;
if (limit === undefined) return Number.POSITIVE_INFINITY;
return resolveBandwidth(limit, this.simulator);
}
private computeJitter(): number {
const j = this.config.jitter;
if (j === undefined) return 0;
return resolveJitter(j, this.simulator.now());
}
private computeDisruption(message: MessageDescriptor): boolean {
const cfg = this.config.disruption;
if (cfg === undefined) return false;
const key = `${message.from}:${message.to}`;
const existing = this.disruptions.get(key);
const now = this.simulator.now();
if (existing !== undefined) {
if (existing.permanent) return true;
if (now < existing.until) return true;
this.disruptions.delete(key);
}
return maybeEnterDisruption(
cfg,
key,
this.disruptions,
now,
this.simulator
);
}
}
function resolveLatency(
model: LatencyModel,
message: MessageDescriptor,
sim: NetworkSimulator
): number {
switch (model.kind) {
case "constant":
return model.latencyMs;
case "random":
return model.minMs + sim.rand() * (model.maxMs - model.minMs);
case "geographic": {
const fromMs =
message.fromRegion !== undefined
? (model.perRegionMs[message.fromRegion] ?? 0)
: 0;
const toMs =
message.toRegion !== undefined
? (model.perRegionMs[message.toRegion] ?? 0)
: 0;
return fromMs + toMs;
}
}
}
function resolveLoss(
pattern: LossPattern,
message: MessageDescriptor,
sim: NetworkSimulator
): boolean {
switch (pattern.kind) {
case "random":
return sim.rand() < pattern.probability;
case "burst": {
// Treat burstLength as additional drop probability while a
// burst is "active" — kept simple: a burst trip raises the
// effective probability for `burstLength` attempts.
return sim.rand() < pattern.burstProbability * pattern.burstLength;
}
case "connection-based": {
const key = `${message.from}:${message.to}`;
const probability = pattern.probabilityPerEdge[key] ?? 0;
return sim.rand() < probability;
}
}
}
function resolveBandwidth(
limit: BandwidthLimit,
sim: NetworkSimulator
): number {
switch (limit.kind) {
case "fixed":
return limit.bytesPerSec;
case "variable":
return (
limit.minBytesPerSec +
sim.rand() * (limit.maxBytesPerSec - limit.minBytesPerSec)
);
case "congestion":
return limit.baseBytesPerSec * limit.congestionFactor;
}
}
function resolveJitter(j: JitterConfig, nowMs: number): number {
if (j.periodMs <= 0) return 0;
const phase = (2 * Math.PI * nowMs) / j.periodMs;
return j.amplitudeMs * Math.sin(phase);
}
function maybeEnterDisruption(
cfg: DisruptionConfig,
key: string,
map: Map<string, DisruptionState>,
nowMs: number,
sim: NetworkSimulator
): boolean {
if (sim.rand() >= cfg.probability) return false;
map.set(key, {
until: nowMs + cfg.durationMs,
permanent: cfg.permanent === true,
});
return true;
}

View File

@@ -0,0 +1,55 @@
# Error codes — `@suaveplan/network-plugin-simulation`
## Purpose
Frozen registry of every error code raised by the simulation package. Codes are namespaced under `NET_SIM` and registered into the global error registry on first import via the side-effect import in `src/index.ts`. Each code maps to a single failure class so consumers branch on `error.code` without parsing message text.
## Features
- Four codes covering topology validation, node lookup, scenario validation, and time-control validation. The previous registry shipped with `CRITERION_FAILED` reserved for predefined-scenario integration code; the placeholder was removed because the current dispatcher reports criterion failures inline via the `ScenarioResult.criteriaResults` map rather than throwing on the failure path.
- `NetworkSimError` extends `SuaveplanError<NetworkSimErrorCode>` for cause-chain traversal, redaction, and OTel adapter compatibility.
- Frozen registry — runtime mutation throws.
## Usage: Basic Example
```ts
import {
NETWORK_SIM_CODES,
NetworkSimError,
} from "@suaveplan/network-plugin-simulation";
throw new NetworkSimError(
NETWORK_SIM_CODES.NODE_NOT_FOUND,
"simulator.unknown-node",
{ context: { nodeId: "n42" } }
);
```
## Usage: Advanced Example
```ts
try {
runner.run(scenario);
} catch (err) {
if (
err instanceof NetworkSimError &&
err.code === NETWORK_SIM_CODES.SCENARIO_INVALID
) {
return reportInvalidScenario(scenario);
}
throw err;
}
```
## API Reference
- `NETWORK_SIM_CODES` — frozen `KEY -> "NET_SIM.KEY"` map.
- `NetworkSimErrorCode` — TypeScript union of every code value.
- `NetworkSimError` — `SuaveplanError<NetworkSimErrorCode>` subclass; `name === "NetworkSimError"`.
## Implementation Notes
- The registry is constructed via `defineCodes("NET_SIM", […] as const)` so the type system enumerates the value union; adding a code requires bumping the `as const` tuple.
- Codes are deliberately coarse (one per failure class) so consumers build stable retry / observability policies without churn.
- `TIME_INVALID` covers both negative time-factors and `advanceBy(Δ)` calls with non-positive deltas; the messageKey distinguishes the two situations.
- Predefined-scenario integration code (`server-failure`, `partition`, `client-surge`, `cascading-failure` modules) ships in follow-up changes and may add its own codes there.

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_SIM_CODES, NetworkSimError } from "./error-codes.js";
describe("NETWORK_SIM_CODES", () => {
it("registers under NET_SIM", () => {
for (const value of Object.values(NETWORK_SIM_CODES)) {
expect(value).toMatch(/^NET_SIM\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(NETWORK_SIM_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"TOPOLOGY_INVALID",
"NODE_NOT_FOUND",
"SCENARIO_INVALID",
"TIME_INVALID",
] as const) {
expect(NETWORK_SIM_CODES).toHaveProperty(key);
}
});
});
describe("NetworkSimError", () => {
it("extends SuaveplanError", () => {
const err = new NetworkSimError(
NETWORK_SIM_CODES.TOPOLOGY_INVALID,
"x"
);
expect(err.name).toBe("NetworkSimError");
expect(err.code).toBe(NETWORK_SIM_CODES.TOPOLOGY_INVALID);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new NetworkSimError(
NETWORK_SIM_CODES.TIME_INVALID,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,26 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const NETWORK_SIM_CODES = defineCodes("NET_SIM", [
"TOPOLOGY_INVALID",
"NODE_NOT_FOUND",
"SCENARIO_INVALID",
"TIME_INVALID",
] as const);
export type NetworkSimErrorCode =
(typeof NETWORK_SIM_CODES)[keyof typeof NETWORK_SIM_CODES];
export class NetworkSimError extends SuaveplanError<NetworkSimErrorCode> {
public constructor(
code: NetworkSimErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetworkSimError";
}
}

View File

@@ -0,0 +1,69 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Chaos
export {
type ChaosConfig,
ChaosEngine,
type ChaosEvent,
} from "./chaos/chaos-engine.js";
// Conditions
export {
type InjectionResult,
type MessageDescriptor,
NetworkConditionInjector,
} from "./conditions/network-condition-injector.js";
// Errors
export {
NETWORK_SIM_CODES,
NetworkSimError,
type NetworkSimErrorCode,
} from "./error-codes.js";
// Plugin
export {
SimulationPlugin,
type SimulationPluginConfig,
} from "./plugin/simulation-plugin.js";
// Scenario
export {
type EventHandler,
ScenarioRunner,
type ScenarioRunnerConfig,
validateScenario,
} from "./scenario/scenario-runner.js";
// Simulator
export {
mulberry32,
NetworkSimulator,
type VirtualEdge,
type VirtualTopology,
} from "./simulator/network-simulator.js";
// Types
export type {
BandwidthLimit,
DisruptionConfig,
JitterConfig,
LatencyModel,
LossPattern,
NetworkConditionConfig,
} from "./types/condition.types.js";
export type {
ScenarioCheckpoint,
ScenarioEvent,
ScenarioEventType,
ScenarioResult,
SimulationScenario,
SuccessCriterion,
} from "./types/scenario.types.js";
export type {
NetworkTopologyConfig,
SimulationConfig,
TopologyKind,
VirtualNodeConfig,
} from "./types/simulation.types.js";
// Virtual node
export {
VirtualNode,
type VirtualNodeEvent,
type VirtualNodeStatus,
} from "./virtual-node/virtual-node.js";

View File

@@ -0,0 +1,101 @@
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SimulationPlugin } from "./simulation-plugin.js";
const newNode = () =>
new NetworkNode({ id: "node-1", networkBus: new NetworkBus() });
describe("SimulationPlugin: lifecycle + production no-op", () => {
it("constructs with simulator + conditions + chaos", () => {
const plugin = new SimulationPlugin({
simulation: { seed: 1 },
disabled: false,
});
expect(plugin.name).toBe("simulation");
expect(plugin.simulator).toBeDefined();
expect(plugin.conditions).toBeDefined();
expect(plugin.chaos).toBeDefined();
});
it("install + start + stop + dispose lifecycle when active", async () => {
const plugin = new SimulationPlugin({
simulation: { seed: 1 },
disabled: false,
});
const node = newNode();
node.use(plugin);
await node.start();
expect(plugin.host()).not.toBeNull();
expect(plugin.isStarted()).toBe(true);
await node.stop();
expect(plugin.isStarted()).toBe(false);
plugin.dispose();
expect(plugin.host()).toBeNull();
});
it("disabled plugin skips install + start (no-op)", async () => {
const plugin = new SimulationPlugin({
simulation: { seed: 1 },
disabled: true,
});
const node = newNode();
node.use(plugin);
await node.start();
expect(plugin.host()).toBeNull();
expect(plugin.isStarted()).toBe(false);
await node.stop();
});
it("isActive reports the disabled state", () => {
expect(
new SimulationPlugin({
simulation: { seed: 1 },
disabled: true,
}).isActive()
).toBe(false);
expect(
new SimulationPlugin({
simulation: { seed: 1 },
disabled: false,
}).isActive()
).toBe(true);
});
it("subsystems remain usable even when the plugin is disabled", () => {
const plugin = new SimulationPlugin({
simulation: { seed: 1 },
disabled: true,
});
plugin.simulator.advanceBy(10);
expect(plugin.simulator.now()).toBe(10);
});
it("dispose clears the chaos event log", () => {
const plugin = new SimulationPlugin({
simulation: { seed: 1 },
chaos: { messageDropProbability: 1 },
disabled: false,
});
plugin.chaos.step([], []);
plugin.dispose();
expect(plugin.chaos.eventLog().length).toBe(0);
});
it("default disabled defers to NODE_ENV", () => {
const previous = process.env["NODE_ENV"];
try {
process.env["NODE_ENV"] = "production";
const a = new SimulationPlugin({ simulation: { seed: 1 } });
expect(a.isActive()).toBe(false);
process.env["NODE_ENV"] = "development";
const b = new SimulationPlugin({ simulation: { seed: 1 } });
expect(b.isActive()).toBe(true);
} finally {
if (previous === undefined) {
delete process.env["NODE_ENV"];
} else {
process.env["NODE_ENV"] = previous;
}
}
});
});

View File

@@ -0,0 +1,62 @@
# `SimulationPlugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single `NetworkPlugin` that bundles the simulation toolkit (`NetworkSimulator`, `NetworkConditionInjector`, `ChaosEngine`) and ties their lifecycle to a host `NetworkNode`. Every subsystem is exposed as a public field so consumers extend behaviour by reaching past the plugin facade. No-op in production builds so accidentally bundling the plugin into a release does not run chaos against real peers.
## Features
- `disabled` defaults to `process.env.NODE_ENV === "production"`; explicit `disabled: false` overrides for staging environments.
- `install` / `start` short-circuit when disabled — the plugin remains constructed but inert.
- `dispose` resets the chaos event log so a re-install starts clean.
## Usage: Basic Example
```ts
import { NetworkBus, NetworkNode } from "@suaveplan/network";
import { SimulationPlugin } from "@suaveplan/network-plugin-simulation";
const node = new NetworkNode({ id: "n", networkBus: new NetworkBus() });
const plugin = new SimulationPlugin({ simulation: { seed: 1 } });
node.use(plugin);
await node.start();
plugin.simulator.advanceBy(10);
```
## Usage: Advanced Example
```ts
import { SimulationPlugin } from "@suaveplan/network-plugin-simulation";
const plugin = new SimulationPlugin({
simulation: { seed: 42, timeFactor: 1_000 },
conditions: {
latency: { kind: "constant", latencyMs: 50 },
loss: { kind: "random", probability: 0.01 },
},
chaos: { crashProbability: 0.001 },
disabled: false,
});
plugin.isActive(); // true
plugin.simulator.now();
```
## API Reference
- `class SimulationPlugin implements NetworkPlugin`
- Public fields: `simulator`, `conditions`, `chaos`.
- `constructor(config: SimulationPluginConfig)`.
- `isActive(): boolean`.
- `install(node): void` / `start(): void` / `stop(): void` / `dispose(): void`.
- `host(): NetworkNode | null` / `isStarted(): boolean`.
- `interface SimulationPluginConfig` — `{ simulation, conditions?, chaos?, disabled? }`.
## Implementation Notes
- The plugin holds _references_ to subsystems rather than re-exporting their methods so refactors inside any subsystem do not reshape the plugin facade.
- The default-disabled-in-production guard reads `process.env.NODE_ENV` defensively (`process` may be undefined in the browser); browser callers wanting simulation explicitly pass `disabled: false`.
- `install` is the only method that gates on `disabled`; the simulator / conditions / chaos fields remain non-null and usable so test code can still exercise them via `plugin.simulator.advanceBy(...)` even while the plugin is "disabled".
- The four predefined integration scenarios (`server-failure`, `network-partition`, `client-surge`, `cascading-failure`) ship as follow-up changes — they require coordination with `failover`, `topology`, `circuit-breaker`, etc., which lives outside this package's contract.

View File

@@ -0,0 +1,78 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import { type ChaosConfig, ChaosEngine } from "../chaos/chaos-engine.js";
import { NetworkConditionInjector } from "../conditions/network-condition-injector.js";
import { NetworkSimulator } from "../simulator/network-simulator.js";
import type { NetworkConditionConfig } from "../types/condition.types.js";
import type { SimulationConfig } from "../types/simulation.types.js";
export interface SimulationPluginConfig {
readonly simulation: SimulationConfig;
readonly conditions?: NetworkConditionConfig;
readonly chaos?: ChaosConfig;
/**
* When true, the plugin behaves as a no-op (`isActive: false`).
* Defaults to `process.env.NODE_ENV === "production"` so production
* builds never accidentally run simulation work.
*/
readonly disabled?: boolean;
}
/**
* `NetworkPlugin` that exposes the simulation toolkit
* (`NetworkSimulator`, `NetworkConditionInjector`, `ChaosEngine`) as
* public fields. No-op in production unless `disabled: false` is
* passed explicitly.
*/
export class SimulationPlugin implements NetworkPlugin {
public readonly name = "simulation";
public readonly simulator: NetworkSimulator;
public readonly conditions: NetworkConditionInjector;
public readonly chaos: ChaosEngine;
private readonly disabled: boolean;
private node: NetworkNode | null = null;
private started = false;
public constructor(config: SimulationPluginConfig) {
this.simulator = new NetworkSimulator(config.simulation);
this.conditions = new NetworkConditionInjector(
this.simulator,
config.conditions
);
this.chaos = new ChaosEngine(this.simulator, config.chaos);
this.disabled =
config.disabled ??
(typeof process !== "undefined" &&
process.env?.["NODE_ENV"] === "production");
}
public isActive(): boolean {
return !this.disabled;
}
public install(node: NetworkNode): void {
if (this.disabled) return;
this.node = node;
}
public start(): void {
if (this.disabled) return;
this.started = true;
}
public stop(): void {
this.started = false;
}
public dispose(): void {
this.chaos.reset();
this.node = null;
}
public host(): NetworkNode | null {
return this.node;
}
public isStarted(): boolean {
return this.started;
}
}

View File

@@ -0,0 +1,102 @@
# `ScenarioRunner`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Executes a declarative `SimulationScenario` against a `NetworkSimulator`. Sorts events by `timeMs`, advances the simulator clock to each event, dispatches the event to a caller-supplied handler, and records checkpoints + final criterion evaluation. The handler is the boundary where consumers wire scenario events to virtual-node failures, condition changes, and chaos injection.
## Features
- Time-sorted event execution so authors can declare events in any order.
- Per-criterion `evaluateAtCheckpoints` opt-in records criteria mid-run for debugging.
- Metric collection via `bumpMetric` / `setMetric` so handlers report counts / averages back into the result.
- `validateScenario` exposed as a free helper for pre-flight CI checks.
- Seed cross-check between scenario and simulator so determinism is enforced before any work runs.
## Usage: Basic Example
```ts
import {
NetworkSimulator,
ScenarioRunner,
type SimulationScenario,
} from "@suaveplan/network-plugin-simulation";
const sim = new NetworkSimulator({ seed: 1 });
const runner = new ScenarioRunner({
simulator: sim,
handler: (event) => console.log("event", event.type, event.timeMs),
});
const scenario: SimulationScenario = {
name: "smoke",
seed: 1,
initialTopology: { kind: "mesh", nodes: [{ id: "n1", tier: 0 }] },
nodeCount: 1,
events: [{ timeMs: 100, type: "fail", targetPeerIds: ["n1"] }],
successCriteria: [],
};
const result = runner.run(scenario);
result.succeeded; // true (no criteria)
```
## Usage: Advanced Example
```ts
import {
NetworkSimulator,
ScenarioRunner,
validateScenario,
type SimulationScenario,
type SuccessCriterion,
} from "@suaveplan/network-plugin-simulation";
const sim = new NetworkSimulator({ seed: 7 });
const allEventsRan: SuccessCriterion = {
id: "ran",
description: "scenario reached completion",
evaluate: () => true,
evaluateAtCheckpoints: true,
};
const scenario: SimulationScenario = {
name: "checkpointed",
seed: 7,
initialTopology: { kind: "star", nodes: [{ id: "h", tier: 0 }, { id: "s", tier: 1 }] },
nodeCount: 2,
events: [
{ timeMs: 100, type: "join", targetPeerIds: ["s"] },
{ timeMs: 200, type: "checkpoint", label: "after-join" },
{ timeMs: 300, type: "leave", targetPeerIds: ["s"] },
],
successCriteria: [allEventsRan],
};
validateScenario(scenario);
const runner = new ScenarioRunner({
simulator: sim,
handler: (event) => runner.bumpMetric(`event.${event.type}`),
});
const result = runner.run(scenario);
result.checkpoints[0]?.label; // "after-join"
result.metrics["event.join"]; // 1
```
## API Reference
- `class ScenarioRunner(config: ScenarioRunnerConfig)`
- `bumpMetric(name, delta?): void`.
- `setMetric(name, value): void`.
- `run(scenario: SimulationScenario): ScenarioResult`.
- `function validateScenario(scenario): void` — throws `SCENARIO_INVALID` for empty name / negative node count / duplicate criterion id.
- `interface ScenarioRunnerConfig` — `{ simulator, handler }`.
- `type EventHandler` — `(event: ScenarioEvent) => void`.
## Implementation Notes
- Events with `type: "checkpoint"` are NOT dispatched to the handler — the runner records the snapshot directly. Other event types always reach the handler.
- The runner advances time _to_ each event's `timeMs` (no-op when the simulator is already at or past it). This keeps timing deterministic when scenarios mix close-together and far-apart events.
- Final-run criteria are always evaluated, regardless of `evaluateAtCheckpoints`. The flag only controls whether mid-run snapshots include the criterion.
- `metrics` and `criteriaResults` are frozen on the returned result so consumers cannot mutate the report after the fact.

View File

@@ -0,0 +1,154 @@
import { NETWORK_SIM_CODES, NetworkSimError } from "../error-codes.js";
import type { NetworkSimulator } from "../simulator/network-simulator.js";
import type {
ScenarioCheckpoint,
ScenarioEvent,
ScenarioResult,
SimulationScenario,
SuccessCriterion,
} from "../types/scenario.types.js";
export type EventHandler = (event: ScenarioEvent) => void;
export interface ScenarioRunnerConfig {
readonly simulator: NetworkSimulator;
/**
* Per-event handler the runner calls when each scheduled event
* fires. Consumers wire this to apply join / leave / fail / etc.
* against virtual nodes / chaos engine / condition injector.
*/
readonly handler: EventHandler;
}
/**
* Executes a `SimulationScenario`. Sorts events by `timeMs`, advances
* the simulator to each event's time, fires the handler, and records
* checkpoints / final criterion evaluation.
*/
export class ScenarioRunner {
private readonly simulator: NetworkSimulator;
private readonly handler: EventHandler;
private readonly metrics = new Map<string, number>();
public constructor(config: ScenarioRunnerConfig) {
this.simulator = config.simulator;
this.handler = config.handler;
}
public bumpMetric(name: string, delta = 1): void {
this.metrics.set(name, (this.metrics.get(name) ?? 0) + delta);
}
public setMetric(name: string, value: number): void {
this.metrics.set(name, value);
}
public run(scenario: SimulationScenario): ScenarioResult {
validateScenario(scenario);
if (scenario.seed !== this.simulator.getSeed()) {
throw new NetworkSimError(
NETWORK_SIM_CODES.SCENARIO_INVALID,
"scenario.seed-mismatch",
{
context: {
scenarioSeed: scenario.seed,
simulatorSeed: this.simulator.getSeed(),
},
}
);
}
const sortedEvents = [...scenario.events].sort(
(a, b) => a.timeMs - b.timeMs
);
const checkpoints: ScenarioCheckpoint[] = [];
const executed: ScenarioEvent[] = [];
for (const event of sortedEvents) {
if (event.timeMs > this.simulator.now()) {
this.simulator.advanceTo(event.timeMs);
}
if (event.type === "checkpoint") {
checkpoints.push(this.snapshotCheckpoint(scenario, event));
executed.push(event);
continue;
}
this.handler(event);
executed.push(event);
}
const criteriaResults: Record<string, boolean> = {};
let succeeded = true;
for (const criterion of scenario.successCriteria) {
const result = criterion.evaluate({
simulatedTimeMs: this.simulator.now(),
});
criteriaResults[criterion.id] = result;
if (!result) succeeded = false;
}
return Object.freeze({
scenario: scenario.name,
succeeded,
checkpoints,
criteriaResults,
metrics: Object.freeze(Object.fromEntries(this.metrics)),
executedEvents: executed,
});
}
private snapshotCheckpoint(
scenario: SimulationScenario,
event: ScenarioEvent
): ScenarioCheckpoint {
const snapshot: Record<string, boolean> = {};
for (const criterion of scenario.successCriteria) {
if (criterion.evaluateAtCheckpoints !== true) continue;
snapshot[criterion.id] = criterion.evaluate({
simulatedTimeMs: this.simulator.now(),
});
}
return event.label !== undefined
? {
atMs: this.simulator.now(),
label: event.label,
criteriaResults: snapshot,
}
: { atMs: this.simulator.now(), criteriaResults: snapshot };
}
}
export function validateScenario(scenario: SimulationScenario): void {
if (typeof scenario.name !== "string" || scenario.name.length === 0) {
throw new NetworkSimError(
NETWORK_SIM_CODES.SCENARIO_INVALID,
"scenario.empty-name"
);
}
if (scenario.nodeCount < 0) {
throw new NetworkSimError(
NETWORK_SIM_CODES.SCENARIO_INVALID,
"scenario.node-count-negative",
{ context: { nodeCount: scenario.nodeCount } }
);
}
for (const criterion of scenario.successCriteria) {
if (typeof criterion.id !== "string" || criterion.id.length === 0) {
throw new NetworkSimError(
NETWORK_SIM_CODES.SCENARIO_INVALID,
"scenario.criterion-id-empty"
);
}
}
seenIdsUnique(scenario.successCriteria);
}
function seenIdsUnique(criteria: ReadonlyArray<SuccessCriterion>): void {
const seen = new Set<string>();
for (const c of criteria) {
if (seen.has(c.id)) {
throw new NetworkSimError(
NETWORK_SIM_CODES.SCENARIO_INVALID,
"scenario.criterion-duplicate-id",
{ context: { id: c.id } }
);
}
seen.add(c.id);
}
}

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_SIM_CODES, type NetworkSimError } from "../error-codes.js";
import { NetworkSimulator } from "../simulator/network-simulator.js";
import type {
ScenarioEvent,
SimulationScenario,
SuccessCriterion,
} from "../types/scenario.types.js";
import { ScenarioRunner, validateScenario } from "./scenario-runner.js";
const baseScenario = (
overrides: Partial<SimulationScenario> = {}
): SimulationScenario => ({
name: "test",
seed: 1,
initialTopology: {
kind: "mesh",
nodes: [{ id: "n1", tier: 0 }],
},
nodeCount: 1,
events: [],
successCriteria: [],
...overrides,
});
describe("ScenarioRunner: execution + criteria + checkpoints + metrics", () => {
it("runs events in time order regardless of declaration order", () => {
const sim = new NetworkSimulator({ seed: 1 });
const fired: string[] = [];
const runner = new ScenarioRunner({
simulator: sim,
handler: (event) => fired.push(`${event.timeMs}:${event.type}`),
});
const scenario = baseScenario({
events: [
{ timeMs: 200, type: "leave", targetPeerIds: ["n1"] },
{ timeMs: 100, type: "join", targetPeerIds: ["n1"] },
],
});
runner.run(scenario);
expect(fired).toEqual(["100:join", "200:leave"]);
});
it("evaluates success criteria at scenario end", () => {
const sim = new NetworkSimulator({ seed: 1 });
const runner = new ScenarioRunner({
simulator: sim,
handler: () => undefined,
});
const passing: SuccessCriterion = {
id: "c1",
description: "always",
evaluate: () => true,
};
const failing: SuccessCriterion = {
id: "c2",
description: "never",
evaluate: () => false,
};
const result = runner.run(
baseScenario({ successCriteria: [passing, failing] })
);
expect(result.criteriaResults).toEqual({ c1: true, c2: false });
expect(result.succeeded).toBe(false);
});
it("checkpoint events snapshot opted-in criteria mid-run", () => {
const sim = new NetworkSimulator({ seed: 1 });
const runner = new ScenarioRunner({
simulator: sim,
handler: () => undefined,
});
const criterion: SuccessCriterion = {
id: "wall",
description: "atMs increments",
evaluate: ({ simulatedTimeMs }) => simulatedTimeMs >= 100,
evaluateAtCheckpoints: true,
};
const result = runner.run(
baseScenario({
events: [
{ timeMs: 100, type: "checkpoint", label: "after-100" },
{
timeMs: 50,
type: "checkpoint",
label: "before-100",
},
],
successCriteria: [criterion],
})
);
expect(result.checkpoints.length).toBe(2);
const before = result.checkpoints.find((c) => c.label === "before-100");
const after = result.checkpoints.find((c) => c.label === "after-100");
expect(before?.criteriaResults["wall"]).toBe(false);
expect(after?.criteriaResults["wall"]).toBe(true);
});
it("checkpoint without label and without opted-in criteria still records", () => {
const sim = new NetworkSimulator({ seed: 1 });
const runner = new ScenarioRunner({
simulator: sim,
handler: () => undefined,
});
const result = runner.run(
baseScenario({
events: [{ timeMs: 50, type: "checkpoint" }],
successCriteria: [
{
id: "x",
description: "y",
evaluate: () => true,
// not opted-in to checkpoints
},
],
})
);
expect(result.checkpoints.length).toBe(1);
expect(result.checkpoints[0]?.criteriaResults).toEqual({});
});
it("metrics: bumpMetric and setMetric appear in result", () => {
const sim = new NetworkSimulator({ seed: 1 });
const runner = new ScenarioRunner({
simulator: sim,
handler: (event) => runner.bumpMetric(`event.${event.type}`),
});
runner.setMetric("constant", 42);
const result = runner.run(
baseScenario({
events: [
{ timeMs: 10, type: "join", targetPeerIds: ["n1"] },
{ timeMs: 20, type: "leave", targetPeerIds: ["n1"] },
],
})
);
expect(result.metrics["event.join"]).toBe(1);
expect(result.metrics["event.leave"]).toBe(1);
expect(result.metrics["constant"]).toBe(42);
});
it("rejects scenario with mismatched seed", () => {
const sim = new NetworkSimulator({ seed: 1 });
const runner = new ScenarioRunner({
simulator: sim,
handler: () => undefined,
});
try {
runner.run(baseScenario({ seed: 99 }));
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSimError).code).toBe(
NETWORK_SIM_CODES.SCENARIO_INVALID
);
}
});
it("validateScenario rejects empty name / negative count / duplicate criteria", () => {
for (const bad of [
baseScenario({ name: "" }),
baseScenario({ nodeCount: -1 }),
baseScenario({
successCriteria: [
{ id: "x", description: "a", evaluate: () => true },
{ id: "x", description: "b", evaluate: () => true },
],
}),
baseScenario({
successCriteria: [
{ id: "", description: "x", evaluate: () => true },
],
}),
]) {
try {
validateScenario(bad);
expect.fail(`should have thrown for ${bad.name}`);
} catch (err) {
expect((err as NetworkSimError).code).toBe(
NETWORK_SIM_CODES.SCENARIO_INVALID
);
}
}
});
it("does not advance backwards when an event is at or before current time", () => {
const sim = new NetworkSimulator({ seed: 1 });
const fired: number[] = [];
const runner = new ScenarioRunner({
simulator: sim,
handler: () => fired.push(sim.now()),
});
runner.run(
baseScenario({
events: [
{ timeMs: 0, type: "join" }, // at-or-before now
{ timeMs: 0, type: "leave" }, // also at now
],
})
);
// Both fire at simulated time 0
expect(fired).toEqual([0, 0]);
});
it("emits executedEvents in time order", () => {
const sim = new NetworkSimulator({ seed: 1 });
const runner = new ScenarioRunner({
simulator: sim,
handler: () => undefined,
});
const events: ScenarioEvent[] = [
{ timeMs: 200, type: "leave" },
{ timeMs: 100, type: "join" },
];
const result = runner.run(baseScenario({ events }));
expect(result.executedEvents.map((e) => e.timeMs)).toEqual([100, 200]);
});
});

View File

@@ -0,0 +1,73 @@
# `NetworkSimulator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Deterministic, time-controlled simulation core. Owns the seeded Mulberry32 PRNG, the simulated wall clock (with a configurable `timeFactor` multiplier), and the four built-in topology factories (star / mesh / tiered / custom). Every other module in the package — `VirtualNode`, `ScenarioRunner`, `NetworkConditionInjector`, `ChaosEngine` — consumes a single `NetworkSimulator` instance so timing and randomness stay coordinated.
## Features
- 32-bit Mulberry32 PRNG seeded from `config.seed` so identical configs produce byte-identical runs.
- `advanceBy(wallMs)` multiplies by `timeFactor`; `advanceTo(absMs)` sets the simulated clock directly (rejects backward jumps).
- `randInt(min, max)` convenience helper for integer ranges.
- Four topology factories (`star` / `mesh` / `tiered` / `custom`) returning normalised `VirtualTopology` `{ nodes, edges }`.
- All time / topology validation throws typed `NetworkSimError`s so caller bugs fail loudly.
## Usage: Basic Example
```ts
import { NetworkSimulator } from "@suaveplan/network-plugin-simulation";
const sim = new NetworkSimulator({ seed: 42, timeFactor: 100 });
sim.now(); // 0
sim.advanceBy(10); // 1000 ms simulated (10 wall * 100 factor)
sim.rand(); // deterministic float in [0,1)
```
## Usage: Advanced Example
```ts
import {
NetworkSimulator,
type NetworkTopologyConfig,
} from "@suaveplan/network-plugin-simulation";
const sim = new NetworkSimulator({ seed: 12345, timeFactor: 1000 });
const topology: NetworkTopologyConfig = {
kind: "tiered",
nodes: [
{ id: "h1", tier: 0 },
{ id: "h2", tier: 0 },
{ id: "s1", tier: 1 },
{ id: "s2", tier: 1 },
{ id: "l1", tier: 2 },
],
};
const built = sim.buildTopology(topology);
console.log(built.edges.length); // 8 — every tier-0 paired with every tier-1, every tier-1 with every tier-2
```
## API Reference
- `class NetworkSimulator(config: SimulationConfig)`
- `getSeed(): number`.
- `now(): number` — simulated time.
- `timeFactorValue(): number`.
- `advanceBy(wallMs): number` — throws `TIME_INVALID` for non-positive deltas.
- `advanceTo(absoluteSimulatedMs): number` — throws `TIME_INVALID` for backward jumps.
- `rand(): number` — `[0, 1)` deterministic.
- `randInt(min, max): number` — `[min, max)` deterministic.
- `buildTopology(config): VirtualTopology` — throws `TOPOLOGY_INVALID` for invalid configs.
- `function mulberry32(seed): () => number` — exported so callers can spawn additional independent streams.
- `interface VirtualTopology` — `{ nodes, edges }`.
- `interface VirtualEdge` — `{ from, to }`.
## Implementation Notes
- Mulberry32 is chosen for speed + zero state-leak between runs. Higher-entropy generators (xoshiro256**) would be overkill for scenario replay and would slow seed-replay tests.
- `star` topology requires at least 2 nodes (hub + 1 spoke); the factory throws `TOPOLOGY_INVALID` for under-capacity configs.
- `tiered` connects every node in tier _N_ with every node in tier _N+1_. Within-tier connections must be added via `custom` if needed.
- Backward time-travel is intentionally blocked because the scenario runner relies on monotonic time to schedule events deterministically.

View File

@@ -0,0 +1,195 @@
import { NETWORK_SIM_CODES, NetworkSimError } from "../error-codes.js";
import type {
NetworkTopologyConfig,
SimulationConfig,
VirtualNodeConfig,
} from "../types/simulation.types.js";
/**
* Mulberry32 PRNG. 32-bit state, fast, deterministic, good enough for
* scenario-replay reproducibility.
*/
export function mulberry32(seed: number): () => number {
let state = (seed | 0) >>> 0;
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export interface VirtualEdge {
readonly from: string;
readonly to: string;
}
export interface VirtualTopology {
readonly nodes: ReadonlyArray<VirtualNodeConfig>;
readonly edges: ReadonlyArray<VirtualEdge>;
}
/**
* Deterministic, time-controlled simulator. Owns the seeded RNG, the
* simulated wall clock, and the topology factory; consumers wrap a
* `NetworkSimulator` instance with `VirtualNode`s, `ScenarioRunner`,
* `NetworkConditionInjector`, and `ChaosEngine`.
*/
export class NetworkSimulator {
private readonly seed: number;
private readonly timeFactor: number;
private currentTimeMs: number;
private readonly random: () => number;
public constructor(config: SimulationConfig) {
if (config.timeFactor !== undefined && config.timeFactor <= 0) {
throw new NetworkSimError(
NETWORK_SIM_CODES.TIME_INVALID,
"simulator.time-factor-invalid",
{ context: { timeFactor: config.timeFactor } }
);
}
this.seed = config.seed;
this.timeFactor = config.timeFactor ?? 1;
this.currentTimeMs = config.initialTimeMs ?? 0;
this.random = mulberry32(this.seed);
}
public getSeed(): number {
return this.seed;
}
public now(): number {
return this.currentTimeMs;
}
public timeFactorValue(): number {
return this.timeFactor;
}
/**
* Advance simulated time by `wallMs` real-world milliseconds — the
* simulator multiplies by `timeFactor` so a `timeFactor: 100` clock
* advances 100ms of simulated time per real ms.
*/
public advanceBy(wallMs: number): number {
if (wallMs <= 0) {
throw new NetworkSimError(
NETWORK_SIM_CODES.TIME_INVALID,
"simulator.advance-non-positive",
{ context: { wallMs } }
);
}
this.currentTimeMs += wallMs * this.timeFactor;
return this.currentTimeMs;
}
public advanceTo(absoluteSimulatedMs: number): number {
if (absoluteSimulatedMs < this.currentTimeMs) {
throw new NetworkSimError(
NETWORK_SIM_CODES.TIME_INVALID,
"simulator.advance-back-in-time",
{
context: {
from: this.currentTimeMs,
to: absoluteSimulatedMs,
},
}
);
}
this.currentTimeMs = absoluteSimulatedMs;
return this.currentTimeMs;
}
public rand(): number {
return this.random();
}
/** Random integer in `[min, max)` driven by the seeded RNG. */
public randInt(min: number, max: number): number {
return Math.floor(this.random() * (max - min)) + min;
}
/**
* Build a `VirtualTopology` for the configured topology kind.
* `custom` requires `edges` to be present.
*/
public buildTopology(config: NetworkTopologyConfig): VirtualTopology {
switch (config.kind) {
case "star":
return this.buildStar(config);
case "mesh":
return this.buildMesh(config);
case "tiered":
return this.buildTiered(config);
case "custom":
return this.buildCustom(config);
}
}
private buildStar(config: NetworkTopologyConfig): VirtualTopology {
const nodes = config.nodes;
if (nodes.length < 2) {
throw new NetworkSimError(
NETWORK_SIM_CODES.TOPOLOGY_INVALID,
"topology.star-needs-2",
{ context: { nodeCount: nodes.length } }
);
}
const hub = nodes[0] as VirtualNodeConfig;
const edges: VirtualEdge[] = [];
for (let i = 1; i < nodes.length; i += 1) {
const spoke = nodes[i] as VirtualNodeConfig;
edges.push({ from: hub.id, to: spoke.id });
}
return { nodes, edges };
}
private buildMesh(config: NetworkTopologyConfig): VirtualTopology {
const nodes = config.nodes;
const edges: VirtualEdge[] = [];
for (let i = 0; i < nodes.length; i += 1) {
for (let j = i + 1; j < nodes.length; j += 1) {
const a = nodes[i] as VirtualNodeConfig;
const b = nodes[j] as VirtualNodeConfig;
edges.push({ from: a.id, to: b.id });
}
}
return { nodes, edges };
}
private buildTiered(config: NetworkTopologyConfig): VirtualTopology {
const nodes = config.nodes;
const byTier = new Map<number, VirtualNodeConfig[]>();
for (const node of nodes) {
const bucket = byTier.get(node.tier) ?? [];
bucket.push(node);
byTier.set(node.tier, bucket);
}
const edges: VirtualEdge[] = [];
const tiers = Array.from(byTier.keys()).sort((a, b) => a - b);
for (let i = 0; i < tiers.length - 1; i += 1) {
const upper = byTier.get(tiers[i] as number) as VirtualNodeConfig[];
const lower = byTier.get(
tiers[i + 1] as number
) as VirtualNodeConfig[];
for (const u of upper) {
for (const l of lower) {
edges.push({ from: u.id, to: l.id });
}
}
}
return { nodes, edges };
}
private buildCustom(config: NetworkTopologyConfig): VirtualTopology {
if (config.edges === undefined) {
throw new NetworkSimError(
NETWORK_SIM_CODES.TOPOLOGY_INVALID,
"topology.custom-missing-edges"
);
}
return { nodes: config.nodes, edges: config.edges };
}
}

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_SIM_CODES, type NetworkSimError } from "../error-codes.js";
import { mulberry32, NetworkSimulator } from "./network-simulator.js";
describe("NetworkSimulator: determinism + time + topology factories", () => {
it("seeded RNG produces identical streams", () => {
const a = new NetworkSimulator({ seed: 42 });
const b = new NetworkSimulator({ seed: 42 });
for (let i = 0; i < 10; i += 1) {
expect(a.rand()).toBe(b.rand());
}
});
it("getSeed and timeFactorValue return the configured values", () => {
const sim = new NetworkSimulator({ seed: 7, timeFactor: 3 });
expect(sim.getSeed()).toBe(7);
expect(sim.timeFactorValue()).toBe(3);
});
it("advanceBy multiplies by timeFactor", () => {
const sim = new NetworkSimulator({ seed: 1, timeFactor: 100 });
expect(sim.advanceBy(5)).toBe(500);
expect(sim.now()).toBe(500);
});
it("advanceTo sets the simulated clock", () => {
const sim = new NetworkSimulator({ seed: 1 });
expect(sim.advanceTo(1_000)).toBe(1_000);
});
it("advanceBy rejects non-positive deltas", () => {
const sim = new NetworkSimulator({ seed: 1 });
try {
sim.advanceBy(0);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSimError).code).toBe(
NETWORK_SIM_CODES.TIME_INVALID
);
}
});
it("advanceTo rejects backward jumps", () => {
const sim = new NetworkSimulator({ seed: 1 });
sim.advanceTo(100);
try {
sim.advanceTo(50);
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSimError).code).toBe(
NETWORK_SIM_CODES.TIME_INVALID
);
}
});
it("constructor rejects non-positive timeFactor", () => {
try {
new NetworkSimulator({ seed: 1, timeFactor: 0 });
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSimError).code).toBe(
NETWORK_SIM_CODES.TIME_INVALID
);
}
});
it("randInt returns integers within bounds", () => {
const sim = new NetworkSimulator({ seed: 1 });
for (let i = 0; i < 100; i += 1) {
const v = sim.randInt(5, 10);
expect(Number.isInteger(v)).toBe(true);
expect(v).toBeGreaterThanOrEqual(5);
expect(v).toBeLessThan(10);
}
});
it("buildTopology star creates hub-spoke edges", () => {
const sim = new NetworkSimulator({ seed: 1 });
const t = sim.buildTopology({
kind: "star",
nodes: [
{ id: "h", tier: 0 },
{ id: "a", tier: 1 },
{ id: "b", tier: 1 },
],
});
expect(t.edges).toEqual([
{ from: "h", to: "a" },
{ from: "h", to: "b" },
]);
});
it("buildTopology star rejects under-2 nodes", () => {
const sim = new NetworkSimulator({ seed: 1 });
try {
sim.buildTopology({
kind: "star",
nodes: [{ id: "h", tier: 0 }],
});
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSimError).code).toBe(
NETWORK_SIM_CODES.TOPOLOGY_INVALID
);
}
});
it("buildTopology mesh creates n*(n-1)/2 edges", () => {
const sim = new NetworkSimulator({ seed: 1 });
const t = sim.buildTopology({
kind: "mesh",
nodes: [
{ id: "a", tier: 0 },
{ id: "b", tier: 0 },
{ id: "c", tier: 0 },
],
});
expect(t.edges.length).toBe(3);
});
it("buildTopology tiered connects between adjacent tiers only", () => {
const sim = new NetworkSimulator({ seed: 1 });
const t = sim.buildTopology({
kind: "tiered",
nodes: [
{ id: "h1", tier: 0 },
{ id: "h2", tier: 0 },
{ id: "s1", tier: 1 },
{ id: "l1", tier: 2 },
],
});
// 2 tier-0 × 1 tier-1 + 1 tier-1 × 1 tier-2 = 3 edges
expect(t.edges.length).toBe(3);
});
it("buildTopology custom requires edges", () => {
const sim = new NetworkSimulator({ seed: 1 });
try {
sim.buildTopology({
kind: "custom",
nodes: [{ id: "a", tier: 0 }],
});
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSimError).code).toBe(
NETWORK_SIM_CODES.TOPOLOGY_INVALID
);
}
});
it("buildTopology custom returns the supplied edges verbatim", () => {
const sim = new NetworkSimulator({ seed: 1 });
const edges = [{ from: "a", to: "b" }];
const t = sim.buildTopology({
kind: "custom",
nodes: [
{ id: "a", tier: 0 },
{ id: "b", tier: 0 },
],
edges,
});
expect(t.edges).toBe(edges);
});
it("uses sensible defaults for timeFactor + initialTimeMs", () => {
const sim = new NetworkSimulator({ seed: 1 });
expect(sim.timeFactorValue()).toBe(1);
expect(sim.now()).toBe(0);
});
it("mulberry32 is exported and deterministic", () => {
const a = mulberry32(99);
const b = mulberry32(99);
expect(a()).toBe(b());
});
});

View File

@@ -0,0 +1,67 @@
# Condition types — `@suaveplan/network-plugin-simulation`
## Purpose
Discriminated unions describing how the `NetworkConditionInjector` deforms simulated traffic. `LatencyModel` covers constant / random / geographic latency curves; `LossPattern` covers random / burst / per-connection drop rates; `BandwidthLimit` covers fixed / variable / congestion-based throughput caps; `JitterConfig` adds amplitude + periodic latency variation; `DisruptionConfig` simulates temporary or permanent connection failures. `NetworkConditionConfig` bundles them so callers configure the entire conditions surface in one struct.
## Features
- Discriminated `kind` field on each model so injectors exhaustively switch — adding a new model is a one-place change.
- All time + bandwidth values use ms / bytes-per-sec so the injector does not perform unit conversions per-call.
- Permanent vs temporary disruption distinguishable via the optional `permanent` flag.
- All inner records are `Readonly` so configurations cannot mutate after construction.
## Usage: Basic Example
```ts
import type {
LatencyModel,
LossPattern,
NetworkConditionConfig,
} from "@suaveplan/network-plugin-simulation";
const latency: LatencyModel = { kind: "constant", latencyMs: 50 };
const loss: LossPattern = { kind: "random", probability: 0.01 };
const conditions: NetworkConditionConfig = { latency, loss };
```
## Usage: Advanced Example
```ts
import type { NetworkConditionConfig } from "@suaveplan/network-plugin-simulation";
const conditions: NetworkConditionConfig = {
latency: {
kind: "geographic",
perRegionMs: { "us-east": 30, "eu-west": 80, "ap-south": 220 },
},
loss: {
kind: "burst",
burstProbability: 0.005,
burstLength: 5,
},
bandwidth: {
kind: "congestion",
baseBytesPerSec: 5_000_000,
congestionFactor: 0.6,
},
jitter: { amplitudeMs: 20, periodMs: 5_000 },
disruption: { probability: 0.001, durationMs: 1_000 },
};
```
## API Reference
- `type LatencyModel` — `constant | random | geographic`.
- `type LossPattern` — `random | burst | connection-based`.
- `type BandwidthLimit` — `fixed | variable | congestion`.
- `interface JitterConfig` — `{ amplitudeMs, periodMs }`.
- `interface DisruptionConfig` — `{ probability, durationMs, permanent? }`.
- `interface NetworkConditionConfig` — `{ latency?, loss?, bandwidth?, jitter?, disruption? }`.
## Implementation Notes
- `connection-based` loss keys connections by `"<from>:<to>"` strings. Callers must normalise the order if they want bidirectional pairing.
- `congestion`'s `congestionFactor` is the fractional multiplier applied to `baseBytesPerSec` while congestion is active; the injector decides when to apply it.
- `disruption` does not embed cooldown semantics — when `permanent: false`, the injector simply restores the connection after `durationMs`. Patterns like "X% disruption per minute" should compose multiple disruption configs across scenario events.
- `jitter.periodMs` is the wave period — amplitude is added on a sine curve so latency oscillates smoothly rather than stepping abruptly.

View File

@@ -0,0 +1,57 @@
export type LatencyModel =
| { readonly kind: "constant"; readonly latencyMs: number }
| {
readonly kind: "random";
readonly minMs: number;
readonly maxMs: number;
}
| {
readonly kind: "geographic";
readonly perRegionMs: Readonly<Record<string, number>>;
};
export type LossPattern =
| { readonly kind: "random"; readonly probability: number }
| {
readonly kind: "burst";
readonly burstProbability: number;
readonly burstLength: number;
}
| {
readonly kind: "connection-based";
readonly probabilityPerEdge: Readonly<Record<string, number>>;
};
export type BandwidthLimit =
| { readonly kind: "fixed"; readonly bytesPerSec: number }
| {
readonly kind: "variable";
readonly minBytesPerSec: number;
readonly maxBytesPerSec: number;
}
| {
readonly kind: "congestion";
readonly baseBytesPerSec: number;
readonly congestionFactor: number;
};
export interface JitterConfig {
readonly amplitudeMs: number;
readonly periodMs: number;
}
export interface DisruptionConfig {
/** Probability per evaluate call. */
readonly probability: number;
readonly durationMs: number;
/** True when the disruption persists past `durationMs`. */
readonly permanent?: boolean;
}
export interface NetworkConditionConfig {
readonly latency?: LatencyModel;
readonly loss?: LossPattern;
readonly bandwidth?: BandwidthLimit;
readonly jitter?: JitterConfig;
readonly disruption?: DisruptionConfig;
}

View File

@@ -0,0 +1,68 @@
# Scenario types — `@suaveplan/network-plugin-simulation`
## Purpose
Declarative shapes the `ScenarioRunner` consumes. `SimulationScenario` describes setup + ordered events + success criteria; `ScenarioEvent` is the unit of mid-run mutation; `SuccessCriterion` is a callable predicate the runner evaluates at scenario end (and optionally at every checkpoint); `ScenarioResult` is the report the runner returns.
## Features
- Closed `ScenarioEventType` union covers join / leave / fail / partition / heal / condition-change / checkpoint.
- `SuccessCriterion.evaluate` is a function so callers express arbitrary assertions without inventing a DSL.
- `ScenarioCheckpoint` records the per-criterion result snapshot at a moment in simulated time, useful for debugging long runs.
- `ScenarioResult.metrics` is `Record<string, number>` so runners report event counts / latency averages without a richer schema.
## Usage: Basic Example
```ts
import type {
ScenarioEvent,
SimulationScenario,
} from "@suaveplan/network-plugin-simulation";
const scenario: SimulationScenario = {
name: "ping-storm",
seed: 1,
initialTopology: { kind: "mesh", nodes: [{ id: "n1", tier: 0 }] },
nodeCount: 1,
events: [
{ timeMs: 100, type: "fail", targetPeerIds: ["n1"] },
],
successCriteria: [],
};
```
## Usage: Advanced Example
```ts
import type {
ScenarioCheckpoint,
ScenarioResult,
SuccessCriterion,
} from "@suaveplan/network-plugin-simulation";
const allHealthy: SuccessCriterion = {
id: "all-healthy",
description: "every peer remains reachable",
evaluate: (ctx) => ctx.simulatedTimeMs > 0,
evaluateAtCheckpoints: true,
};
declare const result: ScenarioResult;
result.checkpoints.forEach((cp) => console.log(cp.atMs, cp.criteriaResults));
```
## API Reference
- `type ScenarioEventType` — closed union of seven event kinds.
- `interface ScenarioEvent` — `{ timeMs, type, targetPeerIds?, condition?, label? }`.
- `interface SuccessCriterion` — `{ id, description, evaluate(ctx), evaluateAtCheckpoints? }`.
- `interface SimulationScenario` — `{ name, seed, initialTopology, nodeCount, events, networkConditions?, successCriteria }`.
- `interface ScenarioCheckpoint` — `{ atMs, label?, criteriaResults }`.
- `interface ScenarioResult` — `{ scenario, succeeded, checkpoints, criteriaResults, metrics, executedEvents }`.
## Implementation Notes
- `ScenarioEvent.targetPeerIds` is optional because some event types (e.g. `condition-change`) apply globally, while others (e.g. `fail`) target a specific peer set.
- `SuccessCriterion.evaluate` is sync — the runner does not await asynchronous predicates because the simulator already lives in simulated time and async-await would re-introduce wall-clock dependence.
- `ScenarioCheckpoint.criteriaResults` only includes criteria that opted in via `evaluateAtCheckpoints: true`. Final-run results live in `ScenarioResult.criteriaResults`.
- `ScenarioResult.executedEvents` echoes the event list the runner actually fired (useful when scenarios are programmatically generated and the caller wants to confirm which events ran).

View File

@@ -0,0 +1,56 @@
import type { NetworkConditionConfig } from "./condition.types.js";
import type { NetworkTopologyConfig } from "./simulation.types.js";
export type ScenarioEventType =
| "join"
| "leave"
| "fail"
| "partition"
| "heal"
| "condition-change"
| "checkpoint";
export interface ScenarioEvent {
readonly timeMs: number;
readonly type: ScenarioEventType;
readonly targetPeerIds?: ReadonlyArray<string>;
readonly condition?: NetworkConditionConfig;
readonly label?: string;
}
export interface SuccessCriterion {
readonly id: string;
readonly description: string;
/**
* Returns `true` when the criterion holds at evaluation time. The
* scenario runner calls every criterion at scenario end and (when
* the criterion opts in) at every checkpoint.
*/
evaluate(context: { simulatedTimeMs: number }): boolean;
readonly evaluateAtCheckpoints?: boolean;
}
export interface SimulationScenario {
readonly name: string;
readonly seed: number;
readonly initialTopology: NetworkTopologyConfig;
readonly nodeCount: number;
readonly events: ReadonlyArray<ScenarioEvent>;
readonly networkConditions?: NetworkConditionConfig;
readonly successCriteria: ReadonlyArray<SuccessCriterion>;
}
export interface ScenarioCheckpoint {
readonly atMs: number;
readonly label?: string;
readonly criteriaResults: Readonly<Record<string, boolean>>;
}
export interface ScenarioResult {
readonly scenario: string;
readonly succeeded: boolean;
readonly checkpoints: ReadonlyArray<ScenarioCheckpoint>;
readonly criteriaResults: Readonly<Record<string, boolean>>;
readonly metrics: Readonly<Record<string, number>>;
readonly executedEvents: ReadonlyArray<ScenarioEvent>;
}

View File

@@ -0,0 +1,68 @@
# Simulation types — `@suaveplan/network-plugin-simulation`
## Purpose
Wire shapes for the `NetworkSimulator` constructor and topology factories. `SimulationConfig` carries the seed + time-factor + initial-time tuple every other module reads from. `VirtualNodeConfig` describes the per-peer hints topology factories consume. `NetworkTopologyConfig` selects between built-in topologies or supplies custom edges.
## Features
- Closed `TopologyKind` union (`star` / `mesh` / `tiered` / `custom`) so factories exhaustively switch.
- `seed` is required so simulations cannot accidentally diverge between runs.
- Optional `transports` list per virtual node so condition injectors know which connection-types exist.
- `timeFactor` defaults to `1` (real-time); test runs typically pick `100` or higher to compress long scenarios.
## Usage: Basic Example
```ts
import type {
SimulationConfig,
VirtualNodeConfig,
} from "@suaveplan/network-plugin-simulation";
const config: SimulationConfig = { seed: 42, timeFactor: 100 };
const nodes: VirtualNodeConfig[] = [
{ id: "n1", tier: 0 },
{ id: "n2", tier: 1, transports: ["websocket"] },
];
```
## Usage: Advanced Example
```ts
import type {
NetworkTopologyConfig,
SimulationConfig,
} from "@suaveplan/network-plugin-simulation";
const config: SimulationConfig = {
seed: 12345,
timeFactor: 1000,
initialTimeMs: 1_700_000_000_000,
};
const topology: NetworkTopologyConfig = {
kind: "custom",
nodes: Array.from({ length: 10 }, (_, i) => ({
id: `n${i}`,
tier: i === 0 ? 0 : 1,
})),
edges: Array.from({ length: 9 }, (_, i) => ({
from: "n0",
to: `n${i + 1}`,
})),
};
```
## API Reference
- `interface SimulationConfig` — `{ seed, timeFactor?, initialTimeMs? }`.
- `interface VirtualNodeConfig` — `{ id, tier, transports? }`.
- `type TopologyKind` — `"star" | "mesh" | "tiered" | "custom"`.
- `interface NetworkTopologyConfig` — `{ kind, nodes, edges? }`.
## Implementation Notes
- `seed` is `number` rather than `bigint` so consumers serialise to JSON without conversion. The simulator uses a 32-bit Mulberry32 PRNG so 53-bit numbers are downcast safely.
- `edges` on `NetworkTopologyConfig` is optional for built-in topologies because the factories derive the edge set from `nodes`. For `custom`, the factory rejects when `edges` is missing.
- `VirtualNodeConfig.transports` lists transport identifiers, not concrete instances — keeps the type independent of the transport-package surface.
- `timeFactor` is intentionally a multiplier (not a target FPS) so callers reason about it as "how much faster than real time".

View File

@@ -0,0 +1,31 @@
/**
* Configuration for `NetworkSimulator`. The seed feeds a
* deterministic RNG so every run with identical config produces
* byte-identical output.
*/
export interface SimulationConfig {
readonly seed: number;
/** Multiplier applied to wall-clock advances. Defaults to 1. */
readonly timeFactor?: number;
/** Initial wall-clock time the simulator reports. Defaults to 0. */
readonly initialTimeMs?: number;
}
/**
* Per-virtual-node capability hints — consumed by topology factories
* to decide how many tiers / which transports to stub.
*/
export interface VirtualNodeConfig {
readonly id: string;
readonly tier: number;
readonly transports?: ReadonlyArray<string>;
}
export type TopologyKind = "star" | "mesh" | "tiered" | "custom";
export interface NetworkTopologyConfig {
readonly kind: TopologyKind;
readonly nodes: ReadonlyArray<VirtualNodeConfig>;
/** For `custom`: explicit edges; for built-ins: auto-derived. */
readonly edges?: ReadonlyArray<{ from: string; to: string }>;
}

View File

@@ -0,0 +1,75 @@
# `VirtualNode`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Simulated peer that wraps the same `NetworkNode` + plugin code real peers run, plus a status flag (`running` / `crashed` / `frozen` / `disconnected`) the scenario runner and chaos engine toggle to inject failures. Records every tick / send / receive / fail / recover event with the simulator's current simulated time, so test assertions can replay the timeline deterministically.
## Features
- Status state-machine with explicit `crash` / `freeze` / `disconnect` / `recover` transitions; idempotent (re-entering a state does not double-record).
- `tick` / `recordSend` / `recordReceive` push events into a per-node history buffer.
- `tick` throws `NODE_NOT_FOUND` when called against a non-running node so callers cannot observe ticks they did not actually run.
- Read-only `events()` snapshot returns a clone so callers cannot mutate the underlying buffer.
- `clearHistory()` for long scenarios that need to free memory between checkpoints.
## Usage: Basic Example
```ts
import {
NetworkSimulator,
VirtualNode,
} from "@suaveplan/network-plugin-simulation";
const sim = new NetworkSimulator({ seed: 1 });
const node = new VirtualNode({ id: "n1", tier: 0 }, sim);
node.tick();
node.crash();
node.events().length; // 2
```
## Usage: Advanced Example
```ts
import {
NetworkSimulator,
VirtualNode,
} from "@suaveplan/network-plugin-simulation";
const sim = new NetworkSimulator({ seed: 7, timeFactor: 1_000 });
const node = new VirtualNode(
{ id: "n2", tier: 1, transports: ["websocket", "webrtc"] },
sim
);
sim.advanceBy(1);
node.recordSend({ kind: "ping" });
sim.advanceBy(1);
node.recordReceive({ kind: "pong" });
node.crash();
sim.advanceBy(5);
node.recover();
node.tick();
console.log(node.events().map((e) => `${e.atMs}:${e.type}`));
```
## API Reference
- `class VirtualNode(config, simulator)`
- `getStatus(): VirtualNodeStatus`.
- `crash(): void` / `freeze(): void` / `disconnect(): void` / `recover(): void` — idempotent transitions.
- `tick(payload?): VirtualNodeEvent` — throws `NODE_NOT_FOUND` when not running.
- `recordSend(payload): void` / `recordReceive(payload): void`.
- `events(): ReadonlyArray<VirtualNodeEvent>` — non-destructive snapshot.
- `clearHistory(): void`.
- `type VirtualNodeStatus` — `"running" | "crashed" | "frozen" | "disconnected"`.
- `interface VirtualNodeEvent` — `{ atMs, type, payload? }`.
## Implementation Notes
- The class is intentionally thin — it only tracks status + history. Wiring to a real `NetworkNode` instance happens in integration tests / predefined scenarios so the core simulator stays free of plugin-specific logic.
- `crash` / `freeze` / `disconnect` all collapse to a single "fail" event in the history buffer; consumers wanting to distinguish should also read `getStatus()` after the failure.
- `tick` rejects when not running because typical scenarios advance the simulator clock independently of node ticks; ticking a crashed node would produce a misleading "still alive" event in the history.
- `recover` is idempotent on `running` so consumers can safely call it from event handlers without checking current status.

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NETWORK_SIM_CODES, type NetworkSimError } from "../error-codes.js";
import { NetworkSimulator } from "../simulator/network-simulator.js";
import { VirtualNode } from "./virtual-node.js";
const setup = () => {
const sim = new NetworkSimulator({ seed: 1 });
const node = new VirtualNode(
{ id: "n1", tier: 0, transports: ["websocket"] },
sim
);
return { sim, node };
};
describe("VirtualNode: status state-machine + history", () => {
it("starts running with empty history", () => {
const { node } = setup();
expect(node.getStatus()).toBe("running");
expect(node.events()).toEqual([]);
});
it("crash + freeze + disconnect transitions are idempotent", () => {
const { node } = setup();
node.crash();
node.crash();
expect(node.getStatus()).toBe("crashed");
expect(node.events().length).toBe(1);
});
it("recover restores running and records a recover event", () => {
const { node } = setup();
node.crash();
node.recover();
expect(node.getStatus()).toBe("running");
const events = node.events();
expect(events[events.length - 1]?.type).toBe("recover");
});
it("recover is no-op when already running", () => {
const { node } = setup();
node.recover();
expect(node.events()).toEqual([]);
});
it("tick records an event with simulator time", () => {
const { sim, node } = setup();
sim.advanceBy(5);
const event = node.tick();
expect(event.atMs).toBe(5);
expect(event.type).toBe("tick");
});
it("tick with payload records the payload", () => {
const { node } = setup();
const event = node.tick({ kind: "ping" });
expect(event.payload).toEqual({ kind: "ping" });
});
it("tick on a non-running node throws NODE_NOT_FOUND", () => {
const { node } = setup();
node.crash();
try {
node.tick();
expect.fail("should have thrown");
} catch (err) {
expect((err as NetworkSimError).code).toBe(
NETWORK_SIM_CODES.NODE_NOT_FOUND
);
}
});
it("recordSend / recordReceive append to history", () => {
const { node } = setup();
node.recordSend({ kind: "ping" });
node.recordReceive({ kind: "pong" });
const types = node.events().map((e) => e.type);
expect(types).toEqual(["send", "receive"]);
});
it("freeze + disconnect cover the other failure transitions", () => {
const { node: a } = setup();
a.freeze();
expect(a.getStatus()).toBe("frozen");
const { node: b } = setup();
b.disconnect();
expect(b.getStatus()).toBe("disconnected");
});
it("clearHistory empties the buffer without affecting status", () => {
const { node } = setup();
node.tick();
node.crash();
node.clearHistory();
expect(node.events()).toEqual([]);
expect(node.getStatus()).toBe("crashed");
});
it("events returns a defensive copy", () => {
const { node } = setup();
node.tick();
const snap = node.events();
(snap as unknown as Array<unknown>).push(null);
expect(node.events().length).toBe(1);
});
it("default transports list is empty when none supplied", () => {
const sim = new NetworkSimulator({ seed: 1 });
const node = new VirtualNode({ id: "x", tier: 0 }, sim);
expect(node.transports).toEqual([]);
});
});

View File

@@ -0,0 +1,114 @@
import { NETWORK_SIM_CODES, NetworkSimError } from "../error-codes.js";
import type { NetworkSimulator } from "../simulator/network-simulator.js";
import type { VirtualNodeConfig } from "../types/simulation.types.js";
export type VirtualNodeStatus =
| "running"
| "crashed"
| "frozen"
| "disconnected";
export interface VirtualNodeEvent {
readonly atMs: number;
readonly type: "tick" | "send" | "receive" | "fail" | "recover";
readonly payload?: unknown;
}
/**
* Simulated peer wrapping a `NetworkNode` (left to integration tests
* to wire) plus a status flag the simulator toggles for failure
* scenarios. Tracks per-node event history for assertion in the
* `ScenarioRunner` and `ChaosEngine`.
*/
export class VirtualNode {
public readonly id: string;
public readonly tier: number;
public readonly transports: ReadonlyArray<string>;
private status: VirtualNodeStatus = "running";
private readonly history: VirtualNodeEvent[] = [];
private readonly simulator: NetworkSimulator;
public constructor(config: VirtualNodeConfig, simulator: NetworkSimulator) {
this.id = config.id;
this.tier = config.tier;
this.transports = config.transports ?? [];
this.simulator = simulator;
}
public getStatus(): VirtualNodeStatus {
return this.status;
}
public crash(): void {
this.transition("crashed", "fail");
}
public freeze(): void {
this.transition("frozen", "fail");
}
public disconnect(): void {
this.transition("disconnected", "fail");
}
public recover(): void {
if (this.status === "running") return;
this.status = "running";
this.history.push({
atMs: this.simulator.now(),
type: "recover",
});
}
public tick(payload?: unknown): VirtualNodeEvent {
if (this.status !== "running") {
throw new NetworkSimError(
NETWORK_SIM_CODES.NODE_NOT_FOUND,
"node.tick-while-not-running",
{ context: { nodeId: this.id, status: this.status } }
);
}
const event: VirtualNodeEvent =
payload === undefined
? { atMs: this.simulator.now(), type: "tick" }
: { atMs: this.simulator.now(), type: "tick", payload };
this.history.push(event);
return event;
}
public recordSend(payload: unknown): void {
this.history.push({
atMs: this.simulator.now(),
type: "send",
payload,
});
}
public recordReceive(payload: unknown): void {
this.history.push({
atMs: this.simulator.now(),
type: "receive",
payload,
});
}
public events(): ReadonlyArray<VirtualNodeEvent> {
return [...this.history];
}
public clearHistory(): void {
this.history.length = 0;
}
private transition(
next: VirtualNodeStatus,
type: VirtualNodeEvent["type"]
): void {
if (this.status === next) return;
this.status = next;
this.history.push({
atMs: this.simulator.now(),
type,
});
}
}

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-simulation dep-graph", () => {
it("loads index.js and exports every public class", async () => {
const mod = await import("../src/index.js");
expect(mod.NetworkSimulator).toBeDefined();
expect(mod.VirtualNode).toBeDefined();
expect(mod.ScenarioRunner).toBeDefined();
expect(mod.NetworkConditionInjector).toBeDefined();
expect(mod.ChaosEngine).toBeDefined();
expect(mod.SimulationPlugin).toBeDefined();
expect(mod.mulberry32).toBeDefined();
expect(mod.validateScenario).toBeDefined();
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-plugin-simulation",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/network",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});