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