conftest.py raw

   1  """Shared fixtures for smesh Selenium tests.
   2  
   3  Test regime uses 127.0.0.x loopback addresses:
   4    127.0.0.1:3334  — relay (WebSocket + static files)
   5    127.0.0.2:3334  — reserved (future: second relay for sync tests)
   6  
   7  All addresses in 127.0.0.0/8 are routable on Linux loopback by default.
   8  """
   9  
  10  import json
  11  import os
  12  import signal
  13  import subprocess
  14  import time
  15  
  16  import pytest
  17  from selenium import webdriver
  18  from selenium.webdriver.firefox.options import Options
  19  from selenium.webdriver.firefox.service import Service
  20  from selenium.webdriver.common.by import By
  21  from selenium.webdriver.support.ui import WebDriverWait
  22  from selenium.webdriver.support import expected_conditions as EC
  23  
  24  PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  25  # Prefer moxie-built binary; fall back to older names.
  26  _bins = ["smesh", "smesh-moxie", "smesh-test"]
  27  RELAY_BIN = next((os.path.join(PROJECT_ROOT, b) for b in _bins
  28                     if os.path.exists(os.path.join(PROJECT_ROOT, b))), "")
  29  STATIC_DIR = os.path.join(PROJECT_ROOT, "web", "static")
  30  EXT_DIR = os.path.join(PROJECT_ROOT, "web", "ext")
  31  XPI_PATH = os.path.join(PROJECT_ROOT, "dist", "smesh-signer.xpi")
  32  DATA_DIR = "/tmp/smesh-test-data"
  33  
  34  RELAY_HOST = "127.0.0.1"
  35  RELAY_PORT = 3334
  36  RELAY_URL = f"http://{RELAY_HOST}:{RELAY_PORT}"
  37  WS_URL = f"ws://{RELAY_HOST}:{RELAY_PORT}"
  38  
  39  VAULT_PASSWORD = "testpass123"
  40  EXT_ID = "signer@smesh.lol"
  41  
  42  
  43  def _build_xpi():
  44      """Pack the signer extension into an .xpi for Firefox."""
  45      os.makedirs(os.path.dirname(XPI_PATH), exist_ok=True)
  46      # Check required files exist.
  47      manifest = os.path.join(EXT_DIR, "manifest.json")
  48      if not os.path.exists(manifest):
  49          pytest.skip(f"Extension not found at {EXT_DIR}")
  50      bg_entry = os.path.join(EXT_DIR, "bg", "$entry.mjs")
  51      if not os.path.exists(bg_entry):
  52          pytest.skip("Extension background not compiled — run: make build-signer-bg")
  53      subprocess.run(
  54          ["zip", "-r", XPI_PATH, "."],
  55          cwd=EXT_DIR, capture_output=True, check=True,
  56      )
  57  
  58  
  59  def _get_ext_uuid(profile_path):
  60      """Extract the internal UUID Firefox assigned to the extension."""
  61      import re
  62      prefs = os.path.join(profile_path, "prefs.js")
  63      for _ in range(15):
  64          if os.path.exists(prefs):
  65              with open(prefs) as f:
  66                  for line in f:
  67                      if "webextensions.uuids" not in line:
  68                          continue
  69                      m = re.search(r'"(\{.+\})"', line)
  70                      if not m:
  71                          continue
  72                      raw = m.group(1).replace('\\"', '"')
  73                      uuids = json.loads(raw)
  74                      if EXT_ID in uuids:
  75                          return uuids[EXT_ID]
  76          time.sleep(1)
  77      return None
  78  
  79  
  80  # ── Fixtures ──────────────────────────────────────
  81  
  82  
  83  @pytest.fixture(scope="session")
  84  def relay():
  85      """Start the smesh relay for the test session."""
  86      if not os.path.exists(RELAY_BIN):
  87          pytest.skip(f"Relay binary not found — run: make build-relay")
  88  
  89      os.makedirs(DATA_DIR, exist_ok=True)
  90      env = os.environ.copy()
  91      env["SMESH_DATA_DIR"] = DATA_DIR
  92      env["SMESH_LISTEN"] = f"{RELAY_HOST}:{RELAY_PORT}"
  93      env["SMESH_STATIC_DIR"] = STATIC_DIR
  94  
  95      proc = subprocess.Popen(
  96          [RELAY_BIN],
  97          env=env,
  98          stdout=subprocess.PIPE,
  99          stderr=subprocess.PIPE,
 100      )
 101  
 102      # Wait for relay to be ready.
 103      for _ in range(30):
 104          try:
 105              import urllib.request
 106              urllib.request.urlopen(RELAY_URL, timeout=1)
 107              break
 108          except Exception:
 109              time.sleep(0.2)
 110      else:
 111          proc.kill()
 112          pytest.fail("Relay failed to start within 6 seconds")
 113  
 114      info = {
 115          "proc": proc,
 116          "url": RELAY_URL,
 117          "ws": WS_URL,
 118          "host": RELAY_HOST,
 119          "port": RELAY_PORT,
 120      }
 121      yield info
 122  
 123      # Log relay stderr if it died.
 124      if proc.poll() is not None:
 125          stderr = proc.stderr.read().decode(errors="replace")
 126          if stderr:
 127              print(f"\n=== RELAY STDERR ===\n{stderr}\n=== END ===\n")
 128  
 129      proc.send_signal(signal.SIGTERM)
 130      try:
 131          proc.wait(timeout=5)
 132      except subprocess.TimeoutExpired:
 133          proc.kill()
 134  
 135  
 136  @pytest.fixture(scope="session")
 137  def xpi():
 138      """Build the signer .xpi and return its path."""
 139      _build_xpi()
 140      return XPI_PATH
 141  
 142  
 143  @pytest.fixture(scope="session")
 144  def browser(request):
 145      """Create a Firefox WebDriver instance for the session."""
 146      opts = Options()
 147      if not request.config.getoption("--headed", default=False):
 148          opts.add_argument("--headless")
 149      opts.set_preference("xpinstall.signatures.required", False)
 150      opts.set_preference("extensions.autoDisableScopes", 0)
 151      opts.set_preference("devtools.console.stdout.content", True)
 152  
 153      svc = Service(log_output="/dev/null")
 154      driver = webdriver.Firefox(options=opts, service=svc)
 155      driver.set_script_timeout(30)
 156      driver.implicitly_wait(3)
 157  
 158      yield driver
 159      driver.quit()
 160  
 161  
 162  @pytest.fixture(scope="session")
 163  def ext(browser, xpi):
 164      """Install the signer extension and return its moz-extension:// base URL."""
 165      browser.install_addon(xpi, temporary=True)
 166      time.sleep(3)
 167      profile = browser.capabilities.get("moz:profile", "")
 168      uuid = _get_ext_uuid(profile)
 169      if not uuid:
 170          pytest.fail("Could not extract extension UUID from Firefox profile")
 171      return f"moz-extension://{uuid}"
 172  
 173  
 174  # ── Helpers available to all tests ────────────────
 175  
 176  
 177  class Helpers:
 178      """Utility methods injected into tests via the `h` fixture."""
 179  
 180      def __init__(self, driver):
 181          self.driver = driver
 182  
 183      def wait_for(self, locator, timeout=10):
 184          return WebDriverWait(self.driver, timeout).until(
 185              EC.presence_of_element_located(locator)
 186          )
 187  
 188      def click(self, locator, timeout=10):
 189          el = WebDriverWait(self.driver, timeout).until(
 190              EC.element_to_be_clickable(locator)
 191          )
 192          el.click()
 193          return el
 194  
 195      def js(self, script, *args):
 196          return self.driver.execute_script(script, *args)
 197  
 198      def js_async(self, script, *args, timeout=10):
 199          """Run async JS: script must call arguments[arguments.length-1](result)."""
 200          self.driver.set_script_timeout(timeout)
 201          return self.driver.execute_async_script(script, *args)
 202  
 203      def console_logs(self):
 204          """Return browser console log entries (requires devtools.console.stdout.content)."""
 205          try:
 206              return self.driver.get_log("browser")
 207          except Exception:
 208              return []
 209  
 210      def has_nostr(self):
 211          return self.js("return typeof window.nostr !== 'undefined'")
 212  
 213      def has_smesh(self):
 214          return self.js("return !!(window.nostr && window.nostr.smesh)")
 215  
 216      def get_public_key(self):
 217          return self.js_async("""
 218              var cb = arguments[arguments.length - 1];
 219              window.nostr.getPublicKey()
 220                  .then(function(k) { cb(k); })
 221                  .catch(function(e) { cb(null); });
 222          """)
 223  
 224      def vault_status(self):
 225          return self.js_async("""
 226              var cb = arguments[arguments.length - 1];
 227              window.nostr.smesh.getVaultStatus()
 228                  .then(function(s) { cb(s); })
 229                  .catch(function(e) { cb(null); });
 230          """)
 231  
 232  
 233  @pytest.fixture
 234  def h(browser):
 235      return Helpers(browser)
 236  
 237  
 238  # ── CLI options ───────────────────────────────────
 239  
 240  
 241  def pytest_addoption(parser):
 242      parser.addoption(
 243          "--headed", action="store_true", default=False,
 244          help="Run Firefox in headed (visible) mode",
 245      )
 246