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