board / client / e2e / mailbox.e2e.mjs
  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
// Offline-delivery e2e — THE requirement-#4 proof: a peer draws ALONE, closes
// the browser, and a peer who was never online with them receives every change
// via the mailbox. Spawns its own static server over client/dist, the dev relay
// and the stub.
//
// The mailbox endpoints are goog-defines now, BAKED AT BUILD TIME — vite's
// dev-server env no longer applies. So build first with the stub pointed at:
//
//   VITE_MAILBOX_URL=http://localhost:8789/mailbox/v1 \
//   VITE_MAILBOX_CREDS_URL=http://localhost:8789/mailbox-credentials \
//     npm run build && node e2e/mailbox.e2e.mjs
//
// The check below fails loudly if the dist was not built that way, rather than
// letting the suite "pass" with the mailbox disabled.
// Run: node e2e/mailbox.e2e.mjs        (node >= 22)
//      MAILBOX_LIVE=1 node e2e/mailbox.e2e.mjs   (against the live node; needs
//      :5173 free — the shared app's allowed-origin list)
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { existsSync, readFileSync } from "node:fs";
import { deflateSync } from "node:zlib";
import { startStub } from "./mailbox-stub.mjs";
import { serve } from "./serve.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 : 5184;
const BASE = `http://localhost:${PORT}`;
const STUB_PORT = 8789;
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: "relay up" });
  const up = !started.external;
  const proc = started.proc;
  if (up) relayProc = proc;
  else console.log("⚠️ dev relay already running externally");
}

// Build the dist this test needs rather than asking the operator to remember a
// command. The old gate failed loudly with instructions, which beat lying — but
// it also meant these scenarios had never actually run. And following those
// instructions alone would not have been enough: resolve-relay-config fetches
// the relay host's /.well-known/ap2p, and a descriptor advertising a mailbox
// WINS over the compile-time pair (lib/descriptor.cljs — the env pair is only
// `defaults`). A release build points RELAY-MULTIADDR at production, so the app
// would have taken production's mailbox and the stub would never have seen a
// byte. Building against the local relay makes the descriptor fetch fail, which
// is exactly when the compile-time defaults apply.
buildForE2E({ VITE_MAILBOX_URL: MAILBOX_URL, VITE_MAILBOX_CREDS_URL: MAILBOX_CREDS_URL });
const staticServer = await serve(PORT);
{
  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?)`);
}

const cleanup = () => {
  staticServer.close();
  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)),
  ]);
}

// ---- scenario ----------------------------------------------------------------

const browser = await chromium.launch({
  headless: !HEADED,
  args: ["--disable-features=WebRtcHideLocalIpsWithMdns"],
});

const flushed = (page, who) =>
  page
    .waitForFunction(() => window.__board?.mbx && window.__board.mbx.state.queued === 0, { timeout: 30000 })
    .catch(() => fail(`${who}: mailbox queue never drained`));

// CORE: alice creates a board ALONE, draws strokes + adds an image, closes.
const ctxA = await browser.newContext();
let a = await ctxA.newPage();
a.on("pageerror", (e) => console.log("[alice] pageerror:", e.message));
await a.goto(BASE);
await a.click("#new-room");
await a.waitForFunction(() => location.hash.length > 40);
const boardURL = await a.evaluate(() => location.href);
await a.fill("#name-in", "alice");
await a.click("#name-ok");
await a.waitForFunction(() => !!window.__board);
console.log("board:", boardURL);

await a
  .waitForFunction(() => !!window.__board?.mbx, { timeout: 10000 })
  .catch(() => fail("mailbox feature is OFF (env not picked up?)"));

// draw two strokes with the mouse
const drawStroke = async (page, x0, y0, x1, y1) => {
  await page.mouse.move(x0, y0);
  await page.mouse.down();
  for (let i = 1; i <= 10; i++) await page.mouse.move(x0 + ((x1 - x0) * i) / 10, y0 + ((y1 - y0) * i) / 10);
  await page.mouse.up();
};
await drawStroke(a, 200, 180, 420, 260);
await drawStroke(a, 420, 180, 200, 260);
// an image, through the same pipeline paste/drop uses
await a.evaluate(async (bytes) => {
  const blob = new Blob([new Uint8Array(bytes)], { type: "image/png" });
  await window.__board.images.addImage(blob, { x: 300, y: 400 }, 600);
}, [...noisePNG()]);
await a.waitForFunction(() => window.__board.store.elements().length === 3, { timeout: 15000 });
await flushed(a, "alice");
if (stub) {
  const s = await stubStats();
  const room = Object.values(s.rooms)[0];
  if (!room || room.count < 4) fail(`stub holds ${room?.count ?? 0} blobs — expected ident+entries+image blocks`);
  console.log(`   stub holds ${room.count} blobs after alice's deposits`);
}
await a.close();
ok("alice drew 2 strokes + 1 image alone and left");

// bob: a FRESH profile that was never online with alice
const ctxB = await browser.newContext();
const b = await ctxB.newPage();
b.on("pageerror", (e) => console.log("[bob] pageerror:", e.message));
await b.goto(boardURL);
await b.fill("#name-in", "bob");
await b.click("#name-ok");
await b.waitForFunction(() => !!window.__board);
await b
  .waitForFunction(() => window.__board.store.elements().length === 3, { timeout: 30000 })
  .catch(() => fail("bob never received alice's offline changes via the mailbox"));
ok("bob (never online with alice) received all 3 elements via mailbox replay");

// attribution must have survived the sealed identity record
const authors = await b.evaluate(() => window.__board.store.elements().map((r) => r.authorName));
if (!authors.every((n) => n === "alice")) fail(`authors are ${JSON.stringify(authors)}, expected all "alice"`);
const verified = await b.evaluate(() => window.__board.store.elements().some((r) => !!r.ident));
if (verified) ok("alice's identity verified from the sealed mailbox record");
else console.log("   (no verified identity on elements — record didn't verify?)");

// the image blob must have arrived and decoded
await b
  .waitForFunction(
    () => {
      const img = window.__board.store.elements().find((r) => r.spec.k === "img");
      return img && !!window.__board.images.imageOf(img.spec.cid);
    },
    { timeout: 30000 },
  )
  .catch(() => fail("bob never decoded alice's image from mailbox blocks"));
ok("image blocks replayed, verified and decoded");

// persistence: reload with the mailbox DOWN — everything must come from IDB
if (stub) {
  await stub.close();
  stub = null;
}
await b.reload();
await b.waitForFunction(() => !!window.__board, { timeout: 30000 });
await b
  .waitForFunction(() => window.__board.store.elements().length === 3, { timeout: 20000 })
  .catch(() => fail("board lost after reload with mailbox down — ingested blocks not pinned?"));
ok("board survives a reload with the mailbox down (IndexedDB + pins)");

console.log("\n🎉 offline mailbox scenarios passed");
await browser.close();
cleanup();
process.exit(0);

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/board.git