aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/group_tab_probe.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness/group_tab_probe.py')
-rw-r--r--packages/meshbay-hub/tests/harness/group_tab_probe.py185
1 files changed, 185 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/group_tab_probe.py b/packages/meshbay-hub/tests/harness/group_tab_probe.py
new file mode 100644
index 0000000..5e4e452
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/group_tab_probe.py
@@ -0,0 +1,185 @@
+#!/usr/bin/env python3
+"""
+Which tab does a group actually open on?
+
+The landing tab is chosen from a preference before the node has said which
+applications the group runs, so the two can disagree — the group has Chat
+disabled while 'chat' is the default, or the reader's preferred app is one this
+group does not offer. Both used to land on a tab that rendered *nothing*: no
+panel, no tab shown active, and nothing on screen to say why.
+
+That is not visible in the source, where the tab is a plain string and the app
+list arrives from a handshake several `await`s later. So this renders the real
+`GroupPage` — the shipped module — against a stub node that answers with a
+chosen `enabled_apps`, and reports the tab that ended up active.
+
+ group_tab_probe.py
+
+Prints JSON: one entry per case, with the tab bar as rendered and whether a
+panel was drawn under it.
+"""
+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 = 8748
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+# name -> (the reader's default_tab preference, what the group runs)
+CASES = [
+ ("every app, default chat", "chat", None),
+ ("chat disabled", "chat", ["files", "video"]),
+ ("preferred app absent", "music", ["chat", "files"]),
+ ("preferred app present", "video", ["chat", "files", "video"]),
+ ("preference names nothing real", "nonsense", ["files", "music"]),
+]
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<div id="root"></div>
+<script>
+// GroupPage reaches for this global; transport.js is a classic script and this
+// stands in for it. `connect()` is where a node says what the group runs.
+window.MeshBayTransport = class {
+ constructor() { this.connected = false; this.memberRole = 'member'; }
+ async connect() {
+ this.connected = true;
+ return { is_node_admin: false, member_upload: true,
+ enabled_apps: %(enabled)s,
+ tmdb_enabled: false, musicbrainz_enabled: false,
+ video_root: '', audio_root: '', photo_roots: [] };
+ }
+ async fetchIndex() { return { entries: [], dirs: [], roots: [] }; }
+ async fetchChatHistory() { return { messages: [], hasMore: false }; }
+ async fetchLinkPreview() { return { ok: false }; }
+ close() {}
+};
+</script>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { GroupPage } from '/group-page.js';
+
+await initLocale();
+render(html`<${GroupPage} groupId="g1" token="t" username="me" userId="u1"
+ group=${{ id: 'g1', name: 'a group', owner_username: 'me', is_admin: true }}
+ userPrefs=${{ default_tab: %(pref)s }} />`, document.getElementById('root'));
+
+setTimeout(() => {
+ const tabs = [...document.querySelectorAll('.group-tabs .group-tab')];
+ const bar = document.querySelector('.group-tabs');
+ parent.postMessage({
+ case: %(index)d,
+ // Titles are translated labels, which is what a person reads off the bar.
+ tabs: tabs.map(b => b.getAttribute('title')),
+ active: tabs.findIndex(b => b.classList.contains('active')),
+ activeTitle: (tabs.find(b => b.classList.contains('active')) || {})
+ .getAttribute?.('title') ?? null,
+ // A tab can be active with nothing under it; that is the whole bug.
+ panelDrawn: !!(bar && bar.nextElementSibling),
+ }, '*');
+}, 1500);
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+const N = %(count)d;
+const seen = [];
+addEventListener('message', (e) => {
+ seen.push(e.data);
+ if (seen.length === N) {
+ fetch('/log', { method: 'POST', body: JSON.stringify(seen) });
+ }
+});
+for (let i = 0; i < N; i++) {
+ const f = document.createElement('iframe');
+ f.src = '/case?n=' + i;
+ f.style.cssText = 'width:1100px;height:700px;border:0;display:block';
+ document.getElementById('frames').appendChild(f);
+}
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ 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 == "/":
+ self._send((PAGE % {"count": len(CASES)}).encode(), "text/html; charset=utf-8")
+ elif path == "/case":
+ index = int(self.path.split("n=")[1])
+ _, pref, enabled = CASES[index]
+ self._send((FRAME % {"index": index,
+ "pref": json.dumps(pref),
+ "enabled": json.dumps(enabled)}).encode(),
+ "text/html; charset=utf-8")
+ elif path == "/v1/groups/g1/nodes":
+ self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json")
+ 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")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory() as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,900",
+ 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()
+ if not RECORDS:
+ print(json.dumps({"error": "no measurement"}), file=sys.stderr)
+ return 1
+ by_case = {r["case"]: r for r in RECORDS[0]}
+ print(json.dumps(
+ [dict(name=CASES[i][0], preference=CASES[i][1], enabled=CASES[i][2],
+ **by_case[i]) for i in sorted(by_case)], indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())