1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255 | // M3b live-mesh e2e — two devices BOTH ONLINE connect through the local dev
// relay (deploy/relay), upgrade to direct WebRTC, and exchange messages LIVE
// (OrbitDB pubsub sync — arrival WITHOUT reload, which the mailbox alone can
// never do between replay kicks); the presence dot must light. Then the relay
// is killed: nothing crashes, messages still flow through the mailbox stub
// (live is an enhancement, never load-bearing), and a relay restart brings
// the live path back via redial. Spawns its own relay — stop any external one.
// Requires Node >= 22 (the relay's engines). Run: node e2e/mesh.e2e.mjs
// NEVER put a seed (or any part of one) into a fail message — it is logged.
import { spawn, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
acceptInvite,
confirmJoinedInvite,
createHarness,
createIdentity as createIdentityFlow,
createInvite,
fail,
ok,
sleep,
waitAllRoomsDrained,
waitChatReady,
waitContactsReady,
} from "./helpers.mjs";
const PORT = 5188;
const STUB_PORT = 8795;
const RELAY_PORT = 9096;
const RELAY_DIR = fileURLToPath(new URL("../../deploy/relay/", import.meta.url));
const RELAY_PATH = RELAY_DIR + "relay.mjs";
// ---- dev relay lifecycle -----------------------------------------------------
if (!existsSync(RELAY_DIR + "node_modules")) {
console.log("installing deploy/relay dependencies (first run)…");
execSync("npm ci --no-audit --no-fund", { cwd: RELAY_DIR, stdio: "inherit" });
}
let relayProc = null;
async function startRelay() {
const proc = spawn(process.execPath, [RELAY_PATH, "--dev", "--port", String(RELAY_PORT)], {
stdio: ["ignore", "pipe", "pipe"],
});
let buf = "";
const peerId = await new Promise((resolve) => {
const t = setTimeout(() => resolve(null), 15000);
proc.stdout.on("data", (d) => {
buf += String(d);
const m = buf.match(/peerId\s+(\S+)/);
if (m && buf.includes("relay up")) {
clearTimeout(t);
resolve(m[1]);
}
});
proc.stderr.on("data", (d) => process.stderr.write(String(d)));
proc.on("exit", () => {
clearTimeout(t);
resolve(null);
});
});
if (!peerId) fail("dev relay did not come up on :" + RELAY_PORT + " (external instance running? Node >= 22?)");
relayProc = proc;
return peerId;
}
const relayPeerId = await startRelay();
const RELAY_MA = `/ip4/127.0.0.1/tcp/${RELAY_PORT}/ws/p2p/${relayPeerId}`;
console.log("dev relay:", RELAY_MA);
const harness = createHarness({ port: PORT, stubPort: STUB_PORT, env: { VITE_RELAY_MULTIADDR: RELAY_MA } });
const BASE = harness.base;
const createIdentity = (page) => createIdentityFlow(page, BASE);
// ---- page helpers ------------------------------------------------------------
const openChats = async (page) => {
await page.click('[data-testid="tab-chats"]');
await page.waitForSelector('[data-testid="chats-panel"]', { timeout: 60000 });
};
const openConversation = async (page, convId) => {
await page.evaluate((h) => {
location.hash = h;
}, `#/c/${convId}`);
await page.waitForSelector('[data-testid="chat-input"]', { timeout: 60000 });
};
const sendMessage = async (page, text) => {
await page.fill('[data-testid="chat-input"]', text);
await page.click('[data-testid="chat-send"]');
await page.waitForFunction(
(t) => [...document.querySelectorAll('[data-testid="chat-msg"]')].some((el) => el.textContent.includes(t)),
text,
{ timeout: 30000 },
);
};
const waitRelayUp = (page, up, timeout = 60000) =>
page.waitForFunction((want) => window.__cp?.hub?.debug.live.relayUp === want, up, { timeout });
/** A same-room device is live on this conversation's replication topic. */
const waitRoomOnline = (page, timeout = 90000) =>
page.waitForFunction(() => window.__cp?.hub?.debug.rooms.some((r) => r.kind !== "self" && r.online > 0), undefined, {
timeout,
});
const waitSees = (page, text, timeout) =>
page.waitForFunction(
(t) => [...document.querySelectorAll('[data-testid="chat-msg"]')].some((el) => el.textContent.includes(t)),
text,
{ timeout },
);
// ---- scenario ----------------------------------------------------------------
async function main() {
await harness.start();
const dirA = harness.newProfileDir("cp-mesh-a-");
const dirB = harness.newProfileDir("cp-mesh-b-");
const errors = { A: [], B: [] };
// --- contacts via the M2 rendezvous flow ------------------------------------
let A = await harness.openDevice("A", dirA);
await createIdentity(A.page);
await waitContactsReady(A.page);
const pubA = await A.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
const { token } = await createInvite(A.page, "Ana");
await waitAllRoomsDrained(A.page);
await A.close();
let B = await harness.openDevice("B", dirB);
B.page.on("pageerror", (e) => errors.B.push(e.message));
await createIdentity(B.page);
await waitContactsReady(B.page);
await acceptInvite(B.page, BASE, token, "Bogdan");
const pubB = await B.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
A = await harness.openDevice("A2", dirA);
A.page.on("pageerror", (e) => errors.A.push(e.message));
await A.page.goto(BASE);
await confirmJoinedInvite(A.page);
await A.page.waitForFunction((pub) => window.__cp?.contacts?.state.contacts.get(pub)?.status === "active", pubB, {
timeout: 60000,
});
await waitChatReady(A.page);
await waitChatReady(B.page);
ok("contacts established; both devices stay ONLINE");
// A confirmed while B is STILL ONLINE — a second invite poll can race the
// contact fold and re-prompt for the already-added card. Dismissing is the
// honest UX answer ("not now"; the contact is active) and unblocks the tabs.
for (let i = 0; i < 10; i++) {
const again = await A.page.$('[data-testid="invite-confirm-no"]');
if (!again) break;
await A.page.click('[data-testid="invite-confirm-no"]').catch(() => {});
await sleep(500);
}
// --- both open the pair conversation ⇒ lazy live mesh starts ----------------
await openChats(A.page);
await openConversation(A.page, pubB);
// B is still on the add-success screen — go home first (hash routing)
await B.page.evaluate(() => {
location.hash = "#/";
});
await openChats(B.page);
await openConversation(B.page, pubA);
await waitRelayUp(A.page, true).catch(() => fail("A never connected to the dev relay"));
await waitRelayUp(B.page, true).catch(() => fail("B never connected to the dev relay"));
ok("live mesh started lazily on conversation open; both relay connections up");
// --- discovery → WebRTC upgrade → gossipsub on the room topic ---------------
await waitRoomOnline(A.page).catch(() => fail("A never saw B on the pair room's replication topic"));
await waitRoomOnline(B.page).catch(() => fail("B never saw A on the pair room's replication topic"));
const aConns = await A.page.evaluate(() => window.__cp.hub.debug.live.conns);
const direct = aConns.some((c) => c.includes("/webrtc"));
ok(`same-room devices meshed (${direct ? "direct webrtc" : "circuit (webrtc pending)"})`);
// presence dot: "online acum" must show in the chat header on both sides
await A.page.waitForSelector('[data-testid="chat-presence"]:not([hidden])', { timeout: 30000 });
await B.page.waitForSelector('[data-testid="chat-presence"]:not([hidden])', { timeout: 30000 });
ok("presence: 'online acum' dot lit on both sides");
// --- LIVE delivery (no reload!): mailbox replay only runs on open/wake ------
await sendMessage(A.page, "salut live A1");
await waitSees(B.page, "salut live A1", 30000).catch(() => fail("B did not receive A's message LIVE"));
await sendMessage(B.page, "salut live B1");
await waitSees(A.page, "salut live B1", 30000).catch(() => fail("A did not receive B's reply LIVE"));
ok("LIVE message delivery both ways (no reload, mailbox replay not involved)");
// --- relay outage: graceful fallback, mailbox stays load-bearing ------------
relayProc.kill("SIGKILL");
relayProc = null;
await waitRelayUp(A.page, false).catch(() => fail("A never noticed the relay died"));
await waitRelayUp(B.page, false).catch(() => fail("B never noticed the relay died"));
ok("relay killed: both sides report live down, no crash");
// established direct webrtc doesn't depend on the relay — chat keeps flowing
await sendMessage(A.page, "supraviețuiește fără relay");
await waitSees(B.page, "supraviețuiește fără relay", 15000).catch(() =>
fail("established direct mesh died with the relay — it must not"),
);
ok("established direct webrtc survives the relay outage (pure p2p)");
// now take B fully OFFLINE, send with the relay still dead: only the
// mailbox can carry it — live is an enhancement, never load-bearing
await B.close();
await sendMessage(A.page, "mesaj în pană de relay");
await waitAllRoomsDrained(A.page); // deposited to the mailbox stub
B = await harness.openDevice("B2", dirB);
B.page.on("pageerror", (e) => errors.B.push(e.message));
await B.page.goto(BASE);
await waitChatReady(B.page);
await openConversation(B.page, pubA);
await waitSees(B.page, "mesaj în pană de relay", 90000).catch(() =>
fail("B did not get the relay-outage message via the MAILBOX — fallback broken"),
);
ok("relay down: fresh boot still delivered via mailbox replay (fallback intact)");
if (errors.A.length > 0 || errors.B.length > 0)
fail(`page errors during outage: ${[...errors.A, ...errors.B].join(" | ")}`);
// --- relay restart: redial + live again -------------------------------------
const restartedPeerId = await startRelay();
if (restartedPeerId !== relayPeerId) fail("dev relay restarted with a different PeerId — determinism broken");
await waitRelayUp(A.page, true, 90000).catch(() => fail("A never redialed the restarted relay"));
await waitRelayUp(B.page, true, 90000).catch(() => fail("B never redialed the restarted relay"));
ok("relay restarted: both sides redialed (jittered backoff)");
await waitRoomOnline(A.page).catch(() => fail("A: room topic never came back live after restart"));
await waitRoomOnline(B.page).catch(() => fail("B: room topic never came back live after restart"));
await sendMessage(A.page, "din nou live A2");
await waitSees(B.page, "din nou live A2", 45000).catch(() => fail("B did not receive LIVE after the relay restart"));
ok("LIVE delivery works again after the restart");
if (errors.A.length > 0 || errors.B.length > 0) fail(`page errors: ${[...errors.A, ...errors.B].join(" | ")}`);
await A.close();
await B.close();
}
let exitCode = 0;
try {
await main();
console.log("\n🎉 mesh (live delivery + presence + relay kill/restart fallback) scenarios passed");
} catch (err) {
console.error("❌ " + (err?.message ?? err));
exitCode = 1;
} finally {
relayProc?.kill("SIGKILL");
await harness.shutdown();
}
process.exit(exitCode);
|