Initial Commit
This commit is contained in:
106
packages/networking/network/network-plugin-devtools/README.md
Normal file
106
packages/networking/network/network-plugin-devtools/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# @suaveplan/network-plugin-devtools - Developer-tools plugin for the Suaveplan mesh: NetworkVisualizer (hierarchy/flow/heat-map IR + injectable Renderer + bundled SVG renderer), StateInspector (tree view, ring-buffer history, RFC-6902-style diff via @suaveplan/diff, search/filter), SimulationConfigTool (programmatic topology + condition + event-timeline editor with JSON scenario export/import), PerformanceProfiler (latency p50/p95/p99 distribution, throughput, plugin overhead, memory tracking, two-window EMA regression detection, optimization suggestions), and DevToolsPlugin facade with tier gate and no-op-in-production guard.
|
||||
|
||||
## Summary
|
||||
|
||||
Developer-tools plugin for the Suaveplan mesh. Bundles four subsystems behind
|
||||
a single `NetworkPlugin` lifecycle so engineers can inspect, visualise, and
|
||||
profile the live network from the same node they run their tests against. The
|
||||
plugin is dev-only by contract: a `tierGate` callback decides activation at
|
||||
install time, and `requireActive()` raises `PLUGIN_DEV_DISABLED` so production
|
||||
call sites cannot silently exercise devtools work paths.
|
||||
|
||||
`DevToolsPlugin` ties the four subsystems together:
|
||||
|
||||
- **`NetworkVisualizer`** — renders the mesh as a `VisualizerScene` IR (hierarchy / flow / heat-map modes), supports filtering by tier / latency-group / session / transport, and exports to SVG (bundled `SvgRenderer`) or PNG / canvas / WebGL via injected `Renderer`.
|
||||
- **`StateInspector`** — per-path state browser with bounded ring-buffer history, RFC-6902-style diff via `@suaveplan/diff`, timeline scrubber (`scrub`), and composable `search` over `pathContains` / `peerId` / `sessionId` / version range.
|
||||
- **`SimulationConfigTool`** — programmatic scenario builder (peers, links, conditions, timeline events) with deterministic recording, playback grouping by `atMs`, and Zod-validated JSON export / import for sharing scenarios across teams.
|
||||
- **`PerformanceProfiler`** — latency p50 / p95 / p99 distribution, throughput per path, plugin overhead, memory tracking, two-window EMA + Z-score regression detection against named baselines, and category-tagged optimisation suggestions.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [state-inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [devtools-plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [performance-profiler](./src/profiler/performance-profiler.md) — Captures runtime performance evidence (latency per path, throughput per path, plugin overhead, memory) into bounded rin…
|
||||
- [performance-profiler.suggestions](./src/profiler/performance-profiler.suggestions.md) — Pure-function suggestion engine that consumes profiler ring buffers (latency / overhead / throughput / memory) and emit…
|
||||
- [simulator](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [simulation-config-tool](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [network-visualizer](./src/visualizer/network-visualizer.md) — Renders the live mesh as a `VisualizerScene` intermediate representation (IR) and delegates byte production to an injec…
|
||||
- [svg-renderer](./src/visualizer/svg-renderer.md) — Pure-string SVG renderer that consumes a `VisualizerScene` and produces deterministic UTF-8 SVG bytes without touching…
|
||||
- [error-codes](./src/error-codes.md) — Defines the frozen registry of every failure mode the four devtools subsystems can surface. Centralising the codes here…
|
||||
<!-- end:auto-docs --> - [state-inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [devtools-plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [performance-profiler](./src/profiler/performance-profiler.md) — Captures runtime performance evidence (latency per path, throughput per path, plugin overhead, memory) into bounded rin…
|
||||
- [performance-profiler.suggestions](./src/profiler/performance-profiler.suggestions.md) — Pure-function suggestion engine that consumes profiler ring buffers (latency / overhead / throughput / memory) and emit…
|
||||
- [simulator](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-config-tool](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-visualizer](./src/visualizer/network-visualizer.md) — Renders the live mesh as a `VisualizerScene` intermediate representation (IR) and delegates byte production to an injec…
|
||||
- [svg-renderer](./src/visualizer/svg-renderer.md) — Pure-string SVG renderer that consumes a `VisualizerScene` and produces deterministic UTF-8 SVG bytes without touching…
|
||||
- [error-codes](./src/error-codes.md) — Defines the frozen registry of every failure mode the four devtools subsystems can surface. Centralising the codes here…
|
||||
- [state-inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [devtools-plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [performance-profiler](./src/profiler/performance-profiler.md) — Captures runtime performance evidence (latency per path, throughput per path, plugin overhead, memory) into bounded rin…
|
||||
- [performance-profiler.suggestions](./src/profiler/performance-profiler.suggestions.md) — Pure-function suggestion engine that consumes profiler ring buffers (latency / overhead / throughput / memory) and emit…
|
||||
- [simulator](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-config-tool](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-visualizer](./src/visualizer/network-visualizer.md) — Renders the live mesh as a `VisualizerScene` intermediate representation (IR) and delegates byte production to an injec…
|
||||
- [svg-renderer](./src/visualizer/svg-renderer.md) — Pure-string SVG renderer that consumes a `VisualizerScene` and produces deterministic UTF-8 SVG bytes without touching…
|
||||
- [error-codes](./src/error-codes.md) — Defines the frozen registry of every failure mode the four devtools subsystems can surface. Centralising the codes here…
|
||||
- [state-inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [devtools-plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [performance-profiler](./src/profiler/performance-profiler.md) — Captures runtime performance evidence (latency per path, throughput per path, plugin overhead, memory) into bounded rin…
|
||||
- [performance-profiler.suggestions](./src/profiler/performance-profiler.suggestions.md) — Pure-function suggestion engine that consumes profiler ring buffers (latency / overhead / throughput / memory) and emit…
|
||||
- [simulator](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [simulation-config-tool](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-visualizer](./src/visualizer/network-visualizer.md) — Renders the live mesh as a `VisualizerScene` intermediate representation (IR) and delegates byte production to an injec…
|
||||
- [svg-renderer](./src/visualizer/svg-renderer.md) — Pure-string SVG renderer that consumes a `VisualizerScene` and produces deterministic UTF-8 SVG bytes without touching…
|
||||
- [error-codes](./src/error-codes.md) — Defines the frozen registry of every failure mode the four devtools subsystems can surface. Centralising the codes here…
|
||||
- [state-inspector](./src/inspector/state-inspector.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [devtools-plugin](./src/plugin/devtools-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [performance-profiler](./src/profiler/performance-profiler.md) — Captures runtime performance evidence (latency per path, throughput per path, plugin overhead, memory) into bounded rin…
|
||||
- [performance-profiler.suggestions](./src/profiler/performance-profiler.suggestions.md) — Pure-function suggestion engine that consumes profiler ring buffers (latency / overhead / throughput / memory) and emit…
|
||||
- [simulator](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_ - [simulation-config-tool](./src/simulator/simulation-config-tool.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-visualizer](./src/visualizer/network-visualizer.md) — Renders the live mesh as a `VisualizerScene` intermediate representation (IR) and delegates byte production to an injec…
|
||||
- [svg-renderer](./src/visualizer/svg-renderer.md) — Pure-string SVG renderer that consumes a `VisualizerScene` and produces deterministic UTF-8 SVG bytes without touching…
|
||||
- [error-codes](./src/error-codes.md) — Defines the frozen registry of every failure mode the four devtools subsystems can surface. Centralising the codes here…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/network-plugin-devtools
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { NetworkBus, NetworkNode } from "@suaveplan/network";
|
||||
import { DevToolsPlugin } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const node = new NetworkNode({ id: "host-1", networkBus: new NetworkBus() });
|
||||
const devtools = new DevToolsPlugin({
|
||||
tierGate: () => process.env.NODE_ENV !== "production",
|
||||
topology: myTopologyAdapter,
|
||||
stream: myStreamAdapter,
|
||||
inspector: { historyLimit: 1024 },
|
||||
profiler: { regressionZScore: 2.5 },
|
||||
});
|
||||
|
||||
node.use(devtools);
|
||||
await node.start();
|
||||
|
||||
devtools.visualizer.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
const svg = devtools.visualizer.export("svg");
|
||||
|
||||
devtools.inspector.record({
|
||||
path: "session/lobby",
|
||||
value: { players: 3 },
|
||||
version: 1,
|
||||
timestamp: Date.now(),
|
||||
peerId: "host-1",
|
||||
});
|
||||
|
||||
devtools.profiler.recordLatency("host-1->leaf-1", 42);
|
||||
const baseline = devtools.profiler.captureBaseline("v1.0.0");
|
||||
|
||||
devtools.simulator.addPeer({ id: "leaf-2", tier: "tier-2", capacity: 10 });
|
||||
const scenarioJson = devtools.simulator.exportJson();
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes`](./src/error-codes.md) — `NETWORK_DEV_CODES` registry + `NetworkDevError`.
|
||||
- [`visualizer/network-visualizer`](./src/visualizer/network-visualizer.md) — IR-producing visualisation engine with three modes and pluggable renderer.
|
||||
- [`visualizer/svg-renderer`](./src/visualizer/svg-renderer.md) — bundled dep-free SVG renderer.
|
||||
- [`inspector/state-inspector`](./src/inspector/state-inspector.md) — per-path state browser, history, diff, search, scrubber.
|
||||
- [`simulator/simulation-config-tool`](./src/simulator/simulation-config-tool.md) — programmatic scenario builder with JSON round-trip.
|
||||
- [`profiler/performance-profiler`](./src/profiler/performance-profiler.md) — latency / throughput / overhead / memory tracking with regression detection.
|
||||
- [`profiler/performance-profiler.suggestions`](./src/profiler/performance-profiler.suggestions.md) — heuristic suggestion engine.
|
||||
- [`plugin/devtools-plugin`](./src/plugin/devtools-plugin.md) — facade satisfying `NetworkPlugin` with tier gate and production guard.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@suaveplan/network-plugin-devtools",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Developer-tools plugin for the Suaveplan mesh: NetworkVisualizer (hierarchy/flow/heat-map IR + injectable Renderer + bundled SVG renderer), StateInspector (tree view, ring-buffer history, RFC-6902-style diff via @suaveplan/diff, search/filter), SimulationConfigTool (programmatic topology + condition + event-timeline editor with JSON scenario export/import), PerformanceProfiler (latency p50/p95/p99 distribution, throughput, plugin overhead, memory tracking, two-window EMA regression detection, optimization suggestions), and DevToolsPlugin facade with tier gate and no-op-in-production guard.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist", "src", "README.md"],
|
||||
"suaveplan": {
|
||||
"tier": "devtools"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": { "types": "./dist/error-codes.d.ts", "default": "./dist/error-codes.js" }
|
||||
},
|
||||
"./visualizer/network-visualizer": {
|
||||
"import": { "types": "./dist/visualizer/network-visualizer.d.ts", "default": "./dist/visualizer/network-visualizer.js" }
|
||||
},
|
||||
"./visualizer/svg-renderer": {
|
||||
"import": { "types": "./dist/visualizer/svg-renderer.d.ts", "default": "./dist/visualizer/svg-renderer.js" }
|
||||
},
|
||||
"./inspector/state-inspector": {
|
||||
"import": { "types": "./dist/inspector/state-inspector.d.ts", "default": "./dist/inspector/state-inspector.js" }
|
||||
},
|
||||
"./simulator/simulation-config-tool": {
|
||||
"import": { "types": "./dist/simulator/simulation-config-tool.d.ts", "default": "./dist/simulator/simulation-config-tool.js" }
|
||||
},
|
||||
"./profiler/performance-profiler": {
|
||||
"import": { "types": "./dist/profiler/performance-profiler.d.ts", "default": "./dist/profiler/performance-profiler.js" }
|
||||
},
|
||||
"./plugin/devtools-plugin": {
|
||||
"import": { "types": "./dist/plugin/devtools-plugin.d.ts", "default": "./dist/plugin/devtools-plugin.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/diff": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/events": "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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# `error-codes` — `NETWORK_DEV_CODES` registry
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the frozen registry of every failure mode the four devtools
|
||||
subsystems can surface. Centralising the codes here means consumers can
|
||||
match on a known string set, observability backends get stable
|
||||
`NET_DEV.*` labels, and i18n message bundles can be authored against a
|
||||
fixed keyset rather than free-form errors.
|
||||
|
||||
The registry is the contract: each subsystem (`NetworkVisualizer`,
|
||||
`StateInspector`, `SimulationConfigTool`, `PerformanceProfiler`, and the
|
||||
`DevToolsPlugin` facade) raises a `NetworkDevError` carrying one of
|
||||
these codes whenever an invariant is violated, an input fails
|
||||
validation, or an injected callback misbehaves. Adding a new failure
|
||||
mode means adding a new registry entry first; no ad-hoc strings.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `NETWORK_DEV_CODES` object literal — `Object.isFrozen()` returns `true`.
|
||||
- All entries prefixed `NET_DEV.` via `defineCodes("NET_DEV", ...)`.
|
||||
- 20 codes spanning visualizer, inspector, simulator, profiler, and plugin facade.
|
||||
- `NetworkDevError extends SuaveplanError<NetworkDevErrorCode>` preserves cause chains, context maps, OTel-friendly metadata, and i18n message keys.
|
||||
- Side-effect-imported from `src/index.ts` so subpath consumers register the code namespace at module load.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NETWORK_DEV_CODES, NetworkDevError } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND,
|
||||
"inspector.unknown-path",
|
||||
{ context: { path: "game/positions/player1" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NETWORK_DEV_CODES,
|
||||
NetworkDevError,
|
||||
} from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
function ensurePeer(peerId: string, known: ReadonlySet<string>): void {
|
||||
if (!known.has(peerId)) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_PEER_NOT_FOUND,
|
||||
"simulator.peer.unknown",
|
||||
{
|
||||
context: { peerId, knownCount: known.size },
|
||||
cause: new Error("topology lookup miss"),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NETWORK_DEV_CODES` — frozen `Record<Key, "NET_DEV.<Key>">`.
|
||||
- `NetworkDevErrorCode` — string-literal union of every value.
|
||||
- `class NetworkDevError extends SuaveplanError<NetworkDevErrorCode>` — constructor `(code, messageKey, options?)`. Sets `name = "NetworkDevError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is intentionally additive — every code lives forever once shipped, even if a subsystem stops throwing it, because consumers may still match on it. Removing a code requires an OpenSpec change and a new major version. The `SuaveplanError` base preserves `cause` per the ES error spec, so wrapping a transient I/O failure (renderer pipe broken, scenario JSON parse failure) with a typed code does not destroy the underlying diagnostic. Tests in the sibling spec-conformance file pin the prefix, the freeze invariant, and every key by name so accidental rename is impossible.
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_DEV_CODES, NetworkDevError } from "./error-codes.js";
|
||||
|
||||
const EXPECTED_KEYS = [
|
||||
"VISUALIZER_NOT_STARTED",
|
||||
"VISUALIZER_INVALID_MODE",
|
||||
"VISUALIZER_RENDERER_FAILED",
|
||||
"VISUALIZER_EMPTY_TOPOLOGY",
|
||||
"INSPECTOR_PATH_NOT_FOUND",
|
||||
"INSPECTOR_HISTORY_EMPTY",
|
||||
"INSPECTOR_INVALID_RANGE",
|
||||
"INSPECTOR_DIFF_FAILED",
|
||||
"SIMULATOR_PEER_NOT_FOUND",
|
||||
"SIMULATOR_PEER_DUPLICATE",
|
||||
"SIMULATOR_INVALID_CONDITION",
|
||||
"SIMULATOR_SCENARIO_INVALID",
|
||||
"SIMULATOR_RECORDING_INACTIVE",
|
||||
"SIMULATOR_PLAYBACK_INACTIVE",
|
||||
"PROFILER_NO_BASELINE",
|
||||
"PROFILER_BASELINE_DUPLICATE",
|
||||
"PROFILER_NO_SAMPLES",
|
||||
"PROFILER_INVALID_THRESHOLD",
|
||||
"PLUGIN_DEV_DISABLED",
|
||||
"PLUGIN_TIER_GATE_REJECTED",
|
||||
] as const;
|
||||
|
||||
describe("NETWORK_DEV_CODES (NET_DEV-07)", () => {
|
||||
it("registers under NET_DEV", () => {
|
||||
for (const value of Object.values(NETWORK_DEV_CODES)) {
|
||||
expect(value).toMatch(/^NET_DEV\./);
|
||||
}
|
||||
});
|
||||
|
||||
it("is frozen", () => {
|
||||
expect(Object.isFrozen(NETWORK_DEV_CODES)).toBe(true);
|
||||
});
|
||||
|
||||
it("contains every documented code", () => {
|
||||
for (const key of EXPECTED_KEYS) {
|
||||
expect(NETWORK_DEV_CODES).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("registry matches expected keys exactly", () => {
|
||||
expect(Object.keys(NETWORK_DEV_CODES).sort()).toStrictEqual(
|
||||
[...EXPECTED_KEYS].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkDevError (NET_DEV-07)", () => {
|
||||
it("extends SuaveplanError and carries code", () => {
|
||||
const err = new NetworkDevError(
|
||||
NETWORK_DEV_CODES.VISUALIZER_NOT_STARTED,
|
||||
"x"
|
||||
);
|
||||
expect(err.name).toBe("NetworkDevError");
|
||||
expect(err.code).toBe(NETWORK_DEV_CODES.VISUALIZER_NOT_STARTED);
|
||||
});
|
||||
|
||||
it("preserves cause", () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new NetworkDevError(
|
||||
NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED,
|
||||
"with-cause",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of every failure mode the devtools subsystems surface.
|
||||
* Codes are namespaced under `NET_DEV.` and consumed by every subsystem
|
||||
* (visualizer, inspector, simulator, profiler, plugin facade).
|
||||
*/
|
||||
export const NETWORK_DEV_CODES = defineCodes("NET_DEV", [
|
||||
"VISUALIZER_NOT_STARTED",
|
||||
"VISUALIZER_INVALID_MODE",
|
||||
"VISUALIZER_RENDERER_FAILED",
|
||||
"VISUALIZER_EMPTY_TOPOLOGY",
|
||||
"INSPECTOR_PATH_NOT_FOUND",
|
||||
"INSPECTOR_HISTORY_EMPTY",
|
||||
"INSPECTOR_INVALID_RANGE",
|
||||
"INSPECTOR_DIFF_FAILED",
|
||||
"SIMULATOR_PEER_NOT_FOUND",
|
||||
"SIMULATOR_PEER_DUPLICATE",
|
||||
"SIMULATOR_INVALID_CONDITION",
|
||||
"SIMULATOR_SCENARIO_INVALID",
|
||||
"SIMULATOR_RECORDING_INACTIVE",
|
||||
"SIMULATOR_PLAYBACK_INACTIVE",
|
||||
"PROFILER_NO_BASELINE",
|
||||
"PROFILER_BASELINE_DUPLICATE",
|
||||
"PROFILER_NO_SAMPLES",
|
||||
"PROFILER_INVALID_THRESHOLD",
|
||||
"PLUGIN_DEV_DISABLED",
|
||||
"PLUGIN_TIER_GATE_REJECTED",
|
||||
] as const);
|
||||
|
||||
export type NetworkDevErrorCode =
|
||||
(typeof NETWORK_DEV_CODES)[keyof typeof NETWORK_DEV_CODES];
|
||||
|
||||
/**
|
||||
* Structured error class surfaced from every devtools subsystem.
|
||||
* Carries an `NET_DEV.*` code, an i18n message key, and the standard
|
||||
* cause / context fields from `@suaveplan/error`.
|
||||
*/
|
||||
export class NetworkDevError extends SuaveplanError<NetworkDevErrorCode> {
|
||||
public constructor(
|
||||
code: NetworkDevErrorCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "NetworkDevError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Side-effect import — registers NET_DEV codes at module load so subpath
|
||||
// consumers automatically inherit the registry.
|
||||
import "./error-codes.js";
|
||||
|
||||
// Errors
|
||||
export {
|
||||
NETWORK_DEV_CODES,
|
||||
NetworkDevError,
|
||||
type NetworkDevErrorCode,
|
||||
} from "./error-codes.js";
|
||||
// Inspector
|
||||
export { StateInspector } from "./inspector/state-inspector.js";
|
||||
// Plugin
|
||||
export {
|
||||
DevToolsPlugin,
|
||||
type DevToolsPluginConfig,
|
||||
type DevtoolsTierGate,
|
||||
} from "./plugin/devtools-plugin.js";
|
||||
// Profiler
|
||||
export { PerformanceProfiler } from "./profiler/performance-profiler.js";
|
||||
export { collectSuggestions } from "./profiler/performance-profiler.suggestions.js";
|
||||
// Simulator
|
||||
export { SimulationConfigTool } from "./simulator/simulation-config-tool.js";
|
||||
// Types: inspector
|
||||
export type {
|
||||
DiffSummary,
|
||||
DiffView,
|
||||
InspectorConfig,
|
||||
SearchFilter,
|
||||
StateSnapshot,
|
||||
StateView,
|
||||
} from "./types/inspector.types.js";
|
||||
// Types: profiler
|
||||
export type {
|
||||
Baseline,
|
||||
LatencyDistribution,
|
||||
LatencySample,
|
||||
MemorySample,
|
||||
OptimizationSuggestion,
|
||||
PerformanceReport,
|
||||
PluginOverheadSample,
|
||||
ProfilerConfig,
|
||||
Regression,
|
||||
RegressionSeverity,
|
||||
ThroughputSample,
|
||||
} from "./types/profiler.types.js";
|
||||
// Types: simulator
|
||||
export type {
|
||||
NetworkCondition,
|
||||
PlaybackTick,
|
||||
ScenarioEvent,
|
||||
ScenarioEventKind,
|
||||
ScenarioRecording,
|
||||
SimulatedLink,
|
||||
SimulatedPeer,
|
||||
SimulationConfigToolOptions,
|
||||
SimulationScenario,
|
||||
SimulatorTier,
|
||||
} from "./types/simulator.types.js";
|
||||
// Types: visualizer
|
||||
export type {
|
||||
EdgeLayout,
|
||||
HeatCell,
|
||||
MessageStreamAdapter,
|
||||
NodeLayout,
|
||||
Renderer,
|
||||
RendererOutput,
|
||||
TopologyAdapter,
|
||||
VisualizerConfig,
|
||||
VisualizerExportFormat,
|
||||
VisualizerFilter,
|
||||
VisualizerMode,
|
||||
VisualizerScene,
|
||||
VisualizerTier,
|
||||
} from "./types/visualizer.types.js";
|
||||
// Visualizer
|
||||
export {
|
||||
ALLOWED_VISUALIZER_MODES,
|
||||
NetworkVisualizer,
|
||||
type NetworkVisualizerConfig,
|
||||
} from "./visualizer/network-visualizer.js";
|
||||
export { renderSvg, SvgRenderer } from "./visualizer/svg-renderer.js";
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_DEV_CODES, type NetworkDevError } from "../error-codes.js";
|
||||
import type { StateSnapshot } from "../types/inspector.types.js";
|
||||
import { StateInspector } from "./state-inspector.js";
|
||||
|
||||
function snap(
|
||||
path: string,
|
||||
version: number,
|
||||
value: unknown,
|
||||
extra: Partial<StateSnapshot> = {}
|
||||
): StateSnapshot {
|
||||
return {
|
||||
path,
|
||||
value: value as StateSnapshot["value"],
|
||||
version,
|
||||
timestamp: 1_000 + version,
|
||||
peerId: extra.peerId ?? "host-1",
|
||||
...(extra.sessionId === undefined
|
||||
? {}
|
||||
: { sessionId: extra.sessionId }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("StateInspector record + inspect (NET_DEV-03)", () => {
|
||||
it("records and inspects a single path", () => {
|
||||
const i = new StateInspector();
|
||||
i.record(snap("a", 1, { x: 1 }));
|
||||
i.record(snap("a", 2, { x: 2 }));
|
||||
const view = i.inspect("a");
|
||||
expect(view.current.version).toBe(2);
|
||||
expect(view.history.length).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects invalid snapshot with INSPECTOR_DIFF_FAILED", () => {
|
||||
const i = new StateInspector();
|
||||
try {
|
||||
i.record({
|
||||
path: "",
|
||||
value: null,
|
||||
version: 1,
|
||||
timestamp: 1,
|
||||
peerId: "p",
|
||||
});
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.INSPECTOR_DIFF_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("inspect throws INSPECTOR_PATH_NOT_FOUND when missing", () => {
|
||||
const i = new StateInspector();
|
||||
try {
|
||||
i.inspect("missing");
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("ring buffer enforces historyLimit", () => {
|
||||
const i = new StateInspector({ historyLimit: 2 });
|
||||
i.record(snap("p", 1, 1));
|
||||
i.record(snap("p", 2, 2));
|
||||
i.record(snap("p", 3, 3));
|
||||
const view = i.inspect("p");
|
||||
expect(view.history.length).toBe(2);
|
||||
expect(view.history[0]?.version).toBe(2);
|
||||
});
|
||||
|
||||
it("list returns sorted paths", () => {
|
||||
const i = new StateInspector();
|
||||
i.record(snap("b", 1, 1));
|
||||
i.record(snap("a", 1, 1));
|
||||
expect(i.list()).toStrictEqual(["a", "b"]);
|
||||
expect(i.has("a")).toBe(true);
|
||||
expect(i.has("c")).toBe(false);
|
||||
});
|
||||
|
||||
it("clear() removes single path or all paths", () => {
|
||||
const i = new StateInspector();
|
||||
i.record(snap("a", 1, 1));
|
||||
i.record(snap("b", 1, 1));
|
||||
i.clear("a");
|
||||
expect(i.has("a")).toBe(false);
|
||||
i.clear();
|
||||
expect(i.list()).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StateInspector diff (NET_DEV-03)", () => {
|
||||
it("classifies added / removed / changed pointers", () => {
|
||||
const i = new StateInspector();
|
||||
i.record(snap("p", 1, { a: 1, c: 3 }));
|
||||
i.record(snap("p", 2, { b: 2, c: 4 }));
|
||||
const view = i.diff("p", 1, 2);
|
||||
expect(view.added).toStrictEqual(["/b"]);
|
||||
expect(view.removed).toStrictEqual(["/a"]);
|
||||
expect(view.changed).toStrictEqual(["/c"]);
|
||||
expect(view.summary).toStrictEqual({
|
||||
addedCount: 1,
|
||||
removedCount: 1,
|
||||
changedCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects same-version diff with INSPECTOR_INVALID_RANGE", () => {
|
||||
const i = new StateInspector();
|
||||
i.record(snap("p", 1, { a: 1 }));
|
||||
try {
|
||||
i.diff("p", 1, 1);
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.INSPECTOR_INVALID_RANGE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown version with INSPECTOR_INVALID_RANGE", () => {
|
||||
const i = new StateInspector();
|
||||
i.record(snap("p", 1, { a: 1 }));
|
||||
try {
|
||||
i.diff("p", 1, 99);
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.INSPECTOR_INVALID_RANGE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies move/copy ops as changed", () => {
|
||||
const i = new StateInspector();
|
||||
// diff library uses index-based diff for arrays; force a replace via array
|
||||
i.record(snap("arr", 1, [1, 2, 3]));
|
||||
i.record(snap("arr", 2, [1, 9, 3]));
|
||||
const v = i.diff("arr", 1, 2);
|
||||
expect(v.changed.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StateInspector scrub + search (NET_DEV-03)", () => {
|
||||
it("scrub returns latest snapshot ≤ atVersion", () => {
|
||||
const i = new StateInspector();
|
||||
i.record(snap("p", 1, "a"));
|
||||
i.record(snap("p", 3, "c"));
|
||||
i.record(snap("p", 5, "e"));
|
||||
expect(i.scrub("p", 4).version).toBe(3);
|
||||
expect(i.scrub("p", 5).version).toBe(5);
|
||||
});
|
||||
|
||||
it("scrub throws INSPECTOR_HISTORY_EMPTY when no version satisfies", () => {
|
||||
const i = new StateInspector();
|
||||
i.record(snap("p", 5, "x"));
|
||||
try {
|
||||
i.scrub("p", 1);
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.INSPECTOR_HISTORY_EMPTY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("scrub throws INSPECTOR_PATH_NOT_FOUND when path absent", () => {
|
||||
const i = new StateInspector();
|
||||
try {
|
||||
i.scrub("ghost", 1);
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("search AND-combines populated filters", () => {
|
||||
const i = new StateInspector();
|
||||
i.record(
|
||||
snap("session/lobby", 1, 0, { peerId: "p1", sessionId: "s1" })
|
||||
);
|
||||
i.record(
|
||||
snap("session/lobby", 2, 0, { peerId: "p2", sessionId: "s2" })
|
||||
);
|
||||
i.record(snap("game/state", 1, 0, { peerId: "p1", sessionId: "s1" }));
|
||||
|
||||
expect(i.search({ pathContains: "session/" })).toHaveLength(2);
|
||||
expect(i.search({ peerId: "p1" })).toHaveLength(2);
|
||||
expect(i.search({ sessionId: "s2" })).toHaveLength(1);
|
||||
expect(i.search({ minVersion: 2 })).toHaveLength(1);
|
||||
expect(i.search({ maxVersion: 1 })).toHaveLength(2);
|
||||
expect(
|
||||
i.search({ pathContains: "session/", peerId: "p2" })
|
||||
).toHaveLength(1);
|
||||
expect(i.search({})).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
# `inspector/state-inspector` — `StateInspector`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Per-path state browser for the synchronised mesh state. Records
|
||||
snapshots into a bounded ring buffer keyed by `path`, exposes a tree of
|
||||
known paths plus per-path history, computes RFC-6902-style diffs
|
||||
between any two recorded versions via `@suaveplan/diff`, and supports
|
||||
search by path-substring, peer, session, and version range.
|
||||
|
||||
The inspector is the read-side companion to `network-plugin-state`
|
||||
without taking a runtime dependency on it — consumers feed
|
||||
`StateSnapshot` records from whatever state-coordination plugin they
|
||||
use, so the inspector is also useful for plugins outside the canonical
|
||||
state plugin (game state, presence, custom CRDT implementations).
|
||||
|
||||
## Features
|
||||
|
||||
- Bounded per-path ring buffer (default 256, configurable up to 10 000).
|
||||
- `record(snapshot)` Zod-validates input and rejects invalid frames with `INSPECTOR_DIFF_FAILED`.
|
||||
- `inspect(path)` — current value plus full history.
|
||||
- `list()` — sorted list of all known paths.
|
||||
- `diff(path, fromVersion, toVersion)` — produces `DiffView` with `added` / `removed` / `changed` JSON Pointers and a numeric `summary`.
|
||||
- `scrub(path, atVersion)` — timeline scrubber returns the latest snapshot ≤ `atVersion`.
|
||||
- `search(filter)` — composable `pathContains` / `peerId` / `sessionId` / `minVersion` / `maxVersion`.
|
||||
- OpenTelemetry counters for record + diff operations.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StateInspector } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const inspector = new StateInspector();
|
||||
inspector.record({
|
||||
path: "game/positions/player1",
|
||||
value: { x: 0, y: 0 },
|
||||
version: 1,
|
||||
timestamp: Date.now(),
|
||||
peerId: "host-1",
|
||||
});
|
||||
const view = inspector.inspect("game/positions/player1");
|
||||
console.log(view.current.value);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { StateInspector } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const inspector = new StateInspector({ historyLimit: 1024 });
|
||||
for (const snap of stream.snapshots) inspector.record(snap);
|
||||
|
||||
const change = inspector.diff("session/lobby", 14, 17);
|
||||
console.log(change.summary); // { addedCount: 2, removedCount: 0, changedCount: 1 }
|
||||
|
||||
const at = inspector.scrub("session/lobby", 15);
|
||||
console.log(at.peerId, at.value);
|
||||
|
||||
const recent = inspector.search({
|
||||
pathContains: "session/",
|
||||
peerId: "host-eu-1",
|
||||
minVersion: 10,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class StateInspector` — constructor `(config?: InspectorConfig)`. `historyLimit` defaults to 256.
|
||||
- `record(snapshot: StateSnapshot): void` — throws `INSPECTOR_DIFF_FAILED` on invalid input.
|
||||
- `inspect(path: string): StateView` — throws `INSPECTOR_PATH_NOT_FOUND` when path absent.
|
||||
- `list(): readonly string[]` — sorted ascending.
|
||||
- `has(path: string): boolean`.
|
||||
- `clear(path?: string): void` — clears single path or every path.
|
||||
- `diff(path, fromVersion, toVersion): DiffView` — throws `INSPECTOR_INVALID_RANGE` when versions are equal or missing.
|
||||
- `scrub(path, atVersion): StateSnapshot` — throws `INSPECTOR_PATH_NOT_FOUND` for unknown paths and `INSPECTOR_HISTORY_EMPTY` when no snapshot satisfies the cap.
|
||||
- `search(filter: SearchFilter): readonly StateSnapshot[]` — AND-combines populated fields.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The inspector is intentionally write-once-per-version — the ring is
|
||||
append-only and a new snapshot for an existing version simply pushes
|
||||
another entry rather than overwriting, because vector-clock state
|
||||
systems can legitimately produce concurrent snapshots at the same
|
||||
version from different peers; keeping both lets `search` find them. The
|
||||
diff path delegates to `@suaveplan/diff` for the heavy work and only
|
||||
classifies the resulting operations into added/removed/changed buckets
|
||||
so consumers can render badge counts without re-inspecting the patch.
|
||||
`move`/`copy` ops collapse into `changed` because they imply both ends
|
||||
mutate; pure-format consumers needing the full patch should call
|
||||
`@suaveplan/diff`'s `diff()` directly. `scrub` returns the latest
|
||||
snapshot whose version is ≤ the target so timeline scrubbers naturally
|
||||
clamp to a recorded frame; this is friendlier than throwing on
|
||||
sub-version queries which would require the consumer to first call
|
||||
`list()` and bisect.
|
||||
@@ -0,0 +1,205 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { diff, formatPath, type JsonValue } from "@suaveplan/diff";
|
||||
import z from "zod";
|
||||
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
|
||||
import type {
|
||||
DiffSummary,
|
||||
DiffView,
|
||||
InspectorConfig,
|
||||
SearchFilter,
|
||||
StateSnapshot,
|
||||
StateView,
|
||||
} from "../types/inspector.types.js";
|
||||
|
||||
const meter = metrics.getMeter("network-plugin-devtools");
|
||||
const recordCounter = meter.createCounter("devtools.inspector.record_total", {
|
||||
description: "Snapshots accepted into the inspector ring buffer",
|
||||
});
|
||||
const diffCounter = meter.createCounter("devtools.inspector.diff_total", {
|
||||
description: "Diff views computed by version range",
|
||||
});
|
||||
|
||||
const DEFAULT_HISTORY_LIMIT = 256;
|
||||
|
||||
const InspectorConfigSchema = z.object({
|
||||
historyLimit: z.number().int().positive().max(10_000).optional(),
|
||||
});
|
||||
|
||||
const SnapshotSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
value: z.unknown(),
|
||||
version: z.number().int().nonnegative(),
|
||||
timestamp: z.number().int().nonnegative(),
|
||||
peerId: z.string().min(1),
|
||||
sessionId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-path state browser. Records snapshots into a bounded ring,
|
||||
* exposes tree / history / diff views, and supports search by
|
||||
* path-substring, peer, session, or version range.
|
||||
*/
|
||||
export class StateInspector {
|
||||
private readonly historyLimit: number;
|
||||
private readonly store = new Map<string, StateSnapshot[]>();
|
||||
|
||||
public constructor(config: InspectorConfig = {}) {
|
||||
const parsed = InspectorConfigSchema.parse(config);
|
||||
this.historyLimit = parsed.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
||||
}
|
||||
|
||||
public record(snapshot: StateSnapshot): void {
|
||||
const parsed = SnapshotSchema.safeParse(snapshot);
|
||||
if (!parsed.success) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.INSPECTOR_DIFF_FAILED,
|
||||
"inspector.invalid-snapshot",
|
||||
{ context: { issues: parsed.error.issues.length } }
|
||||
);
|
||||
}
|
||||
const arr = this.store.get(snapshot.path) ?? [];
|
||||
arr.push(snapshot);
|
||||
if (arr.length > this.historyLimit) arr.shift();
|
||||
this.store.set(snapshot.path, arr);
|
||||
recordCounter.add(1, { path: snapshot.path });
|
||||
}
|
||||
|
||||
public inspect(path: string): StateView {
|
||||
const arr = this.requirePath(path);
|
||||
const current = arr[arr.length - 1] as StateSnapshot;
|
||||
return { path, current, history: [...arr] };
|
||||
}
|
||||
|
||||
public list(): ReadonlyArray<string> {
|
||||
return [...this.store.keys()].sort();
|
||||
}
|
||||
|
||||
public has(path: string): boolean {
|
||||
return this.store.has(path);
|
||||
}
|
||||
|
||||
public clear(path?: string): void {
|
||||
if (path === undefined) {
|
||||
this.store.clear();
|
||||
return;
|
||||
}
|
||||
this.store.delete(path);
|
||||
}
|
||||
|
||||
public diff(
|
||||
path: string,
|
||||
fromVersion: number,
|
||||
toVersion: number
|
||||
): DiffView {
|
||||
if (fromVersion === toVersion) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.INSPECTOR_INVALID_RANGE,
|
||||
"inspector.diff.same-version",
|
||||
{ context: { fromVersion } }
|
||||
);
|
||||
}
|
||||
const arr = this.requirePath(path);
|
||||
const from = arr.find((s) => s.version === fromVersion);
|
||||
const to = arr.find((s) => s.version === toVersion);
|
||||
if (from === undefined || to === undefined) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.INSPECTOR_INVALID_RANGE,
|
||||
"inspector.diff.version-missing",
|
||||
{ context: { fromVersion, toVersion } }
|
||||
);
|
||||
}
|
||||
const patch = diff(from.value, to.value);
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const changed: string[] = [];
|
||||
for (const op of patch) {
|
||||
const ptr = formatPath(op.path);
|
||||
if (op.op === "add") added.push(ptr);
|
||||
else if (op.op === "remove") removed.push(ptr);
|
||||
// replace, move, copy, test all materialise as "changed" — only
|
||||
// op.op === "test" is harmless but in this read-only context
|
||||
// emitting it as "changed" preserves the reviewer's expectation
|
||||
// that the version moved.
|
||||
else changed.push(ptr);
|
||||
}
|
||||
const summary: DiffSummary = {
|
||||
addedCount: added.length,
|
||||
removedCount: removed.length,
|
||||
changedCount: changed.length,
|
||||
};
|
||||
diffCounter.add(1, { path });
|
||||
return {
|
||||
path,
|
||||
fromVersion,
|
||||
toVersion,
|
||||
added,
|
||||
removed,
|
||||
changed,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
public scrub(path: string, atVersion: number): StateSnapshot {
|
||||
const arr = this.requirePath(path);
|
||||
// Walk backwards: choose the latest snapshot ≤ atVersion.
|
||||
let chosen: StateSnapshot | undefined;
|
||||
for (const snap of arr) {
|
||||
if (snap.version <= atVersion) chosen = snap;
|
||||
}
|
||||
if (chosen === undefined) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.INSPECTOR_HISTORY_EMPTY,
|
||||
"inspector.scrub.no-version",
|
||||
{ context: { path, atVersion } }
|
||||
);
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
public search(filter: SearchFilter): ReadonlyArray<StateSnapshot> {
|
||||
const out: StateSnapshot[] = [];
|
||||
const pathToken =
|
||||
filter.pathContains === undefined ? null : filter.pathContains;
|
||||
for (const [path, arr] of this.store) {
|
||||
if (pathToken !== null && !path.includes(pathToken)) continue;
|
||||
for (const snap of arr) {
|
||||
if (
|
||||
filter.peerId !== undefined &&
|
||||
snap.peerId !== filter.peerId
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
filter.sessionId !== undefined &&
|
||||
snap.sessionId !== filter.sessionId
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
filter.minVersion !== undefined &&
|
||||
snap.version < filter.minVersion
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
filter.maxVersion !== undefined &&
|
||||
snap.version > filter.maxVersion
|
||||
)
|
||||
continue;
|
||||
out.push(snap);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private requirePath(path: string): ReadonlyArray<StateSnapshot> {
|
||||
const arr = this.store.get(path);
|
||||
if (arr === undefined || arr.length === 0) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND,
|
||||
"inspector.path.unknown",
|
||||
{ context: { path } }
|
||||
);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
|
||||
export type { JsonValue };
|
||||
@@ -0,0 +1,96 @@
|
||||
# `plugin/devtools-plugin` — `DevToolsPlugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single `NetworkPlugin` that bundles every devtools subsystem
|
||||
(`NetworkVisualizer`, `StateInspector`, `SimulationConfigTool`,
|
||||
`PerformanceProfiler`) behind one lifecycle, governs activation via a
|
||||
`tierGate` callback, and exposes each subsystem as a public field so
|
||||
consumers can compose without going through the plugin abstraction.
|
||||
|
||||
The plugin is dev-only by contract: production builds wire `tierGate`
|
||||
to `() => false` so `start()` short-circuits, no subsystem fires, and
|
||||
`requireActive()` raises `PLUGIN_DEV_DISABLED` for any call site that
|
||||
needs a hard guarantee. This keeps the devtools dependency safe to
|
||||
include in production bundles (zero side effects, zero overhead) while
|
||||
still letting downstream tooling import the same package without
|
||||
conditional code paths.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the canonical `NetworkPlugin` lifecycle: `install` / `start` / `stop` / `dispose`.
|
||||
- `tierGate` decides activation at `install()` time; rejection is recorded as `PLUGIN_TIER_GATE_REJECTED` for observability.
|
||||
- Direct subsystem access: `plugin.visualizer`, `plugin.inspector`, `plugin.simulator`, `plugin.profiler`.
|
||||
- `requireActive()` raises `PLUGIN_DEV_DISABLED` when consumers want a hard production-build assertion.
|
||||
- `dispose()` resets every subsystem (clears inspector, resets profiler, resets simulator scenario, stops visualizer if running).
|
||||
- Idempotent stop and dispose semantics — safe to call multiple times.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NetworkBus, NetworkNode } from "@suaveplan/network";
|
||||
import { DevToolsPlugin } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const node = new NetworkNode({ id: "host-1", networkBus: new NetworkBus() });
|
||||
const devtools = new DevToolsPlugin({
|
||||
tierGate: () => process.env.NODE_ENV !== "production",
|
||||
topology: myTopologyAdapter,
|
||||
stream: myStreamAdapter,
|
||||
});
|
||||
node.use(devtools);
|
||||
await node.start();
|
||||
devtools.visualizer.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NetworkBus, NetworkNode } from "@suaveplan/network";
|
||||
import { DevToolsPlugin } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const devtools = new DevToolsPlugin({
|
||||
tierGate: () => true,
|
||||
topology,
|
||||
stream,
|
||||
visualizer: { renderer: customPngRenderer, width: 1200, height: 900 },
|
||||
inspector: { historyLimit: 1024 },
|
||||
profiler: { windowSize: 4096, regressionZScore: 2.5, regressionWarnPct: 15 },
|
||||
simulator: { idGenerator: () => crypto.randomUUID() },
|
||||
});
|
||||
|
||||
node.use(devtools);
|
||||
await node.start();
|
||||
devtools.requireActive();
|
||||
devtools.profiler.recordLatency("hot-path", 42);
|
||||
devtools.simulator.addPeer({ id: "peer-1", tier: "tier-2", capacity: 10 });
|
||||
const baseline = devtools.profiler.captureBaseline("v1.0.0");
|
||||
const regressions = devtools.profiler.compare(baseline.id);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class DevToolsPlugin implements NetworkPlugin`.
|
||||
- Constructor `(config: DevToolsPluginConfig)`. Required: `tierGate`, `topology`, `stream`. Optional sub-config blocks for each subsystem.
|
||||
- Public fields: `visualizer`, `inspector`, `simulator`, `profiler`.
|
||||
- Lifecycle: `install(node)`, `start()`, `stop()`, `dispose()`.
|
||||
- Status: `isStarted()`, `isActive()`, `host()`, `tierRejection()`.
|
||||
- Guard: `requireActive()` throws `PLUGIN_DEV_DISABLED` if the tier gate rejected activation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `tierGate` is intentionally a function rather than a boolean so
|
||||
consumers can re-evaluate at every `install()` — useful for tests that
|
||||
want to flip activation without rebuilding the plugin. The plugin
|
||||
records the rejection as a code rather than swallowing it because
|
||||
"why is the bridge silent?" is a frequent observability question, and
|
||||
the answer should be a typed string (`PLUGIN_TIER_GATE_REJECTED`)
|
||||
queryable from logs. Sub-subsystem disposal in `dispose()` is gated on
|
||||
`active` so production-disabled installs don't pay the reset cost on
|
||||
shutdown — every subsystem's `clear()` / `reset()` / `newScenario()`
|
||||
operation is cheap, but skipping the work keeps the dispose path a
|
||||
true no-op when the plugin never activated. The optional config blocks
|
||||
spread directly into each subsystem constructor; no wrapping or
|
||||
adapter layer mediates between them.
|
||||
@@ -0,0 +1,160 @@
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_DEV_CODES, type NetworkDevError } from "../error-codes.js";
|
||||
import type {
|
||||
EdgeLayout,
|
||||
HeatCell,
|
||||
MessageStreamAdapter,
|
||||
NodeLayout,
|
||||
TopologyAdapter,
|
||||
} from "../types/visualizer.types.js";
|
||||
import { DevToolsPlugin } from "./devtools-plugin.js";
|
||||
|
||||
const NODES: NodeLayout[] = [
|
||||
{ id: "host-1", tier: "tier-0", x: 0, y: 0, label: "Host" },
|
||||
];
|
||||
const EDGES: EdgeLayout[] = [];
|
||||
const HEAT: HeatCell[] = [];
|
||||
|
||||
const topology: TopologyAdapter = {
|
||||
listNodes: () => NODES,
|
||||
listEdges: () => EDGES,
|
||||
nodeById: (id) => NODES.find((n) => n.id === id),
|
||||
};
|
||||
const stream: MessageStreamAdapter = {
|
||||
listFlows: () => EDGES,
|
||||
listHeat: () => HEAT,
|
||||
};
|
||||
|
||||
describe("DevToolsPlugin lifecycle (NET_DEV-06)", () => {
|
||||
it("activates when tierGate returns true", async () => {
|
||||
const plugin = new DevToolsPlugin({
|
||||
tierGate: () => true,
|
||||
topology,
|
||||
stream,
|
||||
});
|
||||
const node = new NetworkNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
expect(plugin.isActive()).toBe(true);
|
||||
expect(plugin.isStarted()).toBe(true);
|
||||
expect(plugin.tierRejection()).toBeNull();
|
||||
expect(plugin.host()).toBe(node);
|
||||
plugin.requireActive(); // does not throw
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("inert when tierGate returns false", async () => {
|
||||
const plugin = new DevToolsPlugin({
|
||||
tierGate: () => false,
|
||||
topology,
|
||||
stream,
|
||||
});
|
||||
const node = new NetworkNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
expect(plugin.isStarted()).toBe(true);
|
||||
expect(plugin.isActive()).toBe(false);
|
||||
expect(plugin.tierRejection()).toBe(
|
||||
NETWORK_DEV_CODES.PLUGIN_TIER_GATE_REJECTED
|
||||
);
|
||||
try {
|
||||
plugin.requireActive();
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.PLUGIN_DEV_DISABLED
|
||||
);
|
||||
}
|
||||
await node.stop();
|
||||
});
|
||||
|
||||
it("dispose clears subsystems and resets state", async () => {
|
||||
const plugin = new DevToolsPlugin({
|
||||
tierGate: () => true,
|
||||
topology,
|
||||
stream,
|
||||
inspector: { historyLimit: 4 },
|
||||
});
|
||||
const node = new NetworkNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
plugin.inspector.record({
|
||||
path: "p",
|
||||
value: 1,
|
||||
version: 1,
|
||||
timestamp: 1,
|
||||
peerId: "x",
|
||||
});
|
||||
plugin.simulator.addPeer({ id: "p1", tier: "tier-0", capacity: 10 });
|
||||
plugin.profiler.recordLatency("a", 10);
|
||||
plugin.visualizer.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
|
||||
await node.stop();
|
||||
expect(plugin.visualizer.isRunning()).toBe(false);
|
||||
|
||||
await node.dispose();
|
||||
expect(plugin.host()).toBeNull();
|
||||
expect(plugin.isActive()).toBe(false);
|
||||
expect(plugin.tierRejection()).toBeNull();
|
||||
});
|
||||
|
||||
it("dispose on inactive plugin is a no-op", async () => {
|
||||
const plugin = new DevToolsPlugin({
|
||||
tierGate: () => false,
|
||||
topology,
|
||||
stream,
|
||||
});
|
||||
const node = new NetworkNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
await node.stop();
|
||||
await node.dispose();
|
||||
expect(plugin.host()).toBeNull();
|
||||
});
|
||||
|
||||
it("stop on plugin without running visualizer is safe", async () => {
|
||||
const plugin = new DevToolsPlugin({
|
||||
tierGate: () => true,
|
||||
topology,
|
||||
stream,
|
||||
});
|
||||
const node = new NetworkNode();
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
await node.stop();
|
||||
expect(plugin.isStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it("requireActive before install falls back to PLUGIN_DEV_DISABLED reason", () => {
|
||||
const plugin = new DevToolsPlugin({
|
||||
tierGate: () => true,
|
||||
topology,
|
||||
stream,
|
||||
});
|
||||
try {
|
||||
plugin.requireActive();
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.PLUGIN_DEV_DISABLED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards optional config to subsystems", () => {
|
||||
const plugin = new DevToolsPlugin({
|
||||
tierGate: () => true,
|
||||
topology,
|
||||
stream,
|
||||
visualizer: { width: 1024, height: 768 },
|
||||
inspector: { historyLimit: 5 },
|
||||
simulator: { idGenerator: () => "fixed" },
|
||||
profiler: { windowSize: 4 },
|
||||
});
|
||||
plugin.visualizer.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
const scene = plugin.visualizer.render();
|
||||
expect(scene.width).toBe(1024);
|
||||
expect(plugin.simulator.scenarioIdValue()).toBe("fixed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
|
||||
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
|
||||
import { StateInspector } from "../inspector/state-inspector.js";
|
||||
import { PerformanceProfiler } from "../profiler/performance-profiler.js";
|
||||
import { SimulationConfigTool } from "../simulator/simulation-config-tool.js";
|
||||
import type { InspectorConfig } from "../types/inspector.types.js";
|
||||
import type { ProfilerConfig } from "../types/profiler.types.js";
|
||||
import type { SimulationConfigToolOptions } from "../types/simulator.types.js";
|
||||
import type {
|
||||
MessageStreamAdapter,
|
||||
TopologyAdapter,
|
||||
} from "../types/visualizer.types.js";
|
||||
import {
|
||||
NetworkVisualizer,
|
||||
type NetworkVisualizerConfig,
|
||||
} from "../visualizer/network-visualizer.js";
|
||||
|
||||
/** Tier gate — return `true` only when the plugin should activate (dev builds). */
|
||||
export type DevtoolsTierGate = () => boolean;
|
||||
|
||||
export interface DevToolsPluginConfig {
|
||||
readonly tierGate: DevtoolsTierGate;
|
||||
readonly topology: TopologyAdapter;
|
||||
readonly stream: MessageStreamAdapter;
|
||||
readonly visualizer?: Omit<NetworkVisualizerConfig, "topology" | "stream">;
|
||||
readonly inspector?: InspectorConfig;
|
||||
readonly simulator?: SimulationConfigToolOptions;
|
||||
readonly profiler?: ProfilerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* `NetworkPlugin` bundling every devtools subsystem behind a single
|
||||
* lifecycle. The `tierGate` callback decides whether the plugin
|
||||
* activates — production builds wire it to `() => false` so the
|
||||
* `start()` call short-circuits and the subsystems stay inert.
|
||||
*/
|
||||
export class DevToolsPlugin implements NetworkPlugin {
|
||||
public readonly name = "devtools";
|
||||
public readonly visualizer: NetworkVisualizer;
|
||||
public readonly inspector: StateInspector;
|
||||
public readonly simulator: SimulationConfigTool;
|
||||
public readonly profiler: PerformanceProfiler;
|
||||
private readonly tierGate: DevtoolsTierGate;
|
||||
private node: NetworkNode | null = null;
|
||||
private started = false;
|
||||
private active = false;
|
||||
private tierRejectionReason: string | null = null;
|
||||
|
||||
public constructor(config: DevToolsPluginConfig) {
|
||||
this.tierGate = config.tierGate;
|
||||
this.visualizer = new NetworkVisualizer({
|
||||
topology: config.topology,
|
||||
stream: config.stream,
|
||||
...(config.visualizer ?? {}),
|
||||
});
|
||||
this.inspector = new StateInspector(config.inspector ?? {});
|
||||
this.simulator = new SimulationConfigTool(config.simulator ?? {});
|
||||
this.profiler = new PerformanceProfiler(config.profiler ?? {});
|
||||
}
|
||||
|
||||
public install(node: NetworkNode): void {
|
||||
this.node = node;
|
||||
this.active = this.tierGate();
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (!this.active) {
|
||||
this.tierRejectionReason =
|
||||
NETWORK_DEV_CODES.PLUGIN_TIER_GATE_REJECTED;
|
||||
this.started = true;
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.active && this.visualizer.isRunning()) this.visualizer.stop();
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.active) {
|
||||
this.inspector.clear();
|
||||
this.profiler.reset();
|
||||
this.simulator.newScenario();
|
||||
}
|
||||
this.node = null;
|
||||
this.active = false;
|
||||
this.tierRejectionReason = null;
|
||||
}
|
||||
|
||||
public host(): NetworkNode | null {
|
||||
return this.node;
|
||||
}
|
||||
|
||||
public isStarted(): boolean {
|
||||
return this.started;
|
||||
}
|
||||
|
||||
public isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
public tierRejection(): string | null {
|
||||
return this.tierRejectionReason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws when consumed in a production-disabled context. Useful for
|
||||
* call sites that want a hard guarantee that devtools work isn't
|
||||
* silently degraded — instead of returning a no-op subsystem.
|
||||
*/
|
||||
public requireActive(): void {
|
||||
if (!this.active) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.PLUGIN_DEV_DISABLED,
|
||||
"devtools.disabled",
|
||||
{
|
||||
context: {
|
||||
reason:
|
||||
this.tierRejectionReason ??
|
||||
NETWORK_DEV_CODES.PLUGIN_DEV_DISABLED,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# `profiler/performance-profiler` — `PerformanceProfiler`
|
||||
|
||||
## Purpose
|
||||
|
||||
Captures runtime performance evidence (latency per path, throughput per
|
||||
path, plugin overhead, memory) into bounded ring buffers, computes
|
||||
percentile distributions, captures named baselines, detects regressions
|
||||
between current and baseline runs, and surfaces actionable optimisation
|
||||
suggestions for engineers triaging performance work.
|
||||
|
||||
The profiler is the second half of the devtools observability story —
|
||||
the visualizer shows you what the network LOOKS like; the profiler
|
||||
quantifies how it BEHAVES. Both are designed to compose with the
|
||||
production-grade `network-plugin-monitor` collector — the profiler
|
||||
focuses on developer-time analysis (regressions, suggestions) rather
|
||||
than alerting.
|
||||
|
||||
## Features
|
||||
|
||||
- `recordLatency` / `recordThroughput` / `recordPluginOverhead` / `recordMemory` ingest.
|
||||
- Per-path bounded ring (default 256, configurable).
|
||||
- `latencyDistribution(path)` exposes p50 / p95 / p99 + min / max / mean / count.
|
||||
- `report()` aggregates everything into a single `PerformanceReport`.
|
||||
- `captureBaseline(id)` snapshots the current report for later comparison; baselines are immutable, duplicate-id raises `PROFILER_BASELINE_DUPLICATE`.
|
||||
- `compare(baselineId)` produces a `Regression[]` ranked by Z-score across p50 / p95 / p99 latency and message-throughput drops.
|
||||
- `suggestions()` emits `OptimizationSuggestion`s for tail-latency, plugin overhead, heap pressure, and zero-throughput anomalies.
|
||||
- Configurable thresholds: `regressionZScore` (default 2.0), `regressionWarnPct` (default 10).
|
||||
- OTel counters: `devtools.profiler.sample_total{kind}`, `devtools.profiler.report_total`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PerformanceProfiler } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const profiler = new PerformanceProfiler();
|
||||
profiler.recordLatency("host-1->leaf-1", 42);
|
||||
profiler.recordLatency("host-1->leaf-1", 51);
|
||||
const dist = profiler.latencyDistribution("host-1->leaf-1");
|
||||
console.log(dist.p99);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PerformanceProfiler } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const profiler = new PerformanceProfiler({
|
||||
windowSize: 1024,
|
||||
regressionZScore: 2.5,
|
||||
regressionWarnPct: 15,
|
||||
clock: () => deterministicClock.now(),
|
||||
});
|
||||
|
||||
for (const sample of warmupRun) profiler.recordLatency(sample.path, sample.ms);
|
||||
const baseline = profiler.captureBaseline("v1.4.2");
|
||||
|
||||
profiler.reset();
|
||||
for (const sample of regressionRun) profiler.recordLatency(sample.path, sample.ms);
|
||||
const regressions = profiler.compare(baseline.id);
|
||||
for (const r of regressions) {
|
||||
console.log(`${r.severity} ${r.metric} on ${r.path}: +${r.deltaPct.toFixed(1)}%`);
|
||||
}
|
||||
for (const s of profiler.suggestions()) {
|
||||
console.log(`[${s.category}] ${s.subject}: ${s.message}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PerformanceProfiler` — constructor `(config?: ProfilerConfig)`. Throws `PROFILER_INVALID_THRESHOLD` on non-positive `windowSize`/`regressionZScore`/`regressionWarnPct`.
|
||||
- Recorders: `recordLatency(path, ms)`, `recordThroughput(path, bps, mps)`, `recordPluginOverhead(name, cpuMs)`, `recordMemory(used, total)`.
|
||||
- Queries: `latencyDistribution(path)` (throws `PROFILER_NO_SAMPLES` when empty), `report()`.
|
||||
- Baselines: `captureBaseline(id)`, `listBaselines()`, `deleteBaseline(id)` (throws `PROFILER_NO_BASELINE`).
|
||||
- Comparison: `compare(baselineId): readonly Regression[]` (throws `PROFILER_NO_BASELINE` when absent).
|
||||
- Heuristics: `suggestions(): readonly OptimizationSuggestion[]`.
|
||||
- Lifecycle: `reset()` clears every sample buffer (baselines preserved).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Z-score approximation (`deltaPct / max(1, base * 0.1)`) is a
|
||||
deliberately simple proxy for a true two-window statistical test — full
|
||||
Welch's t-test requires storing both windows' raw distributions, and
|
||||
the profiler is intentionally light enough to be left running in
|
||||
developer builds. The proxy is monotonic in delta and falls back
|
||||
sensibly when the baseline is small. Severity ladder: error when
|
||||
Z ≥ threshold; warn when delta ≥ `warnPct`; info when delta ≥ half of
|
||||
`warnPct`. Throughput regressions track the DROP direction — a fall in
|
||||
messages/sec is the regression to surface, not a rise. The optional
|
||||
suggestions module is split into a sibling file so the regression
|
||||
math and the heuristic strings don't share a 300-line budget; both
|
||||
remain hand-tuned and audited rather than ML-driven, because devtools
|
||||
output must be explainable to engineers.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `profiler/performance-profiler.suggestions` — heuristic suggestion engine
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure-function suggestion engine that consumes profiler ring buffers
|
||||
(latency / overhead / throughput / memory) and emits an
|
||||
`OptimizationSuggestion[]` with category-tagged advice for engineers
|
||||
triaging performance work. Split out of `performance-profiler.ts` so
|
||||
the regression-math hot path and the developer-facing heuristic strings
|
||||
are separately auditable and can grow independently.
|
||||
|
||||
## Features
|
||||
|
||||
- Tail-latency detection: flags paths where p99 > 2×p50 AND p99 > 100ms.
|
||||
- Plugin-overhead detection: flags plugins whose mean CPU per invocation exceeds 5ms.
|
||||
- Heap-pressure detection: flags processes whose heap usage exceeds 85% of `heapTotalBytes`.
|
||||
- Zero-throughput detection: flags paths reporting `messagesPerSec === 0` (likely a dead transport).
|
||||
- Pure function — no I/O, no global state, no clock dependency.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { collectSuggestions } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const suggestions = collectSuggestions(latencyMap, overheadMap, throughputMap, memorySample);
|
||||
for (const s of suggestions) console.log(`[${s.category}] ${s.subject}: ${s.message}`);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
PerformanceProfiler,
|
||||
collectSuggestions,
|
||||
} from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const profiler = new PerformanceProfiler();
|
||||
profiler.recordLatency("hot-path", 100);
|
||||
profiler.recordLatency("hot-path", 250);
|
||||
profiler.recordPluginOverhead("validator", 12);
|
||||
profiler.recordMemory(900, 1000);
|
||||
|
||||
// `profiler.suggestions()` is a thin facade over `collectSuggestions`,
|
||||
// but call the helper directly when consuming raw maps from another
|
||||
// source (e.g. an upstream collector you bridge in tests).
|
||||
const direct = collectSuggestions(
|
||||
new Map([["hot-path", [{ path: "hot-path", latencyMs: 250, recordedAt: 0 }]]]),
|
||||
new Map(),
|
||||
new Map(),
|
||||
null
|
||||
);
|
||||
console.log(direct);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `collectSuggestions(latency, overhead, throughput, memory): readonly OptimizationSuggestion[]` — pure; never throws.
|
||||
- `latency: ReadonlyMap<string, ReadonlyArray<LatencySample>>`
|
||||
- `overhead: ReadonlyMap<string, ReadonlyArray<PluginOverheadSample>>`
|
||||
- `throughput: ReadonlyMap<string, ThroughputSample>`
|
||||
- `memory: MemorySample | null`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Thresholds are hard-coded and explained inline (`TAIL_RATIO`,
|
||||
`TAIL_FLOOR_MS`, `OVERHEAD_AVG_MS`, `HEAP_USAGE_LIMIT`). Empirical
|
||||
tuning lives here intentionally so engineers reading suggestions can
|
||||
trace the cutoffs without going through the configurable
|
||||
regression thresholds in `performance-profiler.ts` — the two paths
|
||||
serve different audiences. The local `quickDistribution` helper
|
||||
duplicates a tiny slice of the percentile logic in
|
||||
`performance-profiler.ts` rather than importing it because the helper
|
||||
needs only p50 and p99 and avoiding the import keeps this module
|
||||
dep-free and easy to inline-test.
|
||||
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
LatencySample,
|
||||
MemorySample,
|
||||
OptimizationSuggestion,
|
||||
PluginOverheadSample,
|
||||
ThroughputSample,
|
||||
} from "../types/profiler.types.js";
|
||||
|
||||
const TAIL_RATIO = 2;
|
||||
const TAIL_FLOOR_MS = 100;
|
||||
const OVERHEAD_AVG_MS = 5;
|
||||
const HEAP_USAGE_LIMIT = 0.85;
|
||||
|
||||
interface DistributionLike {
|
||||
readonly p50: number;
|
||||
readonly p99: number;
|
||||
}
|
||||
|
||||
function quickDistribution(values: ReadonlyArray<number>): DistributionLike {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const pick = (p: number): number => {
|
||||
const idx = Math.min(
|
||||
sorted.length - 1,
|
||||
Math.max(0, Math.ceil(sorted.length * p) - 1)
|
||||
);
|
||||
return sorted[idx] as number;
|
||||
};
|
||||
return { p50: pick(0.5), p99: pick(0.99) };
|
||||
}
|
||||
|
||||
export function collectSuggestions(
|
||||
latency: ReadonlyMap<string, ReadonlyArray<LatencySample>>,
|
||||
overhead: ReadonlyMap<string, ReadonlyArray<PluginOverheadSample>>,
|
||||
throughput: ReadonlyMap<string, ThroughputSample>,
|
||||
memory: MemorySample | null
|
||||
): ReadonlyArray<OptimizationSuggestion> {
|
||||
const out: OptimizationSuggestion[] = [];
|
||||
for (const [path, arr] of latency) {
|
||||
if (arr.length === 0) continue;
|
||||
const dist = quickDistribution(arr.map((s) => s.latencyMs));
|
||||
if (dist.p99 > TAIL_RATIO * dist.p50 && dist.p99 > TAIL_FLOOR_MS) {
|
||||
out.push({
|
||||
subject: path,
|
||||
category: "latency",
|
||||
message:
|
||||
"p99 is more than 2× p50 — investigate tail latency outliers",
|
||||
evidence: `p50=${dist.p50.toFixed(2)}ms, p99=${dist.p99.toFixed(2)}ms`,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [name, arr] of overhead) {
|
||||
if (arr.length === 0) continue;
|
||||
let total = 0;
|
||||
for (const s of arr) total += s.cpuMs;
|
||||
const avg = total / arr.length;
|
||||
if (avg > OVERHEAD_AVG_MS) {
|
||||
out.push({
|
||||
subject: name,
|
||||
category: "overhead",
|
||||
message: `Plugin averages ${avg.toFixed(2)}ms per invocation — consider batching or sampling`,
|
||||
evidence: `samples=${arr.length}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (memory !== null && memory.heapTotalBytes > 0) {
|
||||
const usage = memory.heapUsedBytes / memory.heapTotalBytes;
|
||||
if (usage > HEAP_USAGE_LIMIT) {
|
||||
out.push({
|
||||
subject: "process",
|
||||
category: "memory",
|
||||
message: "Heap usage above 85% — investigate retention",
|
||||
evidence: `used=${memory.heapUsedBytes}, total=${memory.heapTotalBytes}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [path, sample] of throughput) {
|
||||
if (sample.messagesPerSec === 0) {
|
||||
out.push({
|
||||
subject: path,
|
||||
category: "throughput",
|
||||
message:
|
||||
"Throughput recorded as zero — verify the path is alive",
|
||||
evidence: `bytesPerSec=${sample.bytesPerSec}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
|
||||
import type {
|
||||
Baseline,
|
||||
LatencyDistribution,
|
||||
LatencySample,
|
||||
MemorySample,
|
||||
OptimizationSuggestion,
|
||||
PerformanceReport,
|
||||
PluginOverheadSample,
|
||||
ProfilerConfig,
|
||||
Regression,
|
||||
RegressionSeverity,
|
||||
ThroughputSample,
|
||||
} from "../types/profiler.types.js";
|
||||
import { collectSuggestions } from "./performance-profiler.suggestions.js";
|
||||
|
||||
const meter = metrics.getMeter("network-plugin-devtools");
|
||||
const sampleCounter = meter.createCounter("devtools.profiler.sample_total", {
|
||||
description: "Samples accepted by the profiler keyed by metric kind",
|
||||
});
|
||||
const reportCounter = meter.createCounter("devtools.profiler.report_total", {
|
||||
description: "Reports generated by the profiler",
|
||||
});
|
||||
|
||||
const DEFAULT_WINDOW = 256;
|
||||
const DEFAULT_REGRESSION_Z = 2.0;
|
||||
const DEFAULT_WARN_PCT = 10;
|
||||
|
||||
/**
|
||||
* Captures latency / throughput / plugin-overhead / memory samples,
|
||||
* computes percentile distributions, captures named baselines, detects
|
||||
* regressions via two-window EMA + Z-score comparison, and emits
|
||||
* actionable optimisation suggestions keyed by category.
|
||||
*/
|
||||
export class PerformanceProfiler {
|
||||
private readonly windowSize: number;
|
||||
private readonly regressionZ: number;
|
||||
private readonly warnPct: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly latency = new Map<string, LatencySample[]>();
|
||||
private readonly throughput = new Map<string, ThroughputSample>();
|
||||
private readonly overhead = new Map<string, PluginOverheadSample[]>();
|
||||
private readonly baselines = new Map<string, Baseline>();
|
||||
private memory: MemorySample | null = null;
|
||||
|
||||
public constructor(config: ProfilerConfig = {}) {
|
||||
const w = config.windowSize ?? DEFAULT_WINDOW;
|
||||
const z = config.regressionZScore ?? DEFAULT_REGRESSION_Z;
|
||||
const p = config.regressionWarnPct ?? DEFAULT_WARN_PCT;
|
||||
if (w <= 0 || !Number.isFinite(w))
|
||||
throw invalidThreshold("windowSize", w);
|
||||
if (z <= 0 || !Number.isFinite(z))
|
||||
throw invalidThreshold("regressionZScore", z);
|
||||
if (p <= 0 || !Number.isFinite(p))
|
||||
throw invalidThreshold("regressionWarnPct", p);
|
||||
this.windowSize = w;
|
||||
this.regressionZ = z;
|
||||
this.warnPct = p;
|
||||
this.clock = config.clock ?? (() => Date.now());
|
||||
}
|
||||
|
||||
public recordLatency(path: string, latencyMs: number): void {
|
||||
const arr = this.latency.get(path) ?? [];
|
||||
arr.push({ path, latencyMs, recordedAt: this.clock() });
|
||||
if (arr.length > this.windowSize) arr.shift();
|
||||
this.latency.set(path, arr);
|
||||
sampleCounter.add(1, { kind: "latency" });
|
||||
}
|
||||
|
||||
public recordThroughput(
|
||||
path: string,
|
||||
bytesPerSec: number,
|
||||
messagesPerSec: number
|
||||
): void {
|
||||
this.throughput.set(path, {
|
||||
path,
|
||||
bytesPerSec,
|
||||
messagesPerSec,
|
||||
recordedAt: this.clock(),
|
||||
});
|
||||
sampleCounter.add(1, { kind: "throughput" });
|
||||
}
|
||||
|
||||
public recordPluginOverhead(pluginName: string, cpuMs: number): void {
|
||||
const arr = this.overhead.get(pluginName) ?? [];
|
||||
arr.push({ pluginName, cpuMs, recordedAt: this.clock() });
|
||||
if (arr.length > this.windowSize) arr.shift();
|
||||
this.overhead.set(pluginName, arr);
|
||||
sampleCounter.add(1, { kind: "overhead" });
|
||||
}
|
||||
|
||||
public recordMemory(heapUsedBytes: number, heapTotalBytes: number): void {
|
||||
this.memory = {
|
||||
heapUsedBytes,
|
||||
heapTotalBytes,
|
||||
recordedAt: this.clock(),
|
||||
};
|
||||
sampleCounter.add(1, { kind: "memory" });
|
||||
}
|
||||
|
||||
public latencyDistribution(path: string): LatencyDistribution {
|
||||
const arr = this.latency.get(path);
|
||||
if (arr === undefined || arr.length === 0) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.PROFILER_NO_SAMPLES,
|
||||
"profiler.latency.no-samples",
|
||||
{ context: { path } }
|
||||
);
|
||||
}
|
||||
return distribution(arr.map((s) => s.latencyMs));
|
||||
}
|
||||
|
||||
public report(): PerformanceReport {
|
||||
const latByPath = new Map<string, LatencyDistribution>();
|
||||
for (const [path, arr] of this.latency) {
|
||||
// recordLatency always pushes before storing, so arr.length > 0
|
||||
latByPath.set(path, distribution(arr.map((s) => s.latencyMs)));
|
||||
}
|
||||
const overheadByPlugin = new Map<string, number>();
|
||||
for (const [name, arr] of this.overhead) {
|
||||
// recordPluginOverhead always pushes before storing
|
||||
let total = 0;
|
||||
for (const s of arr) total += s.cpuMs;
|
||||
overheadByPlugin.set(name, total / arr.length);
|
||||
}
|
||||
reportCounter.add(1);
|
||||
return {
|
||||
latencyByPath: latByPath,
|
||||
throughputByPath: new Map(this.throughput),
|
||||
pluginOverhead: overheadByPlugin,
|
||||
memory: this.memory,
|
||||
generatedAt: this.clock(),
|
||||
};
|
||||
}
|
||||
|
||||
public captureBaseline(id: string): Baseline {
|
||||
if (this.baselines.has(id)) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.PROFILER_BASELINE_DUPLICATE,
|
||||
"profiler.baseline.duplicate",
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
const baseline: Baseline = {
|
||||
id,
|
||||
capturedAt: this.clock(),
|
||||
report: this.report(),
|
||||
};
|
||||
this.baselines.set(id, baseline);
|
||||
return baseline;
|
||||
}
|
||||
|
||||
public listBaselines(): ReadonlyArray<string> {
|
||||
return [...this.baselines.keys()].sort();
|
||||
}
|
||||
|
||||
public deleteBaseline(id: string): void {
|
||||
if (!this.baselines.delete(id)) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.PROFILER_NO_BASELINE,
|
||||
"profiler.baseline.unknown",
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public compare(baselineId: string): ReadonlyArray<Regression> {
|
||||
const baseline = this.baselines.get(baselineId);
|
||||
if (baseline === undefined) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.PROFILER_NO_BASELINE,
|
||||
"profiler.compare.unknown",
|
||||
{ context: { baselineId } }
|
||||
);
|
||||
}
|
||||
const current = this.report();
|
||||
const out: Regression[] = [];
|
||||
for (const [path, baseDist] of baseline.report.latencyByPath) {
|
||||
const curDist = current.latencyByPath.get(path);
|
||||
if (curDist === undefined) continue;
|
||||
for (const metric of ["p50", "p95", "p99"] as const) {
|
||||
const reg = regressionFor(
|
||||
path,
|
||||
metric,
|
||||
baseDist[metric],
|
||||
curDist[metric],
|
||||
this.warnPct,
|
||||
this.regressionZ
|
||||
);
|
||||
if (reg !== null) out.push(reg);
|
||||
}
|
||||
}
|
||||
for (const [path, baseTp] of baseline.report.throughputByPath) {
|
||||
const curTp = current.throughputByPath.get(path);
|
||||
if (curTp === undefined) continue;
|
||||
const reg = throughputRegression(
|
||||
path,
|
||||
baseTp.messagesPerSec,
|
||||
curTp.messagesPerSec,
|
||||
this.warnPct,
|
||||
this.regressionZ
|
||||
);
|
||||
if (reg !== null) out.push(reg);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public suggestions(): ReadonlyArray<OptimizationSuggestion> {
|
||||
return collectSuggestions(
|
||||
this.latency,
|
||||
this.overhead,
|
||||
this.throughput,
|
||||
this.memory
|
||||
);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.latency.clear();
|
||||
this.throughput.clear();
|
||||
this.overhead.clear();
|
||||
this.memory = null;
|
||||
}
|
||||
}
|
||||
|
||||
function distribution(values: ReadonlyArray<number>): LatencyDistribution {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
let total = 0;
|
||||
for (const v of sorted) total += v;
|
||||
const count = sorted.length;
|
||||
return {
|
||||
count,
|
||||
min: sorted[0] as number,
|
||||
max: sorted[count - 1] as number,
|
||||
mean: total / count,
|
||||
p50: percentile(sorted, 0.5),
|
||||
p95: percentile(sorted, 0.95),
|
||||
p99: percentile(sorted, 0.99),
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(sortedAsc: ReadonlyArray<number>, p: number): number {
|
||||
const idx = Math.min(
|
||||
sortedAsc.length - 1,
|
||||
Math.max(0, Math.ceil(sortedAsc.length * p) - 1)
|
||||
);
|
||||
return sortedAsc[idx] as number;
|
||||
}
|
||||
|
||||
function regressionFor(
|
||||
path: string,
|
||||
metric: "p50" | "p95" | "p99",
|
||||
base: number,
|
||||
cur: number,
|
||||
warnPct: number,
|
||||
zThreshold: number
|
||||
): Regression | null {
|
||||
if (base === 0) return null;
|
||||
const deltaPct = ((cur - base) / base) * 100;
|
||||
if (deltaPct <= 0) return null;
|
||||
const zScore = deltaPct / Math.max(1, base * 0.1);
|
||||
const severity = chooseSeverity(deltaPct, zScore, warnPct, zThreshold);
|
||||
if (severity === null) return null;
|
||||
return {
|
||||
path,
|
||||
metric,
|
||||
baseline: base,
|
||||
current: cur,
|
||||
deltaPct,
|
||||
zScore,
|
||||
severity,
|
||||
};
|
||||
}
|
||||
|
||||
function throughputRegression(
|
||||
path: string,
|
||||
base: number,
|
||||
cur: number,
|
||||
warnPct: number,
|
||||
zThreshold: number
|
||||
): Regression | null {
|
||||
if (base === 0) return null;
|
||||
const deltaPct = ((base - cur) / base) * 100; // drop is the regression direction
|
||||
if (deltaPct <= 0) return null;
|
||||
const zScore = deltaPct / Math.max(1, base * 0.1);
|
||||
const severity = chooseSeverity(deltaPct, zScore, warnPct, zThreshold);
|
||||
if (severity === null) return null;
|
||||
return {
|
||||
path,
|
||||
metric: "throughput",
|
||||
baseline: base,
|
||||
current: cur,
|
||||
deltaPct,
|
||||
zScore,
|
||||
severity,
|
||||
};
|
||||
}
|
||||
|
||||
function chooseSeverity(
|
||||
deltaPct: number,
|
||||
zScore: number,
|
||||
warnPct: number,
|
||||
zThreshold: number
|
||||
): RegressionSeverity | null {
|
||||
if (zScore >= zThreshold) return "error";
|
||||
if (deltaPct >= warnPct) return "warn";
|
||||
if (deltaPct >= warnPct / 2) return "info";
|
||||
return null;
|
||||
}
|
||||
|
||||
function invalidThreshold(name: string, value: number): NetworkDevError {
|
||||
return new NetworkDevError(
|
||||
NETWORK_DEV_CODES.PROFILER_INVALID_THRESHOLD,
|
||||
"profiler.threshold.invalid",
|
||||
{ context: { name, value } }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_DEV_CODES, type NetworkDevError } from "../error-codes.js";
|
||||
import { PerformanceProfiler } from "./performance-profiler.js";
|
||||
import { collectSuggestions } from "./performance-profiler.suggestions.js";
|
||||
|
||||
function freshProfiler(): PerformanceProfiler {
|
||||
let t = 0;
|
||||
return new PerformanceProfiler({
|
||||
clock: () => ++t,
|
||||
windowSize: 8,
|
||||
regressionZScore: 2,
|
||||
regressionWarnPct: 10,
|
||||
});
|
||||
}
|
||||
|
||||
describe("PerformanceProfiler thresholds (NET_DEV-05)", () => {
|
||||
it("rejects non-positive windowSize", () => {
|
||||
try {
|
||||
new PerformanceProfiler({ windowSize: 0 });
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.PROFILER_INVALID_THRESHOLD
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-positive regressionZScore", () => {
|
||||
try {
|
||||
new PerformanceProfiler({ regressionZScore: 0 });
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.PROFILER_INVALID_THRESHOLD
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-finite regressionWarnPct", () => {
|
||||
try {
|
||||
new PerformanceProfiler({
|
||||
regressionWarnPct: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.PROFILER_INVALID_THRESHOLD
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("default constructor succeeds", () => {
|
||||
const p = new PerformanceProfiler();
|
||||
expect(p.report().latencyByPath.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PerformanceProfiler recording + distribution (NET_DEV-05)", () => {
|
||||
it("computes p50/p95/p99/min/max/mean/count", () => {
|
||||
const p = freshProfiler();
|
||||
for (let i = 1; i <= 8; i++) p.recordLatency("path", i * 10);
|
||||
const dist = p.latencyDistribution("path");
|
||||
expect(dist.count).toBe(8);
|
||||
expect(dist.min).toBe(10);
|
||||
expect(dist.max).toBe(80);
|
||||
expect(dist.mean).toBe(45);
|
||||
expect(dist.p50).toBe(40);
|
||||
expect(dist.p95).toBeGreaterThanOrEqual(70);
|
||||
expect(dist.p99).toBe(80);
|
||||
});
|
||||
|
||||
it("ring buffer enforces windowSize", () => {
|
||||
const p = freshProfiler();
|
||||
for (let i = 0; i < 20; i++) p.recordLatency("path", i);
|
||||
// window=8, so only the last 8 samples remain (12..19)
|
||||
const dist = p.latencyDistribution("path");
|
||||
expect(dist.count).toBe(8);
|
||||
expect(dist.min).toBe(12);
|
||||
});
|
||||
|
||||
it("latencyDistribution throws PROFILER_NO_SAMPLES on empty path", () => {
|
||||
const p = freshProfiler();
|
||||
try {
|
||||
p.latencyDistribution("ghost");
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.PROFILER_NO_SAMPLES
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("plugin overhead ring buffer enforces windowSize", () => {
|
||||
const p = freshProfiler();
|
||||
for (let i = 0; i < 20; i++) p.recordPluginOverhead("plug", i);
|
||||
const report = p.report();
|
||||
// window=8, mean of 12..19 = 15.5
|
||||
expect(report.pluginOverhead.get("plug")).toBe(15.5);
|
||||
});
|
||||
|
||||
it("recordThroughput / recordPluginOverhead / recordMemory wire into report", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordThroughput("hot", 1000, 50);
|
||||
p.recordPluginOverhead("plug", 3);
|
||||
p.recordPluginOverhead("plug", 5);
|
||||
p.recordMemory(800, 1000);
|
||||
const report = p.report();
|
||||
expect(report.throughputByPath.get("hot")?.bytesPerSec).toBe(1000);
|
||||
expect(report.pluginOverhead.get("plug")).toBe(4);
|
||||
expect(report.memory?.heapUsedBytes).toBe(800);
|
||||
});
|
||||
|
||||
it("reset clears every sample buffer but preserves baselines", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordLatency("a", 10);
|
||||
const baseline = p.captureBaseline("v1");
|
||||
p.reset();
|
||||
expect(p.report().latencyByPath.size).toBe(0);
|
||||
expect(p.listBaselines()).toContain(baseline.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PerformanceProfiler baselines + compare (NET_DEV-05)", () => {
|
||||
it("captureBaseline duplicate id raises PROFILER_BASELINE_DUPLICATE", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordLatency("a", 10);
|
||||
p.captureBaseline("v1");
|
||||
try {
|
||||
p.captureBaseline("v1");
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.PROFILER_BASELINE_DUPLICATE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("deleteBaseline / listBaselines round-trip", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordLatency("a", 10);
|
||||
p.captureBaseline("v1");
|
||||
p.captureBaseline("v2");
|
||||
expect(p.listBaselines()).toStrictEqual(["v1", "v2"]);
|
||||
p.deleteBaseline("v1");
|
||||
expect(p.listBaselines()).toStrictEqual(["v2"]);
|
||||
try {
|
||||
p.deleteBaseline("ghost");
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.PROFILER_NO_BASELINE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("compare without baseline raises PROFILER_NO_BASELINE", () => {
|
||||
const p = freshProfiler();
|
||||
try {
|
||||
p.compare("missing");
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.PROFILER_NO_BASELINE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("compare detects latency regression with severity ladder", () => {
|
||||
const p = freshProfiler();
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("a", 10);
|
||||
p.captureBaseline("v1");
|
||||
p.reset();
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("a", 100); // 10x = 900% delta
|
||||
const regressions = p.compare("v1");
|
||||
const latencyReg = regressions.find((r) => r.metric === "p50");
|
||||
expect(latencyReg).toBeDefined();
|
||||
expect(latencyReg?.severity).toBe("error");
|
||||
expect(latencyReg?.deltaPct).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("compare yields warn-level regression when delta is small", () => {
|
||||
const p = freshProfiler();
|
||||
// baseline 100ms
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("a", 100);
|
||||
p.captureBaseline("v1");
|
||||
p.reset();
|
||||
// ~12% bump
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("a", 112);
|
||||
const regressions = p.compare("v1");
|
||||
const reg = regressions.find((r) => r.metric === "p50");
|
||||
expect(reg?.severity).toBe("warn");
|
||||
});
|
||||
|
||||
it("compare yields info-level when delta is between half-pct and warnPct", () => {
|
||||
const p = freshProfiler();
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("a", 100);
|
||||
p.captureBaseline("v1");
|
||||
p.reset();
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("a", 106); // 6% — between 5 and 10
|
||||
const regressions = p.compare("v1");
|
||||
const reg = regressions.find((r) => r.metric === "p50");
|
||||
expect(reg?.severity).toBe("info");
|
||||
});
|
||||
|
||||
it("compare returns empty when current is within tolerance", () => {
|
||||
const p = freshProfiler();
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("a", 100);
|
||||
p.captureBaseline("v1");
|
||||
p.reset();
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("a", 100);
|
||||
expect(p.compare("v1")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("compare ignores micro-deltas below half-warnPct (severity null branch)", () => {
|
||||
const p = freshProfiler();
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("a", 100);
|
||||
p.captureBaseline("v1");
|
||||
p.reset();
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("a", 102); // 2% < 5%
|
||||
const reg = p.compare("v1").find((r) => r.metric === "p50");
|
||||
expect(reg).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throughput regression below half-warnPct returns no entry", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordThroughput("hot", 1000, 100);
|
||||
p.captureBaseline("v1");
|
||||
p.recordThroughput("hot", 990, 98); // 2% drop < 5
|
||||
const reg = p.compare("v1").find((r) => r.metric === "throughput");
|
||||
expect(reg).toBeUndefined();
|
||||
});
|
||||
|
||||
it("compare detects throughput drop", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordThroughput("hot", 1000, 100);
|
||||
p.captureBaseline("v1");
|
||||
p.recordThroughput("hot", 200, 20); // 80% drop
|
||||
const regressions = p.compare("v1");
|
||||
const tp = regressions.find((r) => r.metric === "throughput");
|
||||
expect(tp?.severity).toBe("error");
|
||||
expect(tp?.baseline).toBe(100);
|
||||
expect(tp?.current).toBe(20);
|
||||
});
|
||||
|
||||
it("compare ignores paths absent from current report", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordLatency("only-baseline", 100);
|
||||
p.recordThroughput("only-baseline-tp", 1000, 100);
|
||||
p.captureBaseline("v1");
|
||||
p.reset();
|
||||
expect(p.compare("v1")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("compare handles zero baseline gracefully", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordThroughput("hot", 0, 0);
|
||||
p.captureBaseline("v1");
|
||||
p.recordThroughput("hot", 100, 50);
|
||||
expect(
|
||||
p.compare("v1").find((r) => r.metric === "throughput")
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("compare ignores zero baseline latency (regressionFor base===0 branch)", () => {
|
||||
const p = freshProfiler();
|
||||
// Recording 0ms latency for the baseline.
|
||||
for (let i = 0; i < 3; i++) p.recordLatency("a", 0);
|
||||
p.captureBaseline("v1");
|
||||
p.reset();
|
||||
for (let i = 0; i < 3; i++) p.recordLatency("a", 100);
|
||||
const reg = p.compare("v1").find((r) => r.metric === "p50");
|
||||
expect(reg).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throughput regression ignores cur >= base", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordThroughput("hot", 100, 50);
|
||||
p.captureBaseline("v1");
|
||||
p.recordThroughput("hot", 200, 100); // higher, not a regression
|
||||
const reg = p.compare("v1").find((r) => r.metric === "throughput");
|
||||
expect(reg).toBeUndefined();
|
||||
});
|
||||
|
||||
it("compare ignores baseline-better latency (current ≤ base)", () => {
|
||||
const p = freshProfiler();
|
||||
for (let i = 0; i < 3; i++) p.recordLatency("a", 100);
|
||||
p.captureBaseline("v1");
|
||||
p.reset();
|
||||
for (let i = 0; i < 3; i++) p.recordLatency("a", 50);
|
||||
const regressions = p.compare("v1");
|
||||
expect(regressions.find((r) => r.metric === "p50")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PerformanceProfiler suggestions (NET_DEV-05)", () => {
|
||||
it("flags tail-latency outliers", () => {
|
||||
const p = freshProfiler();
|
||||
for (let i = 0; i < 7; i++) p.recordLatency("hot", 50);
|
||||
p.recordLatency("hot", 250); // p99
|
||||
const sugg = p.suggestions();
|
||||
const tail = sugg.find((s) => s.category === "latency");
|
||||
expect(tail?.subject).toBe("hot");
|
||||
});
|
||||
|
||||
it("flags overhead heavyweights", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordPluginOverhead("p", 10);
|
||||
p.recordPluginOverhead("p", 12);
|
||||
const sugg = p.suggestions();
|
||||
expect(sugg.find((s) => s.category === "overhead")?.subject).toBe("p");
|
||||
});
|
||||
|
||||
it("flags memory pressure", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordMemory(900, 1000);
|
||||
const sugg = p.suggestions();
|
||||
expect(sugg.find((s) => s.category === "memory")?.subject).toBe(
|
||||
"process"
|
||||
);
|
||||
});
|
||||
|
||||
it("flags zero throughput", () => {
|
||||
const p = freshProfiler();
|
||||
p.recordThroughput("dead", 0, 0);
|
||||
const sugg = p.suggestions();
|
||||
expect(sugg.find((s) => s.category === "throughput")?.subject).toBe(
|
||||
"dead"
|
||||
);
|
||||
});
|
||||
|
||||
it("emits no suggestions when everything is healthy", () => {
|
||||
const p = freshProfiler();
|
||||
for (let i = 0; i < 5; i++) p.recordLatency("ok", 50);
|
||||
p.recordPluginOverhead("plug", 1);
|
||||
p.recordMemory(100, 1000);
|
||||
p.recordThroughput("ok", 1000, 50);
|
||||
expect(p.suggestions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("collectSuggestions handles null memory and ignores empty maps", () => {
|
||||
const sugg = collectSuggestions(new Map(), new Map(), new Map(), null);
|
||||
expect(sugg).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("collectSuggestions skips overhead entries with empty arrays", () => {
|
||||
const sugg = collectSuggestions(
|
||||
new Map([["a", []]]),
|
||||
new Map([["plug", []]]),
|
||||
new Map(),
|
||||
null
|
||||
);
|
||||
expect(sugg).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("collectSuggestions ignores zero heapTotalBytes", () => {
|
||||
const sugg = collectSuggestions(new Map(), new Map(), new Map(), {
|
||||
heapUsedBytes: 1,
|
||||
heapTotalBytes: 0,
|
||||
recordedAt: 0,
|
||||
});
|
||||
expect(sugg).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
# `simulator/simulation-config-tool` — `SimulationConfigTool`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Programmatic builder for mesh simulation scenarios. NOT a UI — exposes
|
||||
a deterministic API for adding/removing peers and links, defining
|
||||
time-windowed network conditions (latency / loss / jitter / bandwidth /
|
||||
partition windows), scheduling timeline events, recording an observed
|
||||
event stream, replaying recordings as `PlaybackTick`s grouped by
|
||||
timestamp, and round-tripping the entire scenario through a
|
||||
JSON-schema-validated artifact for sharing across teams.
|
||||
|
||||
The tool intentionally has zero rendering or simulation logic — it is
|
||||
the configuration surface that feeds into
|
||||
`@suaveplan/network-plugin-simulation`'s simulation engine. Decoupling
|
||||
the editor from the engine means scenario JSON can be authored,
|
||||
validated, and version-controlled without spinning up an actual
|
||||
simulation runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- `addPeer` / `removePeer` / `listPeers` with Zod boundary; `SIMULATOR_PEER_DUPLICATE` on collision; cascading link removal on peer deletion.
|
||||
- `addLink` validates endpoint existence and surfaces `SIMULATOR_PEER_NOT_FOUND` for dangling references.
|
||||
- `setCondition` / `clearCondition` / `listConditions` with `endMs > startMs` Zod refine; sorted output.
|
||||
- `addEvent` / `listEvents` for `peer-join`, `peer-leave`, `link-up`, `link-down`, `condition-apply`, `condition-clear` events; recording capture appends in parallel.
|
||||
- `startRecording` / `stopRecording` produces a `ScenarioRecording` keyed by current scenario id and a clock-stamped `recordedAtMs`.
|
||||
- `loadPlayback` enforces scenario-id match before replay; `playback()` groups events by `atMs` for tick-accurate replay.
|
||||
- `exportJson` / `importJson` with full Zod validation; `JSON.parse` failures and schema failures both surface as `SIMULATOR_SCENARIO_INVALID`.
|
||||
- Injectable `clock` and `idGenerator` for fully deterministic tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SimulationConfigTool } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const tool = new SimulationConfigTool();
|
||||
tool.addPeer({ id: "host-1", tier: "tier-0", capacity: 100 });
|
||||
tool.addPeer({ id: "leaf-1", tier: "tier-2", capacity: 10 });
|
||||
tool.addLink({
|
||||
from: "host-1",
|
||||
to: "leaf-1",
|
||||
latencyMs: 50,
|
||||
jitterMs: 5,
|
||||
lossRate: 0.01,
|
||||
bandwidthKbps: 10_000,
|
||||
});
|
||||
const scenario = tool.toScenario();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SimulationConfigTool } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const tool = new SimulationConfigTool({
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: () => "scen-001",
|
||||
});
|
||||
tool.setMetadata({
|
||||
name: "client-surge",
|
||||
description: "100 leaves join in 5 seconds",
|
||||
durationMs: 30_000,
|
||||
});
|
||||
tool.startRecording();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tool.addPeer({ id: `leaf-${i}`, tier: "tier-2", capacity: 10 });
|
||||
tool.addEvent({
|
||||
id: `e-${i}`,
|
||||
atMs: i * 50,
|
||||
kind: "peer-join",
|
||||
target: `leaf-${i}`,
|
||||
});
|
||||
}
|
||||
const recording = tool.stopRecording();
|
||||
|
||||
const replay = new SimulationConfigTool({ idGenerator: () => "scen-001" });
|
||||
replay.loadPlayback(recording);
|
||||
for (const tick of replay.playback()) {
|
||||
console.log(`@${tick.atMs}ms`, tick.events.length, "events");
|
||||
}
|
||||
|
||||
const json = tool.exportJson();
|
||||
await Bun.write("./client-surge.json", json);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SimulationConfigTool` — constructor `(options?)`. `options.clock` defaults to `Date.now`; `options.idGenerator` defaults to a base-36 random.
|
||||
- Peer ops: `addPeer(SimulatedPeer)`, `removePeer(id)`, `listPeers()`. Throws `SIMULATOR_PEER_DUPLICATE` / `SIMULATOR_PEER_NOT_FOUND` / `SIMULATOR_INVALID_CONDITION`.
|
||||
- Link ops: `addLink(SimulatedLink)`, `listLinks()`. Throws `SIMULATOR_PEER_NOT_FOUND` when endpoints missing.
|
||||
- Condition ops: `setCondition(NetworkCondition)`, `clearCondition(id)`, `listConditions()`. Throws `SIMULATOR_INVALID_CONDITION` on schema failure or unknown id.
|
||||
- Event ops: `addEvent(ScenarioEvent)`, `listEvents()`.
|
||||
- Metadata: `setMetadata({ name?, description?, durationMs? })`. Bad duration → `SIMULATOR_INVALID_CONDITION`.
|
||||
- Recording: `startRecording()`, `stopRecording(): ScenarioRecording`. Stop without start → `SIMULATOR_RECORDING_INACTIVE`.
|
||||
- Playback: `loadPlayback(recording)`, `playback(): readonly PlaybackTick[]`. Mismatched scenario → `SIMULATOR_SCENARIO_INVALID`; playback before load → `SIMULATOR_PLAYBACK_INACTIVE`.
|
||||
- Round-trip: `exportJson(): string`, `importJson(json): void`. JSON parse / schema fail → `SIMULATOR_SCENARIO_INVALID`.
|
||||
- `toScenario(): SimulationScenario`, `scenarioIdValue()`, `newScenario()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tool keeps mutable internal collections but every public listing
|
||||
method returns a defensive copy so consumers cannot mutate the internal
|
||||
state by holding the reference. Validation happens at the API boundary,
|
||||
not on every internal mutation, because the tool re-validates the full
|
||||
scenario on `importJson` and trusting the post-Zod data internally
|
||||
keeps the hot path cheap. Cascade-on-delete for `removePeer` is
|
||||
intentional — leaving dangling links would force every consumer to
|
||||
re-scan after deletion. The `loadPlayback` scenario-id guard prevents
|
||||
the common foot-gun of replaying a recording against the wrong
|
||||
scenario; consumers wanting to migrate a recording call `newScenario()`
|
||||
followed by `importJson` and only then `loadPlayback`. The `validate`
|
||||
helper accepts a structural `SafeParser` shape rather than a Zod schema
|
||||
type so unit tests can substitute lightweight validators when stress
|
||||
testing input fan-in.
|
||||
@@ -0,0 +1,62 @@
|
||||
import z from "zod";
|
||||
|
||||
export const TIER_VALUES = ["tier-0", "tier-1", "tier-2"] as const;
|
||||
|
||||
export const PeerSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
tier: z.enum(TIER_VALUES),
|
||||
region: z.string().min(1).optional(),
|
||||
capacity: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const LinkSchema = z.object({
|
||||
from: z.string().min(1),
|
||||
to: z.string().min(1),
|
||||
latencyMs: z.number().nonnegative(),
|
||||
jitterMs: z.number().nonnegative(),
|
||||
lossRate: z.number().min(0).max(1),
|
||||
bandwidthKbps: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
export const ConditionSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
startMs: z.number().int().nonnegative(),
|
||||
endMs: z.number().int().nonnegative(),
|
||||
latencyMs: z.number().nonnegative(),
|
||||
jitterMs: z.number().nonnegative(),
|
||||
lossRate: z.number().min(0).max(1),
|
||||
bandwidthKbps: z.number().nonnegative(),
|
||||
partitioned: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
.refine((c) => c.endMs > c.startMs, {
|
||||
message: "endMs must be greater than startMs",
|
||||
});
|
||||
|
||||
export const EventSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
atMs: z.number().int().nonnegative(),
|
||||
kind: z.enum([
|
||||
"peer-join",
|
||||
"peer-leave",
|
||||
"link-up",
|
||||
"link-down",
|
||||
"condition-apply",
|
||||
"condition-clear",
|
||||
]),
|
||||
target: z.string().min(1),
|
||||
payload: z
|
||||
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ScenarioSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
peers: z.array(PeerSchema),
|
||||
links: z.array(LinkSchema),
|
||||
conditions: z.array(ConditionSchema),
|
||||
events: z.array(EventSchema),
|
||||
durationMs: z.number().int().positive(),
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
|
||||
import type {
|
||||
NetworkCondition,
|
||||
PlaybackTick,
|
||||
ScenarioEvent,
|
||||
ScenarioRecording,
|
||||
SimulatedLink,
|
||||
SimulatedPeer,
|
||||
SimulationConfigToolOptions,
|
||||
SimulationScenario,
|
||||
} from "../types/simulator.types.js";
|
||||
import {
|
||||
ConditionSchema,
|
||||
EventSchema,
|
||||
LinkSchema,
|
||||
PeerSchema,
|
||||
ScenarioSchema,
|
||||
} from "./simulation-config-tool.schemas.js";
|
||||
|
||||
const meter = metrics.getMeter("network-plugin-devtools");
|
||||
const eventCounter = meter.createCounter("devtools.simulator.event_total", {
|
||||
description: "Scenario events recorded by the simulator config tool",
|
||||
});
|
||||
|
||||
const DEFAULT_DURATION_MS = 60_000;
|
||||
|
||||
interface MetadataPatch {
|
||||
readonly name?: string;
|
||||
readonly description?: string;
|
||||
readonly durationMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic builder for simulation scenarios. NOT a UI — exposes a
|
||||
* deterministic API for adding/removing peers, links, conditions, and
|
||||
* timeline events; recording an observed event stream; replaying via
|
||||
* `playback()`; and round-tripping through JSON for sharing.
|
||||
*/
|
||||
export class SimulationConfigTool {
|
||||
private readonly clock: () => number;
|
||||
private readonly idGenerator: () => string;
|
||||
private readonly peers = new Map<string, SimulatedPeer>();
|
||||
private readonly links: SimulatedLink[] = [];
|
||||
private readonly conditions = new Map<string, NetworkCondition>();
|
||||
private readonly events: ScenarioEvent[] = [];
|
||||
private scenarioId: string;
|
||||
private name = "untitled";
|
||||
private description = "";
|
||||
private durationMs = DEFAULT_DURATION_MS;
|
||||
private recording: ScenarioEvent[] | null = null;
|
||||
private playbackEvents: ScenarioEvent[] | null = null;
|
||||
|
||||
public constructor(options: SimulationConfigToolOptions = {}) {
|
||||
this.clock = options.clock ?? (() => Date.now());
|
||||
this.idGenerator =
|
||||
options.idGenerator ??
|
||||
(() => Math.random().toString(36).slice(2, 10));
|
||||
this.scenarioId = this.idGenerator();
|
||||
}
|
||||
|
||||
public addPeer(peer: SimulatedPeer): void {
|
||||
validate(PeerSchema, peer, "simulator.peer.invalid");
|
||||
if (this.peers.has(peer.id))
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_PEER_DUPLICATE,
|
||||
"simulator.peer.duplicate",
|
||||
{ context: { peerId: peer.id } }
|
||||
);
|
||||
this.peers.set(peer.id, peer);
|
||||
}
|
||||
|
||||
public removePeer(peerId: string): void {
|
||||
if (!this.peers.delete(peerId)) throw peerNotFound(peerId);
|
||||
for (let i = this.links.length - 1; i >= 0; i--) {
|
||||
const link = this.links[i] as SimulatedLink;
|
||||
if (link.from === peerId || link.to === peerId)
|
||||
this.links.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public listPeers(): ReadonlyArray<SimulatedPeer> {
|
||||
return [...this.peers.values()];
|
||||
}
|
||||
|
||||
public addLink(link: SimulatedLink): void {
|
||||
validate(LinkSchema, link, "simulator.link.invalid");
|
||||
if (!this.peers.has(link.from) || !this.peers.has(link.to))
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_PEER_NOT_FOUND,
|
||||
"simulator.link.endpoint-missing",
|
||||
{ context: { from: link.from, to: link.to } }
|
||||
);
|
||||
this.links.push(link);
|
||||
}
|
||||
|
||||
public listLinks(): ReadonlyArray<SimulatedLink> {
|
||||
return [...this.links];
|
||||
}
|
||||
|
||||
public setCondition(condition: NetworkCondition): void {
|
||||
validate(ConditionSchema, condition, "simulator.condition.invalid");
|
||||
this.conditions.set(condition.id, condition);
|
||||
}
|
||||
|
||||
public clearCondition(id: string): void {
|
||||
if (!this.conditions.delete(id)) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION,
|
||||
"simulator.condition.unknown",
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public listConditions(): ReadonlyArray<NetworkCondition> {
|
||||
return [...this.conditions.values()].sort(
|
||||
(a, b) => a.startMs - b.startMs
|
||||
);
|
||||
}
|
||||
|
||||
public addEvent(event: ScenarioEvent): void {
|
||||
validate(EventSchema, event, "simulator.event.invalid");
|
||||
this.events.push(event);
|
||||
eventCounter.add(1, { kind: event.kind });
|
||||
if (this.recording !== null) this.recording.push(event);
|
||||
}
|
||||
|
||||
public listEvents(): ReadonlyArray<ScenarioEvent> {
|
||||
return [...this.events].sort((a, b) => a.atMs - b.atMs);
|
||||
}
|
||||
|
||||
public setMetadata(meta: MetadataPatch): void {
|
||||
if (meta.name !== undefined) this.name = meta.name;
|
||||
if (meta.description !== undefined) this.description = meta.description;
|
||||
if (meta.durationMs === undefined) return;
|
||||
if (!Number.isFinite(meta.durationMs) || meta.durationMs <= 0) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION,
|
||||
"simulator.metadata.duration",
|
||||
{ context: { durationMs: meta.durationMs } }
|
||||
);
|
||||
}
|
||||
this.durationMs = meta.durationMs;
|
||||
}
|
||||
|
||||
public startRecording(): void {
|
||||
this.recording = [];
|
||||
}
|
||||
|
||||
public stopRecording(): ScenarioRecording {
|
||||
if (this.recording === null) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_RECORDING_INACTIVE,
|
||||
"simulator.recording.inactive"
|
||||
);
|
||||
}
|
||||
const recording: ScenarioRecording = {
|
||||
scenarioId: this.scenarioId,
|
||||
recordedAtMs: this.clock(),
|
||||
events: [...this.recording],
|
||||
};
|
||||
this.recording = null;
|
||||
return recording;
|
||||
}
|
||||
|
||||
public loadPlayback(recording: ScenarioRecording): void {
|
||||
if (recording.scenarioId !== this.scenarioId) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID,
|
||||
"simulator.playback.scenario-mismatch",
|
||||
{
|
||||
context: {
|
||||
expected: this.scenarioId,
|
||||
received: recording.scenarioId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
this.playbackEvents = [...recording.events].sort(
|
||||
(a, b) => a.atMs - b.atMs
|
||||
);
|
||||
}
|
||||
|
||||
public playback(): ReadonlyArray<PlaybackTick> {
|
||||
if (this.playbackEvents === null) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_PLAYBACK_INACTIVE,
|
||||
"simulator.playback.inactive"
|
||||
);
|
||||
}
|
||||
return groupTicks(this.playbackEvents);
|
||||
}
|
||||
|
||||
public toScenario(): SimulationScenario {
|
||||
return {
|
||||
id: this.scenarioId,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
peers: this.listPeers(),
|
||||
links: this.listLinks(),
|
||||
conditions: this.listConditions(),
|
||||
events: this.listEvents(),
|
||||
durationMs: this.durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
public exportJson(): string {
|
||||
return JSON.stringify(this.toScenario());
|
||||
}
|
||||
|
||||
public importJson(json: string): void {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(json);
|
||||
} catch (err) {
|
||||
// JSON.parse always throws a SyntaxError, which is an Error subtype.
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID,
|
||||
"simulator.scenario.parse-failed",
|
||||
{ cause: err as Error }
|
||||
);
|
||||
}
|
||||
const parsed = ScenarioSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID,
|
||||
"simulator.scenario.invalid",
|
||||
{ context: { issues: parsed.error.issues.length } }
|
||||
);
|
||||
}
|
||||
this.applyScenario(parsed.data as SimulationScenario);
|
||||
}
|
||||
|
||||
private applyScenario(scenario: SimulationScenario): void {
|
||||
this.scenarioId = scenario.id;
|
||||
this.name = scenario.name;
|
||||
this.description = scenario.description;
|
||||
this.durationMs = scenario.durationMs;
|
||||
this.peers.clear();
|
||||
this.links.length = 0;
|
||||
this.conditions.clear();
|
||||
this.events.length = 0;
|
||||
for (const peer of scenario.peers) this.peers.set(peer.id, peer);
|
||||
for (const link of scenario.links) this.links.push(link);
|
||||
for (const c of scenario.conditions) this.conditions.set(c.id, c);
|
||||
for (const event of scenario.events) this.events.push(event);
|
||||
}
|
||||
|
||||
public scenarioIdValue(): string {
|
||||
return this.scenarioId;
|
||||
}
|
||||
|
||||
public newScenario(): void {
|
||||
this.scenarioId = this.idGenerator();
|
||||
this.peers.clear();
|
||||
this.links.length = 0;
|
||||
this.conditions.clear();
|
||||
this.events.length = 0;
|
||||
this.recording = null;
|
||||
this.playbackEvents = null;
|
||||
this.name = "untitled";
|
||||
this.description = "";
|
||||
this.durationMs = DEFAULT_DURATION_MS;
|
||||
}
|
||||
}
|
||||
|
||||
function groupTicks(
|
||||
events: ReadonlyArray<ScenarioEvent>
|
||||
): ReadonlyArray<PlaybackTick> {
|
||||
const ticks = new Map<number, ScenarioEvent[]>();
|
||||
for (const event of events) {
|
||||
const slot = ticks.get(event.atMs) ?? [];
|
||||
slot.push(event);
|
||||
ticks.set(event.atMs, slot);
|
||||
}
|
||||
const out: PlaybackTick[] = [];
|
||||
for (const [atMs, list] of [...ticks.entries()].sort(
|
||||
(a, b) => a[0] - b[0]
|
||||
)) {
|
||||
out.push({ atMs, events: list });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type SafeParseResult =
|
||||
| { success: true }
|
||||
| { success: false; error: { issues: ReadonlyArray<unknown> } };
|
||||
|
||||
interface SafeParser {
|
||||
safeParse(v: unknown): SafeParseResult;
|
||||
}
|
||||
|
||||
function peerNotFound(peerId: string): NetworkDevError {
|
||||
return new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_PEER_NOT_FOUND,
|
||||
"simulator.peer.unknown",
|
||||
{ context: { peerId } }
|
||||
);
|
||||
}
|
||||
|
||||
function validate<T>(schema: SafeParser, value: T, messageKey: string): void {
|
||||
const parsed = schema.safeParse(value);
|
||||
if (parsed.success) return;
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION,
|
||||
messageKey,
|
||||
{ context: { issues: parsed.error.issues.length } }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_DEV_CODES, type NetworkDevError } from "../error-codes.js";
|
||||
import type {
|
||||
NetworkCondition,
|
||||
ScenarioRecording,
|
||||
SimulatedLink,
|
||||
SimulatedPeer,
|
||||
} from "../types/simulator.types.js";
|
||||
import { SimulationConfigTool } from "./simulation-config-tool.js";
|
||||
|
||||
const PEER: SimulatedPeer = { id: "p1", tier: "tier-0", capacity: 10 };
|
||||
const PEER2: SimulatedPeer = { id: "p2", tier: "tier-2", capacity: 10 };
|
||||
|
||||
function freshTool(): SimulationConfigTool {
|
||||
return new SimulationConfigTool({
|
||||
clock: () => 1_700_000_000_000,
|
||||
idGenerator: (() => {
|
||||
let i = 0;
|
||||
return () => `id-${i++}`;
|
||||
})(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("SimulationConfigTool defaults (NET_DEV-04)", () => {
|
||||
it("default constructor uses Date.now and random ids", () => {
|
||||
const t = new SimulationConfigTool();
|
||||
expect(typeof t.scenarioIdValue()).toBe("string");
|
||||
expect(t.scenarioIdValue().length).toBeGreaterThan(0);
|
||||
// Trigger the default Date.now clock via stopRecording.
|
||||
t.startRecording();
|
||||
const rec = t.stopRecording();
|
||||
expect(typeof rec.recordedAtMs).toBe("number");
|
||||
expect(rec.recordedAtMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("removePeer also removes links where peer is the destination", () => {
|
||||
const t = freshTool();
|
||||
t.addPeer(PEER);
|
||||
t.addPeer(PEER2);
|
||||
t.addLink({
|
||||
from: "p1",
|
||||
to: "p2",
|
||||
latencyMs: 1,
|
||||
jitterMs: 0,
|
||||
lossRate: 0,
|
||||
bandwidthKbps: 1,
|
||||
});
|
||||
t.removePeer("p2");
|
||||
expect(t.listLinks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removePeer leaves unrelated links untouched", () => {
|
||||
const t = freshTool();
|
||||
t.addPeer(PEER);
|
||||
t.addPeer(PEER2);
|
||||
t.addPeer({ id: "p3", tier: "tier-2", capacity: 1 });
|
||||
t.addLink({
|
||||
from: "p1",
|
||||
to: "p2",
|
||||
latencyMs: 1,
|
||||
jitterMs: 0,
|
||||
lossRate: 0,
|
||||
bandwidthKbps: 1,
|
||||
});
|
||||
t.removePeer("p3");
|
||||
expect(t.listLinks()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SimulationConfigTool peers + links (NET_DEV-04)", () => {
|
||||
it("addPeer / removePeer cascades link removal", () => {
|
||||
const t = freshTool();
|
||||
t.addPeer(PEER);
|
||||
t.addPeer(PEER2);
|
||||
t.addLink({
|
||||
from: "p1",
|
||||
to: "p2",
|
||||
latencyMs: 10,
|
||||
jitterMs: 0,
|
||||
lossRate: 0,
|
||||
bandwidthKbps: 1000,
|
||||
});
|
||||
expect(t.listLinks()).toHaveLength(1);
|
||||
t.removePeer("p1");
|
||||
expect(t.listLinks()).toHaveLength(0);
|
||||
expect(t.listPeers()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("duplicate addPeer rejected with SIMULATOR_PEER_DUPLICATE", () => {
|
||||
const t = freshTool();
|
||||
t.addPeer(PEER);
|
||||
try {
|
||||
t.addPeer(PEER);
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_PEER_DUPLICATE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("removePeer on unknown id raises SIMULATOR_PEER_NOT_FOUND", () => {
|
||||
const t = freshTool();
|
||||
try {
|
||||
t.removePeer("ghost");
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_PEER_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("addLink with unknown endpoint raises SIMULATOR_PEER_NOT_FOUND", () => {
|
||||
const t = freshTool();
|
||||
t.addPeer(PEER);
|
||||
const link: SimulatedLink = {
|
||||
from: "p1",
|
||||
to: "ghost",
|
||||
latencyMs: 5,
|
||||
jitterMs: 1,
|
||||
lossRate: 0,
|
||||
bandwidthKbps: 100,
|
||||
};
|
||||
try {
|
||||
t.addLink(link);
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_PEER_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("addPeer Zod failure raises SIMULATOR_INVALID_CONDITION", () => {
|
||||
const t = freshTool();
|
||||
try {
|
||||
t.addPeer({
|
||||
id: "",
|
||||
tier: "tier-0",
|
||||
capacity: -1,
|
||||
} as unknown as SimulatedPeer);
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("addLink Zod failure raises SIMULATOR_INVALID_CONDITION", () => {
|
||||
const t = freshTool();
|
||||
try {
|
||||
t.addLink({
|
||||
from: "p1",
|
||||
to: "p2",
|
||||
latencyMs: -1,
|
||||
jitterMs: 0,
|
||||
lossRate: 0,
|
||||
bandwidthKbps: 0,
|
||||
});
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SimulationConfigTool conditions + events (NET_DEV-04)", () => {
|
||||
it("setCondition + listConditions sorted by startMs", () => {
|
||||
const t = freshTool();
|
||||
const c1: NetworkCondition = {
|
||||
id: "c1",
|
||||
startMs: 1000,
|
||||
endMs: 2000,
|
||||
latencyMs: 0,
|
||||
jitterMs: 0,
|
||||
lossRate: 0,
|
||||
bandwidthKbps: 100,
|
||||
};
|
||||
const c2: NetworkCondition = {
|
||||
...c1,
|
||||
id: "c2",
|
||||
startMs: 100,
|
||||
endMs: 200,
|
||||
};
|
||||
t.setCondition(c1);
|
||||
t.setCondition(c2);
|
||||
expect(t.listConditions().map((c) => c.id)).toStrictEqual(["c2", "c1"]);
|
||||
});
|
||||
|
||||
it("setCondition Zod refine: endMs > startMs", () => {
|
||||
const t = freshTool();
|
||||
try {
|
||||
t.setCondition({
|
||||
id: "c",
|
||||
startMs: 100,
|
||||
endMs: 50,
|
||||
latencyMs: 0,
|
||||
jitterMs: 0,
|
||||
lossRate: 0,
|
||||
bandwidthKbps: 0,
|
||||
});
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("clearCondition unknown id raises SIMULATOR_INVALID_CONDITION", () => {
|
||||
const t = freshTool();
|
||||
try {
|
||||
t.clearCondition("ghost");
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("clearCondition succeeds for known id", () => {
|
||||
const t = freshTool();
|
||||
t.setCondition({
|
||||
id: "c",
|
||||
startMs: 0,
|
||||
endMs: 1,
|
||||
latencyMs: 0,
|
||||
jitterMs: 0,
|
||||
lossRate: 0,
|
||||
bandwidthKbps: 0,
|
||||
});
|
||||
t.clearCondition("c");
|
||||
expect(t.listConditions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("addEvent Zod failure raises SIMULATOR_INVALID_CONDITION", () => {
|
||||
const t = freshTool();
|
||||
try {
|
||||
t.addEvent({
|
||||
id: "",
|
||||
atMs: 0,
|
||||
kind: "peer-join",
|
||||
target: "p",
|
||||
});
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("listEvents sorted by atMs", () => {
|
||||
const t = freshTool();
|
||||
t.addEvent({ id: "e1", atMs: 200, kind: "peer-join", target: "p1" });
|
||||
t.addEvent({ id: "e2", atMs: 100, kind: "peer-join", target: "p2" });
|
||||
expect(t.listEvents().map((e) => e.id)).toStrictEqual(["e2", "e1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SimulationConfigTool metadata + recording + playback (NET_DEV-04)", () => {
|
||||
it("setMetadata mutates fields and rejects bad duration", () => {
|
||||
const t = freshTool();
|
||||
t.setMetadata({ name: "n", description: "d", durationMs: 1000 });
|
||||
expect(t.toScenario().name).toBe("n");
|
||||
try {
|
||||
t.setMetadata({ durationMs: 0 });
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
|
||||
);
|
||||
}
|
||||
try {
|
||||
t.setMetadata({ durationMs: Number.NaN });
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_INVALID_CONDITION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("setMetadata with no duration leaves it untouched", () => {
|
||||
const t = freshTool();
|
||||
const before = t.toScenario().durationMs;
|
||||
t.setMetadata({ name: "x" });
|
||||
expect(t.toScenario().durationMs).toBe(before);
|
||||
});
|
||||
|
||||
it("stopRecording without start raises SIMULATOR_RECORDING_INACTIVE", () => {
|
||||
const t = freshTool();
|
||||
try {
|
||||
t.stopRecording();
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_RECORDING_INACTIVE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("playback without load raises SIMULATOR_PLAYBACK_INACTIVE", () => {
|
||||
const t = freshTool();
|
||||
try {
|
||||
t.playback();
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_PLAYBACK_INACTIVE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("loadPlayback with mismatched scenario id raises SIMULATOR_SCENARIO_INVALID", () => {
|
||||
const t = freshTool();
|
||||
const recording: ScenarioRecording = {
|
||||
scenarioId: "other",
|
||||
recordedAtMs: 0,
|
||||
events: [],
|
||||
};
|
||||
try {
|
||||
t.loadPlayback(recording);
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("recording captures events; playback groups by atMs", () => {
|
||||
const t = freshTool();
|
||||
const id = t.scenarioIdValue();
|
||||
t.startRecording();
|
||||
t.addEvent({ id: "e1", atMs: 100, kind: "peer-join", target: "p1" });
|
||||
t.addEvent({ id: "e2", atMs: 100, kind: "peer-join", target: "p2" });
|
||||
t.addEvent({ id: "e3", atMs: 100, kind: "peer-join", target: "p3" });
|
||||
t.addEvent({ id: "e4", atMs: 200, kind: "peer-leave", target: "p1" });
|
||||
const rec = t.stopRecording();
|
||||
expect(rec.scenarioId).toBe(id);
|
||||
t.loadPlayback(rec);
|
||||
const ticks = t.playback();
|
||||
expect(ticks).toHaveLength(2);
|
||||
expect(ticks[0]?.atMs).toBe(100);
|
||||
expect(ticks[0]?.events).toHaveLength(3);
|
||||
expect(ticks[1]?.events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SimulationConfigTool JSON round-trip (NET_DEV-04)", () => {
|
||||
it("export then import preserves scenario state", () => {
|
||||
const t = freshTool();
|
||||
t.addPeer(PEER);
|
||||
t.addPeer(PEER2);
|
||||
t.addLink({
|
||||
from: "p1",
|
||||
to: "p2",
|
||||
latencyMs: 10,
|
||||
jitterMs: 1,
|
||||
lossRate: 0.01,
|
||||
bandwidthKbps: 1000,
|
||||
});
|
||||
t.setCondition({
|
||||
id: "c",
|
||||
startMs: 0,
|
||||
endMs: 1000,
|
||||
latencyMs: 50,
|
||||
jitterMs: 5,
|
||||
lossRate: 0.05,
|
||||
bandwidthKbps: 500,
|
||||
partitioned: ["p2"],
|
||||
});
|
||||
t.addEvent({ id: "e1", atMs: 100, kind: "peer-leave", target: "p2" });
|
||||
t.setMetadata({
|
||||
name: "test",
|
||||
description: "d",
|
||||
durationMs: 5000,
|
||||
});
|
||||
const json = t.exportJson();
|
||||
|
||||
const t2 = freshTool();
|
||||
t2.importJson(json);
|
||||
expect(t2.listPeers()).toHaveLength(2);
|
||||
expect(t2.listLinks()).toHaveLength(1);
|
||||
expect(t2.listConditions()).toHaveLength(1);
|
||||
expect(t2.listEvents()).toHaveLength(1);
|
||||
expect(t2.toScenario().name).toBe("test");
|
||||
expect(t2.scenarioIdValue()).toBe(t.scenarioIdValue());
|
||||
});
|
||||
|
||||
it("importJson rejects malformed JSON with SIMULATOR_SCENARIO_INVALID", () => {
|
||||
const t = freshTool();
|
||||
try {
|
||||
t.importJson("{ not json");
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("importJson rejects schema-invalid data with SIMULATOR_SCENARIO_INVALID", () => {
|
||||
const t = freshTool();
|
||||
try {
|
||||
t.importJson(JSON.stringify({ id: "", name: "x" }));
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_SCENARIO_INVALID
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("newScenario resets every collection", () => {
|
||||
const t = freshTool();
|
||||
t.addPeer(PEER);
|
||||
t.startRecording();
|
||||
const before = t.scenarioIdValue();
|
||||
t.newScenario();
|
||||
expect(t.scenarioIdValue()).not.toBe(before);
|
||||
expect(t.listPeers()).toHaveLength(0);
|
||||
// recording was wiped — stop now must throw
|
||||
try {
|
||||
t.stopRecording();
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.SIMULATOR_RECORDING_INACTIVE
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
# `types/inspector.types` — inspector type contracts
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type definitions for the `StateInspector` subsystem. Producers
|
||||
(state-coordination plugins, tests, snapshot recorders) materialise
|
||||
`StateSnapshot` objects against this contract; consumers (UIs, log
|
||||
shippers, replay tools) consume `StateView` and `DiffView` projections
|
||||
without importing the implementation file.
|
||||
|
||||
## Features
|
||||
|
||||
- `StateSnapshot` carrying `path` / `value` / `version` / `timestamp` / `peerId` / optional `sessionId`.
|
||||
- `StateView` returned from `inspect(path)` — current snapshot plus full ring.
|
||||
- `InspectorConfig` with optional `historyLimit` (defaults to 256).
|
||||
- `DiffView` carrying JSON-Pointer arrays for added / removed / changed plus a `DiffSummary` count badge.
|
||||
- `SearchFilter` AND-combining `pathContains`, `peerId`, `sessionId`, `minVersion`, `maxVersion`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { StateSnapshot } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
function snapshotOf(path: string, value: unknown, version: number): StateSnapshot {
|
||||
return {
|
||||
path,
|
||||
value: value as StateSnapshot["value"],
|
||||
version,
|
||||
timestamp: Date.now(),
|
||||
peerId: "host-1",
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { DiffView, SearchFilter, StateView } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
function badge(view: DiffView): string {
|
||||
const { addedCount, removedCount, changedCount } = view.summary;
|
||||
return `+${addedCount} -${removedCount} ~${changedCount}`;
|
||||
}
|
||||
|
||||
const filter: SearchFilter = {
|
||||
pathContains: "session/",
|
||||
peerId: "host-eu-1",
|
||||
minVersion: 100,
|
||||
maxVersion: 200,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface StateSnapshot { path, value (JsonValue), version, timestamp, peerId, sessionId? }`.
|
||||
- `interface StateView { path, current: StateSnapshot, history: readonly StateSnapshot[] }`.
|
||||
- `interface InspectorConfig { historyLimit? }`.
|
||||
- `interface DiffView { path, fromVersion, toVersion, added, removed, changed, summary }`.
|
||||
- `interface DiffSummary { addedCount, removedCount, changedCount }`.
|
||||
- `interface SearchFilter { pathContains?, peerId?, sessionId?, minVersion?, maxVersion? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`value` is typed as `JsonValue` (re-exported from `@suaveplan/diff`) so
|
||||
diff and patch operations have a stable boundary. Using `JsonValue`
|
||||
rather than `unknown` means the inspector cannot accidentally store
|
||||
non-serialisable values (functions, symbols, class instances) — those
|
||||
would silently break the diff path. `version` is a number rather than
|
||||
a vector clock because the inspector is per-path; cross-path causality
|
||||
is the responsibility of the upstream state plugin. The optional
|
||||
`sessionId` field is omitted (not `undefined`) to satisfy
|
||||
`exactOptionalPropertyTypes`; consumers building snapshots conditionally
|
||||
should spread it only when populated.
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { JsonValue } from "@suaveplan/diff";
|
||||
|
||||
/**
|
||||
* Snapshot of a synchronised state path at one logical instant, keyed by
|
||||
* a vector-clock-style monotonic version. The inspector keeps a ring
|
||||
* buffer of these per path.
|
||||
*/
|
||||
export interface StateSnapshot {
|
||||
readonly path: string;
|
||||
readonly value: JsonValue;
|
||||
readonly version: number;
|
||||
readonly timestamp: number;
|
||||
readonly peerId: string;
|
||||
readonly sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* View materialised by `StateInspector.inspect(path)` — current value
|
||||
* plus full known history (oldest → newest).
|
||||
*/
|
||||
export interface StateView {
|
||||
readonly path: string;
|
||||
readonly current: StateSnapshot;
|
||||
readonly history: ReadonlyArray<StateSnapshot>;
|
||||
}
|
||||
|
||||
export interface InspectorConfig {
|
||||
/** Per-path ring-buffer cap. Defaults to 256 if omitted. */
|
||||
readonly historyLimit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff result between two snapshot versions of the same path. Op-level
|
||||
* deltas come straight from `@suaveplan/diff` (RFC-6902 patches);
|
||||
* `summary` is a fast count for UI badges.
|
||||
*/
|
||||
export interface DiffView {
|
||||
readonly path: string;
|
||||
readonly fromVersion: number;
|
||||
readonly toVersion: number;
|
||||
readonly added: ReadonlyArray<string>;
|
||||
readonly removed: ReadonlyArray<string>;
|
||||
readonly changed: ReadonlyArray<string>;
|
||||
readonly summary: DiffSummary;
|
||||
}
|
||||
|
||||
export interface DiffSummary {
|
||||
readonly addedCount: number;
|
||||
readonly removedCount: number;
|
||||
readonly changedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search filter for `StateInspector.search`. All fields are optional;
|
||||
* AND-combined when populated.
|
||||
*/
|
||||
export interface SearchFilter {
|
||||
readonly pathContains?: string;
|
||||
readonly peerId?: string;
|
||||
readonly sessionId?: string;
|
||||
readonly minVersion?: number;
|
||||
readonly maxVersion?: number;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# `types/profiler.types` — profiler type contracts
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type definitions for the `PerformanceProfiler` subsystem.
|
||||
Producers (instrumented call sites, telemetry pipelines, tests)
|
||||
materialise sample shapes against these contracts; consumers (report
|
||||
viewers, regression dashboards, suggestion renderers) read
|
||||
`PerformanceReport`, `Baseline`, `Regression`, and
|
||||
`OptimizationSuggestion` projections without importing the
|
||||
implementation file.
|
||||
|
||||
## Features
|
||||
|
||||
- Four sample shapes: `LatencySample`, `ThroughputSample`, `PluginOverheadSample`, `MemorySample`.
|
||||
- `LatencyDistribution` — count + min / max / mean + p50 / p95 / p99.
|
||||
- `PerformanceReport` — typed read-only `Map`s of every aggregate.
|
||||
- `Baseline` — frozen capture for later `compare(baselineId)` calls.
|
||||
- `Regression` + `RegressionSeverity` — discriminated by metric (`p50` / `p95` / `p99` / `throughput`) and severity ladder (`info` / `warn` / `error`).
|
||||
- `OptimizationSuggestion` with category enum (`latency` / `throughput` / `overhead` / `memory`).
|
||||
- `ProfilerConfig` injectable thresholds + clock.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { LatencyDistribution } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
function summary(d: LatencyDistribution): string {
|
||||
return `n=${d.count} p50=${d.p50.toFixed(1)} p99=${d.p99.toFixed(1)}`;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Baseline,
|
||||
PerformanceReport,
|
||||
Regression,
|
||||
} from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
function report(b: Baseline, current: PerformanceReport): string {
|
||||
const aged = (Date.now() - b.capturedAt) / 60_000;
|
||||
return `baseline ${b.id} (age ${aged.toFixed(1)} min)\n` +
|
||||
`${current.latencyByPath.size} paths tracked`;
|
||||
}
|
||||
|
||||
function severityBadge(r: Regression): string {
|
||||
return `[${r.severity}] ${r.metric} on ${r.path}: +${r.deltaPct.toFixed(1)}%`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface LatencySample { path, latencyMs, recordedAt }`.
|
||||
- `interface ThroughputSample { path, bytesPerSec, messagesPerSec, recordedAt }`.
|
||||
- `interface PluginOverheadSample { pluginName, cpuMs, recordedAt }`.
|
||||
- `interface MemorySample { heapUsedBytes, heapTotalBytes, recordedAt }`.
|
||||
- `interface LatencyDistribution { count, p50, p95, p99, min, max, mean }`.
|
||||
- `interface PerformanceReport { latencyByPath, throughputByPath, pluginOverhead, memory, generatedAt }`.
|
||||
- `interface Baseline { id, capturedAt, report }`.
|
||||
- `type RegressionSeverity = "info" | "warn" | "error"`.
|
||||
- `interface Regression { path, metric, baseline, current, deltaPct, zScore, severity }`.
|
||||
- `interface OptimizationSuggestion { subject, category, message, evidence }`.
|
||||
- `interface ProfilerConfig { windowSize?, regressionZScore?, regressionWarnPct?, clock? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`LatencyDistribution` is intentionally flat (no nested `percentiles`
|
||||
sub-object) so a downstream metrics adapter can map every field to a
|
||||
gauge without re-shaping. The choice of p50 / p95 / p99 mirrors the
|
||||
Prometheus / OpenTelemetry industry standard; emitting p99.9 / max-only
|
||||
distributions would force a wider sample buffer and fewer engineers
|
||||
intuit those numbers reliably. `Regression.severity` is a closed
|
||||
three-value union — adding a level requires an OpenSpec change because
|
||||
downstream alert wiring commits to it. `OptimizationSuggestion.subject`
|
||||
is intentionally a free string (not a path or peer id) because
|
||||
suggestion subjects span paths, plugin names, and synthetic categories
|
||||
like `"process"` for memory pressure; downstream renderers should
|
||||
treat it as a display label, not a navigable identifier.
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Single latency observation in milliseconds tagged with the path the
|
||||
* message took (transport id or peer pair). Stored in a per-path ring.
|
||||
*/
|
||||
export interface LatencySample {
|
||||
readonly path: string;
|
||||
readonly latencyMs: number;
|
||||
readonly recordedAt: number;
|
||||
}
|
||||
|
||||
export interface ThroughputSample {
|
||||
readonly path: string;
|
||||
readonly bytesPerSec: number;
|
||||
readonly messagesPerSec: number;
|
||||
readonly recordedAt: number;
|
||||
}
|
||||
|
||||
export interface PluginOverheadSample {
|
||||
readonly pluginName: string;
|
||||
readonly cpuMs: number;
|
||||
readonly recordedAt: number;
|
||||
}
|
||||
|
||||
export interface MemorySample {
|
||||
readonly heapUsedBytes: number;
|
||||
readonly heapTotalBytes: number;
|
||||
readonly recordedAt: number;
|
||||
}
|
||||
|
||||
export interface LatencyDistribution {
|
||||
readonly count: number;
|
||||
readonly p50: number;
|
||||
readonly p95: number;
|
||||
readonly p99: number;
|
||||
readonly min: number;
|
||||
readonly max: number;
|
||||
readonly mean: number;
|
||||
}
|
||||
|
||||
export interface PerformanceReport {
|
||||
readonly latencyByPath: ReadonlyMap<string, LatencyDistribution>;
|
||||
readonly throughputByPath: ReadonlyMap<string, ThroughputSample>;
|
||||
readonly pluginOverhead: ReadonlyMap<string, number>;
|
||||
readonly memory: MemorySample | null;
|
||||
readonly generatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen baseline captured by `profiler.captureBaseline(id)`. Used by
|
||||
* `profiler.compare(baselineId)` to detect regressions.
|
||||
*/
|
||||
export interface Baseline {
|
||||
readonly id: string;
|
||||
readonly capturedAt: number;
|
||||
readonly report: PerformanceReport;
|
||||
}
|
||||
|
||||
export type RegressionSeverity = "info" | "warn" | "error";
|
||||
|
||||
export interface Regression {
|
||||
readonly path: string;
|
||||
readonly metric: "p50" | "p95" | "p99" | "throughput";
|
||||
readonly baseline: number;
|
||||
readonly current: number;
|
||||
readonly deltaPct: number;
|
||||
readonly zScore: number;
|
||||
readonly severity: RegressionSeverity;
|
||||
}
|
||||
|
||||
export interface OptimizationSuggestion {
|
||||
readonly subject: string;
|
||||
readonly category: "latency" | "throughput" | "overhead" | "memory";
|
||||
readonly message: string;
|
||||
readonly evidence: string;
|
||||
}
|
||||
|
||||
export interface ProfilerConfig {
|
||||
/** Per-path ring-buffer cap for latency samples. */
|
||||
readonly windowSize?: number;
|
||||
/** Z-score threshold above which a regression escalates to `error`. */
|
||||
readonly regressionZScore?: number;
|
||||
/** Percent delta threshold for warn-level regressions. */
|
||||
readonly regressionWarnPct?: number;
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# `types/simulator.types` — simulator type contracts
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type definitions for the `SimulationConfigTool` subsystem.
|
||||
Producers (scenario authors, recording capture, JSON importers) and
|
||||
consumers (the simulation engine downstream, scenario serialisers, UI
|
||||
editors) both reference these contracts. Keeping the types separate
|
||||
from the implementation file means consumers can author scenarios
|
||||
purely against the types without instantiating the tool.
|
||||
|
||||
## Features
|
||||
|
||||
- `SimulatorTier` mirrors mesh tiers (`tier-0` / `tier-1` / `tier-2`).
|
||||
- `SimulatedPeer` — id / tier / optional region / capacity.
|
||||
- `SimulatedLink` — endpoints + latency / jitter / loss / bandwidth.
|
||||
- `NetworkCondition` — time-windowed `[startMs, endMs]` shape with optional `partitioned` peer ids.
|
||||
- `ScenarioEvent` + `ScenarioEventKind` — six kinds covering peer/link lifecycle and condition application.
|
||||
- `SimulationScenario` — the top-level artifact serialised by `exportJson`.
|
||||
- `ScenarioRecording` / `PlaybackTick` — capture + replay shapes.
|
||||
- `SimulationConfigToolOptions` — injectable `clock` and `idGenerator`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SimulatedPeer } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const peer: SimulatedPeer = {
|
||||
id: "leaf-1",
|
||||
tier: "tier-2",
|
||||
capacity: 10,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
NetworkCondition,
|
||||
ScenarioEvent,
|
||||
SimulationScenario,
|
||||
} from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const condition: NetworkCondition = {
|
||||
id: "spike",
|
||||
startMs: 5_000,
|
||||
endMs: 10_000,
|
||||
latencyMs: 200,
|
||||
jitterMs: 30,
|
||||
lossRate: 0.2,
|
||||
bandwidthKbps: 500,
|
||||
partitioned: ["leaf-7"],
|
||||
};
|
||||
|
||||
const event: ScenarioEvent = {
|
||||
id: "ev-1",
|
||||
atMs: 5_000,
|
||||
kind: "condition-apply",
|
||||
target: "spike",
|
||||
payload: { reason: "rolling-restart" },
|
||||
};
|
||||
|
||||
const scenario: SimulationScenario = {
|
||||
id: "client-surge",
|
||||
name: "Client surge",
|
||||
description: "100 leaves join in 5s",
|
||||
peers: [],
|
||||
links: [],
|
||||
conditions: [condition],
|
||||
events: [event],
|
||||
durationMs: 30_000,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SimulatorTier = "tier-0" | "tier-1" | "tier-2"`.
|
||||
- `interface SimulatedPeer { id, tier, region?, capacity }`.
|
||||
- `interface SimulatedLink { from, to, latencyMs, jitterMs, lossRate, bandwidthKbps }`.
|
||||
- `interface NetworkCondition { id, startMs, endMs, latencyMs, jitterMs, lossRate, bandwidthKbps, partitioned? }`.
|
||||
- `type ScenarioEventKind = "peer-join" | "peer-leave" | "link-up" | "link-down" | "condition-apply" | "condition-clear"`.
|
||||
- `interface ScenarioEvent { id, atMs, kind, target, payload? }`.
|
||||
- `interface SimulationScenario { id, name, description, peers, links, conditions, events, durationMs }`.
|
||||
- `interface ScenarioRecording { scenarioId, recordedAtMs, events }`.
|
||||
- `interface PlaybackTick { atMs, events }`.
|
||||
- `interface SimulationConfigToolOptions { clock?, idGenerator? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `payload` field on `ScenarioEvent` is intentionally a
|
||||
`Record<string, string | number | boolean>` rather than `JsonValue` —
|
||||
JSON-import safety is much easier to validate against scalar payloads,
|
||||
and engineers editing scenarios by hand rarely need nested structures.
|
||||
If a future engine needs richer payloads, a versioned schema upgrade
|
||||
in `simulation-config-tool.schemas.ts` is the migration path. Tier
|
||||
strings duplicate `VisualizerTier` rather than aliasing because
|
||||
scenarios round-trip through JSON and a single source of literal
|
||||
strings keeps the schema robust against renames in either domain.
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Tier label assigned to each simulated peer. Mirrors the canonical
|
||||
* mesh tiers so scenarios are interchangeable with `network-plugin-simulation`.
|
||||
*/
|
||||
export type SimulatorTier = "tier-0" | "tier-1" | "tier-2";
|
||||
|
||||
export interface SimulatedPeer {
|
||||
readonly id: string;
|
||||
readonly tier: SimulatorTier;
|
||||
readonly region?: string;
|
||||
readonly capacity: number;
|
||||
}
|
||||
|
||||
export interface SimulatedLink {
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
readonly latencyMs: number;
|
||||
readonly jitterMs: number;
|
||||
readonly lossRate: number;
|
||||
readonly bandwidthKbps: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single editable network condition. Conditions are time-indexed via a
|
||||
* `windowMs` range so a scenario can express "loss climbs to 20% between
|
||||
* 10s and 30s, then recovers" deterministically.
|
||||
*/
|
||||
export interface NetworkCondition {
|
||||
readonly id: string;
|
||||
readonly startMs: number;
|
||||
readonly endMs: number;
|
||||
readonly latencyMs: number;
|
||||
readonly jitterMs: number;
|
||||
readonly lossRate: number;
|
||||
readonly bandwidthKbps: number;
|
||||
readonly partitioned?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export type ScenarioEventKind =
|
||||
| "peer-join"
|
||||
| "peer-leave"
|
||||
| "link-up"
|
||||
| "link-down"
|
||||
| "condition-apply"
|
||||
| "condition-clear";
|
||||
|
||||
export interface ScenarioEvent {
|
||||
readonly id: string;
|
||||
readonly atMs: number;
|
||||
readonly kind: ScenarioEventKind;
|
||||
readonly target: string;
|
||||
readonly payload?: Readonly<Record<string, string | number | boolean>>;
|
||||
}
|
||||
|
||||
export interface SimulationScenario {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly peers: ReadonlyArray<SimulatedPeer>;
|
||||
readonly links: ReadonlyArray<SimulatedLink>;
|
||||
readonly conditions: ReadonlyArray<NetworkCondition>;
|
||||
readonly events: ReadonlyArray<ScenarioEvent>;
|
||||
readonly durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording captured by the simulator's recorder. A recording is a
|
||||
* scenario plus the observed event stream so it can be replayed
|
||||
* deterministically against the same simulation engine.
|
||||
*/
|
||||
export interface ScenarioRecording {
|
||||
readonly scenarioId: string;
|
||||
readonly recordedAtMs: number;
|
||||
readonly events: ReadonlyArray<ScenarioEvent>;
|
||||
}
|
||||
|
||||
export interface PlaybackTick {
|
||||
readonly atMs: number;
|
||||
readonly events: ReadonlyArray<ScenarioEvent>;
|
||||
}
|
||||
|
||||
export interface SimulationConfigToolOptions {
|
||||
readonly clock?: () => number;
|
||||
readonly idGenerator?: () => string;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# `types/visualizer.types` — visualizer type contracts
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type definitions for the `NetworkVisualizer` subsystem and every
|
||||
consumer that produces or consumes a `VisualizerScene` IR. Centralising
|
||||
the contracts here means renderers, topology adapters, and stream
|
||||
adapters can be implemented independently without circular references
|
||||
into the implementation file. The types are runtime-free and tree-shake
|
||||
to nothing in consumer bundles.
|
||||
|
||||
## Features
|
||||
|
||||
- `VisualizerMode` — `"hierarchy" | "flow" | "heat"` literal union.
|
||||
- `VisualizerTier` — `"tier-0" | "tier-1" | "tier-2"` mirrors the canonical mesh tiers without taking a runtime dep on `network-plugin-topology`.
|
||||
- `NodeLayout` / `EdgeLayout` / `HeatCell` — primitive scene atoms.
|
||||
- `VisualizerFilter` — composable tier / latency-group / session / transport filters.
|
||||
- `VisualizerConfig` — `start({ mode, refreshRate, filter?, focus? })` input.
|
||||
- `TopologyAdapter` / `MessageStreamAdapter` — caller-supplied data sources.
|
||||
- `VisualizerScene` — the IR consumed by every `Renderer`.
|
||||
- `Renderer` / `RendererOutput` — pluggable byte-emitter contract.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { VisualizerScene } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
function describe(scene: VisualizerScene): string {
|
||||
return `${scene.mode}: ${scene.nodes.length} nodes, ${scene.edges.length} edges`;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MessageStreamAdapter,
|
||||
Renderer,
|
||||
TopologyAdapter,
|
||||
} from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
class TopologyBridge implements TopologyAdapter {
|
||||
constructor(private readonly index: TopologyIndex) {}
|
||||
listNodes() {
|
||||
return this.index.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
tier: n.tier,
|
||||
x: n.coords.x,
|
||||
y: n.coords.y,
|
||||
label: n.label,
|
||||
}));
|
||||
}
|
||||
listEdges() {
|
||||
return this.index.edges;
|
||||
}
|
||||
nodeById(id: string) {
|
||||
return this.listNodes().find((n) => n.id === id);
|
||||
}
|
||||
}
|
||||
|
||||
const renderer: Renderer = {
|
||||
render(scene) {
|
||||
// produce caller-specific bytes (PNG, canvas, terminal, …)
|
||||
return {
|
||||
format: "png",
|
||||
bytes: encode(scene),
|
||||
mimeType: "image/png",
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type VisualizerMode = "hierarchy" | "flow" | "heat"`.
|
||||
- `type VisualizerTier = "tier-0" | "tier-1" | "tier-2"`.
|
||||
- `interface NodeLayout { id, tier, x, y, label }`.
|
||||
- `interface EdgeLayout { from, to, latencyMs, throughput, drops }`.
|
||||
- `interface HeatCell { id, intensity, latencyMs, saturation }`.
|
||||
- `interface VisualizerFilter { tiers?, latencyGroups?, sessions?, transports? }`.
|
||||
- `interface VisualizerConfig { mode, refreshRate, filter?, focus? }`.
|
||||
- `interface TopologyAdapter { listNodes(), listEdges(), nodeById(id) }`.
|
||||
- `interface MessageStreamAdapter { listFlows(), listHeat() }`.
|
||||
- `type VisualizerExportFormat = "svg" | "png"`.
|
||||
- `interface VisualizerScene { mode, nodes, edges, heat, width, height, focus? }`.
|
||||
- `interface RendererOutput { format, bytes, mimeType }`.
|
||||
- `interface Renderer { render(scene): RendererOutput }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The IR shape is the package's wire contract — every public renderer,
|
||||
test harness, snapshot generator, and downstream consumer reads it.
|
||||
Adding a field is backwards-compatible (consumers ignore unknown
|
||||
fields); removing or renaming a field is a major-version change. The
|
||||
intensity field on `HeatCell` is normalised 0..1 by convention but the
|
||||
SVG renderer clamps defensively, so producers that emit out-of-range
|
||||
values still render. Tier strings are mirrored from `network-plugin-topology`
|
||||
rather than re-imported because the visualizer should be usable in
|
||||
package boundaries that haven't installed the topology plugin (e.g. a
|
||||
pure-test consumer composing a fake topology by hand).
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Modes the `NetworkVisualizer` can render. Each mode produces a
|
||||
* different intermediate representation — `hierarchy` shows the
|
||||
* tier-tree, `flow` shows live message paths, `heat` shows latency /
|
||||
* saturation as a colour-mapped grid.
|
||||
*/
|
||||
export type VisualizerMode = "hierarchy" | "flow" | "heat";
|
||||
|
||||
/**
|
||||
* Tier label propagated from `network-plugin-topology`. Mirrored locally
|
||||
* so the visualizer doesn't take a hard runtime dep on the topology
|
||||
* plugin — consumers map their own tier representation onto these
|
||||
* canonical labels.
|
||||
*/
|
||||
export type VisualizerTier = "tier-0" | "tier-1" | "tier-2";
|
||||
|
||||
export interface NodeLayout {
|
||||
readonly id: string;
|
||||
readonly tier: VisualizerTier;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface EdgeLayout {
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
/** EWMA latency in ms over the rolling sample window. */
|
||||
readonly latencyMs: number;
|
||||
/** Throughput in messages per second. */
|
||||
readonly throughput: number;
|
||||
/** Number of dropped frames in the same window. */
|
||||
readonly drops: number;
|
||||
}
|
||||
|
||||
export interface HeatCell {
|
||||
readonly id: string;
|
||||
/** 0..1 normalised intensity used to derive the colour. */
|
||||
readonly intensity: number;
|
||||
readonly latencyMs: number;
|
||||
readonly saturation: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter applied at render time. Empty arrays mean "no restriction" —
|
||||
* the visualizer applies AND semantics across populated fields and OR
|
||||
* semantics within each field.
|
||||
*/
|
||||
export interface VisualizerFilter {
|
||||
readonly tiers?: ReadonlyArray<VisualizerTier>;
|
||||
readonly latencyGroups?: ReadonlyArray<string>;
|
||||
readonly sessions?: ReadonlyArray<string>;
|
||||
readonly transports?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface VisualizerConfig {
|
||||
readonly mode: VisualizerMode;
|
||||
/** Frames per second the consumer plans to repaint at. */
|
||||
readonly refreshRate: number;
|
||||
readonly filter?: VisualizerFilter;
|
||||
/** Optional focused peer id; non-matching nodes are dimmed. */
|
||||
readonly focus?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of truth for the network the visualizer is rendering. Supplied
|
||||
* by the consumer (typically built on top of `network-plugin-topology`).
|
||||
*/
|
||||
export interface TopologyAdapter {
|
||||
listNodes(): ReadonlyArray<NodeLayout>;
|
||||
listEdges(): ReadonlyArray<EdgeLayout>;
|
||||
nodeById(id: string): NodeLayout | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live message-flow source. Supplied by consumer; usually wraps a
|
||||
* `network-plugin-monitor` collector.
|
||||
*/
|
||||
export interface MessageStreamAdapter {
|
||||
listFlows(): ReadonlyArray<EdgeLayout>;
|
||||
listHeat(): ReadonlyArray<HeatCell>;
|
||||
}
|
||||
|
||||
export type VisualizerExportFormat = "svg" | "png";
|
||||
|
||||
/**
|
||||
* Intermediate representation produced by the visualizer. The bundled
|
||||
* SVG renderer consumes it, but consumers may plug their own renderer
|
||||
* (canvas, terminal, WebGL) and consume the same IR.
|
||||
*/
|
||||
export interface VisualizerScene {
|
||||
readonly mode: VisualizerMode;
|
||||
readonly nodes: ReadonlyArray<NodeLayout>;
|
||||
readonly edges: ReadonlyArray<EdgeLayout>;
|
||||
readonly heat: ReadonlyArray<HeatCell>;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly focus?: string;
|
||||
}
|
||||
|
||||
export interface RendererOutput {
|
||||
readonly format: VisualizerExportFormat;
|
||||
readonly bytes: Uint8Array;
|
||||
readonly mimeType: string;
|
||||
}
|
||||
|
||||
export interface Renderer {
|
||||
render(scene: VisualizerScene): RendererOutput;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# `visualizer/network-visualizer` — `NetworkVisualizer`
|
||||
|
||||
## Purpose
|
||||
|
||||
Renders the live mesh as a `VisualizerScene` intermediate
|
||||
representation (IR) and delegates byte production to an injected
|
||||
`Renderer`. The IR captures three orthogonal views — the tier hierarchy,
|
||||
the live message-flow graph, and a per-node heat map of latency /
|
||||
saturation — and ships nodes, edges, and heat cells as plain data so any
|
||||
front-end (terminal, SVG, canvas, WebGL, Storybook) can consume the same
|
||||
output. The bundled `SvgRenderer` covers the common documentation /
|
||||
bug-report use case without pulling a heavyweight image library.
|
||||
|
||||
## Features
|
||||
|
||||
- Three modes: `hierarchy`, `flow`, `heat` — `start({ mode, refreshRate })` selects.
|
||||
- Filtering by tier, latency group, session, transport (Zod-validated).
|
||||
- Focus tracking — `focus(peerId)` / `clearFocus()`; non-existing peers throw `INSPECTOR_PATH_NOT_FOUND`.
|
||||
- `export(format)` — runs the injected renderer, validates `format` round-trip, surfaces failures as `VISUALIZER_RENDERER_FAILED`.
|
||||
- OpenTelemetry counters: `devtools.visualizer.render_total{mode}`, `devtools.visualizer.export_total{format}`.
|
||||
- Pluggable renderer via the `Renderer` interface — defaults to bundled SVG.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NetworkVisualizer } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const viz = new NetworkVisualizer({ topology, stream });
|
||||
viz.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
const scene = viz.render();
|
||||
console.log(scene.nodes.length, scene.edges.length);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NetworkVisualizer,
|
||||
type Renderer,
|
||||
type VisualizerScene,
|
||||
} from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const pngRenderer: Renderer = {
|
||||
render(scene: VisualizerScene) {
|
||||
const bytes = encodePng(scene);
|
||||
return { format: "png", bytes, mimeType: "image/png" };
|
||||
},
|
||||
};
|
||||
|
||||
const viz = new NetworkVisualizer({
|
||||
topology,
|
||||
stream,
|
||||
renderer: pngRenderer,
|
||||
width: 1200,
|
||||
height: 900,
|
||||
});
|
||||
viz.start({
|
||||
mode: "heat",
|
||||
refreshRate: 15,
|
||||
filter: { tiers: ["tier-0", "tier-1"], sessions: ["lobby"] },
|
||||
});
|
||||
viz.focus("host-eu-1");
|
||||
const out = viz.export("png");
|
||||
await fs.writeFile("mesh.png", out.bytes);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class NetworkVisualizer` — constructor `(config: NetworkVisualizerConfig)`. `config.topology` and `config.stream` required; `config.renderer` defaults to bundled SVG; `config.width`/`config.height` default to 800×600.
|
||||
- `start(config: VisualizerConfig): void` — Zod-validates; throws `VISUALIZER_INVALID_MODE`.
|
||||
- `stop(): void` — clears active config.
|
||||
- `focus(peerId: string): void` — throws `INSPECTOR_PATH_NOT_FOUND` if unknown.
|
||||
- `clearFocus(): void` — resets focus.
|
||||
- `mode(): VisualizerMode` — current mode (throws `VISUALIZER_NOT_STARTED` if stopped).
|
||||
- `isRunning(): boolean`.
|
||||
- `render(): VisualizerScene` — emits IR; throws `VISUALIZER_EMPTY_TOPOLOGY` when filter eliminates all nodes.
|
||||
- `export(format: 'svg' | 'png'): RendererOutput` — runs renderer; throws `VISUALIZER_RENDERER_FAILED` on renderer failure or format mismatch.
|
||||
- `ALLOWED_VISUALIZER_MODES` — exported readonly tuple.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The IR is the contract — keeping the `VisualizerScene` shape stable means rendering technology can change without breaking consumers. PNG support is delivered via dependency injection rather than a hard `sharp`/`canvas` dep so universal consumers (Bun, browser, deno) can pick the encoder appropriate to their runtime. The `applyNodeFilter` step short-circuits when no `filter` is supplied so the hot rendering loop avoids per-node Set lookups in the common case. The `clearFocus()` method intentionally rebuilds the config object so `exactOptionalPropertyTypes` keeps the `focus` field absent rather than `undefined` — TypeScript's strictest mode treats those distinctly. The `INSPECTOR_PATH_NOT_FOUND` reuse for an unknown focus peer is deliberate: a peer id is a path-like identifier, and the inspector / visualizer share that vocabulary.
|
||||
@@ -0,0 +1,227 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import z from "zod";
|
||||
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
|
||||
import type {
|
||||
EdgeLayout,
|
||||
HeatCell,
|
||||
MessageStreamAdapter,
|
||||
NodeLayout,
|
||||
Renderer,
|
||||
RendererOutput,
|
||||
TopologyAdapter,
|
||||
VisualizerConfig,
|
||||
VisualizerExportFormat,
|
||||
VisualizerFilter,
|
||||
VisualizerMode,
|
||||
VisualizerScene,
|
||||
VisualizerTier,
|
||||
} from "../types/visualizer.types.js";
|
||||
import { renderSvg } from "./svg-renderer.js";
|
||||
|
||||
const meter = metrics.getMeter("network-plugin-devtools");
|
||||
const renderCounter = meter.createCounter("devtools.visualizer.render_total", {
|
||||
description: "Number of scenes produced by NetworkVisualizer.render()",
|
||||
});
|
||||
const exportCounter = meter.createCounter("devtools.visualizer.export_total", {
|
||||
description: "Number of bytes-export operations by format",
|
||||
});
|
||||
|
||||
const ALLOWED_MODES: ReadonlyArray<VisualizerMode> = [
|
||||
"hierarchy",
|
||||
"flow",
|
||||
"heat",
|
||||
];
|
||||
|
||||
const VisualizerConfigSchema = z.object({
|
||||
mode: z.enum(["hierarchy", "flow", "heat"]),
|
||||
refreshRate: z.number().int().positive().max(240),
|
||||
filter: z
|
||||
.object({
|
||||
tiers: z.array(z.enum(["tier-0", "tier-1", "tier-2"])).optional(),
|
||||
latencyGroups: z.array(z.string().min(1)).optional(),
|
||||
sessions: z.array(z.string().min(1)).optional(),
|
||||
transports: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
focus: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export interface NetworkVisualizerConfig {
|
||||
readonly topology: TopologyAdapter;
|
||||
readonly stream: MessageStreamAdapter;
|
||||
/** Defaults to bundled SVG renderer if omitted. */
|
||||
readonly renderer?: Renderer;
|
||||
readonly width?: number;
|
||||
readonly height?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 800;
|
||||
const DEFAULT_HEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Mesh visualisation engine. Builds a `VisualizerScene` IR per
|
||||
* `render()` call and delegates pixel/byte production to an injected
|
||||
* `Renderer`. A bundled `SvgRenderer` covers the common case; PNG
|
||||
* support is delivered by injecting an encoder-aware renderer.
|
||||
*/
|
||||
export class NetworkVisualizer {
|
||||
private readonly topology: TopologyAdapter;
|
||||
private readonly stream: MessageStreamAdapter;
|
||||
private readonly renderer: Renderer;
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private current: VisualizerConfig | null = null;
|
||||
|
||||
public constructor(config: NetworkVisualizerConfig) {
|
||||
this.topology = config.topology;
|
||||
this.stream = config.stream;
|
||||
this.renderer = config.renderer ?? {
|
||||
render: (scene) => renderSvg(scene),
|
||||
};
|
||||
this.width = config.width ?? DEFAULT_WIDTH;
|
||||
this.height = config.height ?? DEFAULT_HEIGHT;
|
||||
}
|
||||
|
||||
public start(config: VisualizerConfig): void {
|
||||
const parsed = VisualizerConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.VISUALIZER_INVALID_MODE,
|
||||
"visualizer.invalid-config",
|
||||
{ context: { issues: parsed.error.issues.length } }
|
||||
);
|
||||
}
|
||||
this.current = parsed.data as VisualizerConfig;
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
public focus(peerId: string): void {
|
||||
const cfg = this.requireRunning();
|
||||
if (this.topology.nodeById(peerId) === undefined) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND,
|
||||
"visualizer.focus.unknown-peer",
|
||||
{ context: { peerId } }
|
||||
);
|
||||
}
|
||||
this.current = { ...cfg, focus: peerId };
|
||||
}
|
||||
|
||||
public clearFocus(): void {
|
||||
const cfg = this.requireRunning();
|
||||
const next: VisualizerConfig = {
|
||||
mode: cfg.mode,
|
||||
refreshRate: cfg.refreshRate,
|
||||
...(cfg.filter === undefined ? {} : { filter: cfg.filter }),
|
||||
};
|
||||
this.current = next;
|
||||
}
|
||||
|
||||
public mode(): VisualizerMode {
|
||||
return this.requireRunning().mode;
|
||||
}
|
||||
|
||||
public isRunning(): boolean {
|
||||
return this.current !== null;
|
||||
}
|
||||
|
||||
public render(): VisualizerScene {
|
||||
const cfg = this.requireRunning();
|
||||
const nodes = this.applyNodeFilter(
|
||||
this.topology.listNodes(),
|
||||
cfg.filter
|
||||
);
|
||||
if (nodes.length === 0) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.VISUALIZER_EMPTY_TOPOLOGY,
|
||||
"visualizer.empty"
|
||||
);
|
||||
}
|
||||
const edges = this.collectEdges(cfg.mode);
|
||||
const heat = cfg.mode === "heat" ? this.stream.listHeat() : [];
|
||||
renderCounter.add(1, { mode: cfg.mode });
|
||||
const scene: VisualizerScene = {
|
||||
mode: cfg.mode,
|
||||
nodes,
|
||||
edges,
|
||||
heat,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
...(cfg.focus === undefined ? {} : { focus: cfg.focus }),
|
||||
};
|
||||
return scene;
|
||||
}
|
||||
|
||||
public export(format: VisualizerExportFormat): RendererOutput {
|
||||
if (format !== "svg" && format !== "png") {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.VISUALIZER_INVALID_MODE,
|
||||
"visualizer.export.unknown-format",
|
||||
{ context: { format } }
|
||||
);
|
||||
}
|
||||
const scene = this.render();
|
||||
let output: RendererOutput;
|
||||
try {
|
||||
output = this.renderer.render(scene);
|
||||
} catch (err) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED,
|
||||
"visualizer.renderer.failed",
|
||||
{ cause: err instanceof Error ? err : undefined }
|
||||
);
|
||||
}
|
||||
if (output.format !== format) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED,
|
||||
"visualizer.renderer.format-mismatch",
|
||||
{
|
||||
context: {
|
||||
requested: format,
|
||||
produced: output.format,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
exportCounter.add(1, { format });
|
||||
return output;
|
||||
}
|
||||
|
||||
private collectEdges(mode: VisualizerMode): ReadonlyArray<EdgeLayout> {
|
||||
if (mode === "flow") return this.stream.listFlows();
|
||||
if (mode === "heat") return [];
|
||||
return this.topology.listEdges();
|
||||
}
|
||||
|
||||
private applyNodeFilter(
|
||||
nodes: ReadonlyArray<NodeLayout>,
|
||||
filter?: VisualizerFilter
|
||||
): ReadonlyArray<NodeLayout> {
|
||||
if (filter === undefined) return nodes;
|
||||
const tierSet =
|
||||
filter.tiers === undefined
|
||||
? null
|
||||
: new Set<VisualizerTier>(filter.tiers);
|
||||
return nodes.filter((node) => {
|
||||
if (tierSet !== null && !tierSet.has(node.tier)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private requireRunning(): VisualizerConfig {
|
||||
if (this.current === null) {
|
||||
throw new NetworkDevError(
|
||||
NETWORK_DEV_CODES.VISUALIZER_NOT_STARTED,
|
||||
"visualizer.not-started"
|
||||
);
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
}
|
||||
|
||||
export const ALLOWED_VISUALIZER_MODES = ALLOWED_MODES;
|
||||
export type { HeatCell };
|
||||
@@ -0,0 +1,82 @@
|
||||
# `visualizer/svg-renderer` — bundled SVG `Renderer`
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure-string SVG renderer that consumes a `VisualizerScene` and produces
|
||||
deterministic UTF-8 SVG bytes without touching the DOM, canvas, or any
|
||||
third-party encoding library. Used as the default renderer for
|
||||
`NetworkVisualizer`, the SVG output is suitable for documentation,
|
||||
PR attachments, regression test snapshots, and embedding in
|
||||
Storybook / MDX docs.
|
||||
|
||||
The renderer is intentionally tiny and dep-free so universal
|
||||
consumers (Bun, Deno, Node, browser) can bundle it without surprise. PNG
|
||||
output is delivered by injecting an encoder-aware renderer instead of
|
||||
shipping `sharp`/`canvas` here.
|
||||
|
||||
## Features
|
||||
|
||||
- `renderSvg(scene)` — pure function from `VisualizerScene` to `RendererOutput`.
|
||||
- `SvgRenderer` — `Renderer` adapter object suitable for direct injection.
|
||||
- Tier-coloured nodes (`tier-0` blue, `tier-1` green, `tier-2` orange).
|
||||
- Edge stroke-width scales with throughput; dropped frames flip stroke red.
|
||||
- Heat cells map a deterministic FNV-1a hash of the cell id to a 16×12 grid so the layout is stable across renders.
|
||||
- XML escaping on every string field so peer ids containing `<` / `&` cannot inject into the output.
|
||||
- Focus-aware opacity dimming on non-focused nodes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { renderSvg } from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const out = renderSvg({
|
||||
mode: "hierarchy",
|
||||
nodes: [{ id: "a", tier: "tier-0", x: 100, y: 100, label: "Host" }],
|
||||
edges: [],
|
||||
heat: [],
|
||||
width: 400,
|
||||
height: 300,
|
||||
});
|
||||
console.log(new TextDecoder().decode(out.bytes));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NetworkVisualizer,
|
||||
SvgRenderer,
|
||||
} from "@suaveplan/network-plugin-devtools";
|
||||
|
||||
const viz = new NetworkVisualizer({
|
||||
topology,
|
||||
stream,
|
||||
renderer: SvgRenderer,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
});
|
||||
viz.start({ mode: "flow", refreshRate: 10, focus: "host-1" });
|
||||
const svgBytes = viz.export("svg").bytes;
|
||||
await Bun.write("./flow.svg", svgBytes);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `renderSvg(scene: VisualizerScene): RendererOutput` — pure; never throws.
|
||||
- `SvgRenderer: Renderer` — adapter object; `SvgRenderer.render === renderSvg` call form.
|
||||
|
||||
`RendererOutput` carries `format: "svg"`, `bytes: Uint8Array`, and `mimeType: "image/svg+xml"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The renderer is deliberately deterministic — every numeric position is
|
||||
rendered via `toFixed(2)` so two semantically-identical scenes produce
|
||||
byte-identical SVG. This makes snapshot tests and visual diffs reliable.
|
||||
Heat-cell placement uses an FNV-1a hash mod a 16×12 grid; the grid size
|
||||
is hard-coded because the IR doesn't carry layout dimensions for cells
|
||||
(those belong to the heat layer, not the topology). Edges are skipped
|
||||
silently when either endpoint is missing from the scene's node list,
|
||||
which mirrors the visualizer's filter semantics — a tier-filtered scene
|
||||
must not crash because a partner node was dropped. XML escaping is
|
||||
applied to every consumer-supplied string (peer ids, labels) to prevent
|
||||
SVG-injection from a malicious or fuzz-generated topology.
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
EdgeLayout,
|
||||
HeatCell,
|
||||
NodeLayout,
|
||||
Renderer,
|
||||
RendererOutput,
|
||||
VisualizerScene,
|
||||
} from "../types/visualizer.types.js";
|
||||
|
||||
const TIER_COLOURS: Record<string, string> = {
|
||||
"tier-0": "#1f77b4",
|
||||
"tier-1": "#2ca02c",
|
||||
"tier-2": "#ff7f0e",
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure-string SVG renderer — produces a deterministic byte stream from
|
||||
* a `VisualizerScene` without any DOM, canvas, or third-party encoder
|
||||
* dep. Output is UTF-8 SVG suitable for documentation, PR attachments,
|
||||
* or test snapshots.
|
||||
*/
|
||||
export function renderSvg(scene: VisualizerScene): RendererOutput {
|
||||
const fragments: string[] = [];
|
||||
fragments.push(svgHeader(scene.width, scene.height));
|
||||
if (scene.mode === "heat") {
|
||||
for (const cell of scene.heat) fragments.push(renderHeatCell(cell));
|
||||
} else {
|
||||
for (const edge of scene.edges) fragments.push(renderEdge(edge, scene));
|
||||
}
|
||||
for (const node of scene.nodes)
|
||||
fragments.push(renderNode(node, scene.focus));
|
||||
fragments.push("</svg>");
|
||||
const body = fragments.join("");
|
||||
const bytes = new TextEncoder().encode(body);
|
||||
return { format: "svg", bytes, mimeType: "image/svg+xml" };
|
||||
}
|
||||
|
||||
function svgHeader(width: number, height: number): string {
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" `,
|
||||
`viewBox="0 0 ${width} ${height}" `,
|
||||
`width="${width}" height="${height}">`,
|
||||
"<style>.label{font:12px sans-serif;fill:#222}</style>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderNode(node: NodeLayout, focus: string | undefined): string {
|
||||
const colour = TIER_COLOURS[node.tier] ?? "#888";
|
||||
const opacity = focus === undefined || focus === node.id ? "1" : "0.35";
|
||||
return [
|
||||
`<g opacity="${opacity}">`,
|
||||
`<circle cx="${fmt(node.x)}" cy="${fmt(node.y)}" r="14" `,
|
||||
`fill="${colour}" stroke="#222" stroke-width="1"/>`,
|
||||
`<text class="label" x="${fmt(node.x + 18)}" y="${fmt(node.y + 4)}">`,
|
||||
escapeXml(node.label),
|
||||
"</text></g>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderEdge(edge: EdgeLayout, scene: VisualizerScene): string {
|
||||
const from = scene.nodes.find((n) => n.id === edge.from);
|
||||
const to = scene.nodes.find((n) => n.id === edge.to);
|
||||
if (from === undefined || to === undefined) return "";
|
||||
const stroke = edge.drops > 0 ? "#d62728" : "#888";
|
||||
const width = Math.max(1, Math.min(6, edge.throughput / 50));
|
||||
return [
|
||||
`<line x1="${fmt(from.x)}" y1="${fmt(from.y)}" `,
|
||||
`x2="${fmt(to.x)}" y2="${fmt(to.y)}" `,
|
||||
`stroke="${stroke}" stroke-width="${width.toFixed(2)}"/>`,
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderHeatCell(cell: HeatCell): string {
|
||||
const intensity = Math.min(1, Math.max(0, cell.intensity));
|
||||
const r = Math.round(255 * intensity);
|
||||
const b = Math.round(255 * (1 - intensity));
|
||||
return [
|
||||
`<rect data-id="${escapeXml(cell.id)}" `,
|
||||
`x="${cellX(cell)}" y="${cellY(cell)}" width="40" height="40" `,
|
||||
`fill="rgb(${r},80,${b})" opacity="0.85"/>`,
|
||||
].join("");
|
||||
}
|
||||
|
||||
function cellX(cell: HeatCell): string {
|
||||
return fmt((hashString(cell.id) % 16) * 50 + 10);
|
||||
}
|
||||
|
||||
function cellY(cell: HeatCell): string {
|
||||
return fmt((Math.floor(hashString(cell.id) / 16) % 12) * 50 + 10);
|
||||
}
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = (h * 16777619) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return Number.isFinite(n) ? n.toFixed(2) : "0";
|
||||
}
|
||||
|
||||
function escapeXml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export const SvgRenderer: Renderer = { render: renderSvg };
|
||||
@@ -0,0 +1,486 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NETWORK_DEV_CODES, NetworkDevError } from "../error-codes.js";
|
||||
import type {
|
||||
EdgeLayout,
|
||||
HeatCell,
|
||||
MessageStreamAdapter,
|
||||
NodeLayout,
|
||||
Renderer,
|
||||
RendererOutput,
|
||||
TopologyAdapter,
|
||||
VisualizerScene,
|
||||
} from "../types/visualizer.types.js";
|
||||
import { NetworkVisualizer } from "./network-visualizer.js";
|
||||
import { renderSvg, SvgRenderer } from "./svg-renderer.js";
|
||||
|
||||
const NODES: NodeLayout[] = [
|
||||
{ id: "host-1", tier: "tier-0", x: 100, y: 100, label: "Host" },
|
||||
{ id: "leaf-1", tier: "tier-2", x: 200, y: 200, label: "Leaf" },
|
||||
{ id: "leaf-2", tier: "tier-2", x: 250, y: 200, label: "Leaf<2>" },
|
||||
];
|
||||
const HIER_EDGES: EdgeLayout[] = [
|
||||
{ from: "host-1", to: "leaf-1", latencyMs: 10, throughput: 100, drops: 0 },
|
||||
];
|
||||
const FLOW_EDGES: EdgeLayout[] = [
|
||||
{ from: "host-1", to: "leaf-1", latencyMs: 12, throughput: 200, drops: 0 },
|
||||
{ from: "host-1", to: "leaf-2", latencyMs: 15, throughput: 50, drops: 1 },
|
||||
{ from: "leaf-1", to: "leaf-2", latencyMs: 8, throughput: 30, drops: 0 },
|
||||
];
|
||||
const HEAT: HeatCell[] = [
|
||||
{ id: "host-1", intensity: 0.9, latencyMs: 12, saturation: 0.8 },
|
||||
{ id: "leaf-1", intensity: 0.5, latencyMs: 30, saturation: 0.4 },
|
||||
{ id: "ghost-id", intensity: 1.5, latencyMs: 1, saturation: 0 }, // out-of-range intensity
|
||||
];
|
||||
|
||||
function makeTopo(
|
||||
nodes: NodeLayout[] = NODES,
|
||||
edges: EdgeLayout[] = HIER_EDGES
|
||||
): TopologyAdapter {
|
||||
return {
|
||||
listNodes: () => nodes,
|
||||
listEdges: () => edges,
|
||||
nodeById: (id) => nodes.find((n) => n.id === id),
|
||||
};
|
||||
}
|
||||
|
||||
function makeStream(): MessageStreamAdapter {
|
||||
return { listFlows: () => FLOW_EDGES, listHeat: () => HEAT };
|
||||
}
|
||||
|
||||
describe("NetworkVisualizer (NET_DEV-01)", () => {
|
||||
it("rejects render before start", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
expect(() => v.render()).toThrowError(NetworkDevError);
|
||||
expect(v.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects mode() before start", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
expect(() => v.mode()).toThrowError(NetworkDevError);
|
||||
});
|
||||
|
||||
it("renders hierarchy edges in hierarchy mode", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
const scene = v.render();
|
||||
expect(scene.mode).toBe("hierarchy");
|
||||
expect(scene.edges.length).toBe(1);
|
||||
expect(scene.heat.length).toBe(0);
|
||||
expect(v.mode()).toBe("hierarchy");
|
||||
expect(v.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it("flow mode reads from the stream adapter", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({ mode: "flow", refreshRate: 30 });
|
||||
expect(v.render().edges.length).toBe(FLOW_EDGES.length);
|
||||
});
|
||||
|
||||
it("heat mode includes heat cells and no edges", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({ mode: "heat", refreshRate: 30 });
|
||||
const scene = v.render();
|
||||
expect(scene.heat.length).toBe(HEAT.length);
|
||||
expect(scene.edges.length).toBe(0);
|
||||
});
|
||||
|
||||
it("Zod rejects invalid config with VISUALIZER_INVALID_MODE", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
try {
|
||||
v.start({ mode: "wrong" as unknown as "flow", refreshRate: 30 });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(NetworkDevError);
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.VISUALIZER_INVALID_MODE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("filters by tier", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({
|
||||
mode: "hierarchy",
|
||||
refreshRate: 30,
|
||||
filter: { tiers: ["tier-0"] },
|
||||
});
|
||||
const scene = v.render();
|
||||
expect(scene.nodes.every((n) => n.tier === "tier-0")).toBe(true);
|
||||
});
|
||||
|
||||
it("VISUALIZER_EMPTY_TOPOLOGY when filter eliminates every node", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({
|
||||
mode: "hierarchy",
|
||||
refreshRate: 30,
|
||||
filter: { tiers: ["tier-1"] },
|
||||
});
|
||||
try {
|
||||
v.render();
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.VISUALIZER_EMPTY_TOPOLOGY
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("focus + clearFocus round-trip", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
v.focus("host-1");
|
||||
expect(v.render().focus).toBe("host-1");
|
||||
v.clearFocus();
|
||||
expect(v.render().focus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearFocus preserves a populated filter", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({
|
||||
mode: "hierarchy",
|
||||
refreshRate: 30,
|
||||
filter: { tiers: ["tier-0", "tier-2"] },
|
||||
focus: "host-1",
|
||||
});
|
||||
v.clearFocus();
|
||||
const scene = v.render();
|
||||
expect(scene.focus).toBeUndefined();
|
||||
expect(scene.nodes.every((n) => n.tier !== "tier-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("filter without tiers field returns every node", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({
|
||||
mode: "hierarchy",
|
||||
refreshRate: 30,
|
||||
filter: { sessions: ["lobby"] },
|
||||
});
|
||||
const scene = v.render();
|
||||
expect(scene.nodes.length).toBe(NODES.length);
|
||||
});
|
||||
|
||||
it("focus on unknown peer raises INSPECTOR_PATH_NOT_FOUND", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
try {
|
||||
v.focus("ghost");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.INSPECTOR_PATH_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("stop() clears running state", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
v.stop();
|
||||
expect(v.isRunning()).toBe(false);
|
||||
expect(() => v.render()).toThrowError(NetworkDevError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NetworkVisualizer.export (NET_DEV-02)", () => {
|
||||
it("uses the bundled SVG renderer by default", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
const out = v.export("svg");
|
||||
expect(out.format).toBe("svg");
|
||||
expect(out.mimeType).toBe("image/svg+xml");
|
||||
expect(new TextDecoder().decode(out.bytes)).toContain("<svg");
|
||||
});
|
||||
|
||||
it("rejects unknown format", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
});
|
||||
v.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
try {
|
||||
v.export("jpg" as unknown as "svg");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.VISUALIZER_INVALID_MODE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates renderer failure as VISUALIZER_RENDERER_FAILED with cause", () => {
|
||||
const failing: Renderer = {
|
||||
render(): RendererOutput {
|
||||
throw new Error("oops");
|
||||
},
|
||||
};
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
renderer: failing,
|
||||
});
|
||||
v.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
try {
|
||||
v.export("svg");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as NetworkDevError;
|
||||
expect(e.code).toBe(NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED);
|
||||
expect((e.cause as Error | undefined)?.message).toBe("oops");
|
||||
}
|
||||
});
|
||||
|
||||
it("renderer that throws non-Error still surfaces VISUALIZER_RENDERER_FAILED", () => {
|
||||
const failing: Renderer = {
|
||||
render(): RendererOutput {
|
||||
const value: unknown = "string-throw";
|
||||
throw value;
|
||||
},
|
||||
};
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
renderer: failing,
|
||||
});
|
||||
v.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
try {
|
||||
v.export("svg");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED
|
||||
);
|
||||
expect((err as NetworkDevError).cause).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects renderer whose output format mismatches", () => {
|
||||
const lying: Renderer = {
|
||||
render(): RendererOutput {
|
||||
return {
|
||||
format: "png",
|
||||
bytes: new Uint8Array([1, 2]),
|
||||
mimeType: "image/png",
|
||||
};
|
||||
},
|
||||
};
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
renderer: lying,
|
||||
});
|
||||
v.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
try {
|
||||
v.export("svg");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as NetworkDevError).code).toBe(
|
||||
NETWORK_DEV_CODES.VISUALIZER_RENDERER_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("custom width/height plumbed through scene", () => {
|
||||
const v = new NetworkVisualizer({
|
||||
topology: makeTopo(),
|
||||
stream: makeStream(),
|
||||
width: 1024,
|
||||
height: 768,
|
||||
});
|
||||
v.start({ mode: "hierarchy", refreshRate: 30 });
|
||||
const scene = v.render();
|
||||
expect(scene.width).toBe(1024);
|
||||
expect(scene.height).toBe(768);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SvgRenderer (NET_DEV-02)", () => {
|
||||
it("renders hierarchy mode with nodes + edges", () => {
|
||||
const scene: VisualizerScene = {
|
||||
mode: "hierarchy",
|
||||
nodes: NODES,
|
||||
edges: HIER_EDGES,
|
||||
heat: [],
|
||||
width: 400,
|
||||
height: 300,
|
||||
focus: "host-1",
|
||||
};
|
||||
const out = renderSvg(scene);
|
||||
const text = new TextDecoder().decode(out.bytes);
|
||||
expect(text).toContain("<svg");
|
||||
expect(text).toContain('opacity="1"');
|
||||
expect(text).toContain('opacity="0.35"');
|
||||
expect(text).toContain('stroke="#888"');
|
||||
expect(text).toContain("Leaf<2>");
|
||||
});
|
||||
|
||||
it("renders flow mode with red stroke for drops", () => {
|
||||
const scene: VisualizerScene = {
|
||||
mode: "flow",
|
||||
nodes: NODES,
|
||||
edges: FLOW_EDGES,
|
||||
heat: [],
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
const text = new TextDecoder().decode(renderSvg(scene).bytes);
|
||||
expect(text).toContain('stroke="#d62728"');
|
||||
});
|
||||
|
||||
it("skips edges with missing endpoint", () => {
|
||||
const scene: VisualizerScene = {
|
||||
mode: "flow",
|
||||
nodes: NODES,
|
||||
edges: [
|
||||
{
|
||||
from: "host-1",
|
||||
to: "ghost",
|
||||
latencyMs: 1,
|
||||
throughput: 10,
|
||||
drops: 0,
|
||||
},
|
||||
{
|
||||
from: "ghost",
|
||||
to: "leaf-1",
|
||||
latencyMs: 1,
|
||||
throughput: 10,
|
||||
drops: 0,
|
||||
},
|
||||
],
|
||||
heat: [],
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
const text = new TextDecoder().decode(renderSvg(scene).bytes);
|
||||
expect(text).not.toContain("ghost");
|
||||
});
|
||||
|
||||
it("renders heat mode with rect cells and clamps intensity", () => {
|
||||
const scene: VisualizerScene = {
|
||||
mode: "heat",
|
||||
nodes: NODES,
|
||||
edges: [],
|
||||
heat: [
|
||||
{
|
||||
id: "host-1",
|
||||
intensity: 0.5,
|
||||
latencyMs: 12,
|
||||
saturation: 0.8,
|
||||
},
|
||||
{
|
||||
id: "host-2",
|
||||
intensity: 1.5,
|
||||
latencyMs: 12,
|
||||
saturation: 0.8,
|
||||
},
|
||||
{
|
||||
id: "host-3",
|
||||
intensity: -0.5,
|
||||
latencyMs: 12,
|
||||
saturation: 0.8,
|
||||
},
|
||||
],
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
const text = new TextDecoder().decode(renderSvg(scene).bytes);
|
||||
expect(text).toContain("<rect");
|
||||
// FNV positions are deterministic across runs:
|
||||
expect(text).toContain('data-id="host-1"');
|
||||
});
|
||||
|
||||
it("handles non-finite coordinate as 0", () => {
|
||||
const scene: VisualizerScene = {
|
||||
mode: "hierarchy",
|
||||
nodes: [
|
||||
{
|
||||
id: "n",
|
||||
tier: "tier-0",
|
||||
x: Number.POSITIVE_INFINITY,
|
||||
y: 100,
|
||||
label: "x",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
heat: [],
|
||||
width: 200,
|
||||
height: 200,
|
||||
};
|
||||
const text = new TextDecoder().decode(renderSvg(scene).bytes);
|
||||
expect(text).toContain('cx="0"');
|
||||
});
|
||||
|
||||
it("colours unknown tiers grey", () => {
|
||||
const scene: VisualizerScene = {
|
||||
mode: "hierarchy",
|
||||
nodes: [
|
||||
{
|
||||
id: "n",
|
||||
tier: "tier-9" as unknown as "tier-0",
|
||||
x: 10,
|
||||
y: 10,
|
||||
label: "x",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
heat: [],
|
||||
width: 200,
|
||||
height: 200,
|
||||
};
|
||||
const text = new TextDecoder().decode(renderSvg(scene).bytes);
|
||||
expect(text).toContain('fill="#888"');
|
||||
});
|
||||
|
||||
it("SvgRenderer adapter object satisfies the Renderer contract", () => {
|
||||
const scene: VisualizerScene = {
|
||||
mode: "hierarchy",
|
||||
nodes: NODES,
|
||||
edges: HIER_EDGES,
|
||||
heat: [],
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
expect(SvgRenderer.render(scene).format).toBe("svg");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"],
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/network-plugin-devtools",
|
||||
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/diff",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/network",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user