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