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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317 | // Offline-delivery e2e: mailbox deposit/replay with peers that are NEVER
// online together. Self-contained — spawns its own static server over dist (with the
// mailbox env pointing at the in-process stub), the dev relay, and the stub.
// Run: node e2e/mailbox.e2e.mjs (stub; full matrix incl. gap/resilience)
// MAILBOX_LIVE=1 node e2e/mailbox.e2e.mjs (core scenario against the
// live node; needs :5173 free — the app's allowed-origin list)
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
import { startStub } from "./mailbox-stub.mjs";
import { buildForE2E } from "./build-for-e2e.mjs";
import { startDevRelay } from "./dev-relay.mjs";
const LIVE = !!process.env.MAILBOX_LIVE;
const HEADED = !!process.env.HEADED;
const PORT = LIVE ? 5173 : 5174; // live mint is origin-checked; 5173 is allowlisted
const BASE = `http://localhost:${PORT}`;
const STUB_PORT = 8788;
const CLIENT_DIR = fileURLToPath(new URL("..", import.meta.url));
const RELAY_PATH = fileURLToPath(new URL("../../deploy/relay/relay.mjs", import.meta.url));
const fail = (msg) => {
console.error("❌ " + msg);
process.exit(1);
};
const ok = (msg) => console.log("✅ " + msg);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---- infrastructure ---------------------------------------------------------
let stub = LIVE ? null : await startStub(STUB_PORT);
const MAILBOX_URL = LIVE ? "https://signal.ardegazu.ro/mailbox/v1" : `http://localhost:${STUB_PORT}/mailbox/v1`;
const MAILBOX_CREDS_URL = LIVE
? "https://signal.ardegazu.ro/mailbox-credentials"
: `http://localhost:${STUB_PORT}/mailbox-credentials`;
let relayProc = null;
{
const started = await startDevRelay(RELAY_PATH, { banner: "sueta relay up" });
const up = !started.external;
const proc = started.proc;
if (up) relayProc = proc;
else console.log("⚠️ dev relay already running externally");
}
// The mailbox endpoints are goog-defines baked in at BUILD time. Passing them
// to the static server is a leftover from the Vite era, where the dev server
// substituted import.meta.env.VITE_* while serving; under shadow-cljs it does
// nothing at all, so this test silently reported "mailbox feature is OFF" and
// exited on every run — leaving the offline deposit/replay path with NO e2e
// coverage. That is the same path whose do-replay bug took chat down for the
// whole life of v24. So build the bundle the test needs, rather than asking a
// file server to inject a compile-time constant.
buildForE2E({ VITE_MAILBOX_URL: MAILBOX_URL, VITE_MAILBOX_CREDS_URL: MAILBOX_CREDS_URL });
const serveProc = spawn(process.execPath, [new URL("./serve.mjs", import.meta.url).pathname, String(PORT)], {
cwd: CLIENT_DIR,
stdio: ["ignore", "pipe", "pipe"],
});
serveProc.stderr.on("data", (d) => process.env.DEBUG && console.log("[serve]", String(d)));
{
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(BASE).then((r) => r.ok, () => false);
if (!up) await sleep(500);
}
if (!up) fail(`the static server did not come up on :${PORT} (already in use? dist built?)`);
}
const cleanup = () => {
serveProc.kill("SIGKILL");
relayProc?.kill("SIGKILL");
};
process.on("exit", cleanup);
const stubStats = async () => {
if (!stub) return null;
return fetch(`http://localhost:${STUB_PORT}/__test/stats`).then((r) => r.json());
};
// uncompressible PNG large enough that the sealed blob spans several mailbox
// frames after the app's re-encode (exercises the fixedSize chunker)
function noisePNG(S = 320) {
const crcT = [...Array(256)].map((_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
const crc = (b) => {
let c = 0xffffffff;
for (const x of b) c = crcT[(c ^ x) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (t, d) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(d.length);
const td = Buffer.concat([Buffer.from(t), d]);
const cc = Buffer.alloc(4);
cc.writeUInt32BE(crc(td));
return Buffer.concat([len, td, cc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(S, 0);
ihdr.writeUInt32BE(S, 4);
ihdr[8] = 8;
ihdr[9] = 2; // RGB
const raw = Buffer.alloc(S * (S * 3 + 1));
for (let y = 0; y < S; y++) for (let i = 1; i <= S * 3; i++) raw[y * (S * 3 + 1) + i] = (Math.random() * 256) | 0;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
// ---- browser helpers ----------------------------------------------------------
const browser = await chromium.launch({
headless: !HEADED,
args: ["--disable-features=WebRtcHideLocalIpsWithMdns", "--autoplay-policy=no-user-gesture-required"],
});
async function openPage(ctx, name, url) {
const page = await ctx.newPage();
page.on("pageerror", (e) => console.log(`[${name}] pageerror:`, e.message));
await page.goto(url);
const named = await page.waitForSelector("#name-in", { timeout: 5000 }).then(() => false, () => true);
if (!named) {
await page.fill("#name-in", name);
await page.click("#name-ok");
}
await page.waitForSelector("#input", { timeout: 15000 });
return page;
}
const flushed = (page, who) =>
page
.waitForFunction(() => window.__sueta?.mbx && window.__sueta.mbx.state.queued === 0, { timeout: 30000 })
.catch(() => fail(`${who}: mailbox queue never drained`));
const hasBubble = (page, text, timeout = 20000) =>
page.waitForFunction(
(t) => [...document.querySelectorAll(".msg-bubble")].some((b) => b.textContent.includes(t)),
text,
{ timeout },
);
// ---- CORE: A deposits text+image, closes; B (never concurrent) replays --------
const ctxA = await browser.newContext();
let a = await ctxA.newPage();
await a.goto(BASE);
await a.click("#new-room");
await a.waitForFunction(() => location.hash.length > 40);
const roomURL = await a.evaluate(() => location.href);
await a.fill("#name-in", "alice");
await a.click("#name-ok");
await a.waitForSelector("#input");
console.log("room:", roomURL);
await a.waitForFunction(() => !!window.__sueta?.mbx, { timeout: 10000 }).catch(() => fail("mailbox feature is OFF (env not picked up?)"));
await a.fill("#input", "offline hello from alice");
await a.click("#send-btn");
await a.setInputFiles("#file-in", { name: "photo.png", mimeType: "image/png", buffer: noisePNG() });
await a.waitForFunction(() => [...document.querySelectorAll(".img-box img")].length >= 1, { timeout: 20000 });
await flushed(a, "alice");
if (stub) {
const s = await stubStats();
const room = Object.values(s.rooms)[0];
if (!room || room.count < 3) fail(`stub holds ${room?.count ?? 0} messages — expected ident+entries+image blocks`);
console.log(` stub holds ${room.count} blobs after alice's deposits`);
}
await a.close(); // alice gone — B must get everything from the mailbox alone
ok("alice deposited text + image and left");
const ctxB = await browser.newContext();
let b = await openPage(ctxB, "bob", roomURL);
await hasBubble(b, "offline hello from alice").catch(() => fail("bob never got alice's text from the mailbox"));
await b
.waitForFunction(() => [...document.querySelectorAll(".img-box img")].length >= 1, { timeout: 30000 })
.catch(() => fail("bob never got alice's image from the mailbox"));
ok("bob (never online with alice) received text + image via mailbox replay");
// fingerprint: the sealed identity record must have made authorship verifiable
const fpShown = await b.evaluate(() => !!document.querySelector(".msg-id"));
if (!fpShown) console.log(" (no fingerprint badge — identity record didn't verify?)");
else ok("alice's identity fingerprint verified from the sealed mailbox record");
// GC: mailbox-ingested blocks must be pinned — run a gc sweep, then reload
// with the mailbox DOWN; history and image must come from local storage alone
await b.evaluate(() => window.__sueta.log.pruneImages(new Set(), []));
if (stub) {
await stub.close();
stub = null;
}
await b.reload();
await b.waitForSelector("#input", { timeout: 15000 });
await hasBubble(b, "offline hello from alice", 10000).catch(() => fail("history lost after reload with mailbox down"));
await b
.waitForFunction(() => [...document.querySelectorAll(".img-box img")].length >= 1, { timeout: 20000 })
.catch(() => fail("image lost after gc + reload with mailbox down — ingested blocks not pinned?"));
ok("history + image survive gc and a reload with the mailbox down (IndexedDB + pins)");
await b.close(); // bob must be OFFLINE while alice posts the gap messages
if (LIVE) {
console.log("\n🎉 live-service core scenario passed");
await browser.close();
cleanup();
process.exit(0);
}
// ---- GAP: ring truncation past bob's cursor ------------------------------------
stub = await startStub(STUB_PORT);
// alice returns alone, posts two messages, leaves again
a = await openPage(ctxA, "alice", roomURL);
await a.fill("#input", "gap-one");
await a.click("#send-btn");
await a.fill("#input", "gap-two");
await a.click("#send-btn");
await flushed(a, "alice");
for (let i = 0; i < 20 && (await stubStats()).deposits < 2; i++) await sleep(250);
if ((await stubStats()).deposits < 2) fail("alice's two gap entries never both reached the stub");
await a.close();
// expire everything but the newest blob (gap-two's entry) — bob's cursor is
// now far behind oldest_seq AND gap-two's ancestor (gap-one) is gone
{
const s = await stubStats();
const roomId = Object.keys(s.rooms)[0];
await fetch(`http://localhost:${STUB_PORT}/__test/truncate?room=${encodeURIComponent(roomId)}&keep=1`, { method: "POST" });
}
b = await openPage(ctxB, "bob", roomURL);
await hasBubble(b, "gap-two", 20000).catch(async () => {
console.log("stub:", JSON.stringify(await stubStats()));
console.log(
"bob:",
JSON.stringify(
await b.evaluate(() => ({
mbx: window.__sueta.mbx.state,
msgs: window.__sueta.store.messages.map((m) => m.text?.slice(0, 20)),
})),
),
);
fail("bob doesn't show the unjoinable entry display-only");
});
const gapState = await b.evaluate(() => window.__sueta.mbx.state);
if (gapState.retry < 1) fail("unjoinable entry is not on the retry list");
await b
.waitForFunction(() => [...document.querySelectorAll(".toast")].some((t) => t.textContent.includes("⌛")), { timeout: 10000 })
.then(() => ok("expiry gap surfaced (⌛ toast) + display-only projection rendered"))
.catch(() => console.log(" (⌛ toast raced past the check — retry list confirms the gap)"));
// alice comes online TOGETHER with bob — live replication backfills gap-one,
// which unblocks gap-two's real join and drains the retry list
a = await openPage(ctxA, "alice", roomURL);
await hasBubble(b, "gap-one", 90000).catch(async () => {
for (const [who, p] of [["alice", a], ["bob", b]]) {
console.log(
`${who}:`,
JSON.stringify(
await p.evaluate(() => ({
ready: window.__sueta.net.debugState().filter((x) => x.ready).length,
peers: window.__sueta.net.debugState().length,
relayUp: window.__sueta.net.relayUp,
mbx: window.__sueta.mbx.state,
msgs: window.__sueta.store.messages.map((m) => m.text?.slice(0, 12)),
})),
),
);
}
fail("live replication never backfilled gap-one");
});
await b
.waitForFunction(() => window.__sueta.mbx.state.retry === 0, { timeout: 30000 })
.catch(() => fail("retry list never drained after live backfill"));
ok("live replication backfilled the gap; deferred entry joined; retry list drained");
await a.close();
await stub.close();
await b.reload();
await b.waitForSelector("#input", { timeout: 15000 });
await hasBubble(b, "gap-one", 10000).catch(() => fail("backfilled history lost across reload"));
await hasBubble(b, "gap-two", 5000).catch(() => fail("formerly-deferred entry lost across reload"));
ok("backfilled + formerly-deferred entries persist across reload (mailbox down)");
// ---- RESILIENCE: deposits queue while the mailbox is down ----------------------
// stub is DOWN. alice posts; the deposit must queue and survive a reload.
a = await openPage(ctxA, "alice", roomURL);
await a.fill("#input", "queued while mailbox down");
await a.click("#send-btn");
await a.waitForFunction(() => window.__sueta.mbx.state.queued >= 1, { timeout: 10000 }).catch(() => fail("deposit was not queued while the mailbox is down"));
await a.reload();
await a.waitForSelector("#input", { timeout: 15000 });
const queuedAfterReload = await a.evaluate(() => window.__sueta.mbx.state.queued);
if (queuedAfterReload < 1) fail("queue did not survive a reload");
ok(`queue persists while the mailbox is down (${queuedAfterReload} item(s) across a reload)`);
stub = await startStub(STUB_PORT);
await a.evaluate(() => window.dispatchEvent(new Event("online")));
await flushed(a, "alice");
const afterFlush = (await stubStats()).deposits;
if (afterFlush < 1) fail("flush after recovery deposited nothing");
await a.evaluate(() => window.dispatchEvent(new Event("online")));
await sleep(1500);
const afterSecond = (await stubStats()).deposits;
if (afterSecond !== afterFlush) fail(`second flush re-deposited (${afterFlush} → ${afterSecond})`);
ok("online kick flushed the queue; a second kick deposits nothing new");
console.log("\n🎉 all mailbox e2e checks passed");
await browser.close();
await stub.close();
cleanup();
process.exit(0);
|