diff options
Diffstat (limited to 'packages/meshbay-hub/tests/harness/group_hosts_probe.py')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/group_hosts_probe.py | 175 |
1 files changed, 175 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/group_hosts_probe.py b/packages/meshbay-hub/tests/harness/group_hosts_probe.py new file mode 100644 index 0000000..0611e3d --- /dev/null +++ b/packages/meshbay-hub/tests/harness/group_hosts_probe.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +A group owner's settings: who was invited, and which other nodes asked to host. + +Renders the shipped `GroupSettingsPanel` with `fetch` stubbed: one member, one +unanswered invitation, one node asking to host and one already approved. Then +clicks Approve on the request and reports what reached the hub. + + group_hosts_probe.py [--engine chrome|firefox] + +Prints JSON. +""" + +import argparse +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8773 +RECORDS = [] +FINISHED = threading.Event() +socketserver.TCPServer.allow_reuse_address = True + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head><body> +<div id="root"></div> +<script type="module"> +import { html, render } from '/vendor/htm-preact.js'; +import { initLocale } from '/i18n.js'; +import { GroupSettingsPanel } from '/group-settings.js'; + +const realFetch = window.fetch.bind(window); +const calls = []; +const json = (body) => ({ ok: true, status: 200, statusText: '', headers: new Headers(), + json: async () => body, text: async () => JSON.stringify(body) }); +window.fetch = async (url, init = {}) => { + const u = String(url); + calls.push(`${init.method || 'GET'} ${u.replace(/^https?:\/\/[^/]+/, '')}`); + if (u.endsWith('/v1/groups/g1/members')) return json({ + group_id: 'g1', admin_id: 'u1', + members: [{ user_id: 'u1', username: 'the-owner' }], + invited: [{ user_id: 'u9', username: 'someone_asked' }] }); + if (u.endsWith('/v1/groups/g1/hosts')) return json({ hosts: [ + { node_id: 'n-asking', pk_node: 'AAAA', username: 'a-member', status: 'pending', + online: true }, + { node_id: 'n-ok', pk_node: 'BBBB', username: 'another-member', status: 'approved', + online: false }] }); + return json({}); +}; + +await initLocale(); +render(html`<${GroupSettingsPanel} groupId="g1" token="t" userId="u1" + group=${{ id: 'g1', name: 'a group', owner_username: 'the-owner', is_admin: true }} + transportRef=${{ current: null }} gekRef=${{ current: null }} + isNodeAdmin=${false} operatorPaired=${false} connected=${false} + enabledApps=${[]} entries=${[]} nodeDirs=${[]} />`, document.getElementById('root')); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const out = {}; +try { + await wait(1200); + const rows = [...document.querySelectorAll('.admin-table tr')].map((r) => r.innerText); + out.invited_row = rows.some((r) => r.includes('someone_asked')); + out.host_rows = rows.filter((r) => r.includes('AAAA') || r.includes('BBBB')).length; + const asking = [...document.querySelectorAll('.admin-table tr')] + .find((r) => r.innerText.includes('AAAA')); + out.buttons_on_request = asking ? asking.querySelectorAll('button').length : -1; + const approved = [...document.querySelectorAll('.admin-table tr')] + .find((r) => r.innerText.includes('BBBB')); + out.buttons_on_approved = approved ? approved.querySelectorAll('button').length : -1; + if (asking) { asking.querySelector('button').click(); await wait(800); } + out.decision = calls.filter((c) => c.includes('/hosts/')); +} catch (e) { + out.error = String(e && e.stack || e); +} +realFetch('/log', { method: 'POST', body: JSON.stringify(out) }); +</script>__HOLD__</body></html>""" + +HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">' +HOLD = "" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) + if self.path == "/log": + RECORDS.append(json.loads(body.decode())) + FINISHED.set() + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/hold": + FINISHED.wait(60) + self._send(b"", "image/gif") + elif path == "/": + self._send(PAGE.replace("__HOLD__", HOLD).encode(), "text/html; charset=utf-8") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +# Launchers and profile rule: see sticky_header_probe.py. +ENGINES = { + "chrome": lambda profile: [ + "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=1100,900"], + "firefox": lambda profile: [ + "firefox", "--headless", "--profile", profile, + "--screenshot", str(Path(profile) / "shot.png"), "--window-size", "1100,900"], +} + + +def main() -> int: + global HOLD + ap = argparse.ArgumentParser() + ap.add_argument("--engine", choices=sorted(ENGINES), default="chrome") + args = ap.parse_args() + HOLD = HOLD_TAG if args.engine == "firefox" else "" + parent = None + if args.engine == "firefox": + snap = Path.home() / "snap" / "firefox" / "common" + parent = str(snap if snap.is_dir() else Path.home()) + with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, + prefix="meshbay-probe-", dir=parent) as profile: + proc = subprocess.Popen(ENGINES[args.engine](profile) + + [f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(300): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(dict(RECORDS[0], engine=args.engine), indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |