e2e_bridge_dm.py raw
1 #!/usr/bin/env python3
2 """E2E test: signer extension MLS + bridge MLS DM round-trip.
3
4 Selenium + Firefox — installs smesh-signer extension, creates vault,
5 generates key, logs into smesh, and tests MLS init + sendDM to the bridge.
6 The bridge speaks MLS only (marmot protocol, kinds 443/444/445).
7
8 Requires:
9 - Local relay: ORLY_LISTEN=127.0.0.1 ORLY_SMESH3_DIR=$PWD/app/smesh3 ORLY_SMESH3_PORT=8090 /tmp/orly-local
10 - Local bridge: ORLY_BRIDGE_RELAY_URL=ws://127.0.0.1:3334 ORLY_BRIDGE_PUBLIC_RELAY_URL=ws://127.0.0.1:3334
11 ORLY_BRIDGE_DATA_DIR=~/.config/orly-bridge-test ORLY_BRIDGE_DOMAIN=bridge.test
12 ORLY_BRIDGE_SMTP_PORT=2525 ORLY_BRIDGE_SMTP_HOST=127.0.0.1
13 ORLY_BRIDGE_SMTP_RELAY_HOST=127.0.0.1 ORLY_BRIDGE_SMTP_RELAY_PORT=2525 /tmp/orly-local bridge
14 - pip install --user --break-system-packages selenium
15 - geckodriver (pacman -S geckodriver)
16 - Signer extension: cd next/signer && bun run build:firefox
17 Bridge npub (locked): npub14jr5zjp8ahx8jqsxcuh6xym256gaqy4gvljzlsa9fzpsnyhsftaq0dt3gd
18
19 Usage:
20 python3 test/e2e_bridge_dm.py [--headed] [--latency 100] [--jitter 30] [--rounds 3]
21 """
22
23 import argparse
24 import json
25 import os
26 import re
27 import signal
28 import sys
29 import time
30 import subprocess
31
32 # Bridge identity — locked nsec in persistent config dir.
33 # npub102g4x0n7prw4cghvcnx6wkhvmzksnt62gt5atwta878sryck8f3q2lwe6h
34 BRIDGE_HEX = "00e57da9f6e38fceacc054af7586b51f9d0321062b0237616a672ad6a6ee11b0"
35 BRIDGE_DATA_DIR = os.path.expanduser("~/.config/orly-bridge-test")
36 BASE_URL = "http://127.0.0.1:8090"
37 XPI_PATH = "/tmp/smesh-signer.xpi"
38 EXT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "next", "signer", "dist", "firefox")
39 VAULT_PASSWORD = "testpass123"
40 EXT_ID = "smesh-signer@orly.dev"
41
42 passed = 0
43 failed = 0
44
45
46 def get_bridge_pubkey():
47 """Read bridge npub from bridge.nsec file and derive pubkey."""
48 nsec_path = os.path.join(BRIDGE_DATA_DIR, "bridge.nsec")
49 if not os.path.exists(nsec_path):
50 return None, None
51 # We can't easily derive hex from nsec in Python without dependencies.
52 # Instead, read the bridge log output or use a subprocess.
53 # For now, we'll get it from the relay by querying kind 443 events.
54 return None, None
55
56
57 def build_xpi():
58 manifest = os.path.join(EXT_DIR, "manifest.json")
59 if not os.path.exists(manifest):
60 print(f"Extension not found at {EXT_DIR}")
61 print("Build it: cd next/signer && bun run build:firefox")
62 sys.exit(1)
63 subprocess.run(["zip", "-r", XPI_PATH, "."], cwd=EXT_DIR, capture_output=True, check=True)
64
65
66 def get_ext_uuid(profile_path):
67 prefs = os.path.join(profile_path, "prefs.js")
68 for _ in range(10):
69 if os.path.exists(prefs):
70 with open(prefs) as f:
71 for line in f:
72 if "webextensions.uuids" not in line:
73 continue
74 m = re.search(r'"(\{.+\})"', line)
75 if not m:
76 continue
77 raw = m.group(1).replace('\\"', '"')
78 uuids = json.loads(raw)
79 if EXT_ID in uuids:
80 return uuids[EXT_ID]
81 time.sleep(1)
82 return None
83
84
85 def approve_prompt(driver, main_handle, By):
86 handles = driver.window_handles
87 approved = 0
88 for h in handles:
89 if h == main_handle:
90 continue
91 try:
92 driver.switch_to.window(h)
93 try:
94 btn = driver.find_element(By.ID, "approveAlwaysButton")
95 btn.click()
96 approved += 1
97 print(f" approved prompt (always)")
98 time.sleep(0.5)
99 except Exception:
100 try:
101 btn = driver.find_element(By.ID, "approveAllButton")
102 btn.click()
103 approved += 1
104 print(f" approved all queued")
105 time.sleep(0.5)
106 except Exception:
107 pass
108 except Exception:
109 pass
110 if approved:
111 time.sleep(1)
112 try:
113 driver.switch_to.window(main_handle)
114 except Exception:
115 pass
116 return approved
117
118
119 def check(name, condition, detail=""):
120 global passed, failed
121 if condition:
122 passed += 1
123 print(f" PASS: {name}")
124 else:
125 failed += 1
126 print(f" FAIL: {name}" + (f" — {detail}" if detail else ""))
127
128
129 def step(msg):
130 print(f"\n{'='*3} {msg} {'='*3}")
131
132
133 # ─────────────────────────────────────────────
134 # Browser extension + MLS bridge tests
135 # ─────────────────────────────────────────────
136
137 def run_browser_tests(args):
138 build_xpi()
139
140 from selenium import webdriver
141 from selenium.webdriver.firefox.options import Options
142 from selenium.webdriver.firefox.service import Service
143 from selenium.webdriver.common.by import By
144 from selenium.webdriver.support.ui import WebDriverWait
145 from selenium.webdriver.support import expected_conditions as EC
146
147 opts = Options()
148 if not args.headed:
149 opts.add_argument("--headless")
150 opts.set_preference("xpinstall.signatures.required", False)
151 opts.set_preference("extensions.autoDisableScopes", 0)
152 opts.set_preference("devtools.console.stdout.content", True)
153
154 svc = Service(log_output="/dev/null")
155 driver = webdriver.Firefox(options=opts, service=svc)
156 driver.set_script_timeout(60)
157
158 try:
159 _browser_tests(driver, args, By, WebDriverWait, EC)
160 finally:
161 if args.headed and sys.stdin.isatty():
162 input("\npress Enter to close browser...")
163 driver.quit()
164
165
166 def _browser_tests(driver, args, By, WebDriverWait, EC):
167 profile = driver.capabilities.get("moz:profile", "")
168
169 step("Install extension")
170 driver.install_addon(XPI_PATH, temporary=True)
171 time.sleep(2)
172 uuid = get_ext_uuid(profile)
173 check("extension UUID extracted", uuid is not None)
174 if not uuid:
175 return
176 ext = f"moz-extension://{uuid}"
177 print(f" {ext}")
178
179 step("Create vault")
180 driver.get(f"{ext}/index.html")
181 time.sleep(3)
182 btn = WebDriverWait(driver, 10).until(
183 EC.element_to_be_clickable((By.XPATH, "//button[.//span[contains(text(),'Create a new vault')]]"))
184 )
185 btn.click()
186 time.sleep(1)
187 pw = WebDriverWait(driver, 5).until(
188 EC.presence_of_element_located((By.CSS_SELECTOR, "input[placeholder='vault password']"))
189 )
190 pw.send_keys(VAULT_PASSWORD)
191 time.sleep(0.5)
192 create = driver.find_element(By.XPATH, "//button[contains(text(),'Create vault')]")
193 create.click()
194 print(" Argon2id derivation...")
195 WebDriverWait(driver, 15).until(lambda d: "home" in d.current_url)
196 check("vault created", "home" in driver.current_url)
197
198 step("Enable reckless mode")
199 try:
200 reckless = WebDriverWait(driver, 5).until(
201 EC.presence_of_element_located((By.XPATH, "//input[@type='checkbox' and ancestor::*[contains(@class,'reckless')]]"))
202 )
203 if not reckless.is_selected():
204 label = driver.find_element(By.XPATH, "//label[contains(@class,'reckless')]")
205 label.click()
206 time.sleep(0.5)
207 check("reckless mode", True)
208 except Exception as e:
209 check("reckless mode", False, str(e))
210
211 step("Add identity")
212 add_btn = WebDriverWait(driver, 5).until(
213 EC.element_to_be_clickable((By.CSS_SELECTOR, "button.add-btn"))
214 )
215 add_btn.click()
216 time.sleep(2)
217 nick = WebDriverWait(driver, 5).until(
218 EC.presence_of_element_located((By.ID, "nickElement"))
219 )
220 nick.send_keys("e2e-test")
221 gen = driver.find_element(By.XPATH, "//button[contains(text(),'Generate private key')]")
222 gen.click()
223 time.sleep(1)
224 pk_input = driver.find_element(By.ID, "privkeyInputElement")
225 nsec = pk_input.get_attribute("value")
226 check("key generated", nsec and nsec.startswith("nsec1"), nsec[:20] if nsec else "empty")
227 save = WebDriverWait(driver, 3).until(
228 EC.element_to_be_clickable((By.XPATH, "//button[contains(text(),'Save')]"))
229 )
230 save.click()
231 time.sleep(2)
232
233 step("Select identity")
234 identity_el = WebDriverWait(driver, 5).until(
235 EC.element_to_be_clickable((By.CSS_SELECTOR, "div.identity"))
236 )
237 identity_el.click()
238 time.sleep(1)
239 is_selected = driver.execute_script(
240 "return document.querySelector('div.identity.selected') !== null"
241 )
242 check("identity selected", is_selected)
243
244 step("Login to smesh")
245 driver.get(BASE_URL)
246 time.sleep(5)
247 has_nostr = driver.execute_script("return typeof window.nostr !== 'undefined'")
248 check("window.nostr injected", has_nostr)
249
250 main_handle = driver.current_window_handle
251 # Click login
252 for sel in ["//button[contains(text(),'login')]", "//button[contains(text(),'Login')]"]:
253 try:
254 btn = driver.find_element(By.XPATH, sel)
255 if btn.is_displayed():
256 btn.click()
257 break
258 except Exception:
259 continue
260 time.sleep(3)
261 approve_prompt(driver, main_handle, By)
262 time.sleep(3)
263
264 # Get pubkey
265 driver.execute_script("""
266 window.__pk_result = null;
267 window.nostr.getPublicKey()
268 .then(k => { window.__pk_result = 'ok:' + k; })
269 .catch(e => { window.__pk_result = 'err:' + e.message; });
270 """)
271 time.sleep(3)
272 approve_prompt(driver, main_handle, By)
273 time.sleep(3)
274 pk = driver.execute_script("return window.__pk_result")
275 if not pk:
276 approve_prompt(driver, main_handle, By)
277 time.sleep(3)
278 pk = driver.execute_script("return window.__pk_result")
279 check("getPublicKey", pk and pk.startswith("ok:"), pk)
280
281 # Extract pubkey hex for later
282 user_pubkey = pk.split(":", 1)[1] if pk and pk.startswith("ok:") else None
283
284 # Wait for SW registration
285 step("Wait for service workers")
286 for _ in range(10):
287 sw_ready = driver.execute_script("""
288 return navigator.serviceWorker && navigator.serviceWorker.controller ? 'ready' : 'waiting';
289 """)
290 if sw_ready == 'ready':
291 break
292 time.sleep(2)
293 print(f" SW controller: {sw_ready}")
294 check("service worker active", sw_ready == 'ready')
295
296 # Check if SW iframes are present (relay SW, marmot SW)
297 iframes = driver.execute_script("""
298 var frames = document.querySelectorAll('iframe[id^="sw-iframe"]');
299 return Array.from(frames).map(f => f.id + ' src=' + f.src);
300 """)
301 print(f" SW iframes: {iframes}")
302
303 relay_url = args.relay_url
304 step(f"MLS init (relay: {relay_url})")
305 # First, tell the shell SW about relay URLs (it needs them for MLS_SUBSCRIBE routing)
306 driver.execute_script(f"""
307 if (navigator.serviceWorker && navigator.serviceWorker.controller) {{
308 navigator.serviceWorker.controller.postMessage('["MLS_INIT",["{relay_url}"]]');
309 }}
310 """)
311 time.sleep(1)
312
313 # Wait for window.nostr.mls to be available (content script injection can lag)
314 for _ in range(10):
315 has_mls = driver.execute_script("return !!(window.nostr && window.nostr.mls)")
316 if has_mls:
317 break
318 time.sleep(1)
319
320 # Retry init up to 3 times — extension port can be stale on first attempt
321 result = None
322 for attempt in range(3):
323 driver.execute_script(f"""
324 window.__mls_init = null;
325 if (!window.nostr || !window.nostr.mls) {{
326 window.__mls_init = 'err:no mls'; return;
327 }}
328 window.nostr.mls.init(['{relay_url}'], 0)
329 .then(r => {{ window.__mls_init = 'ok:' + r; }})
330 .catch(e => {{ window.__mls_init = 'err:' + e.message; }});
331 """)
332 for _ in range(10):
333 time.sleep(2)
334 approve_prompt(driver, main_handle, By)
335 result = driver.execute_script("return window.__mls_init")
336 if result:
337 break
338 if result and result.startswith("ok:"):
339 break
340 if result and "Receiving end" in result:
341 print(f" mls.init attempt {attempt+1}: port stale, retrying...")
342 time.sleep(2)
343 continue
344 break
345 check("mls.init", result and result.startswith("ok:"), result)
346
347 step("MLS publish key package")
348 driver.execute_script("""
349 window.__mls_kp = null;
350 if (!window.nostr || !window.nostr.mls) {
351 window.__mls_kp = 'err:no mls'; return;
352 }
353 window.nostr.mls.publishKP()
354 .then(r => { window.__mls_kp = 'ok:' + r; })
355 .catch(e => { window.__mls_kp = 'err:' + e.message; });
356 """)
357 for _ in range(10):
358 time.sleep(2)
359 approve_prompt(driver, main_handle, By)
360 result = driver.execute_script("return window.__mls_kp")
361 if result:
362 break
363 check("mls.publishKP", result and result.startswith("ok:"), result)
364
365 step("MLS subscribe (start subscription loop)")
366 driver.execute_script("""
367 window.__mls_sub = null;
368 if (!window.nostr || !window.nostr.mls) {
369 window.__mls_sub = 'err:no mls'; return;
370 }
371 window.nostr.mls.subscribe()
372 .then(r => { window.__mls_sub = 'ok:' + (r || 'started'); })
373 .catch(e => { window.__mls_sub = 'err:' + e.message; });
374 """)
375 for _ in range(5):
376 time.sleep(1)
377 approve_prompt(driver, main_handle, By)
378 result = driver.execute_script("return window.__mls_sub")
379 if result:
380 break
381 check("mls.subscribe", result and result.startswith("ok:"), result)
382
383 # Bridge pubkey is locked — same nsec persists across test runs.
384 bridge_hex = BRIDGE_HEX
385 print(f" bridge pubkey: {bridge_hex}")
386
387 # Set up MLS status listener before sending
388 driver.execute_script("""
389 window.__mls_statuses = [];
390 window.__mls_dms = [];
391 window.addEventListener('nostr-mls', (e) => {
392 const d = e.detail;
393 if (d && d.cmd === 'status') {
394 window.__mls_statuses.push(d.msg);
395 console.log('[e2e] MLS status: ' + d.msg);
396 }
397 if (d && d.cmd === 'dm') {
398 window.__mls_dms.push(d);
399 console.log('[e2e] MLS DM received: ' + JSON.stringify(d));
400 }
401 });
402 """)
403
404 # Also monitor console for MLS-related messages
405 driver.execute_script("""
406 window.__console_logs = [];
407 const origLog = console.log;
408 const origWarn = console.warn;
409 const origErr = console.error;
410 function capture(level, args) {
411 var msg = Array.from(args).map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
412 if (msg.includes('mls') || msg.includes('MLS') || msg.includes('marmot') || msg.includes('subscribe') || msg.includes('[signer]') || msg.includes('relay') || msg.includes('publish')) {
413 window.__console_logs.push(level + ': ' + msg);
414 }
415 }
416 console.log = function() { capture('LOG', arguments); origLog.apply(console, arguments); };
417 console.warn = function() { capture('WARN', arguments); origWarn.apply(console, arguments); };
418 console.error = function() { capture('ERR', arguments); origErr.apply(console, arguments); };
419 """)
420
421 # ── Helper: send DM and wait for reply ──
422 def send_and_wait(msg, label, timeout_rounds=20):
423 """Send a DM to bridge and wait for a reply. Returns reply content or None."""
424 escaped = msg.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n").replace("\r", "")
425 driver.execute_script("window.__mls_dms = []; window.__mls_statuses = [];")
426 driver.execute_script(f"""
427 window.__mls_send = null;
428 window.nostr.mls.sendDM('{bridge_hex}', '{escaped}')
429 .then(r => {{ window.__mls_send = 'ok:' + r; }})
430 .catch(e => {{ window.__mls_send = 'err:' + e.message; }});
431 """)
432 for _ in range(15):
433 time.sleep(2)
434 approve_prompt(driver, main_handle, By)
435 result = driver.execute_script("return window.__mls_send")
436 if result:
437 break
438 if not (result and result.startswith("ok:")):
439 check(f"sendDM({label})", False, result)
440 return None
441 # Wait for reply DM
442 for i in range(timeout_rounds):
443 time.sleep(2)
444 approve_prompt(driver, main_handle, By)
445 dms = driver.execute_script("return window.__mls_dms")
446 if dms:
447 break
448 if dms:
449 content = dms[0].get("content", "")
450 print(f" ← reply: {content[:120]}")
451 return content
452 return None
453
454 # ── Diagnostic: check bus state ──
455 bus_state = driver.execute_script("""
456 var state = {};
457 state.busPorts = window._busPorts ? Object.keys(window._busPorts) : [];
458 state.swController = !!navigator.serviceWorker.controller;
459 state.consoleLogs = (window.__console_logs || []).slice(-20);
460 return state;
461 """)
462 print(f" bus ports: {bus_state.get('busPorts', [])}")
463 print(f" SW controller: {bus_state.get('swController')}")
464 for lg in bus_state.get('consoleLogs', []):
465 print(f" {lg[:120]}")
466
467 # ── 1. Initial status (before subscription) ──
468 step("Send 'status' to bridge (before subscribe)")
469 reply = send_and_wait("status", "status")
470 # Dump console logs after first sendDM attempt
471 logs = driver.execute_script("return (window.__console_logs || []).slice(-30)")
472 if logs:
473 print(f" console logs ({len(logs)}):")
474 for lg in logs:
475 print(f" {lg[:140]}")
476 # On first contact the bridge sends a welcome/help text THEN the status reply.
477 # Collect all DMs and check across them.
478 all_dms = driver.execute_script("return window.__mls_dms") or []
479 all_replies = " ".join(d.get("content", "") for d in all_dms).lower()
480 check("status reply received", reply is not None, "no reply")
481 if reply:
482 check("status shows no subscription",
483 "no active subscription" in all_replies
484 or "no subscription" in all_replies
485 or "expired" in all_replies
486 or "marmot email bridge" in all_replies, # welcome text = first contact, no sub
487 reply[:80])
488
489 # ── 2. Subscribe ──
490 step("Send 'subscribe' to bridge")
491 reply = send_and_wait("subscribe", "subscribe")
492 check("subscribe reply received", reply is not None, "no reply")
493 if reply:
494 check("subscription activated",
495 "active" in reply.lower() or "payment received" in reply.lower()
496 or "expires" in reply.lower(), reply[:120])
497
498 # ── 3. Status after subscribe ──
499 step("Send 'status' to bridge (after subscribe)")
500 reply = send_and_wait("status", "status-after")
501 check("status reply received (2)", reply is not None, "no reply")
502 if reply:
503 check("status shows active", "active" in reply.lower() or "expires" in reply.lower(), reply[:80])
504
505 # ── 4. Send email to self via bridge ──
506 step("Send email to self via bridge")
507 # Build npub from user_pubkey for the email address
508 npub_addr = driver.execute_script("""
509 // Bech32-encode the pubkey for the email address.
510 // Minimal bech32 encoder for npub.
511 function bech32Encode(hrp, data) {
512 const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
513 const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
514 function polymod(values) {
515 let chk = 1;
516 for (const v of values) {
517 const b = chk >> 25;
518 chk = ((chk & 0x1ffffff) << 5) ^ v;
519 for (let i = 0; i < 5; i++) { if ((b >> i) & 1) chk ^= GEN[i]; }
520 }
521 return chk;
522 }
523 function hrpExpand(h) {
524 const r = [];
525 for (let i = 0; i < h.length; i++) r.push(h.charCodeAt(i) >> 5);
526 r.push(0);
527 for (let i = 0; i < h.length; i++) r.push(h.charCodeAt(i) & 31);
528 return r;
529 }
530 function convertBits(data, fromBits, toBits, pad) {
531 let acc = 0, bits = 0;
532 const ret = [];
533 const maxv = (1 << toBits) - 1;
534 for (const v of data) {
535 acc = (acc << fromBits) | v;
536 bits += fromBits;
537 while (bits >= toBits) { bits -= toBits; ret.push((acc >> bits) & maxv); }
538 }
539 if (pad && bits > 0) ret.push((acc << (toBits - bits)) & maxv);
540 return ret;
541 }
542 const bytes = [];
543 for (let i = 0; i < data.length; i += 2) bytes.push(parseInt(data.substr(i, 2), 16));
544 const words = convertBits(bytes, 8, 5, true);
545 const chkData = hrpExpand(hrp).concat(words).concat([0,0,0,0,0,0]);
546 const pm = polymod(chkData) ^ 1;
547 const checksum = [];
548 for (let i = 0; i < 6; i++) checksum.push((pm >> (5*(5-i))) & 31);
549 return hrp + '1' + words.concat(checksum).map(v => CHARSET[v]).join('');
550 }
551 return bech32Encode('npub', window.__pk_result.split(':')[1]);
552 """)
553 email_addr = f"{npub_addr}@bridge.test"
554 print(f" email: {email_addr[:40]}...")
555
556 email_body = f"To: {email_addr}\nSubject: E2E loopback\n\nHello from smesh E2E test"
557 # Send email and collect ALL replies (confirmation + inbound loopback may arrive together)
558 escaped = email_body.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n").replace("\r", "")
559 driver.execute_script("window.__mls_dms = []; window.__mls_statuses = [];")
560 driver.execute_script(f"""
561 window.__mls_send = null;
562 window.nostr.mls.sendDM('{bridge_hex}', '{escaped}')
563 .then(r => {{ window.__mls_send = 'ok:' + r; }})
564 .catch(e => {{ window.__mls_send = 'err:' + e.message; }});
565 """)
566 for _ in range(15):
567 time.sleep(2)
568 approve_prompt(driver, main_handle, By)
569 result = driver.execute_script("return window.__mls_send")
570 if result:
571 break
572 check("email DM accepted", result and result.startswith("ok:"), result)
573
574 # ── 5. Wait for email confirmation + inbound loopback ──
575 step("Wait for email round-trip (send confirmation + SMTP loopback)")
576 # Wait for both the "Email sent" confirmation AND the inbound email DM
577 got_confirmation = False
578 got_inbound = False
579 for i in range(25):
580 time.sleep(2)
581 approve_prompt(driver, main_handle, By)
582 dms = driver.execute_script("return window.__mls_dms")
583 for dm in (dms or []):
584 c = dm.get("content", "").lower()
585 if "email sent" in c or "sent to" in c:
586 got_confirmation = True
587 print(f" ← confirmation: {dm.get('content', '')[:80]}")
588 if "from:" in c and "subject:" in c:
589 got_inbound = True
590 print(f" ← inbound email: {dm.get('content', '')[:120]}")
591 if got_confirmation and got_inbound:
592 break
593 if got_inbound and i > 5:
594 break # inbound is enough — confirmation may have been missed
595
596 check("email send confirmed or looped back", got_confirmation or got_inbound,
597 f"confirm={got_confirmation} inbound={got_inbound}")
598
599 # Check inbound email content
600 inbound_content = ""
601 dms = driver.execute_script("return window.__mls_dms") or []
602 for dm in dms:
603 c = dm.get("content", "")
604 if "from:" in c.lower() and "subject:" in c.lower():
605 inbound_content = c
606 break
607 if inbound_content:
608 check("email contains subject", "e2e loopback" in inbound_content.lower(), inbound_content[:80])
609 check("email contains body", "hello from smesh" in inbound_content.lower(), inbound_content[:80])
610 else:
611 check("inbound email received", False, "no inbound email DM found")
612
613 # ── 6. Verify chat UI shows DMs ──
614 step("Check chat UI — navigate to messages")
615 # The sidebar has icon buttons: first child = feed, second child = messages.
616 # Clicking the messages icon triggers switchPage("messaging") → initMessaging() → DM_LIST.
617 driver.execute_script("""
618 // Find the sidebar (44px wide column on the left) and click the 2nd icon button.
619 var sidebar = document.querySelector('div[style*="width: 44px"], div[style*="width:44px"]');
620 if (!sidebar) {
621 // Fallback: find by structure — narrow flex column.
622 var divs = document.querySelectorAll('div');
623 for (var d of divs) {
624 var s = d.style;
625 if (s && s.width === '44px' && s.flexShrink === '0') { sidebar = d; break; }
626 }
627 }
628 if (sidebar) {
629 var icons = sidebar.children;
630 if (icons.length >= 2) {
631 icons[1].click(); // 2nd button = messages
632 window.__sidebar_clicked = 'ok:' + icons.length;
633 } else {
634 window.__sidebar_clicked = 'err:only ' + icons.length + ' children';
635 }
636 } else {
637 window.__sidebar_clicked = 'err:sidebar not found';
638 }
639 """)
640 sidebar_result = driver.execute_script("return window.__sidebar_clicked")
641 print(f" sidebar click: {sidebar_result}")
642 time.sleep(3)
643
644 # Wait for DM_LIST response to populate conversations
645 for _ in range(5):
646 time.sleep(2)
647 convos = driver.execute_script("""
648 // The messaging page contains a list container. Conversation rows are
649 // child divs with display:flex and cursor:pointer (clickable rows).
650 // Find the visible messaging page, then look for flex row children.
651 var pages = document.querySelectorAll('div[style*="padding: 16px"]');
652 var msgPage = null;
653 for (var p of pages) {
654 if (p.style.display !== 'none' && p.style.position === 'relative') {
655 msgPage = p; break;
656 }
657 }
658 if (!msgPage) return {found: false, reason: 'no msgPage visible'};
659 // First child of msgPage is the list container.
660 var listContainer = msgPage.firstElementChild;
661 if (!listContainer) return {found: false, reason: 'no list container'};
662 // Conversation rows: DIV children with cursor:pointer (not BUTTON = "new chat").
663 var rows = [];
664 for (var ch of listContainer.children) {
665 if (ch.tagName === 'DIV' && ch.style && ch.style.cursor === 'pointer') {
666 rows.push(ch.textContent.substring(0, 80));
667 }
668 }
669 return {found: rows.length > 0, count: rows.length, rows: rows.slice(0, 5)};
670 """)
671 if convos and convos.get("found"):
672 break
673
674 if convos and convos.get("found"):
675 print(f" conversations: {convos['count']} found")
676 for r in convos.get("rows", []):
677 print(f" {r[:60]}")
678 check("conversations visible in chat UI", True)
679 else:
680 print(f" conversations: {convos}")
681 check("conversations visible in chat UI", False, str(convos))
682
683 # Open the bridge thread and verify messages
684 thread_msgs = driver.execute_script(f"""
685 // Click the first conversation row to open the thread.
686 var pages = document.querySelectorAll('div[style*="padding: 16px"]');
687 var msgPage = null;
688 for (var p of pages) {{
689 if (p.style.display !== 'none' && p.style.position === 'relative') {{
690 msgPage = p; break;
691 }}
692 }}
693 if (!msgPage) return null;
694 var listContainer = msgPage.firstElementChild;
695 if (!listContainer) return null;
696 for (var ch of listContainer.children) {{
697 if (ch.tagName === 'DIV' && ch.style && ch.style.cursor === 'pointer') {{
698 ch.click(); break;
699 }}
700 }}
701 return 'clicked';
702 """)
703 # Poll for message bubbles in the thread view (DM_HISTORY is async via bus)
704 bubbles = []
705 for attempt in range(8):
706 time.sleep(2)
707 bubbles = driver.execute_script("""
708 var pages = document.querySelectorAll('div[style*="padding: 16px"]');
709 var msgPage = null;
710 for (var p of pages) {
711 if (p.style.position === 'relative') { msgPage = p; break; }
712 }
713 if (!msgPage) return {msgs: [], debug: 'no msgPage'};
714 var threadContainer = msgPage.children[1];
715 if (!threadContainer) return {msgs: [], debug: 'no threadContainer'};
716 if (threadContainer.style.display === 'none') return {msgs: [], debug: 'thread hidden'};
717 // The message area is the flex:1 scrollable child.
718 var msgArea = null;
719 for (var ch of threadContainer.children) {
720 if (ch.style && ch.style.flex === '1') { msgArea = ch; break; }
721 }
722 if (!msgArea) {
723 // Fallback: the message area is the second child (index 1) of the thread container.
724 // flex:1 may be normalized by the browser to flex-grow/flex-shrink/flex-basis.
725 if (threadContainer.children.length >= 2) {
726 msgArea = threadContainer.children[1];
727 }
728 if (!msgArea) return {msgs: [], debug: 'no msgArea, children=' + threadContainer.children.length};
729 }
730 // Each message is a wrapper div containing a bubble div.
731 var msgs = [];
732 for (var wrapper of msgArea.children) {
733 var bubble = wrapper.firstElementChild;
734 if (bubble) {
735 var text = bubble.textContent.substring(0, 120);
736 var align = wrapper.style.justifyContent === 'flex-end' ? 'sent' : 'recv';
737 msgs.push({text: text, align: align});
738 }
739 }
740 return {msgs: msgs, debug: 'msgArea children=' + msgArea.children.length};
741 """)
742 if bubbles and bubbles.get("msgs"):
743 bubbles = bubbles["msgs"]
744 break
745 print(f" thread poll {attempt}: {bubbles.get('debug', '?')}")
746 else:
747 bubbles = []
748
749 if bubbles:
750 print(f" thread messages: {len(bubbles)} bubbles")
751 for b in bubbles[:8]:
752 print(f" [{b['align']}] {b['text'][:80]}")
753 check("messages rendered in thread", True)
754 # Verify we see bridge replies
755 has_bridge_reply = any("subscription" in b["text"].lower() or "email" in b["text"].lower()
756 or "active" in b["text"].lower() or "from:" in b["text"].lower()
757 for b in bubbles if b["align"] == "recv")
758 check("bridge replies visible", has_bridge_reply)
759 else:
760 print(f" thread messages: none rendered")
761 check("messages rendered in thread", False, "no bubbles found")
762
763
764 # ─────────────────────────────────────────────
765 # Main
766 # ─────────────────────────────────────────────
767
768 def start_latency_proxy(latency_ms, jitter_ms):
769 """Start the latency proxy as a subprocess. Returns (proc, proxy_port)."""
770 proxy_port = 3335
771 proxy_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "latency_proxy.py")
772 proc = subprocess.Popen(
773 [sys.executable, proxy_script,
774 "--listen", f"127.0.0.1:{proxy_port}",
775 "--target", "127.0.0.1:3334",
776 "--latency", str(latency_ms),
777 "--jitter", str(jitter_ms)],
778 stdout=subprocess.PIPE, stderr=subprocess.PIPE
779 )
780 time.sleep(0.5)
781 if proc.poll() is not None:
782 err = proc.stderr.read().decode()
783 print(f"latency proxy failed to start: {err}")
784 return None, None
785 print(f"latency proxy: 127.0.0.1:{proxy_port} -> 127.0.0.1:3334 ({latency_ms}ms +{jitter_ms}ms jitter)")
786 return proc, proxy_port
787
788
789 def main():
790 global passed, failed
791
792 parser = argparse.ArgumentParser()
793 parser.add_argument("--headed", action="store_true")
794 parser.add_argument("--latency", type=int, default=0,
795 help="Add network latency in ms via TCP proxy (0 = direct)")
796 parser.add_argument("--jitter", type=int, default=0,
797 help="Add random jitter in ms (requires --latency)")
798 parser.add_argument("--rounds", type=int, default=1,
799 help="Run the full test N times (stress testing)")
800 args = parser.parse_args()
801
802 proxy_proc = None
803 if args.latency > 0:
804 # Check if proxy is already running (started by test-local.sh)
805 import socket as _sock
806 s = _sock.socket(_sock.AF_INET, _sock.SOCK_STREAM)
807 try:
808 s.connect(("127.0.0.1", 3335))
809 s.close()
810 print("latency proxy already running on :3335")
811 args.relay_url = "ws://127.0.0.1:3335"
812 except ConnectionRefusedError:
813 s.close()
814 proxy_proc, proxy_port = start_latency_proxy(args.latency, args.jitter)
815 if proxy_proc is None:
816 sys.exit(1)
817 args.relay_url = f"ws://127.0.0.1:{proxy_port}"
818 else:
819 args.relay_url = "ws://127.0.0.1:3334"
820
821 try:
822 for rnd in range(args.rounds):
823 if args.rounds > 1:
824 print(f"\n{'#'*50}")
825 print(f"# Round {rnd+1}/{args.rounds}")
826 print(f"{'#'*50}")
827 run_browser_tests(args)
828 except Exception as e:
829 print(f"\nTEST ERROR: {e}")
830 import traceback; traceback.print_exc()
831 failed += 1
832 finally:
833 if proxy_proc:
834 proxy_proc.terminate()
835 proxy_proc.wait()
836
837 # Summary
838 total = passed + failed
839 print(f"\n{'='*40}")
840 print(f"Results: {passed}/{total} passed, {failed} failed")
841 if args.latency > 0:
842 print(f"Latency: {args.latency}ms + {args.jitter}ms jitter")
843 print(f"{'='*40}")
844 sys.exit(1 if failed else 0)
845
846
847 if __name__ == "__main__":
848 main()
849