1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
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())
|