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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261 | // M1 identity + multi-device e2e — the requirement proof: an identity created
// on device A (with forced seed backup), a language change synced through the
// home log, and device B becoming the same person by importing the seed —
// with NO mesh: the mailbox stub is the only transport between A and B.
// Self-contained: spawns its own vite dev server (mailbox env → local stub).
// Run: node e2e/identity.e2e.mjs (HEADED=1 to watch)
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { startStub } from "./mailbox-stub.mjs";
const PORT = 5183;
const BASE = `http://localhost:${PORT}`;
const STUB_PORT = 8790;
const HEADED = !!process.env.HEADED;
const CLIENT_DIR = fileURLToPath(new URL("..", import.meta.url));
// fail() throws: control leaves the scenario, the outer try/finally closes the
// browser (and with it every context), the stub and vite before exiting.
// NEVER put the seed (or any part of it) into a fail message — it is logged.
const fail = (msg) => {
throw new Error(msg);
};
const ok = (msg) => console.log("✅ " + msg);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---- infrastructure ---------------------------------------------------------
let stub = null;
let viteProc = null;
let browser = null;
// npx re-spawns vite as a grandchild, so killing viteProc alone leaves the
// actual dev server running; vite is spawned detached (own process group) and
// killed via the NEGATIVE pid, which nukes the whole group.
const killVite = () => {
try {
if (viteProc?.pid) process.kill(-viteProc.pid, "SIGKILL");
} catch {}
};
const shutdown = async () => {
try {
await browser?.close(); // closes all contexts + the browser child process
} catch {}
try {
await stub?.close();
} catch {}
killVite();
};
// last-resort: never leave the vite child running whatever the exit path
process.on("exit", killVite);
const stubStats = () => fetch(`http://localhost:${STUB_PORT}/__test/stats`).then((r) => r.json());
// ---- scenario ----------------------------------------------------------------
async function main() {
stub = await startStub(STUB_PORT);
viteProc = spawn("npx", ["vite", "--port", String(PORT), "--strictPort"], {
cwd: CLIENT_DIR,
env: {
...process.env,
// an ip4 multiaddr has no descriptor host → the boot probe is skipped and
// the baked defaults (these env overrides) win — no network beyond the stub
VITE_RELAY_MULTIADDR: "/ip4/127.0.0.1/tcp/1/ws/p2p/12D3KooWLocalStubOnly",
VITE_MAILBOX_URL: `http://localhost:${STUB_PORT}/mailbox/v1`,
VITE_MAILBOX_CREDS_URL: `http://localhost:${STUB_PORT}/mailbox-credentials`,
},
stdio: ["ignore", "pipe", "pipe"],
detached: true, // own process group → killVite() can SIGKILL npx AND vite
});
{
let up = false;
for (let i = 0; i < 60 && !up; i++) {
up = await fetch(BASE).then((r) => r.ok, () => false);
if (!up) await sleep(500);
}
if (!up) fail("vite dev server did not come up on :" + PORT);
}
browser = await chromium.launch({ headless: !HEADED });
// Romanian locale on both contexts so the app STARTS in RO — B ending up in EN
// can then only come from the replayed settings op, not browser detection.
const ctxA = await browser.newContext({ locale: "ro-RO" });
const a = await ctxA.newPage();
a.on("pageerror", (e) => console.log("[A] pageerror:", e.message));
a.on("console", (m) => {
if (m.type() === "warning" || m.type() === "error") console.log(`[A] console.${m.type()}:`, m.text());
});
// --- A: onboarding, create with forced backup --------------------------------
await a.goto(BASE);
await a.waitForSelector('[data-testid="onboarding"]', { timeout: 20000 });
ok("A: onboarding shown on a fresh profile");
await a.click('[data-testid="create-id"]');
await a.waitForSelector('[data-testid="seed-text"]');
const seed = (await a.textContent('[data-testid="seed-text"]')).trim();
if (!/^[A-Za-z0-9_-]{43}$/.test(seed)) fail(`generated seed has an unexpected format (length ${seed.length})`);
const fpNew = (await a.textContent('[data-testid="fp-new"]')).trim();
if (!fpNew) fail("no fingerprint shown on the create screen");
if (!(await a.$('[data-testid="seed-qr"]'))) fail("no QR code on the create screen");
// the backup gate must hold until BOTH proofs are given
if (!(await a.isDisabled('[data-testid="backup-confirm"]'))) fail("backup confirm enabled before any proof");
await a.check('[data-testid="backup-check"]');
if (!(await a.isDisabled('[data-testid="backup-confirm"]'))) fail("backup confirm enabled without the last-4 retype");
await a.fill('[data-testid="backup-last4"]', "zzz9");
if (!(await a.isDisabled('[data-testid="backup-confirm"]'))) fail("backup confirm enabled with WRONG last-4");
await a.fill('[data-testid="backup-last4"]', seed.slice(-4));
await a.waitForSelector('[data-testid="backup-confirm"]:not([disabled])');
ok("A: backup gate demands checkbox + correct last-4 retype");
await a.click('[data-testid="backup-confirm"]');
await a.waitForSelector('[data-testid="tab-profile"]', { timeout: 20000 });
ok("A: identity confirmed → lobby");
// selfroom up (background stack), then read the fingerprint from the Profil tab
await a.waitForFunction(() => !!window.__cp?.selfroom, undefined, { timeout: 60000 });
await a.click('[data-testid="tab-profile"]');
await a.waitForSelector('[data-testid="fp"]');
const fpA = (await a.textContent('[data-testid="fp"]')).trim();
if (fpA !== fpNew) fail(`profile fingerprint ${fpA} != create-screen fingerprint ${fpNew}`);
ok(`A: fingerprint ${fpA}`);
// --- A: switch language to EN (appends a settings op to the home log) --------
await a.click('[data-testid="lang-en"]');
await a.waitForFunction(() => document.documentElement.lang === "en");
await a
.waitForFunction(() => window.__cp?.selfroom?.syncStatus.queued === 0, undefined, { timeout: 30000 })
.catch(() => fail("A: mailbox queue never drained"));
{
// ident record + settings entry must be in the stub before A leaves.
// The selfroom mailbox is the room with the deposits — the feed tab's
// default shard replay also REGISTERS (empty) rooms in the stub map, so
// take the fullest room, not the first-registered one.
let count = 0;
for (let i = 0; i < 40; i++) {
const s = await stubStats();
count = Math.max(0, ...Object.values(s.rooms).map((r) => r.count));
if (count >= 2) break;
await sleep(500);
}
if (count < 2) fail(`stub holds ${count} blobs — expected identity record + settings entry`);
ok(`A: deposited ${count} blobs (identity record + settings op) to the mailbox`);
}
await ctxA.close();
ok("A: left — devices were never online together from here on");
// --- B: fresh profile, malformed import must be rejected ---------------------
const ctxB = await browser.newContext({ locale: "ro-RO" });
const b = await ctxB.newPage();
b.on("pageerror", (e) => console.log("[B] pageerror:", e.message));
b.on("console", (m) => {
if (m.type() === "warning" || m.type() === "error") console.log(`[B] console.${m.type()}:`, m.text());
});
await b.goto(BASE);
await b.waitForSelector('[data-testid="import-id"]', { timeout: 20000 });
await b.click('[data-testid="import-id"]');
await b.fill('[data-testid="import-seed"]', "definitely-not-a-valid-identity-seed!!");
await b.waitForSelector('[data-testid="import-error"]:not([hidden])');
if (!(await b.isDisabled('[data-testid="import-confirm"]'))) fail("B: malformed seed but confirm is enabled");
ok("B: malformed seed rejected (error shown, confirm disabled)");
// --- B: import A's seed — fingerprint preview must match A -------------------
await b.fill('[data-testid="import-seed"]', seed);
await b.waitForSelector('[data-testid="import-fp"]');
const fpPreview = (await b.textContent('[data-testid="import-fp"]')).trim();
if (fpPreview !== fpA) fail(`B: preview fingerprint ${fpPreview} != A's ${fpA}`);
ok("B: import preview shows A's fingerprint");
await b.click('[data-testid="import-confirm"]');
await b.waitForSelector('[data-testid="tab-profile"]', { timeout: 20000 });
// --- B: home log replays from the mailbox → language folds to EN -------------
await b
.waitForFunction(() => document.documentElement.lang === "en", undefined, { timeout: 90000 })
.catch(() => fail("B: language never became EN — settings op not replayed/folded"));
const profileLabel = (await b.textContent('[data-testid="tab-profile"]')).trim();
if (profileLabel !== "Profile") fail(`B: profile tab label is "${profileLabel}", expected the EN "Profile"`);
ok("B: replayed home log folded lang=en — UI switched to English");
await b.click('[data-testid="tab-profile"]');
await b.waitForSelector('[data-testid="fp"]');
const fpB = (await b.textContent('[data-testid="fp"]')).trim();
if (fpB !== fpA) fail(`B: fingerprint ${fpB} != A's ${fpA}`);
ok("B: same fingerprint as A — same identity on both devices");
// --- B: simulated storage eviction → recovery screen, never a silent new id --
await b.evaluate(() => localStorage.clear()); // the marker cookie survives
await b.reload();
await b
.waitForSelector('[data-testid="recovery"]', { timeout: 20000 })
.catch(() => fail("B: wiped storage did not show the recovery screen"));
if (await b.$('[data-testid="onboarding"]')) fail("B: onboarding (new identity path) shown instead of recovery");
ok("B: wiped storage detected → recovery screen (no silent new identity)");
await b.fill('[data-testid="import-seed"]', seed);
await b.waitForSelector('[data-testid="import-fp"]');
await b.click('[data-testid="import-confirm"]');
await b.waitForSelector('[data-testid="tab-profile"]', { timeout: 20000 });
ok("B: recovered by re-importing the saved seed");
// --- B: profile segmented control → Hungarian; must persist across reload ----
await b.waitForFunction(() => !!window.__cp?.selfroom, undefined, { timeout: 60000 });
await b.click('[data-testid="tab-profile"]');
await b.waitForSelector('[data-testid="lang-hu"]');
await b.click('[data-testid="lang-hu"]');
await b.waitForFunction(() => document.documentElement.lang === "hu");
const chatsLabelHu = (await b.textContent('[data-testid="tab-chats"]')).trim();
if (chatsLabelHu !== "Csevegések") fail(`B: chats tab label is "${chatsLabelHu}", expected the HU "Csevegések"`);
ok("B: profile language control switched the UI to Hungarian");
// runtime parity guarantee: every ro key resolves to a non-empty hu string,
// and t() answers a sampled key set under hu (tsc's `satisfies` enforces the
// key set statically; this asserts it end-to-end through the served modules)
const parity = await b.evaluate(async () => {
const [{ ro }, { hu }, { t, getLang }] = await Promise.all([
import("/src/app/locales/ro.ts"),
import("/src/app/locales/hu.ts"),
import("/src/app/i18n.ts"),
]);
const keys = Object.keys(ro);
const missing = keys.filter((k) => typeof hu[k] !== "string" || hu[k].trim() === "");
const sampled = ["tabChats", "profLang", "mtSentPendingOne", "mtSentPending", "ctInviteExplain", "aboutTitle"];
const bad = getLang() === "hu" ? sampled.filter((k) => !(t(k, { n: 3, hours: 168 }) ?? "").trim()) : sampled;
return { total: keys.length, missing, bad };
});
if (parity.missing.length) fail(`B: hu locale has missing/empty keys: ${parity.missing.join(", ")}`);
if (parity.bad.length) fail(`B: t() under hu failed for: ${parity.bad.join(", ")}`);
ok(`B: hu locale covers all ${parity.total} keys; t() resolves the sampled set under hu`);
// the hu settings op must reach the mailbox BEFORE the reload — otherwise
// the replayed fold (still lang=en) would flip the UI back after reload
await b
.waitForFunction(() => window.__cp?.selfroom?.syncStatus.queued === 0, undefined, { timeout: 30000 })
.catch(() => fail("B: mailbox queue never drained after the hu switch"));
await b.reload();
await b.waitForSelector('[data-testid="tab-profile"]', { timeout: 20000 });
await b
.waitForFunction(() => document.documentElement.lang === "hu", undefined, { timeout: 20000 })
.catch(() => fail("B: Hungarian did not persist across reload"));
const chatsLabelHu2 = (await b.textContent('[data-testid="tab-chats"]')).trim();
if (chatsLabelHu2 !== "Csevegések") fail(`B: after reload chats tab label is "${chatsLabelHu2}", expected "Csevegések"`);
ok("B: Hungarian persisted across reload (localStorage override + folded settings)");
await ctxB.close();
}
let exitCode = 0;
try {
await main();
console.log("\n🎉 identity + multi-device scenarios passed");
} catch (err) {
console.error("❌ " + (err?.message ?? err));
exitCode = 1;
} finally {
await shutdown();
}
process.exit(exitCode);
|