board / client / e2e / board.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
230
231
232
233
234
235
236
237
// End-to-end board convergence test with real Chromium (WebRTC needs real UDP).
// Self-contained: spawns a static server over client/dist (e2e/serve.mjs) and
// the dev relay (deploy/relay --dev on :9090) — stop any externally-running
// relay first. BUILD FIRST (`npm run build`): there is no vite dev server in
// this repo any more, the suite drives the shipped dist.
// Run: node e2e/board.e2e.mjs   (node >= 22)
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
import { serve } from "./serve.mjs";
import { buildForE2E } from "./build-for-e2e.mjs";
import { startDevRelay } from "./dev-relay.mjs";

const PORT = Number(process.env.PORT ?? 5183);
const BASE = process.env.BASE_URL ?? `http://localhost:${PORT}`;
const HEADED = !!process.env.HEADED;
const RELAY_PATH = fileURLToPath(new URL("../../deploy/relay/relay.mjs", import.meta.url));

// A plain `npm run build` points the app at the PRODUCTION relay (config.cljs:
// or-default(VITE_RELAY_MULTIADDR, goog.DEBUG ? DEV : PROD), and goog.DEBUG is
// false in a release build), so the relay this script spawns would be bypassed.
if (!process.env.E2E_NO_BUILD) buildForE2E();

let relayProc = null;
async function startRelay() {
  const started = await startDevRelay(RELAY_PATH, { banner: "relay up" });
  const up = !started.external;
  const proc = started.proc;
  if (up) relayProc = proc;
  return up;
}
await startRelay();

// ---- the app under test: the SHIPPED dist, served statically ----------------
const CLIENT_DIR = fileURLToPath(new URL("..", import.meta.url));
if (!existsSync(`${CLIENT_DIR}/dist/index.html`)) {
  console.error("❌ client/dist is not built — run `npm run build` first");
  process.exit(1);
}
const staticServer = process.env.BASE_URL ? null : await serve(PORT);
// Retire the relay on EVERY exit path, not just the happy tail below. `fail()`
// and any uncaught error call process.exit, which used to leave it listening on
// :9090 — and the next run then read that orphan as "an external relay is
// already running", so one failed run poisoned every run after it.
process.on("exit", () => {
  staticServer?.close();
  relayProc?.kill("SIGKILL");
});

const fail = (msg) => {
  console.error("❌ " + msg);
  process.exit(1);
};
const ok = (msg) => console.log("✅ " + msg);

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

async function openPeer(name, url) {
  const ctx = await browser.newContext({ permissions: [] });
  const page = await ctx.newPage();
  page.on("pageerror", (e) => console.log(`[${name}] pageerror:`, e.message));
  await page.goto(url);
  await page.fill("#name-in", name);
  await page.click("#name-ok");
  await page.waitForFunction(() => !!window.__board);
  return page;
}

// ---- board setup: alice creates -------------------------------------------

const first = await (async () => {
  const ctx = await browser.newContext();
  const page = await ctx.newPage();
  page.on("pageerror", (e) => console.log(`[alice] pageerror:`, e.message));
  await page.goto(BASE);
  await page.click("#new-room");
  await page.waitForFunction(() => location.hash.length > 40);
  const url = await page.evaluate(() => location.href);
  await page.fill("#name-in", "alice");
  await page.click("#name-ok");
  await page.waitForFunction(() => !!window.__board);
  return { page, url };
})();
const alice = first.page;
const boardURL = first.url;
console.log("board:", boardURL);
if (!/#[A-Za-z0-9_-]{43}\.[A-Za-z0-9_-]{43}$/.test(new URL(boardURL).hash)) {
  fail("board link is missing the creator-pub suffix");
}
ok("new board link carries #secret.creatorPub");

const bob = await openPeer("bob", boardURL);

// ---- 1. peers become ready -------------------------------------------------

for (const [who, page] of [["alice", alice], ["bob", bob]]) {
  await page
    .waitForFunction(() => window.__board?.net?.debugState().some((p) => p.ready), { timeout: 40000 })
    .catch(() => fail(`${who}: no peer became ready`));
}
ok("2 peers connected");

// ---- 2. alice draws a stroke; bob converges --------------------------------

const drawStroke = async (page, x0, y0, x1, y1) => {
  await page.mouse.move(x0, y0);
  await page.mouse.down();
  const steps = 12;
  for (let i = 1; i <= steps; i++) {
    await page.mouse.move(x0 + ((x1 - x0) * i) / steps, y0 + ((y1 - y0) * i) / steps);
  }
  await page.mouse.up();
};

await drawStroke(alice, 200, 200, 400, 300);
await alice
  .waitForFunction(() => window.__board.store.elements().length === 1, { timeout: 5000 })
  .catch(() => fail("alice's own stroke never folded"));
await bob
  .waitForFunction(() => window.__board.store.elements().length === 1, { timeout: 15000 })
  .catch(() => fail("bob never received alice's stroke"));
ok("stroke replicated alice → bob");

// authorship attribution carried through
const author = await bob.evaluate(() => window.__board.store.elements()[0].authorName);
if (author !== "alice") fail(`bob sees author "${author}", expected "alice"`);
ok("stroke authorship attributed to alice");

// ---- 3. bob draws; alice converges ------------------------------------------

await drawStroke(bob, 500, 350, 300, 150);
await alice
  .waitForFunction(() => window.__board.store.elements().length === 2, { timeout: 15000 })
  .catch(() => fail("alice never received bob's stroke"));
ok("stroke replicated bob → alice");

// ---- 4. shapes + text ops via the store API (tool UI covered elsewhere) -----

await alice.evaluate(() => {
  const id = "e2eShapeAAAAAAAAAAAAAA"; // 22 chars base64url-ish
  return window.__board.store.addElement({
    id,
    k: "shape",
    shape: "rect",
    x: 100,
    y: 100,
    w: 120,
    h: 80,
    c: 3,
    sw: 2,
  });
});
await bob
  .waitForFunction(() => window.__board.store.elements().some((r) => r.spec.k === "shape"), { timeout: 10000 })
  .catch(() => fail("bob never received the rect"));
ok("shape op replicated");

// ---- 5. concurrent edit convergence (per-field LWW) --------------------------

// alice moves the rect while bob recolors it — both effects must apply
await Promise.all([
  alice.evaluate(() => window.__board.store.editElement("e2eShapeAAAAAAAAAAAAAA", { x: 555, y: 111 })),
  bob.evaluate(() => window.__board.store.editElement("e2eShapeAAAAAAAAAAAAAA", { c: 5 })),
]);
for (const [who, page] of [["alice", alice], ["bob", bob]]) {
  await page
    .waitForFunction(
      () => {
        const r = window.__board.store.get("e2eShapeAAAAAAAAAAAAAA");
        return r && r.spec.x === 555 && r.spec.y === 111 && r.spec.c === 5;
      },
      { timeout: 10000 },
    )
    .catch(() => fail(`${who}: concurrent move+recolor did not converge to both effects`));
}
ok("concurrent per-field edits converge (move + recolor both apply)");

// ---- 6. connector attached to the rect follows it ----------------------------

await bob.evaluate(() =>
  window.__board.store.addElement({
    id: "e2eConnAAAAAAAAAAAAAAA",
    k: "conn",
    a: { el: "e2eShapeAAAAAAAAAAAAAA", side: "e" },
    b: { x: 900, y: 130 },
    c: 0,
    sw: 2,
    arrow: "end",
  }),
);
await alice.waitForFunction(() => !!window.__board.store.get("e2eConnAAAAAAAAAAAAAAA"), { timeout: 10000 });
const before = await alice.evaluate(() => window.__board.store.resolveAnchor({ el: "e2eShapeAAAAAAAAAAAAAA", side: "e" }));
await alice.evaluate(() => window.__board.store.editElement("e2eShapeAAAAAAAAAAAAAA", { x: 200 }));
await alice.waitForFunction(
  (bx) => {
    const p = window.__board.store.resolveAnchor({ el: "e2eShapeAAAAAAAAAAAAAA", side: "e" });
    return p && p.x !== bx;
  },
  before.x,
  { timeout: 5000 },
);
ok("connector anchor follows the moved shape");

// ---- 7. tombstone erase ------------------------------------------------------

await alice.evaluate(() => window.__board.store.deleteElements(["e2eShapeAAAAAAAAAAAAAA"]));
for (const [who, page] of [["alice", alice], ["bob", bob]]) {
  await page
    .waitForFunction(() => !window.__board.store.get("e2eShapeAAAAAAAAAAAAAA"), { timeout: 10000 })
    .catch(() => fail(`${who}: delete never folded`));
}
// a late edit to a tombstoned id must NOT resurrect it
await bob.evaluate(() => window.__board.store.editElement("e2eShapeAAAAAAAAAAAAAA", { x: 1 }));
await new Promise((r) => setTimeout(r, 1500));
const resurrected = await alice.evaluate(() => !!window.__board.store.get("e2eShapeAAAAAAAAAAAAAA"));
if (resurrected) fail("tombstoned element resurrected by a late edit");
ok("tombstones are terminal (no resurrection)");

// ---- 8. reload replays from the local blockstore -----------------------------

const countBefore = await alice.evaluate(() => window.__board.store.elements().length);
await alice.reload();
await alice.waitForFunction(() => !!window.__board, { timeout: 30000 });
await alice
  .waitForFunction((n) => window.__board.store.elements().length >= n, countBefore, { timeout: 20000 })
  .catch(() => fail("reload did not replay the board"));
ok(`reload replays the full board (${countBefore} elements)`);

// ---- done --------------------------------------------------------------------

console.log("\nall board e2e scenarios passed");
await browser.close();
process.exit(0);

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