y-reticulum 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @file messages.test.js
3
+ * @description Unit tests for the Yjs sync wire codec ({@link readMessage}).
4
+ *
5
+ * These exercise the protocol logic directly — no networking — so they're fast
6
+ * and pin the request/reply behaviour (syncStep1→step2, updates, awareness,
7
+ * queryAwareness) that the integration smoketests only cover end-to-end.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import test from "node:test";
12
+ import * as encoding from "lib0/encoding";
13
+ import * as awarenessProtocol from "y-protocols/awareness";
14
+ import * as syncProtocol from "y-protocols/sync";
15
+ import * as Y from "yjs";
16
+ import {
17
+ messageAwareness,
18
+ messageQueryAwareness,
19
+ messageSync,
20
+ readMessage,
21
+ } from "../src/messages.js";
22
+
23
+ /** Wraps `body` (written by `write`) in a tagged message frame. */
24
+ function frame(tag, write) {
25
+ const enc = encoding.createEncoder();
26
+ encoding.writeVarUint(enc, tag);
27
+ write?.(enc);
28
+ return encoding.toUint8Array(enc);
29
+ }
30
+
31
+ test("message tags match the y-webrtc wire format", () => {
32
+ assert.equal(messageSync, 0);
33
+ assert.equal(messageAwareness, 1);
34
+ assert.equal(messageQueryAwareness, 3);
35
+ });
36
+
37
+ test("syncStep1 is answered with a syncStep2 carrying our state", () => {
38
+ // `doc` holds the state; an empty peer requests it via syncStep1.
39
+ const doc = new Y.Doc();
40
+ doc.getMap("m").set("k", "v");
41
+ const peer = new Y.Doc();
42
+
43
+ const step1 = frame(messageSync, (enc) =>
44
+ syncProtocol.writeSyncStep1(enc, peer),
45
+ );
46
+
47
+ let syncedCalls = 0;
48
+ const reply = readMessage(
49
+ doc,
50
+ new awarenessProtocol.Awareness(doc),
51
+ step1,
52
+ "origin",
53
+ false,
54
+ () => {
55
+ syncedCalls++;
56
+ },
57
+ );
58
+
59
+ // syncStep1 always produces a reply; it does not itself mark the room synced.
60
+ assert.ok(reply instanceof Uint8Array);
61
+ assert.equal(syncedCalls, 0);
62
+
63
+ // Applying the reply (a syncStep2) to the peer delivers the state and fires
64
+ // the one-shot synced callback.
65
+ const reply2 = readMessage(
66
+ peer,
67
+ new awarenessProtocol.Awareness(peer),
68
+ reply,
69
+ "origin",
70
+ false,
71
+ () => {
72
+ syncedCalls++;
73
+ },
74
+ );
75
+ assert.equal(reply2, null); // syncStep2 needs no reply
76
+ assert.equal(syncedCalls, 1);
77
+ assert.equal(peer.getMap("m").get("k"), "v");
78
+ });
79
+
80
+ test("the synced callback is suppressed once the room is synced", () => {
81
+ const doc = new Y.Doc();
82
+ doc.getMap("m").set("k", "v");
83
+ const sv = Y.encodeStateVector(new Y.Doc());
84
+ const step2 = frame(messageSync, (enc) =>
85
+ syncProtocol.writeSyncStep2(enc, doc, sv),
86
+ );
87
+
88
+ let syncedCalls = 0;
89
+ const receiver = new Y.Doc();
90
+ readMessage(
91
+ receiver,
92
+ new awarenessProtocol.Awareness(receiver),
93
+ step2,
94
+ "origin",
95
+ true, // room already synced
96
+ () => {
97
+ syncedCalls++;
98
+ },
99
+ );
100
+ assert.equal(syncedCalls, 0);
101
+ });
102
+
103
+ test("a sync update message applies to the doc and needs no reply", () => {
104
+ // Capture a real update from a doc.
105
+ const source = new Y.Doc();
106
+ /** @type {Uint8Array|undefined} */ let update;
107
+ source.on("update", (/** @type {Uint8Array} */ u) => {
108
+ update = u;
109
+ });
110
+ source.getMap("m").set("k", "v");
111
+ assert.ok(update);
112
+
113
+ const updateMsg = frame(messageSync, (enc) =>
114
+ syncProtocol.writeUpdate(enc, /** @type {Uint8Array} */ (update)),
115
+ );
116
+
117
+ const receiver = new Y.Doc();
118
+ const reply = readMessage(
119
+ receiver,
120
+ new awarenessProtocol.Awareness(receiver),
121
+ updateMsg,
122
+ "origin",
123
+ false,
124
+ () => {},
125
+ );
126
+ assert.equal(reply, null);
127
+ assert.equal(receiver.getMap("m").get("k"), "v");
128
+ });
129
+
130
+ test("queryAwareness is answered with an awareness update", () => {
131
+ const doc = new Y.Doc();
132
+ const awareness = new awarenessProtocol.Awareness(doc);
133
+ awareness.setLocalState({ user: "alice" });
134
+
135
+ const query = frame(messageQueryAwareness);
136
+ const reply = readMessage(doc, awareness, query, "origin", false, () => {});
137
+ assert.ok(reply instanceof Uint8Array);
138
+
139
+ // Applying the reply to a fresh awareness delivers the state.
140
+ const peerDoc = new Y.Doc();
141
+ const peerAwareness = new awarenessProtocol.Awareness(peerDoc);
142
+ const reply2 = readMessage(
143
+ peerDoc,
144
+ peerAwareness,
145
+ reply,
146
+ "origin",
147
+ false,
148
+ () => {},
149
+ );
150
+ assert.equal(reply2, null);
151
+ assert.deepEqual(peerAwareness.getStates().get(doc.clientID), {
152
+ user: "alice",
153
+ });
154
+ });
155
+
156
+ test("an unknown message tag yields no reply", () => {
157
+ const doc = new Y.Doc();
158
+ const reply = readMessage(
159
+ doc,
160
+ new awarenessProtocol.Awareness(doc),
161
+ frame(99),
162
+ "origin",
163
+ false,
164
+ () => {},
165
+ );
166
+ assert.equal(reply, null);
167
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @file peer-conn.smoke.js
3
+ * @description Channel transport smoketest for {@link PeerConn}.
4
+ *
5
+ * Establishes a single Link between two in-process Reticulum instances (over a
6
+ * TCP loopback), wraps each end in a PeerConn, and verifies the two send paths:
7
+ *
8
+ * - small payloads (≤ the channel MDU) round-trip as reliable Channel
9
+ * messages, in both directions;
10
+ * - a payload larger than the channel MDU is delivered via a chunked,
11
+ * compressed Reticulum Resource.
12
+ *
13
+ * No Yjs machinery — this pins the PeerConn transport layer directly.
14
+ */
15
+ import assert from "node:assert/strict";
16
+ import test from "node:test";
17
+ import { Destination, DestType, Identity } from "@reticulum/core";
18
+ import {
19
+ getCompressionProvider,
20
+ PeerConn,
21
+ roomDestinationName,
22
+ } from "../src/index.js";
23
+ import { makeLoopback, waitFor } from "./loopback.js";
24
+
25
+ const ROOM = "y-reticulum-peer-conn-smoke";
26
+
27
+ /** Constant-time-ish equality for two Uint8Arrays of equal length. */
28
+ function bytesEqual(a, b) {
29
+ if (a.length !== b.length) return false;
30
+ let diff = 0;
31
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
32
+ return diff === 0;
33
+ }
34
+
35
+ /**
36
+ * Resolves with the first validated announce on `rns` whose `nameHash` matches
37
+ * `roomNameHash` (a peer in the same room).
38
+ * @param {import("@reticulum/core").Reticulum} rns
39
+ * @param {Uint8Array} roomNameHash
40
+ * @returns {Promise<{ destinationHash: Uint8Array, identity: Identity }>}
41
+ */
42
+ function waitForPeer(rns, roomNameHash) {
43
+ return new Promise((resolve) => {
44
+ const listener = (/** @type {any} */ event) => {
45
+ const detail = event.detail;
46
+ if (
47
+ bytesEqual(/** @type {Uint8Array} */ (detail.nameHash), roomNameHash)
48
+ ) {
49
+ rns.transport.removeEventListener("announce", listener);
50
+ resolve({
51
+ destinationHash: detail.destinationHash,
52
+ identity: detail.identity,
53
+ });
54
+ }
55
+ };
56
+ rns.transport.addEventListener("announce", listener);
57
+ });
58
+ }
59
+
60
+ test("PeerConn exchanges small payloads over the Channel and large over a Resource", {
61
+ timeout: 15000,
62
+ }, async () => {
63
+ const { rnsA, rnsB, close } = await makeLoopback();
64
+ const bz2 = await getCompressionProvider();
65
+ const appName = await roomDestinationName(ROOM);
66
+
67
+ const idA = await Identity.generate();
68
+ const idB = await Identity.generate();
69
+ const destA = await Destination.IN(appName, DestType.SINGLE, idA, rnsA);
70
+ rnsA.transport.bindLocalDestination(destA);
71
+ const destB = await Destination.IN(appName, DestType.SINGLE, idB, rnsB);
72
+ rnsB.transport.bindLocalDestination(destB);
73
+
74
+ // Discover each other before opening a link.
75
+ const aSeesB = waitForPeer(rnsA, destB.nameHash);
76
+ const bSeesA = waitForPeer(rnsB, destA.nameHash);
77
+ await destA.announce();
78
+ await destB.announce();
79
+ const peerBOnA = await aSeesB;
80
+ const peerAOnB = await bSeesA;
81
+
82
+ /** @type {PeerConn | null} */ let connA = null;
83
+ /** @type {Uint8Array[]} */ const aReceived = [];
84
+
85
+ // A is the responder: accept the link and wrap it.
86
+ destA.addEventListener("link_request", async (/** @type {any} */ event) => {
87
+ const link = await destA.acceptLink(event.detail.packet);
88
+ connA = new PeerConn({
89
+ link,
90
+ remoteDestHash: null,
91
+ bz2,
92
+ onData: (payload) => {
93
+ aReceived.push(payload);
94
+ },
95
+ onClose: () => {},
96
+ });
97
+ });
98
+
99
+ // B is the initiator: open the link and wrap it.
100
+ const outDest = await Destination.OUT(
101
+ appName,
102
+ DestType.SINGLE,
103
+ peerAOnB.identity,
104
+ rnsB,
105
+ );
106
+ const linkB = await outDest.createLink();
107
+ /** @type {Uint8Array[]} */ const bReceived = [];
108
+ const connB = new PeerConn({
109
+ link: linkB,
110
+ remoteDestHash: peerBOnA.destinationHash,
111
+ bz2,
112
+ onData: (payload) => {
113
+ bReceived.push(payload);
114
+ },
115
+ onClose: () => {},
116
+ });
117
+
118
+ await waitFor(() => connA != null, 5000);
119
+ const connALocal = /** @type {PeerConn} */ (connA);
120
+
121
+ // Sanity: the channel is wired up and its MDU is below the link MDU.
122
+ assert.ok(connB.channel.mdu > 0);
123
+ assert.ok(connB.channel.mdu < linkB.mdu);
124
+
125
+ // --- Small payloads round-trip over the Channel (both directions) -----
126
+ const small = new Uint8Array([1, 2, 3, 4, 5]);
127
+ await connB.send(small); // B → A
128
+ await waitFor(() => aReceived.length >= 1, 5000);
129
+ assert.deepEqual(aReceived[0], small);
130
+
131
+ await connALocal.send(small); // A → B
132
+ await waitFor(() => bReceived.length >= 1, 5000);
133
+ assert.deepEqual(bReceived[0], small);
134
+
135
+ // --- A payload larger than the channel MDU travels as a Resource ------
136
+ const large = new Uint8Array(2000);
137
+ for (let i = 0; i < large.length; i++) large[i] = i & 0xff;
138
+ assert.ok(
139
+ large.length > connB.channel.mdu,
140
+ "fixture must exceed channel MDU",
141
+ );
142
+ await connB.send(large); // B → A
143
+ await waitFor(() => aReceived.some((p) => p.length === large.length), 8000);
144
+ const gotLarge = aReceived.find((p) => p.length === large.length);
145
+ assert.deepEqual(gotLarge, large);
146
+
147
+ connALocal.destroy();
148
+ connB.destroy();
149
+ await close();
150
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @file provider.smoke.js
3
+ * @description Phase 2 smoketest — the {@link ReticulumProvider} connection
4
+ * lifecycle and peer mesh, without any Yjs sync yet.
5
+ *
6
+ * Two providers, each on its own in-process Reticulum instance wired over a TCP
7
+ * loopback, connect to the same room. Both should announce, discover each
8
+ * other, establish exactly one Link between them (via the glare-avoidance
9
+ * rule), and each emit a `peers` event with the other added.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import net from "node:net";
14
+ import test from "node:test";
15
+ import { Identity, Reticulum } from "@reticulum/core";
16
+ import { TCPClientInterface, TCPServerInterface } from "@reticulum/node";
17
+ import * as Y from "yjs";
18
+ import { ReticulumProvider } from "../src/index.js";
19
+ import { nudgeAnnounce } from "./loopback.js";
20
+
21
+ const ROOM = "y-reticulum-provider-smoke";
22
+ const HOST = "127.0.0.1";
23
+
24
+ /** Resolves with a free localhost TCP port (ephemeral, immediately released). */
25
+ function getFreePort() {
26
+ return new Promise((resolve, reject) => {
27
+ const probe = net.createServer();
28
+ probe.unref();
29
+ probe.on("error", reject);
30
+ probe.listen({ host: HOST, port: 0 }, () => {
31
+ const { port } = /** @type {net.AddressInfo} */ (probe.address());
32
+ probe.close(() => resolve(port));
33
+ });
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Wires two in-process Reticulum instances over a TCP loopback (A listens, B
39
+ * dials) and returns them plus a `close()` that tears the link down.
40
+ */
41
+ async function makeLoopback() {
42
+ const port = await getFreePort();
43
+ const rnsA = new Reticulum();
44
+ const rnsB = new Reticulum();
45
+
46
+ // A listens. The TCPServerInterface has no writable stream itself, so do not
47
+ // addInterface() it — only its spawned children get wired in, marked default
48
+ // so the leaf can emit handshake packets via the default-interface fallback.
49
+ const server = new TCPServerInterface({ port });
50
+ await server.connect();
51
+ const spawned = new Promise((resolve) => {
52
+ server.addEventListener(
53
+ "connection",
54
+ (/** @type {any} */ event) => {
55
+ rnsA.addInterface(event.detail, true);
56
+ resolve();
57
+ },
58
+ { once: true },
59
+ );
60
+ });
61
+
62
+ const client = new TCPClientInterface({ host: HOST, port });
63
+ await client.connect();
64
+ rnsB.addInterface(client, true);
65
+ await spawned;
66
+
67
+ return {
68
+ rnsA,
69
+ rnsB,
70
+ async close() {
71
+ await client.disconnect().catch(() => {});
72
+ await server.disconnect().catch(() => {});
73
+ },
74
+ };
75
+ }
76
+
77
+ /** Polls `cond()` every 50ms until true, rejecting after `timeoutMs`. */
78
+ function waitFor(cond, timeoutMs) {
79
+ return new Promise((resolve, reject) => {
80
+ const deadline = Date.now() + timeoutMs;
81
+ const tick = () => {
82
+ if (cond()) return resolve(undefined);
83
+ if (Date.now() >= deadline) return reject(new Error("waitFor timed out"));
84
+ setTimeout(tick, 50);
85
+ };
86
+ tick();
87
+ });
88
+ }
89
+
90
+ test("two providers discover each other and form a single Link", {
91
+ timeout: 15000,
92
+ }, async () => {
93
+ const { rnsA, rnsB, close } = await makeLoopback();
94
+ const idA = await Identity.generate();
95
+ const idB = await Identity.generate();
96
+
97
+ const providerA = new ReticulumProvider(ROOM, new Y.Doc(), {
98
+ reticulum: rnsA,
99
+ identity: idA,
100
+ });
101
+ const providerB = new ReticulumProvider(ROOM, new Y.Doc(), {
102
+ reticulum: rnsB,
103
+ identity: idB,
104
+ });
105
+
106
+ /** @type {string[]} */
107
+ const aPeers = [];
108
+ /** @type {string[]} */
109
+ const bPeers = [];
110
+ let aConnected = false;
111
+ let bConnected = false;
112
+ providerA.on("status", (/** @type {any} */ e) => {
113
+ aConnected = aConnected || e.connected;
114
+ });
115
+ providerB.on("status", (/** @type {any} */ e) => {
116
+ bConnected = bConnected || e.connected;
117
+ });
118
+ providerA.on("peers", (/** @type {any} */ e) => aPeers.push(...e.added));
119
+ providerB.on("peers", (/** @type {any} */ e) => bPeers.push(...e.added));
120
+
121
+ await providerA.connect();
122
+ await providerB.connect();
123
+
124
+ // The periodic re-announce cadence is now @reticulum/core's job (and clamped
125
+ // to ≥60 s), so nudge one explicit announce per side to mesh up fast.
126
+ await nudgeAnnounce(providerA, providerB);
127
+
128
+ // Both should connect, discover exactly one peer, and hold one Link.
129
+ await waitFor(() => aPeers.length >= 1 && bPeers.length >= 1, 10000);
130
+ assert.ok(aConnected, "provider A never reported connected");
131
+ assert.ok(bConnected, "provider B never reported connected");
132
+ assert.equal(
133
+ /** @type {any} */ (providerA).room.peerConns.size,
134
+ 1,
135
+ "provider A should hold exactly one peer Link",
136
+ );
137
+ assert.equal(
138
+ /** @type {any} */ (providerB).room.peerConns.size,
139
+ 1,
140
+ "provider B should hold exactly one peer Link",
141
+ );
142
+
143
+ await providerA.destroy();
144
+ await providerB.destroy();
145
+ await close();
146
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @file reannounce.smoke.js
3
+ * @description Smoketest for delegating the periodic re-announce loop to
4
+ * @reticulum/core's `Destination.startAnnouncing` / `stopAnnouncing`.
5
+ *
6
+ * The cadence (and its §9.7 60 s floor) is the library's responsibility now;
7
+ * this test just locks in that y-reticulum wires it up correctly: the loop is
8
+ * running while a room is connected, the requested interval is forwarded (and
9
+ * clamped to the floor), and the loop is stopped again on disconnect.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import test from "node:test";
14
+ import { Identity } from "@reticulum/core";
15
+ import * as Y from "yjs";
16
+ import { ReticulumProvider } from "../src/index.js";
17
+ import { makeLoopback } from "./loopback.js";
18
+
19
+ const ROOM = "y-reticulum-reannounce-smoke";
20
+
21
+ test("connect starts the periodic re-announce loop; disconnect stops it", async () => {
22
+ const { rnsA, close } = await makeLoopback();
23
+ const provider = new ReticulumProvider(ROOM, new Y.Doc(), {
24
+ reticulum: rnsA,
25
+ identity: await Identity.generate(),
26
+ });
27
+
28
+ await provider.connect();
29
+ // @ts-expect-error -- reaching into the room for a white-box check
30
+ const dest = provider.room.dest;
31
+ assert.equal(dest.isAnnouncing(), true, "loop should run while connected");
32
+
33
+ // disconnect() nulls Room.dest, so capture the reference first; the object
34
+ // outlives teardown and its timer must have been cleared.
35
+ await provider.disconnect();
36
+ assert.equal(dest.isAnnouncing(), false, "loop should stop on disconnect");
37
+
38
+ await provider.destroy();
39
+ await close();
40
+ });
41
+
42
+ test("announceIntervalMs is forwarded and clamped to the 60s floor", async () => {
43
+ const { rnsA, close } = await makeLoopback();
44
+ const provider = new ReticulumProvider(ROOM, new Y.Doc(), {
45
+ reticulum: rnsA,
46
+ identity: await Identity.generate(),
47
+ announceIntervalMs: 5_000, // below the §9.7 floor
48
+ });
49
+
50
+ await provider.connect();
51
+ // @ts-expect-error -- reaching into the room for a white-box check
52
+ const { dest } = provider.room;
53
+ assert.equal(dest.isAnnouncing(), true);
54
+ assert.equal(
55
+ dest.announceIntervalMs,
56
+ 60_000,
57
+ "sub-floor interval must be clamped to 60s",
58
+ );
59
+
60
+ await provider.destroy();
61
+ await close();
62
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @file reconnect.smoke.js
3
+ * @description Phase 5 smoketest — a dropped peer Link is re-established.
4
+ *
5
+ * Two providers sync over a TCP loopback. We then tear down the underlying
6
+ * Link from one side (simulating a peer/link drop): each provider should emit
7
+ * `peers removed` and flip `synced` to false (it lost its last peer). The
8
+ * initiator — the peer with the lexicographically smaller destination hash, per
9
+ * the glare-avoidance rule — then re-discovers the responder (its scheduled
10
+ * path request beats the periodic announce) and opens a fresh Link, so both
11
+ * emit `peers added` again, re-run the sync handshake, and end up synced. An
12
+ * edit made after reconnect must still flow across — proving a live Link, not a
13
+ * stale one.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import test from "node:test";
18
+ import { Identity } from "@reticulum/core";
19
+ import * as Y from "yjs";
20
+ import { ReticulumProvider } from "../src/index.js";
21
+ import { makeLoopback, nudgeAnnounce, waitFor } from "./loopback.js";
22
+
23
+ const ROOM = "y-reticulum-reconnect-smoke";
24
+
25
+ /**
26
+ * @param {ReticulumProvider} p
27
+ * @returns {Map<string, unknown>}
28
+ */
29
+ function peerConns(p) {
30
+ return /** @type {any} */ (p).room.peerConns;
31
+ }
32
+
33
+ test("a dropped Link is re-established and re-synced", {
34
+ timeout: 20000,
35
+ }, async () => {
36
+ const { rnsA, rnsB, close } = await makeLoopback();
37
+ const docA = new Y.Doc();
38
+ const docB = new Y.Doc();
39
+
40
+ const providerA = new ReticulumProvider(ROOM, docA, {
41
+ reticulum: rnsA,
42
+ identity: await Identity.generate(),
43
+ // Leave the announce cadence at the default (60 s floor, owned by
44
+ // @reticulum/core). Initial mesh-up is nudged explicitly below; the
45
+ // post-drop reconnect must therefore be driven by the scheduled path
46
+ // request (1.5 s), not the announce cadence — which is what this test
47
+ // exercises.
48
+ });
49
+ const providerB = new ReticulumProvider(ROOM, docB, {
50
+ reticulum: rnsB,
51
+ identity: await Identity.generate(),
52
+ });
53
+
54
+ /** @type {boolean[]} */ const syncedA = [];
55
+ /** @type {boolean[]} */ const syncedB = [];
56
+ providerA.on("synced", (/** @type {any} */ e) => syncedA.push(e.synced));
57
+ providerB.on("synced", (/** @type {any} */ e) => syncedB.push(e.synced));
58
+
59
+ /** @type {string[]} */ const aAdded = [];
60
+ /** @type {string[]} */ const aRemoved = [];
61
+ /** @type {string[]} */ const bAdded = [];
62
+ /** @type {string[]} */ const bRemoved = [];
63
+ providerA.on("peers", (/** @type {any} */ e) => {
64
+ aAdded.push(...e.added);
65
+ aRemoved.push(...e.removed);
66
+ });
67
+ providerB.on("peers", (/** @type {any} */ e) => {
68
+ bAdded.push(...e.added);
69
+ bRemoved.push(...e.removed);
70
+ });
71
+
72
+ await providerA.connect();
73
+ await providerB.connect();
74
+ // Nudge initial discovery (the periodic cadence is ≥60 s); the post-drop
75
+ // reconnect below is then driven by the scheduled path request.
76
+ await nudgeAnnounce(providerA, providerB);
77
+
78
+ // --- Initial mesh: each discovers exactly one peer and syncs -----------
79
+ await waitFor(() => aAdded.length >= 1 && bAdded.length >= 1, 10000);
80
+ await waitFor(() => peerConns(providerA).size === 1, 10000);
81
+ await waitFor(() => peerConns(providerB).size === 1, 10000);
82
+ await waitFor(() => syncedA.includes(true) && syncedB.includes(true), 10000);
83
+
84
+ // Sanity: an edit flows A → B before we break anything.
85
+ docA.getMap("m").set("first", "yes");
86
+ await waitFor(() => docB.getMap("m").get("first") === "yes", 5000);
87
+
88
+ // --- Drop the Link from A's side; both ends get `close` ---------------
89
+ const connA = [...peerConns(providerA).values()][0];
90
+ await /** @type {any} */ (connA).link.teardown();
91
+
92
+ // Both observe the peer leaving and drop to zero peers.
93
+ await waitFor(() => aRemoved.length >= 1 && bRemoved.length >= 1, 10000);
94
+ await waitFor(() => peerConns(providerA).size === 0, 10000);
95
+ await waitFor(() => peerConns(providerB).size === 0, 10000);
96
+
97
+ // Losing the last peer must flip synced to false (the zero-peer rule).
98
+ await waitFor(() => syncedA.includes(false) && syncedB.includes(false), 5000);
99
+
100
+ // --- The initiator re-establishes the Link ----------------------------
101
+ await waitFor(() => aAdded.length >= 2 && bAdded.length >= 2, 10000);
102
+ await waitFor(() => peerConns(providerA).size === 1, 10000);
103
+ await waitFor(() => peerConns(providerB).size === 1, 10000);
104
+
105
+ // Re-sync handshake completes; final synced state is true on both.
106
+ await waitFor(() => syncedA[syncedA.length - 1] === true, 10000);
107
+ await waitFor(() => syncedB[syncedB.length - 1] === true, 10000);
108
+
109
+ // An edit made *after* reconnect flows A → B — live sync, not a stale link.
110
+ docA.getMap("m").set("after", "reconnect");
111
+ await waitFor(() => docB.getMap("m").get("after") === "reconnect", 5000);
112
+ assert.equal(docB.getMap("m").get("after"), "reconnect");
113
+
114
+ await providerA.destroy();
115
+ await providerB.destroy();
116
+ await close();
117
+ });