218 lines
8.3 KiB
TypeScript
218 lines
8.3 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|