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 | // Shared e2e helpers (contacts + chat suites): the stub+vite harness, device
// (persistent Chromium profile) management, identity onboarding and the
// contact-exchange flows. Keeps the suites DRY without hiding assertions —
// scenario logic and its checks stay in each suite. `browserArgs` adds
// per-suite Chromium flags (calls suite: fake camera/mic devices).
// NEVER put a seed (or any part of one) into a fail message — it is logged.
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { startStub } from "./mailbox-stub.mjs";
export const fail = (msg) => {
throw new Error(msg);
};
export const ok = (msg) => console.log("✅ " + msg);
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const HEADED = !!process.env.HEADED;
const CLIENT_DIR = fileURLToPath(new URL("..", import.meta.url));
/**
* One suite's infrastructure: the in-memory mailbox stub + a vite dev server
* wired to it (ip4 relay multiaddr ⇒ no descriptor probe; the env-overridden
* mailbox endpoints win — no network beyond the local stub), plus device and
* scratch-profile bookkeeping with a single shutdown(). `env` overrides the
* VITE_* defaults (mesh suite points VITE_RELAY_MULTIADDR at the dev relay).
*/
export function createHarness({ port, stubPort, env = {}, browserArgs = [] }) {
const base = `http://localhost:${port}`;
let stub = null;
let viteProc = null;
const openContexts = new Set();
const profileDirs = [];
// 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 {}
};
// last-resort: never leave the vite child running whatever the exit path
process.on("exit", killVite);
const harness = {
base,
async start() {
stub = await startStub(stubPort);
viteProc = spawn("npx", ["vite", "--port", String(port), "--strictPort"], {
cwd: CLIENT_DIR,
env: {
...process.env,
VITE_RELAY_MULTIADDR: "/ip4/127.0.0.1/tcp/1/ws/p2p/12D3KooWLocalStubOnly",
VITE_MAILBOX_URL: `http://localhost:${stubPort}/mailbox/v1`,
VITE_MAILBOX_CREDS_URL: `http://localhost:${stubPort}/mailbox-credentials`,
// keep every request local: the stub 404s TURN mints and IceConfig
// honestly continues without ICE servers (loopback host candidates)
VITE_TURN_CREDS_URL: `http://localhost:${stubPort}/turn-credentials`,
...env,
},
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);
},
stubStats: () => fetch(`http://localhost:${stubPort}/__test/stats`).then((r) => r.json()),
async roomCount(roomId) {
return (await harness.stubStats()).rooms[roomId]?.count ?? 0;
},
/** Scratch dir for one device profile; removed at shutdown. */
newProfileDir(prefix) {
const dir = mkdtempSync(join(tmpdir(), prefix));
profileDirs.push(dir);
return dir;
},
/** One isolated "device": a persistent Chromium profile in `dir`. */
async openDevice(label, dir) {
const ctx = await chromium.launchPersistentContext(dir, {
headless: !HEADED,
locale: "ro-RO",
args: [
// loopback WebRTC between headless instances (mesh suite): host
// candidates instead of mDNS obfuscation; media autoplay for voice
"--disable-features=WebRtcHideLocalIpsWithMdns",
"--autoplay-policy=no-user-gesture-required",
"--use-fake-ui-for-media-stream", // auto-grant mic (voice suite)
...browserArgs,
],
});
openContexts.add(ctx);
const page = ctx.pages()[0] ?? (await ctx.newPage());
page.on("pageerror", (e) => console.log(`[${label}] pageerror:`, e.message));
page.on("console", (m) => {
if (m.type() === "warning" || m.type() === "error") console.log(`[${label}] console.${m.type()}:`, m.text());
});
const close = async () => {
openContexts.delete(ctx);
await ctx.close();
};
return { ctx, page, close };
},
async shutdown() {
for (const ctx of openContexts) {
try {
await ctx.close();
} catch {}
}
try {
await stub?.close();
} catch {}
killVite();
for (const dir of profileDirs) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {}
}
},
};
return harness;
}
// ---- app flows ---------------------------------------------------------------
/** Onboarding: create a fresh identity with the forced backup gate. */
export async function createIdentity(page, base) {
await page.goto(base);
await page.waitForSelector('[data-testid="onboarding"]', { timeout: 20000 });
await page.click('[data-testid="create-id"]');
await page.waitForSelector('[data-testid="seed-text"]');
const seed = (await page.textContent('[data-testid="seed-text"]')).trim();
const fp = (await page.textContent('[data-testid="fp-new"]')).trim();
await page.check('[data-testid="backup-check"]');
await page.fill('[data-testid="backup-last4"]', seed.slice(-4));
await page.waitForSelector('[data-testid="backup-confirm"]:not([disabled])');
await page.click('[data-testid="backup-confirm"]');
await page.waitForSelector('[data-testid="tab-profile"]', { timeout: 20000 });
return { fp };
}
/** Onboarding: import an existing seed (fresh profile). */
export async function importIdentity(page, base, seed) {
await page.goto(base);
await page.waitForSelector('[data-testid="import-id"]', { timeout: 20000 });
await page.click('[data-testid="import-id"]');
await page.fill('[data-testid="import-seed"]', seed);
await page.waitForSelector('[data-testid="import-fp"]');
await page.click('[data-testid="import-confirm"]');
await page.waitForSelector('[data-testid="tab-profile"]', { timeout: 20000 });
}
export const waitContactsReady = (page) =>
page.waitForFunction(() => !!window.__cp?.contacts, undefined, { timeout: 60000 });
export const waitChatReady = (page) => page.waitForFunction(() => !!window.__cp?.chat, undefined, { timeout: 60000 });
/** Selfroom's own mailbox deposit queue drained. */
export const waitQueueDrained = (page) =>
page.waitForFunction(() => window.__cp?.selfroom?.syncStatus.queued === 0, undefined, { timeout: 60000 });
/** EVERY open room's (self + conversations) deposit queue drained. */
export const waitAllRoomsDrained = (page) =>
page.waitForFunction(
() => {
const d = window.__cp?.hub?.debug;
return !!d && d.rooms.every((r) => r.queued === 0);
},
undefined,
{ timeout: 60000 },
);
/** The EXACT mailbox room id a room secret derives to (client code path). */
export const roomIdOf = (page, secret) =>
page.evaluate(async (s) => {
const { RoomCrypto } = await import("/src/lib/crypto.ts");
const cfg = await import("/src/config.ts");
const rc = await RoomCrypto.create(s, cfg.APP_SALT);
return rc.mailboxRoomId();
}, secret);
/** The mailbox room id of MY pair room with `peerPub` (via pairSecret §2). */
export const pairMailboxRoomOf = (page, peerPub) =>
page.evaluate(async (peer) => {
const { pairSecret } = await import("/src/core/pairwise.ts");
const { RoomCrypto } = await import("/src/lib/crypto.ts");
const cfg = await import("/src/config.ts");
const id = window.__cp.identity;
const contact = window.__cp.contacts.state.contacts.get(peer);
if (!contact) throw new Error("peer is not a contact");
const secret = await pairSecret(
{ seed: id.seed, pub: id.identity.publicKeyB64 },
{ pub: contact.pub, xPub: contact.xPub },
);
const rc = await RoomCrypto.create(secret, cfg.APP_SALT);
return rc.mailboxRoomId();
}, peerPub);
/** Close whatever bottom sheet is open (backdrop tap, top of the screen). */
export const closeSheet = (page) => page.mouse.click(640, 30);
/**
* Contacts tab: create an invite as `name`; returns the parsed link/token.
* Leaves the invite sheet CLOSED.
*/
export async function createInvite(page, name) {
await page.click('[data-testid="tab-contacts"]');
await page.waitForSelector('[data-testid="invite-create"]');
await page.click('[data-testid="invite-create"]');
await page.fill('[data-testid="invite-name"]', name);
await page.click('[data-testid="invite-name-ok"]');
await page.waitForSelector('[data-testid="invite-link"]');
const link = (await page.textContent('[data-testid="invite-link"]')).trim();
const token = link.split("#/add/")[1];
await closeSheet(page);
return { link, token };
}
/**
* Invitee side of the rendezvous flow: open #/add/<token>, pass the
* fingerprint compare as `myName`, land on the success screen.
*/
export async function acceptInvite(page, base, token, myName) {
await page.goto(`${base}/#/add/${token}`);
await page.waitForSelector('[data-testid="add-fp"]', { timeout: 30000 });
await page.fill('[data-testid="add-my-name"]', myName);
await page.click('[data-testid="add-confirm"]');
await page.waitForSelector('[data-testid="add-success"]', { timeout: 30000 });
}
/** Creator side: the boot poll found a deposited card — confirm it. */
export async function confirmJoinedInvite(page) {
await page.waitForSelector('[data-testid="invite-confirm"]', { timeout: 90000 });
await page.click('[data-testid="invite-confirm-yes"]');
}
|