e2e_marmot.py raw

   1  #!/usr/bin/env python3
   2  """Playwright tests for Marmot MLS DM system.
   3  
   4  Tests the CryptoProvider proxy chain, NIP-07 extension signing,
   5  and end-to-end DM round-trips through the real browser stack.
   6  
   7  Usage:
   8      python3 test/e2e_marmot.py [--headed] [--nip-io]
   9  
  10  Requires:
  11      - Local server running on :8090 (smesh.test or nip.io)
  12      - /etc/hosts entry OR --nip-io flag for nip.io DNS
  13      - pip install playwright && playwright install chromium
  14  """
  15  
  16  import argparse
  17  import json
  18  import os
  19  import sys
  20  import time
  21  from pathlib import Path
  22  from playwright.sync_api import sync_playwright, expect
  23  
  24  # Test credentials — same as e2e.py.
  25  TEST_SK = "328615b6c92aa6527fc175a67670222daabc69fa2b84c2ded5f6907f78f2b0f8"
  26  TEST_PK = "5dbeb1d7e84d0a4fb3fac47868438dc9135b35f25e27ae933e306e3584bf69a8"
  27  
  28  EXT_DIR = str(Path(__file__).parent / "extension")
  29  LOG_FILE = "/tmp/browser-debug.log"
  30  
  31  
  32  def make_urls(mode):
  33      if mode == "nip-io":
  34          base = "http://smesh.127.0.0.1.nip.io:8090"
  35          marmot = "http://marmot.smesh.127.0.0.1.nip.io:8090"
  36          relay = "http://relay.smesh.127.0.0.1.nip.io:8090"
  37      elif mode == "localhost":
  38          base = "http://smesh.localhost:8090"
  39          marmot = "http://marmot.smesh.localhost:8090"
  40          relay = "http://relay.smesh.localhost:8090"
  41      else:
  42          base = "http://smesh.test:8090"
  43          marmot = "http://marmot.smesh.test:8090"
  44          relay = "http://relay.smesh.test:8090"
  45      return base, marmot, relay
  46  
  47  
  48  class TestResult:
  49      def __init__(self):
  50          self.results = {}
  51          self.errors = []
  52  
  53      def check(self, name, passed, detail=""):
  54          self.results[name] = passed
  55          if not passed and detail:
  56              self.errors.append(f"{name}: {detail}")
  57  
  58      def report(self):
  59          ok = True
  60          for name, passed in self.results.items():
  61              status = "PASS" if passed else "FAIL"
  62              print(f"  {status}: {name}")
  63              if not passed:
  64                  ok = False
  65          if self.errors:
  66              print("\n--- ERRORS ---")
  67              for e in self.errors:
  68                  print(f"  {e}")
  69          return ok
  70  
  71  
  72  def clear_log():
  73      try:
  74          open(LOG_FILE, "w").close()
  75      except OSError:
  76          pass
  77  
  78  
  79  def read_log():
  80      try:
  81          with open(LOG_FILE) as f:
  82              return f.read()
  83      except FileNotFoundError:
  84          return ""
  85  
  86  
  87  def test_nsec_bus_connectivity(p, base, marmot_url, relay_url):
  88      """Test 1: nsec auth — SWs connect to bus, app renders."""
  89      r = TestResult()
  90  
  91      browser = p.chromium.launch(headless=True)
  92      ctx = browser.new_context()
  93      page = ctx.new_page()
  94  
  95      logs = []
  96      page.on("console", lambda m: logs.append(f"{m.type}: {m.text}"))
  97  
  98      page.add_init_script(f"""
  99      localStorage.setItem('smesh-key', '{TEST_SK}');
 100      localStorage.setItem('smesh-pubkey', '{TEST_PK}');
 101      localStorage.setItem('smesh-mode', 'nsec');
 102      """)
 103  
 104      clear_log()
 105      page.goto(base, wait_until="networkidle", timeout=30000)
 106      time.sleep(8)
 107  
 108      server_logs = read_log()
 109  
 110      r.check("page_loaded", page.title() != "")
 111      r.check("app_render", page.evaluate(
 112          "document.querySelector('#app-root') !== null"
 113      ))
 114      r.check("shell_bus", "[shell]" in server_logs and "bus connected" in server_logs)
 115      r.check("relay_bus", "[relay]" in server_logs)
 116      r.check("marmot_bus", "[marmot]" in server_logs)
 117  
 118      # Check for JS errors (excluding known benign ones).
 119      js_errors = [
 120          l for l in logs
 121          if "error" in l.lower()
 122          and "sw-diag" not in l
 123          and "favicon" not in l.lower()
 124          and "ERR_INSUFFICIENT_RESOURCES" not in l
 125      ]
 126      r.check("no_js_errors", len(js_errors) == 0,
 127              f"{len(js_errors)} errors: {js_errors[:3]}")
 128  
 129      browser.close()
 130      return r
 131  
 132  
 133  def test_nip07_auth(p, base, marmot_url, relay_url):
 134      """Test 2: NIP-07 extension auth — signEvent via crypto proxy chain."""
 135      r = TestResult()
 136  
 137      # Clean persistent context so SW re-installs and onActivate fires
 138      # (which establishes bus connection). Without this, a cached SW from
 139      # a previous run skips activation and the bus never connects.
 140      import shutil
 141      shutil.rmtree("/tmp/pw-chrome-nip07", ignore_errors=True)
 142  
 143      # Extensions require headed mode + persistent context in Playwright.
 144      ctx = p.chromium.launch_persistent_context(
 145          "/tmp/pw-chrome-nip07",
 146          headless=False,
 147          args=[
 148              f"--disable-extensions-except={EXT_DIR}",
 149              f"--load-extension={EXT_DIR}",
 150          ],
 151      )
 152      page = ctx.new_page()
 153  
 154      logs = []
 155      page.on("console", lambda m: logs.append(f"{m.type}: {m.text}"))
 156  
 157      # Don't inject nsec — let the app use NIP-07 mode.
 158      page.add_init_script(f"""
 159      localStorage.setItem('smesh-mode', 'extension');
 160      localStorage.setItem('smesh-pubkey', '{TEST_PK}');
 161      localStorage.removeItem('smesh-key');
 162      """)
 163  
 164      clear_log()
 165      page.goto(base, wait_until="networkidle", timeout=30000)
 166  
 167      # Wait for shell SW bus connection before sending messages.
 168      for _ in range(10):
 169          time.sleep(1)
 170          if "bus connected" in read_log():
 171              break
 172  
 173      # Check that the test signer injected window.nostr.
 174      has_nostr = page.evaluate("typeof window.nostr !== 'undefined'")
 175      r.check("window_nostr_present", has_nostr)
 176  
 177      # Check that getPublicKey returns our test key.
 178      if has_nostr:
 179          pk = page.evaluate("window.nostr.getPublicKey()")
 180          r.check("correct_pubkey", pk == TEST_PK, f"got {pk}")
 181      else:
 182          r.check("correct_pubkey", False, "no window.nostr")
 183  
 184      # Check that the test signer log appeared.
 185      signer_log = any("[test-signer]" in l for l in logs)
 186      r.check("signer_injected", signer_log)
 187  
 188      # Wait for SW controller, then send pubkey + MLS init to trigger
 189      # the marmot WS connection and NIP-07 proxy auth chain.
 190      page.evaluate(f"""
 191      (async () => {{
 192          // Wait for SW controller.
 193          if (!navigator.serviceWorker.controller) {{
 194              await new Promise(resolve => {{
 195                  navigator.serviceWorker.addEventListener('controllerchange', resolve);
 196                  setTimeout(resolve, 3000);
 197              }});
 198          }}
 199          const sw = navigator.serviceWorker.controller;
 200          if (!sw) return;
 201          sw.postMessage('["SET_PUBKEY","{TEST_PK}"]');
 202          await new Promise(r => setTimeout(r, 500));
 203          sw.postMessage('["MLS_INIT",["wss://relay.orly.dev"]]');
 204      }})()
 205      """)
 206      # Poll for marmot auth (SW lifecycle isn't instantaneous).
 207      marmot_auth = False
 208      for _ in range(10):
 209          time.sleep(1)
 210          if "marmot-sw: authenticated" in read_log():
 211              marmot_auth = True
 212              break
 213  
 214      server_logs = read_log()
 215      r.check("marmot_authenticated", marmot_auth,
 216              "no 'marmot-sw: authenticated' in server logs")
 217  
 218      # Debug: dump console logs on failure.
 219      if not marmot_auth:
 220          print("  --- console logs ---")
 221          for l in logs:
 222              print(f"    {l}")
 223          print(f"  --- server logs ---")
 224          for line in server_logs.strip().split("\n")[-10:]:
 225              print(f"    {line}")
 226  
 227      ctx.close()
 228      return r
 229  
 230  
 231  def test_marmot_ws_protocol(p, base, marmot_url, relay_url):
 232      """Test 3: Direct marmot WS protocol — nsec auth, status, reset."""
 233      r = TestResult()
 234  
 235      browser = p.chromium.launch(headless=True)
 236      ctx = browser.new_context()
 237      page = ctx.new_page()
 238  
 239      page.goto(base, wait_until="networkidle", timeout=30000)
 240      time.sleep(2)
 241  
 242      # Open a direct WebSocket to the marmot endpoint.
 243      ws_results = page.evaluate(f"""
 244      async () => {{
 245          const results = {{}};
 246          const ws = new WebSocket('{base.replace("http", "ws")}/__marmot');
 247          await new Promise((resolve, reject) => {{
 248              ws.onopen = resolve;
 249              ws.onerror = reject;
 250              setTimeout(reject, 5000);
 251          }});
 252  
 253          function sendAndWait(msg, timeout = 5000) {{
 254              return new Promise((resolve, reject) => {{
 255                  const timer = setTimeout(() => reject('timeout'), timeout);
 256                  ws.onmessage = (e) => {{
 257                      clearTimeout(timer);
 258                      resolve(JSON.parse(e.data));
 259                  }};
 260                  ws.send(JSON.stringify(msg));
 261              }});
 262          }}
 263  
 264          // Auth with nsec.
 265          const auth = await sendAndWait({{
 266              method: 'auth',
 267              nsec: '{TEST_SK}',
 268          }});
 269          results.auth_ok = auth.ok === true;
 270          results.auth_method = auth.method;
 271  
 272          // Status check.
 273          const status = await sendAndWait({{ method: 'status' }});
 274          results.status_method = status.method;
 275          results.status_pubkey = status.pubkey || '';
 276  
 277          // Reset.
 278          const reset = await sendAndWait({{ method: 'reset' }});
 279          results.reset_ok = reset.ok === true;
 280  
 281          ws.close();
 282          return results;
 283      }}
 284      """)
 285  
 286      r.check("ws_auth", ws_results.get("auth_ok", False))
 287      r.check("ws_auth_method", ws_results.get("auth_method") == "auth")
 288      r.check("ws_status", ws_results.get("status_method") == "status")
 289      r.check("ws_status_pubkey", ws_results.get("status_pubkey") == TEST_PK,
 290              f"got {ws_results.get('status_pubkey', '')}")
 291      r.check("ws_reset", ws_results.get("reset_ok", False))
 292  
 293      browser.close()
 294      return r
 295  
 296  
 297  def test_crypto_req_signEvent(p, base, marmot_url, relay_url):
 298      """Test 4: crypto_req/crypto_resp for signEvent via WS.
 299  
 300      Opens a marmot WS in pubkey+sig mode, verifies the backend sends
 301      crypto_req for operations and accepts crypto_resp.
 302      """
 303      r = TestResult()
 304  
 305      import shutil
 306      shutil.rmtree("/tmp/pw-chrome-crypto", ignore_errors=True)
 307  
 308      # Extensions require headed mode + persistent context in Playwright.
 309      ctx = p.chromium.launch_persistent_context(
 310          "/tmp/pw-chrome-crypto",
 311          headless=False,
 312          args=[
 313              f"--disable-extensions-except={EXT_DIR}",
 314              f"--load-extension={EXT_DIR}",
 315          ],
 316      )
 317      page = ctx.new_page()
 318  
 319      logs = []
 320      page.on("console", lambda m: logs.append(f"{m.type}: {m.text}"))
 321  
 322      page.add_init_script(f"""
 323      localStorage.setItem('smesh-mode', 'extension');
 324      localStorage.setItem('smesh-pubkey', '{TEST_PK}');
 325      localStorage.removeItem('smesh-key');
 326      """)
 327  
 328      clear_log()
 329      page.goto(base, wait_until="networkidle", timeout=30000)
 330  
 331      # Wait for shell SW bus connection before sending messages.
 332      for _ in range(10):
 333          time.sleep(1)
 334          if "bus connected" in read_log():
 335              break
 336  
 337      # Trigger marmot auth via SW messages.
 338      page.evaluate(f"""
 339      (async () => {{
 340          if (!navigator.serviceWorker.controller) {{
 341              await new Promise(resolve => {{
 342                  navigator.serviceWorker.addEventListener('controllerchange', resolve);
 343                  setTimeout(resolve, 3000);
 344              }});
 345          }}
 346          const sw = navigator.serviceWorker.controller;
 347          if (!sw) return;
 348          sw.postMessage('["SET_PUBKEY","{TEST_PK}"]');
 349          await new Promise(r => setTimeout(r, 500));
 350          sw.postMessage('["MLS_INIT",["wss://relay.orly.dev"]]');
 351      }})()
 352      """)
 353  
 354      # Wait for marmot auth (poll log instead of fixed sleep).
 355      auth_ok = False
 356      for _ in range(10):
 357          time.sleep(1)
 358          if "marmot-sw: authenticated" in read_log():
 359              auth_ok = True
 360              break
 361  
 362      server_logs = read_log()
 363  
 364      r.check("crypto_proxy_auth", auth_ok,
 365              "NIP-07 crypto proxy auth chain failed")
 366  
 367      # Check that crypto_req messages were sent.
 368      crypto_logs = [l for l in logs if "crypto" in l.lower()]
 369      r.check("crypto_activity", len(crypto_logs) > 0 or auth_ok,
 370              "no crypto activity in logs")
 371  
 372      ctx.close()
 373      return r
 374  
 375  
 376  def main():
 377      parser = argparse.ArgumentParser()
 378      parser.add_argument("--headed", action="store_true",
 379                         help="Run in headed mode (visible browser)")
 380      parser.add_argument("--nip-io", action="store_true",
 381                         help="Use nip.io DNS instead of localhost")
 382      parser.add_argument("--hosts", action="store_true",
 383                         help="Use /etc/hosts (smesh.test) instead of localhost")
 384      parser.add_argument("--test", type=str, default="all",
 385                         help="Run specific test (nsec,nip07,ws,crypto,all)")
 386      args = parser.parse_args()
 387  
 388      mode = "localhost"
 389      if args.nip_io:
 390          mode = "nip-io"
 391      elif args.hosts:
 392          mode = "hosts"
 393      base, marmot_url, relay_url = make_urls(mode)
 394  
 395      tests = {
 396          "ws": ("marmot_ws_protocol", test_marmot_ws_protocol),
 397          "nip07": ("nip07_auth", test_nip07_auth),
 398          "crypto": ("crypto_req_signEvent", test_crypto_req_signEvent),
 399          "nsec": ("nsec_bus_connectivity", test_nsec_bus_connectivity),
 400      }
 401  
 402      if args.test == "all":
 403          run = list(tests.keys())
 404      else:
 405          run = [t.strip() for t in args.test.split(",")]
 406  
 407      all_ok = True
 408      with sync_playwright() as p:
 409          for i, key in enumerate(run):
 410              if key not in tests:
 411                  print(f"Unknown test: {key}")
 412                  continue
 413              if i > 0:
 414                  time.sleep(2)  # settle between tests
 415              name, fn = tests[key]
 416              print(f"\n=== {name} ===")
 417              result = fn(p, base, marmot_url, relay_url)
 418              if not result.report():
 419                  all_ok = False
 420  
 421      print(f"\n{'ALL PASSED' if all_ok else 'SOME FAILED'}")
 422      sys.exit(0 if all_ok else 1)
 423  
 424  
 425  if __name__ == "__main__":
 426      main()
 427