aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/group_tab_probe.py
blob: 473bcdcd6ee854eca2646c7fed9c08db7c5c890e (plain) (blame)
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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
#!/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"]),
    # The preferences are fetched by app.js after sign-in, so a group opened
    # directly (a reload, a link) mounts before they arrive.
    ("preference arrives after the group opened", "video", ["chat", "files", "video"]),
    ("tab chosen before the preference arrives", "video", ["chat", "files", "video"]),
]

# How each case delivers the preference: at mount, or after mount — and, for
# the second, after the reader has already clicked Files.
LATE = {"preference arrives after the group opened"}
CLICKED_FIRST = {"tab chosen before the preference arrives"}

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,
             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();
const draw = (prefs) => render(html`<${GroupPage} groupId="g1" token="t" username="me" userId="u1"
  group=${{ id: 'g1', name: 'a group', owner_username: 'me', is_admin: true }}
  userPrefs=${prefs} />`, document.getElementById('root'));
const prefs = { default_tab: %(pref)s };
if (%(late)s || %(clicked)s) {
  draw({});
  if (%(clicked)s) {
    setTimeout(() => document.querySelectorAll('.group-tabs .group-tab')[1].click(), 400);
  }
  setTimeout(() => draw(prefs), 800);
} else {
  draw(prefs);
}

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),
                                 "late": json.dumps(CASES[index][0] in LATE),
                                 "clicked": json.dumps(CASES[index][0] in CLICKED_FIRST),
                                 }).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()
        # ignore_cleanup_errors: Chrome's children (zygote, renderer, gpu)
        # outlive terminate() on the parent by a moment and go on writing into
        # the profile. rmtree then walks a directory that gains a file between
        # its readdir and its rmdir and raises "Directory not empty" -- which
        # failed the probe, which failed every test in the file, intermittently
        # and for a reason nowhere near the chat code they were testing. A few
        # bytes left in a throwaway profile are harmless; failing the run is not.
        with tempfile.TemporaryDirectory(
                ignore_cleanup_errors=True) 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()
                proc.wait()
    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())