e2e_marmot.py raw
1 #!/usr/bin/env python3
2 """Playwright tests for Marmot MLS DM system.
3
4 Tests the CryptoProvider proxy chain, NIP-07 extension signing,
5 and end-to-end DM round-trips through the real browser stack.
6
7 Usage:
8 python3 test/e2e_marmot.py [--headed] [--nip-io]
9
10 Requires:
11 - Local server running on :8090 (smesh.test or nip.io)
12 - /etc/hosts entry OR --nip-io flag for nip.io DNS
13 - pip install playwright && playwright install chromium
14 """
15
16 import argparse
17 import json
18 import os
19 import sys
20 import time
21 from pathlib import Path
22 from playwright.sync_api import sync_playwright, expect
23
24 # Test credentials — same as e2e.py.
25 TEST_SK = "328615b6c92aa6527fc175a67670222daabc69fa2b84c2ded5f6907f78f2b0f8"
26 TEST_PK = "5dbeb1d7e84d0a4fb3fac47868438dc9135b35f25e27ae933e306e3584bf69a8"
27
28 EXT_DIR = str(Path(__file__).parent / "extension")
29 LOG_FILE = "/tmp/browser-debug.log"
30
31
32 def make_urls(mode):
33 if mode == "nip-io":
34 base = "http://smesh.127.0.0.1.nip.io:8090"
35 marmot = "http://marmot.smesh.127.0.0.1.nip.io:8090"
36 relay = "http://relay.smesh.127.0.0.1.nip.io:8090"
37 elif mode == "localhost":
38 base = "http://smesh.localhost:8090"
39 marmot = "http://marmot.smesh.localhost:8090"
40 relay = "http://relay.smesh.localhost:8090"
41 else:
42 base = "http://smesh.test:8090"
43 marmot = "http://marmot.smesh.test:8090"
44 relay = "http://relay.smesh.test:8090"
45 return base, marmot, relay
46
47
48 class TestResult:
49 def __init__(self):
50 self.results = {}
51 self.errors = []
52
53 def check(self, name, passed, detail=""):
54 self.results[name] = passed
55 if not passed and detail:
56 self.errors.append(f"{name}: {detail}")
57
58 def report(self):
59 ok = True
60 for name, passed in self.results.items():
61 status = "PASS" if passed else "FAIL"
62 print(f" {status}: {name}")
63 if not passed:
64 ok = False
65 if self.errors:
66 print("\n--- ERRORS ---")
67 for e in self.errors:
68 print(f" {e}")
69 return ok
70
71
72 def clear_log():
73 try:
74 open(LOG_FILE, "w").close()
75 except OSError:
76 pass
77
78
79 def read_log():
80 try:
81 with open(LOG_FILE) as f:
82 return f.read()
83 except FileNotFoundError:
84 return ""
85
86
87 def test_nsec_bus_connectivity(p, base, marmot_url, relay_url):
88 """Test 1: nsec auth — SWs connect to bus, app renders."""
89 r = TestResult()
90
91 browser = p.chromium.launch(headless=True)
92 ctx = browser.new_context()
93 page = ctx.new_page()
94
95 logs = []
96 page.on("console", lambda m: logs.append(f"{m.type}: {m.text}"))
97
98 page.add_init_script(f"""
99 localStorage.setItem('smesh-key', '{TEST_SK}');
100 localStorage.setItem('smesh-pubkey', '{TEST_PK}');
101 localStorage.setItem('smesh-mode', 'nsec');
102 """)
103
104 clear_log()
105 page.goto(base, wait_until="networkidle", timeout=30000)
106 time.sleep(8)
107
108 server_logs = read_log()
109
110 r.check("page_loaded", page.title() != "")
111 r.check("app_render", page.evaluate(
112 "document.querySelector('#app-root') !== null"
113 ))
114 r.check("shell_bus", "[shell]" in server_logs and "bus connected" in server_logs)
115 r.check("relay_bus", "[relay]" in server_logs)
116 r.check("marmot_bus", "[marmot]" in server_logs)
117
118 # Check for JS errors (excluding known benign ones).
119 js_errors = [
120 l for l in logs
121 if "error" in l.lower()
122 and "sw-diag" not in l
123 and "favicon" not in l.lower()
124 and "ERR_INSUFFICIENT_RESOURCES" not in l
125 ]
126 r.check("no_js_errors", len(js_errors) == 0,
127 f"{len(js_errors)} errors: {js_errors[:3]}")
128
129 browser.close()
130 return r
131
132
133 def test_nip07_auth(p, base, marmot_url, relay_url):
134 """Test 2: NIP-07 extension auth — signEvent via crypto proxy chain."""
135 r = TestResult()
136
137 # Clean persistent context so SW re-installs and onActivate fires
138 # (which establishes bus connection). Without this, a cached SW from
139 # a previous run skips activation and the bus never connects.
140 import shutil
141 shutil.rmtree("/tmp/pw-chrome-nip07", ignore_errors=True)
142
143 # Extensions require headed mode + persistent context in Playwright.
144 ctx = p.chromium.launch_persistent_context(
145 "/tmp/pw-chrome-nip07",
146 headless=False,
147 args=[
148 f"--disable-extensions-except={EXT_DIR}",
149 f"--load-extension={EXT_DIR}",
150 ],
151 )
152 page = ctx.new_page()
153
154 logs = []
155 page.on("console", lambda m: logs.append(f"{m.type}: {m.text}"))
156
157 # Don't inject nsec — let the app use NIP-07 mode.
158 page.add_init_script(f"""
159 localStorage.setItem('smesh-mode', 'extension');
160 localStorage.setItem('smesh-pubkey', '{TEST_PK}');
161 localStorage.removeItem('smesh-key');
162 """)
163
164 clear_log()
165 page.goto(base, wait_until="networkidle", timeout=30000)
166
167 # Wait for shell SW bus connection before sending messages.
168 for _ in range(10):
169 time.sleep(1)
170 if "bus connected" in read_log():
171 break
172
173 # Check that the test signer injected window.nostr.
174 has_nostr = page.evaluate("typeof window.nostr !== 'undefined'")
175 r.check("window_nostr_present", has_nostr)
176
177 # Check that getPublicKey returns our test key.
178 if has_nostr:
179 pk = page.evaluate("window.nostr.getPublicKey()")
180 r.check("correct_pubkey", pk == TEST_PK, f"got {pk}")
181 else:
182 r.check("correct_pubkey", False, "no window.nostr")
183
184 # Check that the test signer log appeared.
185 signer_log = any("[test-signer]" in l for l in logs)
186 r.check("signer_injected", signer_log)
187
188 # Wait for SW controller, then send pubkey + MLS init to trigger
189 # the marmot WS connection and NIP-07 proxy auth chain.
190 page.evaluate(f"""
191 (async () => {{
192 // Wait for SW controller.
193 if (!navigator.serviceWorker.controller) {{
194 await new Promise(resolve => {{
195 navigator.serviceWorker.addEventListener('controllerchange', resolve);
196 setTimeout(resolve, 3000);
197 }});
198 }}
199 const sw = navigator.serviceWorker.controller;
200 if (!sw) return;
201 sw.postMessage('["SET_PUBKEY","{TEST_PK}"]');
202 await new Promise(r => setTimeout(r, 500));
203 sw.postMessage('["MLS_INIT",["wss://relay.orly.dev"]]');
204 }})()
205 """)
206 # Poll for marmot auth (SW lifecycle isn't instantaneous).
207 marmot_auth = False
208 for _ in range(10):
209 time.sleep(1)
210 if "marmot-sw: authenticated" in read_log():
211 marmot_auth = True
212 break
213
214 server_logs = read_log()
215 r.check("marmot_authenticated", marmot_auth,
216 "no 'marmot-sw: authenticated' in server logs")
217
218 # Debug: dump console logs on failure.
219 if not marmot_auth:
220 print(" --- console logs ---")
221 for l in logs:
222 print(f" {l}")
223 print(f" --- server logs ---")
224 for line in server_logs.strip().split("\n")[-10:]:
225 print(f" {line}")
226
227 ctx.close()
228 return r
229
230
231 def test_marmot_ws_protocol(p, base, marmot_url, relay_url):
232 """Test 3: Direct marmot WS protocol — nsec auth, status, reset."""
233 r = TestResult()
234
235 browser = p.chromium.launch(headless=True)
236 ctx = browser.new_context()
237 page = ctx.new_page()
238
239 page.goto(base, wait_until="networkidle", timeout=30000)
240 time.sleep(2)
241
242 # Open a direct WebSocket to the marmot endpoint.
243 ws_results = page.evaluate(f"""
244 async () => {{
245 const results = {{}};
246 const ws = new WebSocket('{base.replace("http", "ws")}/__marmot');
247 await new Promise((resolve, reject) => {{
248 ws.onopen = resolve;
249 ws.onerror = reject;
250 setTimeout(reject, 5000);
251 }});
252
253 function sendAndWait(msg, timeout = 5000) {{
254 return new Promise((resolve, reject) => {{
255 const timer = setTimeout(() => reject('timeout'), timeout);
256 ws.onmessage = (e) => {{
257 clearTimeout(timer);
258 resolve(JSON.parse(e.data));
259 }};
260 ws.send(JSON.stringify(msg));
261 }});
262 }}
263
264 // Auth with nsec.
265 const auth = await sendAndWait({{
266 method: 'auth',
267 nsec: '{TEST_SK}',
268 }});
269 results.auth_ok = auth.ok === true;
270 results.auth_method = auth.method;
271
272 // Status check.
273 const status = await sendAndWait({{ method: 'status' }});
274 results.status_method = status.method;
275 results.status_pubkey = status.pubkey || '';
276
277 // Reset.
278 const reset = await sendAndWait({{ method: 'reset' }});
279 results.reset_ok = reset.ok === true;
280
281 ws.close();
282 return results;
283 }}
284 """)
285
286 r.check("ws_auth", ws_results.get("auth_ok", False))
287 r.check("ws_auth_method", ws_results.get("auth_method") == "auth")
288 r.check("ws_status", ws_results.get("status_method") == "status")
289 r.check("ws_status_pubkey", ws_results.get("status_pubkey") == TEST_PK,
290 f"got {ws_results.get('status_pubkey', '')}")
291 r.check("ws_reset", ws_results.get("reset_ok", False))
292
293 browser.close()
294 return r
295
296
297 def test_crypto_req_signEvent(p, base, marmot_url, relay_url):
298 """Test 4: crypto_req/crypto_resp for signEvent via WS.
299
300 Opens a marmot WS in pubkey+sig mode, verifies the backend sends
301 crypto_req for operations and accepts crypto_resp.
302 """
303 r = TestResult()
304
305 import shutil
306 shutil.rmtree("/tmp/pw-chrome-crypto", ignore_errors=True)
307
308 # Extensions require headed mode + persistent context in Playwright.
309 ctx = p.chromium.launch_persistent_context(
310 "/tmp/pw-chrome-crypto",
311 headless=False,
312 args=[
313 f"--disable-extensions-except={EXT_DIR}",
314 f"--load-extension={EXT_DIR}",
315 ],
316 )
317 page = ctx.new_page()
318
319 logs = []
320 page.on("console", lambda m: logs.append(f"{m.type}: {m.text}"))
321
322 page.add_init_script(f"""
323 localStorage.setItem('smesh-mode', 'extension');
324 localStorage.setItem('smesh-pubkey', '{TEST_PK}');
325 localStorage.removeItem('smesh-key');
326 """)
327
328 clear_log()
329 page.goto(base, wait_until="networkidle", timeout=30000)
330
331 # Wait for shell SW bus connection before sending messages.
332 for _ in range(10):
333 time.sleep(1)
334 if "bus connected" in read_log():
335 break
336
337 # Trigger marmot auth via SW messages.
338 page.evaluate(f"""
339 (async () => {{
340 if (!navigator.serviceWorker.controller) {{
341 await new Promise(resolve => {{
342 navigator.serviceWorker.addEventListener('controllerchange', resolve);
343 setTimeout(resolve, 3000);
344 }});
345 }}
346 const sw = navigator.serviceWorker.controller;
347 if (!sw) return;
348 sw.postMessage('["SET_PUBKEY","{TEST_PK}"]');
349 await new Promise(r => setTimeout(r, 500));
350 sw.postMessage('["MLS_INIT",["wss://relay.orly.dev"]]');
351 }})()
352 """)
353
354 # Wait for marmot auth (poll log instead of fixed sleep).
355 auth_ok = False
356 for _ in range(10):
357 time.sleep(1)
358 if "marmot-sw: authenticated" in read_log():
359 auth_ok = True
360 break
361
362 server_logs = read_log()
363
364 r.check("crypto_proxy_auth", auth_ok,
365 "NIP-07 crypto proxy auth chain failed")
366
367 # Check that crypto_req messages were sent.
368 crypto_logs = [l for l in logs if "crypto" in l.lower()]
369 r.check("crypto_activity", len(crypto_logs) > 0 or auth_ok,
370 "no crypto activity in logs")
371
372 ctx.close()
373 return r
374
375
376 def main():
377 parser = argparse.ArgumentParser()
378 parser.add_argument("--headed", action="store_true",
379 help="Run in headed mode (visible browser)")
380 parser.add_argument("--nip-io", action="store_true",
381 help="Use nip.io DNS instead of localhost")
382 parser.add_argument("--hosts", action="store_true",
383 help="Use /etc/hosts (smesh.test) instead of localhost")
384 parser.add_argument("--test", type=str, default="all",
385 help="Run specific test (nsec,nip07,ws,crypto,all)")
386 args = parser.parse_args()
387
388 mode = "localhost"
389 if args.nip_io:
390 mode = "nip-io"
391 elif args.hosts:
392 mode = "hosts"
393 base, marmot_url, relay_url = make_urls(mode)
394
395 tests = {
396 "ws": ("marmot_ws_protocol", test_marmot_ws_protocol),
397 "nip07": ("nip07_auth", test_nip07_auth),
398 "crypto": ("crypto_req_signEvent", test_crypto_req_signEvent),
399 "nsec": ("nsec_bus_connectivity", test_nsec_bus_connectivity),
400 }
401
402 if args.test == "all":
403 run = list(tests.keys())
404 else:
405 run = [t.strip() for t in args.test.split(",")]
406
407 all_ok = True
408 with sync_playwright() as p:
409 for i, key in enumerate(run):
410 if key not in tests:
411 print(f"Unknown test: {key}")
412 continue
413 if i > 0:
414 time.sleep(2) # settle between tests
415 name, fn = tests[key]
416 print(f"\n=== {name} ===")
417 result = fn(p, base, marmot_url, relay_url)
418 if not result.report():
419 all_ok = False
420
421 print(f"\n{'ALL PASSED' if all_ok else 'SOME FAILED'}")
422 sys.exit(0 if all_ok else 1)
423
424
425 if __name__ == "__main__":
426 main()
427