Initial Commit
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("network-discovery-hybrid dep-graph", () => {
|
||||
it("must not have circular dependencies", { timeout: 5000 }, async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod.HybridDiscoveryManager).toBeDefined();
|
||||
expect(mod.StrategyManager).toBeDefined();
|
||||
expect(mod.PeerTracker).toBeDefined();
|
||||
expect(mod.Lifecycle).toBeDefined();
|
||||
expect(mod.loadStrategy).toBeDefined();
|
||||
expect(mod.NET_DISC_HYBRID_CODES).toBeDefined();
|
||||
expect(mod.NetDiscHybridError).toBeDefined();
|
||||
expect(mod.HybridDiscoveryConfigSchema).toBeDefined();
|
||||
expect(mod.StrategyConfigSchema).toBeDefined();
|
||||
expect(mod.recordPeerSource).toBeDefined();
|
||||
expect(mod.recordStrategyHealth).toBeDefined();
|
||||
expect(mod.recordFallback).toBeDefined();
|
||||
expect(mod.recordActiveStrategyDelta).toBeDefined();
|
||||
expect(mod.recordDiscoverDuration).toBeDefined();
|
||||
expect(mod.withStartSpan).toBeDefined();
|
||||
expect(mod.withDiscoverSpan).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Integration suite for `HybridDiscoveryManager`.
|
||||
*
|
||||
* Per the spec (Phase 6 of the proposal): exercises the orchestrator
|
||||
* against real `DiscoveryStrategy` implementations (the in-package
|
||||
* `TestStrategy` real fixture under `tests/setup/test-strategy.ts`),
|
||||
* NOT mocks. NO `vi.mock` / `jest.mock` / `__mocks__` anywhere in this
|
||||
* suite. The five shipped Wave-2 sibling discovery packages are
|
||||
* dependency-graph imported via `dep-graph-siblings.test.ts` (separate
|
||||
* file) to prove they are reachable as optional peer-deps — but the
|
||||
* orchestrator-level behaviour assertions in THIS file run against the
|
||||
* faster, deterministic `TestStrategy` so the suite is hermetic.
|
||||
*
|
||||
* Scenarios covered:
|
||||
* 1. Register all five Wave-2 sibling IDs + verify peer dedup with
|
||||
* `PeerSource` correctness.
|
||||
* 2. Degrade one strategy and verify fallback to next-healthiest.
|
||||
* 3. Lifecycle (start all → stop → restart) idempotency.
|
||||
*/
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { HybridDiscoveryManager } from "../src/index.js";
|
||||
import { makePeer, TestStrategy } from "./setup/test-strategy.js";
|
||||
|
||||
describe("HybridDiscoveryManager — integration against real strategies", () => {
|
||||
it("registers all five Wave-2 sibling ids and dedupes with PeerSource provenance", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
// Shared peer "alice" is observed by 3 strategies; "bob" by 2;
|
||||
// "charlie" by 1; each strategy also contributes a unique peer.
|
||||
const sharedAlice = makePeer("alice", "shared");
|
||||
const sharedBob = makePeer("bob", "shared");
|
||||
|
||||
const peerJs = new TestStrategy({
|
||||
name: "peerjs",
|
||||
peers: [sharedAlice, makePeer("uniq-peerjs", "peerjs")],
|
||||
});
|
||||
const trystero = new TestStrategy({
|
||||
name: "trystero",
|
||||
peers: [
|
||||
sharedAlice,
|
||||
sharedBob,
|
||||
makePeer("uniq-trystero", "trystero"),
|
||||
],
|
||||
});
|
||||
const crdt = new TestStrategy({
|
||||
name: "crdt",
|
||||
peers: [sharedAlice, makePeer("uniq-crdt", "crdt")],
|
||||
});
|
||||
const webtorrent = new TestStrategy({
|
||||
name: "webtorrent",
|
||||
peers: [sharedBob, makePeer("uniq-webtorrent", "webtorrent")],
|
||||
});
|
||||
const gundb = new TestStrategy({
|
||||
name: "gundb",
|
||||
peers: [
|
||||
makePeer("charlie", "gundb"),
|
||||
makePeer("uniq-gundb", "gundb"),
|
||||
],
|
||||
});
|
||||
|
||||
const m = new HybridDiscoveryManager({
|
||||
strategy: "hybrid",
|
||||
strategies: [
|
||||
{ id: "peerjs", priority: "primary", strategy: peerJs },
|
||||
{ id: "trystero", priority: "primary", strategy: trystero },
|
||||
{ id: "crdt", priority: "fallback", strategy: crdt },
|
||||
{
|
||||
id: "webtorrent",
|
||||
priority: "fallback",
|
||||
strategy: webtorrent,
|
||||
},
|
||||
{ id: "gundb", priority: "backup", strategy: gundb },
|
||||
],
|
||||
});
|
||||
|
||||
await m.start();
|
||||
const peers = await m.discover(2000);
|
||||
// Unique peer ids: alice, bob, charlie, uniq-peerjs, uniq-trystero,
|
||||
// uniq-crdt, uniq-webtorrent, uniq-gundb = 8.
|
||||
expect(peers).toHaveLength(8);
|
||||
|
||||
const status = m.getStatus();
|
||||
// PeerSource provenance: alice → 3 strategies; bob → 2; charlie → 1
|
||||
// Cross-check via the per-strategy peer counts.
|
||||
expect(status.peersByStrategy.peerjs).toBe(2);
|
||||
expect(status.peersByStrategy.trystero).toBe(3);
|
||||
expect(status.peersByStrategy.crdt).toBe(2);
|
||||
expect(status.peersByStrategy.webtorrent).toBe(2);
|
||||
expect(status.peersByStrategy.gundb).toBe(2);
|
||||
expect(status.totalPeers).toBe(8);
|
||||
expect(status.activeStrategies).toEqual([
|
||||
"peerjs",
|
||||
"trystero",
|
||||
"crdt",
|
||||
"webtorrent",
|
||||
"gundb",
|
||||
]);
|
||||
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it("degrades one strategy and falls back to the next healthiest", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const primary = new TestStrategy({
|
||||
name: "peerjs",
|
||||
peers: [makePeer("a", "peerjs")],
|
||||
});
|
||||
// Initially-failing factory that the fallback path will retry.
|
||||
let factoryAttempts = 0;
|
||||
const m = new HybridDiscoveryManager({
|
||||
strategy: "hybrid",
|
||||
autoFallback: true,
|
||||
strategies: [
|
||||
{ id: "peerjs", priority: "primary", strategy: primary },
|
||||
{
|
||||
id: "trystero",
|
||||
priority: "fallback",
|
||||
factory: async () => {
|
||||
factoryAttempts += 1;
|
||||
if (factoryAttempts === 1) {
|
||||
// Simulate "signaling broken" on first attempt;
|
||||
// the orchestrator records UNHEALTHY and treats
|
||||
// it as dormant. NO mocks.
|
||||
throw new Error("signaling unreachable");
|
||||
}
|
||||
return new TestStrategy({
|
||||
name: "trystero",
|
||||
peers: [makePeer("b", "trystero")],
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await m.start();
|
||||
// Engineered degradation: cut the primary's signal. NO mocks.
|
||||
primary.setAvailable(false);
|
||||
|
||||
// Drive the health-check directly so the suite stays under 10 s.
|
||||
await (
|
||||
m as unknown as {
|
||||
strategyManager: { runHealthCheck(): Promise<void> };
|
||||
}
|
||||
).strategyManager.runHealthCheck();
|
||||
|
||||
const status = m.getStatus();
|
||||
const trysteroHealth = status.strategies.find(
|
||||
(s) => s.id === "trystero"
|
||||
);
|
||||
expect(trysteroHealth?.active).toBe(true);
|
||||
expect(factoryAttempts).toBe(2);
|
||||
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it("lifecycle: start → stop → restart is idempotent + safe", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
const s1 = new TestStrategy({
|
||||
name: "peerjs",
|
||||
peers: [makePeer("a", "peerjs")],
|
||||
});
|
||||
const s2 = new TestStrategy({
|
||||
name: "trystero",
|
||||
peers: [makePeer("b", "trystero")],
|
||||
});
|
||||
const m = new HybridDiscoveryManager({
|
||||
strategy: "hybrid",
|
||||
strategies: [
|
||||
{ id: "peerjs", priority: "primary", strategy: s1 },
|
||||
{ id: "trystero", priority: "fallback", strategy: s2 },
|
||||
],
|
||||
});
|
||||
|
||||
await m.start();
|
||||
let peers = await m.discover(1000);
|
||||
expect(peers).toHaveLength(2);
|
||||
|
||||
await m.stop();
|
||||
expect(await m.isAvailable()).toBe(false);
|
||||
|
||||
// Restart — verify everything is functional again.
|
||||
await m.start();
|
||||
peers = await m.discover(1000);
|
||||
expect(peers).toHaveLength(2);
|
||||
expect(s1.startedCount).toBe(2);
|
||||
expect(s2.startedCount).toBe(2);
|
||||
|
||||
await m.stop();
|
||||
});
|
||||
|
||||
it("shipped Wave-2 sibling packages are importable as optional peer-deps", {
|
||||
timeout: 10_000,
|
||||
}, async () => {
|
||||
// Pure dep-graph reachability check — verifies that the five
|
||||
// sibling packages are present in the workspace and that the
|
||||
// hybrid orchestrator can dynamically load them via the factory
|
||||
// path. We import the package's class symbols and assert they
|
||||
// exist; we do NOT instantiate (each has transport-specific
|
||||
// construction requirements that are exercised in the sibling's
|
||||
// own test suite).
|
||||
const peerjs = await import("@suaveplan/network-discovery-peerjs");
|
||||
const crdt = await import("@suaveplan/network-discovery-crdt");
|
||||
const trystero = await import("@suaveplan/network-discovery-trystero");
|
||||
const webtorrent = await import(
|
||||
"@suaveplan/network-discovery-webtorrent"
|
||||
);
|
||||
const gundb = await import("@suaveplan/network-discovery-gundb");
|
||||
|
||||
expect(peerjs.PeerJSDiscovery).toBeDefined();
|
||||
expect(crdt.CRDTDiscovery).toBeDefined();
|
||||
expect(trystero.TrysteroDiscovery).toBeDefined();
|
||||
expect(webtorrent.WebTorrentDiscovery).toBeDefined();
|
||||
expect(gundb.GunDBDiscovery).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
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,135 @@
|
||||
/**
|
||||
* Real in-package `DiscoveryStrategy` fixture used by unit tests.
|
||||
*
|
||||
* This is NOT a mock. It is a complete, deterministic implementation of the
|
||||
* `@suaveplan/network-bootstrap` `DiscoveryStrategy` contract whose
|
||||
* behaviour is controlled entirely by constructor options. Tests configure
|
||||
* the peer list, the failure mode, and the announce delay to engineer
|
||||
* specific orchestrator-level scenarios (e.g. priority fallback, peer
|
||||
* dedup commutativity, race-to-fastest correctness) without ever calling
|
||||
* `vi.mock` or registering a module-level stub.
|
||||
*
|
||||
* The fixture lives under `tests/setup/` so the coverage gate ignores it
|
||||
* (the `vitest.config.ts` `coverage.exclude` glob covers `tests/**`).
|
||||
*/
|
||||
import type { DiscoveryStrategy, PeerInfo } from "@suaveplan/network-bootstrap";
|
||||
|
||||
export interface TestStrategyOptions {
|
||||
readonly name: string;
|
||||
/** Peers to return from `discover()`. Defaults to `[]`. */
|
||||
readonly peers?: readonly PeerInfo[];
|
||||
/** Wall-clock delay before `discover()` resolves (ms). Default 0. */
|
||||
readonly delayMs?: number;
|
||||
/** If true, `discover()` rejects with `lastError`. */
|
||||
readonly failsDiscover?: boolean;
|
||||
/** If true, `start()` rejects with `lastError`. */
|
||||
readonly failsStart?: boolean;
|
||||
/** If true, `isAvailable()` returns false. */
|
||||
readonly unavailable?: boolean;
|
||||
/** Error to throw when `failsDiscover` or `failsStart` is true. */
|
||||
readonly error?: Error;
|
||||
}
|
||||
|
||||
export class TestStrategy implements DiscoveryStrategy {
|
||||
public readonly name: string;
|
||||
public startedCount = 0;
|
||||
public stoppedCount = 0;
|
||||
public discoverCount = 0;
|
||||
|
||||
private peers: PeerInfo[];
|
||||
private readonly delayMs: number;
|
||||
private failsDiscover: boolean;
|
||||
private readonly failsStart: boolean;
|
||||
private available: boolean;
|
||||
private readonly error: Error;
|
||||
|
||||
public constructor(options: TestStrategyOptions) {
|
||||
this.name = options.name;
|
||||
this.peers = [...(options.peers ?? [])];
|
||||
this.delayMs = options.delayMs ?? 0;
|
||||
this.failsDiscover = options.failsDiscover ?? false;
|
||||
this.failsStart = options.failsStart ?? false;
|
||||
this.available = !(options.unavailable ?? false);
|
||||
this.error =
|
||||
options.error ?? new Error("TestStrategy: induced failure");
|
||||
}
|
||||
|
||||
public async start(_signal?: AbortSignal): Promise<void> {
|
||||
this.startedCount += 1;
|
||||
if (this.failsStart) throw this.error;
|
||||
}
|
||||
|
||||
public async stop(_signal?: AbortSignal): Promise<void> {
|
||||
this.stoppedCount += 1;
|
||||
}
|
||||
|
||||
public async discover(
|
||||
_timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<PeerInfo[]> {
|
||||
this.discoverCount += 1;
|
||||
if (this.delayMs > 0) await wait(this.delayMs, signal);
|
||||
if (this.failsDiscover) throw this.error;
|
||||
if (signal?.aborted) throw makeAbortError();
|
||||
return [...this.peers];
|
||||
}
|
||||
|
||||
public async isAvailable(_signal?: AbortSignal): Promise<boolean> {
|
||||
return this.available;
|
||||
}
|
||||
|
||||
// Test-only setters so a single fixture instance can be re-purposed
|
||||
// mid-run (e.g. to simulate degradation between calls).
|
||||
public setAvailable(value: boolean): void {
|
||||
this.available = value;
|
||||
}
|
||||
|
||||
public setFailsDiscover(value: boolean): void {
|
||||
this.failsDiscover = value;
|
||||
}
|
||||
|
||||
public setPeers(peers: readonly PeerInfo[]): void {
|
||||
this.peers = [...peers];
|
||||
}
|
||||
}
|
||||
|
||||
function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(makeAbortError());
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = (): void => {
|
||||
cleanup();
|
||||
reject(makeAbortError());
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
function cleanup(): void {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function makeAbortError(): Error {
|
||||
const err = new Error("Aborted");
|
||||
err.name = "AbortError";
|
||||
return err;
|
||||
}
|
||||
|
||||
export function makePeer(
|
||||
peerId: string,
|
||||
source: string,
|
||||
lastSeen = 0
|
||||
): PeerInfo {
|
||||
return {
|
||||
peerId,
|
||||
addresses: [],
|
||||
lastSeen,
|
||||
source,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user