chat / client / e2e / storage-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
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);

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