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 | // Storage-namespace migration: seed the legacy hardcoded "sueta" namespace
// (localStorage + Helia IDB stores), boot v13, assert everything is copied
// into the derived namespace (slug of the app salt), the marker is set, the
// lobby lists the migrated room, and originals are left untouched.
// Requires `npm run dev` on :5173. No relay needed — everything is pre-boot.
import { chromium } from "playwright";
const BASE = process.env.BASE_URL ?? "http://localhost:5173";
// slug("chat.ardegazu.ro/v2") — must match client/src/config.ts
const NS = "chat-ardegazu-ro-v2";
const fail = (msg) => {
console.error("❌ " + msg);
process.exit(1);
};
const ok = (msg) => console.log("✅ " + msg);
const browser = await chromium.launch();
const page = await browser.newContext().then((c) => c.newPage());
page.on("pageerror", (e) => console.log("pageerror:", e.message));
const secret = (() => {
const b = new Uint8Array(32);
crypto.getRandomValues(b);
return Buffer.from(b).toString("base64url");
})();
const seed43 = (() => {
const b = new Uint8Array(32);
crypto.getRandomValues(b);
return Buffer.from(b).toString("base64url");
})();
// 1. seed the LEGACY namespace pre-boot (same-origin page, then again before reload)
await page.goto(BASE);
await page.evaluate(
async ({ secret, seed43 }) => {
localStorage.clear();
localStorage.setItem("sueta:id", seed43);
localStorage.setItem("sueta:name", "migrated-mia");
localStorage.setItem("sueta:notify", "1");
localStorage.setItem("sueta:trust", JSON.stringify({ names: { mia: "PUBKEY" }, verified: {} }));
localStorage.setItem("sueta:rooms", JSON.stringify([{ s: secret, label: "room · legacy", ts: Date.now() }]));
localStorage.setItem("sueta:migrated:someV1room", "12345");
// Helia-store shape: DB name === object store name, out-of-line string keys
for (const name of ["sueta-blocks", "sueta-data"]) {
await new Promise((resolve, reject) => {
const req = indexedDB.open(name, 1);
req.onupgradeneeded = () => req.result.createObjectStore(name);
req.onsuccess = () => {
const tx = req.result.transaction(name, "readwrite");
const st = tx.objectStore(name);
st.put(new Uint8Array([1, 2, 3]), `${name}-key-a`);
st.put(new Uint8Array([4, 5, 6]), `${name}-key-b`);
tx.oncomplete = () => (req.result.close(), resolve());
tx.onerror = () => reject(tx.error);
};
req.onerror = () => reject(req.error);
});
}
},
{ secret, seed43 },
);
// 2. reload → boot() runs migrateStorageNS() before the lobby
await page.reload();
await page.waitForFunction((ns) => localStorage.getItem(`${ns}:storage-migrated`) !== null, NS, { timeout: 15000 }).catch(() => fail("migration marker never set"));
const ls = await page.evaluate((ns) => ({
id: localStorage.getItem(`${ns}:id`),
name: localStorage.getItem(`${ns}:name`),
notify: localStorage.getItem(`${ns}:notify`),
trust: localStorage.getItem(`${ns}:trust`),
rooms: localStorage.getItem(`${ns}:rooms`),
v1flag: localStorage.getItem(`${ns}:migrated:someV1room`),
legacyId: localStorage.getItem("sueta:id"),
legacyRooms: localStorage.getItem("sueta:rooms"),
}), NS);
if (ls.id !== seed43) fail("identity seed not migrated");
if (ls.name !== "migrated-mia") fail("display name not migrated");
if (ls.notify !== "1") fail("notify pref not migrated");
if (!ls.trust?.includes("PUBKEY")) fail("trust store not migrated");
if (!ls.rooms?.includes(secret)) fail("room list not migrated");
if (ls.v1flag !== "12345") fail("v1 migration flags not migrated");
if (ls.legacyId !== seed43 || !ls.legacyRooms) fail("legacy keys were deleted (must be copy, not move)");
ok("localStorage: id, name, notify, trust, rooms, v1-flags copied; originals intact");
const idb = await page.evaluate(async (ns) => {
const read = (db, store) =>
new Promise((resolve) => {
const req = indexedDB.open(db);
let created = false;
req.onupgradeneeded = () => (created = true);
req.onsuccess = () => {
const d = req.result;
if (created || !d.objectStoreNames.contains(store)) {
d.close();
resolve(null);
return;
}
const r = d.transaction(store).objectStore(store).getAllKeys();
r.onsuccess = () => (d.close(), resolve(r.result.map(String)));
r.onerror = () => (d.close(), resolve(null));
};
req.onerror = () => resolve(null);
});
return {
blocks: await read(`${ns}-blocks`, `${ns}-blocks`),
data: await read(`${ns}-data`, `${ns}-data`),
};
}, NS);
if (idb.blocks?.length !== 2 || !idb.blocks.includes("sueta-blocks-key-a")) fail(`blockstore copy wrong: ${JSON.stringify(idb.blocks)}`);
if (idb.data?.length !== 2 || !idb.data.includes("sueta-data-key-b")) fail(`datastore copy wrong: ${JSON.stringify(idb.data)}`);
ok("IndexedDB: blockstore + datastore copied with renamed object stores");
// 3. lobby lists the migrated room under the new namespace
const hasRoom = await page.evaluate(() => document.body.textContent.includes("room · legacy"));
if (!hasRoom) fail("lobby does not list the migrated room");
ok("lobby lists the migrated room");
// 4. idempotence: second reload must not re-copy (marker short-circuits)
const markerBefore = await page.evaluate((ns) => localStorage.getItem(`${ns}:storage-migrated`), NS);
await page.reload();
await page.waitForTimeout(1500);
const markerAfter = await page.evaluate((ns) => localStorage.getItem(`${ns}:storage-migrated`), NS);
if (markerBefore !== markerAfter) fail("migration re-ran on second boot");
ok("second boot is a no-op (marker respected)");
console.log("\n🎉 storage-migrate e2e passed");
await browser.close();
process.exit(0);
|