chat / client / e2e / migrate.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
// Legacy v1 → v2 history migration: seed a v1 IndexedDB snapshot, boot v2,
// assert the messages are published into the log once — including when TWO
// devices race to migrate the same snapshot ("v1:<lid>" ids fold dupes).
// Requires `npm run dev` on :5173; spawns its own dev relay.
import { chromium } from "playwright";
import { fileURLToPath } from "node:url";
import { startDevRelay } from "./dev-relay.mjs";

const BASE = process.env.BASE_URL ?? "http://localhost:5173";
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 { proc: relayProc } = await startDevRelay(RELAY_PATH, { banner: "sueta relay up" });
// Retire the relay on EVERY exit path, not just the happy one. `fail()` and any
// uncaught error call process.exit, which used to leave the relay listening on
// :9090 — and the NEXT run then spawned its own, watched it die on the busy
// port, and read that death as "an external relay is already running". Two runs
// back to back therefore tested whatever the orphan was, and a run after a
// failed one could test with no relay at all.
process.on("exit", () => relayProc?.kill("SIGKILL"));

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

// a fresh 43-char secret, plus its V1 roomId (v1 salt) computed like v1 did
const secret = (() => {
  const b = new Uint8Array(32);
  crypto.getRandomValues(b);
  return Buffer.from(b).toString("base64url");
})();

const V1_MSGS = [
  { id: "aaaa0001:1", from: "aaaa0001", name: "old-alice", ts: Date.now() - 86400_000, seq: 1, text: "ancient wisdom" },
  { id: "aaaa0001:2", from: "aaaa0001", name: "old-alice", ts: Date.now() - 86300_000, seq: 2, text: "second scroll" },
  { id: "bbbb0002:1", from: "bbbb0002", name: "old-bob", ts: Date.now() - 86200_000, seq: 1, text: "a reply from the past", rx: [["👍", ["aaaa0001"]]] },
];

async function seedAndOpen(label, mineIds) {
  const page = await browser.newContext().then((c) => c.newPage());
  page.on("pageerror", (e) => console.log(`[${label}] pageerror:`, e.message));
  await page.goto(BASE); // same-origin page so IDB/localStorage are reachable pre-boot
  await page.evaluate(
    async ({ secret, msgs, mineIds }) => {
      const te = new TextEncoder();
      const ikm = await crypto.subtle.importKey("raw", Uint8Array.from(atob(secret.replaceAll("-", "+").replaceAll("_", "/")), (c) => c.charCodeAt(0)), "HKDF", false, ["deriveBits"]);
      const bits = await crypto.subtle.deriveBits(
        { name: "HKDF", hash: "SHA-256", salt: te.encode("chat.ardegazu.ro/v1"), info: te.encode("roomid") },
        ikm,
        256,
      );
      const b = new Uint8Array(bits);
      let s = "";
      for (const x of b) s += String.fromCharCode(x);
      const roomIdV1 = btoa(s).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
      await new Promise((resolve, reject) => {
        const req = indexedDB.open("sueta-history", 1);
        req.onupgradeneeded = () => req.result.createObjectStore("rooms");
        req.onsuccess = () => {
          const tx = req.result.transaction("rooms", "readwrite");
          tx.objectStore("rooms").put({ msgs, mineIds, savedAt: Date.now() }, roomIdV1);
          tx.oncomplete = () => resolve();
          tx.onerror = () => reject(tx.error);
        };
        req.onerror = () => reject(req.error);
      });
    },
    { secret, msgs: V1_MSGS, mineIds },
  );
  await page.goto(`${BASE}/#${secret}`);
  await page.reload(); // fragment-only navigation doesn't re-run boot from the lobby
  await page.fill("#name-in", label);
  await page.click("#name-ok");
  return page;
}

// device A migrates
const a = await seedAndOpen("neo-alice", ["aaaa0001:1", "aaaa0001:2"]);
await a
  .waitForFunction(() => [...document.querySelectorAll(".msg-bubble")].filter((b) => /ancient wisdom|second scroll|reply from the past/.test(b.textContent)).length === 3, { timeout: 20000 })
  .catch(() => fail("device A did not materialize migrated messages"));
const aMine = await a.evaluate(() => document.querySelectorAll(".msg.mine").length);
if (aMine !== 2) fail(`device A 'mine' styling wrong: ${aMine} (want 2)`);
const aChip = await a.evaluate(() => [...document.querySelectorAll(".chip")].some((c) => c.textContent.includes("👍 1")));
if (!aChip) fail("migrated reaction lost");
ok("v1 snapshot migrated into the log (text, authorship, reactions)");

// reload: entries come from the log now; no duplicate import (flag set)
await a.reload(); // stored name → no modal on reload
await a.waitForFunction(() => document.querySelectorAll(".msg-bubble").length >= 3, { timeout: 20000 }).catch(() => fail("messages lost after reload"));
await a.waitForTimeout(2500);
const aCount = await a.evaluate(() => [...document.querySelectorAll(".msg-bubble")].filter((b) => /ancient wisdom|second scroll|reply from the past/.test(b.textContent)).length);
if (aCount !== 3) fail(`duplicates after reload: ${aCount} legacy messages (want 3)`);
ok("reload keeps exactly one copy (migration is one-shot)");

// device B holds the SAME v1 snapshot and races its own migration
const b = await seedAndOpen("neo-bob", ["bbbb0002:1"]);
await b
  .waitForFunction(() => [...document.querySelectorAll(".msg-bubble")].filter((x) => /ancient wisdom|second scroll|reply from the past/.test(x.textContent)).length === 3, { timeout: 30000 })
  .catch(() => fail("device B did not converge on migrated history"));
await b.waitForTimeout(3000);
const bCount = await b.evaluate(() => [...document.querySelectorAll(".msg-bubble")].filter((x) => /ancient wisdom|second scroll|reply from the past/.test(x.textContent)).length);
const aCount2 = await a.evaluate(() => [...document.querySelectorAll(".msg-bubble")].filter((x) => /ancient wisdom|second scroll|reply from the past/.test(x.textContent)).length);
if (bCount !== 3 || aCount2 !== 3) fail(`racing migrations duplicated: A=${aCount2} B=${bCount} (want 3/3)`);
const bMine = await b.evaluate(() => document.querySelectorAll(".msg.mine").length);
if (bMine !== 1) fail(`device B 'mine' styling wrong: ${bMine} (want 1)`);
ok("racing migrations fold into one copy; per-device authorship kept");

console.log("\n🎉 migration e2e passed");
await browser.close();
process.exit(0);

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