conftest.py raw

   1  """Fixtures for the musiquay smoke suite.
   2  
   3  The relay binary lives in ../morly and serves this repo's web/static; the app
   4  is driven with Playwright (Chromium). Nothing here is mocked: the vault is
   5  created through the real signer worker and the page is reloaded so the app's
   6  own auto-login path puts it in the shell.
   7  """
   8  
   9  import os
  10  import re
  11  import shutil
  12  import signal
  13  import subprocess
  14  import time
  15  import urllib.request
  16  
  17  import pytest
  18  
  19  PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  20  STATIC_DIR = os.path.join(PROJECT_ROOT, "web", "static")
  21  ARTIFACTS_DIR = os.path.join(PROJECT_ROOT, "test", "artifacts")
  22  
  23  # The relay is built in ../morly (make build) and can be overridden with MORLY.
  24  RELAY_BIN = os.environ.get("MORLY", os.path.join(PROJECT_ROOT, os.pardir, "morly", "morly"))
  25  RELAY_HOST = "127.0.0.1"
  26  RELAY_PORT = int(os.environ.get("MUSIQUAY_TEST_PORT", "3401"))
  27  RELAY_URL = f"http://{RELAY_HOST}:{RELAY_PORT}"
  28  DATA_DIR = os.environ.get("MUSIQUAY_TEST_DATA", "/tmp/musiquay-test-data")
  29  RELAY_LOG = "/tmp/musiquay-test-relay.log"
  30  
  31  # A canned identity: the signer validates the nsec, so it has to be real.
  32  # TEST_PK is the x-only pubkey of TEST_NSEC, derived independently (secp256k1
  33  # k*G in a throwaway script) so a signer that derives the wrong key fails here.
  34  VAULT_PASSWORD = "testpass123"
  35  TEST_NSEC = "nsec1jz5zyz6np29zg9k7wu5zvnpusuha8ca84v8ahdme9pdwjlalcsjsp59265"
  36  TEST_PK = "840307b8e49601725ae66f45e83046d09c9aa221a38af9ad72744f11d40a4c27"
  37  
  38  
  39  def _port_pids(port):
  40      """PIDs listening on a TCP port, via ss. Empty if ss cannot say."""
  41      try:
  42          out = subprocess.run(
  43              ["ss", "-ltnpH", f"sport = :{port}"],
  44              capture_output=True, text=True, timeout=5,
  45          ).stdout
  46      except Exception:
  47          return []
  48      return sorted({int(m.group(1)) for m in re.finditer(r"pid=(\d+)", out)})
  49  
  50  
  51  def _stop_relay(proc):
  52      """Terminate the relay's whole process group, then reap it."""
  53      if proc.poll() is not None:
  54          return
  55      try:
  56          pgid = os.getpgid(proc.pid)
  57      except Exception:
  58          pgid = None
  59      for sig in (signal.SIGTERM, signal.SIGKILL):
  60          try:
  61              if pgid is not None:
  62                  os.killpg(pgid, sig)
  63              else:
  64                  proc.send_signal(sig)
  65          except Exception:
  66              pass
  67          try:
  68              proc.wait(timeout=5 if sig == signal.SIGTERM else 2)
  69              break
  70          except subprocess.TimeoutExpired:
  71              continue
  72  
  73  
  74  @pytest.fixture(scope="session")
  75  def relay():
  76      """Start the morly relay against this repo's static directory."""
  77      if not os.path.exists(RELAY_BIN):
  78          pytest.skip(f"relay not built at {RELAY_BIN} - run: cd ../morly && make build")
  79  
  80      # The relay replays its whole WAL at open; a store left over from an
  81      # earlier session makes startup slower than the readiness wait and every
  82      # test then talks to a relay with events the tests did not publish.
  83      shutil.rmtree(os.path.join(DATA_DIR, "db"), ignore_errors=True)
  84      os.makedirs(DATA_DIR, exist_ok=True)
  85  
  86      holders = _port_pids(RELAY_PORT)
  87      if holders:
  88          pytest.fail(f"port {RELAY_PORT} is held by pid(s) {holders}; stop them first")
  89  
  90      env = os.environ.copy()
  91      env["ORLY_LISTEN"] = RELAY_HOST
  92      env["ORLY_PORT"] = str(RELAY_PORT)
  93      env["ORLY_DATA_DIR"] = DATA_DIR
  94      env["ORLY_STATIC_DIR"] = STATIC_DIR
  95      env["ORLY_CRAWLER_ENABLED"] = "false"
  96  
  97      # Log to a file: the relay is chatty and a full pipe would stall it.
  98      log = open(RELAY_LOG, "wb")
  99      proc = subprocess.Popen(
 100          [RELAY_BIN],
 101          env=env,
 102          stdout=log,
 103          stderr=subprocess.STDOUT,
 104          start_new_session=True,
 105      )
 106  
 107      ready = False
 108      for _ in range(300):
 109          if proc.poll() is not None:
 110              break
 111          try:
 112              urllib.request.urlopen(RELAY_URL, timeout=1)
 113              ready = True
 114              break
 115          except Exception:
 116              time.sleep(0.2)
 117      if not ready:
 118          _stop_relay(proc)
 119          tail = ""
 120          try:
 121              with open(RELAY_LOG, "rb") as f:
 122                  tail = f.read()[-800:].decode(errors="replace")
 123          except Exception:
 124              pass
 125          pytest.fail(f"relay failed to start (see {RELAY_LOG})\n{tail}")
 126  
 127      yield {"url": RELAY_URL, "host": RELAY_HOST, "port": RELAY_PORT, "proc": proc}
 128  
 129      _stop_relay(proc)
 130      log.close()
 131  
 132  
 133  @pytest.fixture(scope="session")
 134  def browser():
 135      """One Chromium instance for the session."""
 136      from playwright.sync_api import sync_playwright
 137  
 138      with sync_playwright() as p:
 139          b = p.chromium.launch()
 140          yield b
 141          b.close()
 142  
 143  
 144  @pytest.fixture(scope="session")
 145  def page(browser, relay):
 146      """A page booted into the app, with the wasm host ready."""
 147      os.makedirs(ARTIFACTS_DIR, exist_ok=True)
 148      ctx = browser.new_context(viewport={"width": 1280, "height": 900})
 149      pg = ctx.new_page()
 150      errors = []
 151      pg.on("pageerror", lambda e: errors.append(str(e)))
 152      pg.goto(relay["url"], wait_until="domcontentloaded")
 153      wait_wasm_ready(pg)
 154      pg.boot_errors = errors
 155      yield pg
 156      ctx.close()
 157  
 158  
 159  def wait_wasm_ready(pg, timeout=60000):
 160      """Wait until the wasm host has finished booting the app."""
 161      pg.wait_for_function(
 162          "() => document.body && document.body.getAttribute('data-wasm-ready') === '1'",
 163          timeout=timeout,
 164      )
 165  
 166  
 167  def boot_state(pg):
 168      """The `#boot-diag` status lines as {name: state}."""
 169      return pg.evaluate("""() => {
 170          const out = {};
 171          for (const el of document.querySelectorAll('#boot-diag > *')) {
 172              const t = (el.textContent || '').trim();
 173              const i = t.indexOf(' ');
 174              if (i > 0) out[t.slice(0, i)] = t.slice(i + 1);
 175          }
 176          return out;
 177      }""")
 178  
 179  
 180  def visible(pg, selector):
 181      return pg.evaluate(
 182          "(sel) => { const el = document.querySelector(sel);"
 183          " if (!el) return false; return getComputedStyle(el).display !== 'none'; }",
 184          selector,
 185      )
 186