aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/invitation_probe.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness/invitation_probe.py')
-rw-r--r--packages/meshbay-hub/tests/harness/invitation_probe.py201
1 files changed, 201 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/invitation_probe.py b/packages/meshbay-hub/tests/harness/invitation_probe.py
new file mode 100644
index 0000000..29133ae
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/invitation_probe.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+"""
+An invitation waiting on the home page, answered in the real application.
+
+Being added to a group is an invitation until it is accepted (AV33). What only
+the running application shows is that the home page lists it, that Accept and
+Decline reach the hub, and that an accepted group then appears among the
+reader's groups — while a declined one does not.
+
+Loads the shipped `app.js` with `fetch` stubbed, once per case:
+
+ accept — the invitation is listed, Accept is clicked
+ decline — the invitation is listed, Decline is clicked
+
+ invitation_probe.py [--engine chrome|firefox]
+
+Prints JSON: one object per case.
+"""
+
+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 = 8772
+RECORDS = []
+FINISHED = threading.Event()
+socketserver.TCPServer.allow_reuse_address = True
+
+GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e"
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head><body>
+<div id="app"></div>
+<script type="module">
+const CASE = new URLSearchParams(location.search).get('case');
+const realFetch = window.fetch.bind(window);
+const post = (o) => realFetch('/log', { method: 'POST', body: JSON.stringify(o) });
+const calls = [];
+let accepted = false;
+const json = (body, status = 200) => ({
+ ok: status < 400, status, statusText: '', headers: new Headers(),
+ json: async () => body, text: async () => JSON.stringify(body),
+});
+const GROUP_ROW = { id: '__GROUP__', name: 'Some Group', owner_username: 'the-owner',
+ visibility: 'private', join_policy: 'invite', is_admin: false, muted: false,
+ created_at: '2026-09-30T00:00:00+00:00', last_activity_at: '2026-09-30T00:00:00+00:00',
+ hosted: true, node_online: false, description: '' };
+window.fetch = async (url, init = {}) => {
+ const u = String(url);
+ calls.push({ url: u, method: init.method || 'GET' });
+ if (u.includes('/v1/users/me/preferences')) return json({});
+ if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' });
+ if (u.includes('/v1/groups/mine')) return json({ groups: accepted ? [GROUP_ROW] : [] });
+ if (u.includes('/v1/groups/invitations')) return json({ invitations: [{
+ group_id: '__GROUP__', name: 'Some Group', owner_username: 'the-owner',
+ invited_by: 'the-owner', created_at: '2026-09-30T00:00:00+00:00' }] });
+ if (u.includes('/invitation/accept')) { accepted = true; return json({ status: 'joined' }); }
+ if (u.includes('/invitation/decline')) return json({ status: 'declined' });
+ if (u.includes('/v1/notifications')) return json({ notifications: [], unread_count: 0 });
+ return json({});
+};
+localStorage.setItem('mb_auth', JSON.stringify({
+ username: 'invitee-account', userId: 'u-1', token: 'tok', refreshToken: 'ref',
+ role: 'user' }));
+history.replaceState(null, '', '/?case=' + CASE + '#/');
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+const text = () => document.getElementById('app').innerText;
+(async () => {
+ const out = { case: CASE };
+ try {
+ await import('/app.js');
+ await wait(1500);
+ out.listed = text().includes('Some Group');
+ // By position, not by label: the browser's language picks the label.
+ const buttons = [...document.querySelectorAll('.invite-links li button')];
+ out.buttons = buttons.length;
+ const button = buttons[CASE === 'accept' ? 0 : 1];
+ if (button) { button.click(); await wait(1200); }
+ out.answer_call = calls.filter((c) => c.url.includes('/invitation/'))
+ .map((c) => `${c.method} ${c.url.replace(/^https?:\/\/[^/]+/, '')}`);
+ out.mine_refetched = calls.filter((c) => c.url.includes('/v1/groups/mine')).length;
+ out.invitation_still_shown = document.querySelectorAll('.invite-links li').length > 0;
+ out.group_card = [...document.querySelectorAll('a.group-card')]
+ .some((a) => a.getAttribute('href') === '#/group/__GROUP__');
+ } catch (e) {
+ out.error = String(e && e.stack || e);
+ }
+ post(out);
+})();
+</script>__HOLD__</body></html>
+""".replace("__GROUP__", GROUP)
+
+HOLD_TAG = '<img src="/hold" style="position:fixed;left:-4px;top:-4px;width:1px">'
+HOLD = ""
+
+
+class H(http.server.SimpleHTTPRequestHandler):
+ 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")
+ return
+ if path == "/":
+ self._send(PAGE.replace("__HOLD__", HOLD).encode(), "text/html; charset=utf-8")
+ return
+ 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
+ ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else (
+ "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream")
+ self._send(asset.read_bytes(), ctype)
+
+
+# Same launchers and the same profile rule as sticky_header_probe.py, which
+# explains both: Firefox is a snap here, and needs a profile under $HOME.
+ENGINES = {
+ "chrome": lambda profile: [
+ "google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}"],
+ "firefox": lambda profile: [
+ "firefox", "--headless", "--profile", profile,
+ "--screenshot", str(Path(profile) / "shot.png")],
+}
+
+
+def _profile_parent(engine: str) -> str | None:
+ if engine != "firefox":
+ return None
+ snap = Path.home() / "snap" / "firefox" / "common"
+ return str(snap if snap.is_dir() else Path.home())
+
+
+def _run(engine: str, case: str) -> dict | None:
+ before = len(RECORDS)
+ FINISHED.clear()
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, prefix="meshbay-probe-",
+ dir=_profile_parent(engine)) as profile:
+ proc = subprocess.Popen(
+ ENGINES[engine](profile) + [f"http://127.0.0.1:{PORT}/?case={case}"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(300):
+ if len(RECORDS) > before:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ return RECORDS[before] if len(RECORDS) > before else None
+
+
+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 ""
+ with socketserver.ThreadingTCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ results = [_run(args.engine, "accept"), _run(args.engine, "decline")]
+ if not all(results):
+ print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr)
+ return 1
+ print(json.dumps([dict(r, engine=args.engine) for r in results], indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())