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);
|