"""Fixtures for the musiquay smoke suite. The relay binary lives in ../morly and serves this repo's web/static; the app is driven with Playwright (Chromium). Nothing here is mocked: the vault is created through the real signer worker and the page is reloaded so the app's own auto-login path puts it in the shell. """ import os import re import shutil import signal import subprocess import time import urllib.request import pytest PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) STATIC_DIR = os.path.join(PROJECT_ROOT, "web", "static") ARTIFACTS_DIR = os.path.join(PROJECT_ROOT, "test", "artifacts") # The relay is built in ../morly (make build) and can be overridden with MORLY. RELAY_BIN = os.environ.get("MORLY", os.path.join(PROJECT_ROOT, os.pardir, "morly", "morly")) RELAY_HOST = "127.0.0.1" RELAY_PORT = int(os.environ.get("MUSIQUAY_TEST_PORT", "3401")) RELAY_URL = f"http://{RELAY_HOST}:{RELAY_PORT}" DATA_DIR = os.environ.get("MUSIQUAY_TEST_DATA", "/tmp/musiquay-test-data") RELAY_LOG = "/tmp/musiquay-test-relay.log" # A canned identity: the signer validates the nsec, so it has to be real. # TEST_PK is the x-only pubkey of TEST_NSEC, derived independently (secp256k1 # k*G in a throwaway script) so a signer that derives the wrong key fails here. VAULT_PASSWORD = "testpass123" TEST_NSEC = "nsec1jz5zyz6np29zg9k7wu5zvnpusuha8ca84v8ahdme9pdwjlalcsjsp59265" TEST_PK = "840307b8e49601725ae66f45e83046d09c9aa221a38af9ad72744f11d40a4c27" def _port_pids(port): """PIDs listening on a TCP port, via ss. Empty if ss cannot say.""" try: out = subprocess.run( ["ss", "-ltnpH", f"sport = :{port}"], capture_output=True, text=True, timeout=5, ).stdout except Exception: return [] return sorted({int(m.group(1)) for m in re.finditer(r"pid=(\d+)", out)}) def _stop_relay(proc): """Terminate the relay's whole process group, then reap it.""" if proc.poll() is not None: return try: pgid = os.getpgid(proc.pid) except Exception: pgid = None for sig in (signal.SIGTERM, signal.SIGKILL): try: if pgid is not None: os.killpg(pgid, sig) else: proc.send_signal(sig) except Exception: pass try: proc.wait(timeout=5 if sig == signal.SIGTERM else 2) break except subprocess.TimeoutExpired: continue @pytest.fixture(scope="session") def relay(): """Start the morly relay against this repo's static directory.""" if not os.path.exists(RELAY_BIN): pytest.skip(f"relay not built at {RELAY_BIN} - run: cd ../morly && make build") # The relay replays its whole WAL at open; a store left over from an # earlier session makes startup slower than the readiness wait and every # test then talks to a relay with events the tests did not publish. shutil.rmtree(os.path.join(DATA_DIR, "db"), ignore_errors=True) os.makedirs(DATA_DIR, exist_ok=True) holders = _port_pids(RELAY_PORT) if holders: pytest.fail(f"port {RELAY_PORT} is held by pid(s) {holders}; stop them first") env = os.environ.copy() env["ORLY_LISTEN"] = RELAY_HOST env["ORLY_PORT"] = str(RELAY_PORT) env["ORLY_DATA_DIR"] = DATA_DIR env["ORLY_STATIC_DIR"] = STATIC_DIR env["ORLY_CRAWLER_ENABLED"] = "false" # Log to a file: the relay is chatty and a full pipe would stall it. log = open(RELAY_LOG, "wb") proc = subprocess.Popen( [RELAY_BIN], env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, ) ready = False for _ in range(300): if proc.poll() is not None: break try: urllib.request.urlopen(RELAY_URL, timeout=1) ready = True break except Exception: time.sleep(0.2) if not ready: _stop_relay(proc) tail = "" try: with open(RELAY_LOG, "rb") as f: tail = f.read()[-800:].decode(errors="replace") except Exception: pass pytest.fail(f"relay failed to start (see {RELAY_LOG})\n{tail}") yield {"url": RELAY_URL, "host": RELAY_HOST, "port": RELAY_PORT, "proc": proc} _stop_relay(proc) log.close() @pytest.fixture(scope="session") def browser(): """One Chromium instance for the session.""" from playwright.sync_api import sync_playwright with sync_playwright() as p: b = p.chromium.launch() yield b b.close() @pytest.fixture(scope="session") def page(browser, relay): """A page booted into the app, with the wasm host ready.""" os.makedirs(ARTIFACTS_DIR, exist_ok=True) ctx = browser.new_context(viewport={"width": 1280, "height": 900}) pg = ctx.new_page() errors = [] pg.on("pageerror", lambda e: errors.append(str(e))) pg.goto(relay["url"], wait_until="domcontentloaded") wait_wasm_ready(pg) pg.boot_errors = errors yield pg ctx.close() def wait_wasm_ready(pg, timeout=60000): """Wait until the wasm host has finished booting the app.""" pg.wait_for_function( "() => document.body && document.body.getAttribute('data-wasm-ready') === '1'", timeout=timeout, ) def boot_state(pg): """The `#boot-diag` status lines as {name: state}.""" return pg.evaluate("""() => { const out = {}; for (const el of document.querySelectorAll('#boot-diag > *')) { const t = (el.textContent || '').trim(); const i = t.indexOf(' '); if (i > 0) out[t.slice(0, i)] = t.slice(i + 1); } return out; }""") def visible(pg, selector): return pg.evaluate( "(sel) => { const el = document.querySelector(sel);" " if (!el) return false; return getComputedStyle(el).display !== 'none'; }", selector, )