aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/chat_scroll_probe.py
blob: 17ec554d4f87d5c01cb31e1dd6a91a6e87f997a1 (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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
#!/usr/bin/env python3
"""
Can the reader scroll up in the chat, and does the panel sit still when nobody
is touching it?

`scroll_probe.py` answers "is the document taller than the window". This one
mounts **the real `ChatPanel`** — the shipped module, not a model of it — in a
browser, drives it the way a person does, and reports where the list ends up.

It exists because the defect it was written for is invisible to every other kind
of test here. `fit()` set the panel's height, read the document's overflow back
and subtracted it, so the document alternately did and did not overflow the
window; the page scrollbar appeared and vanished with it, `visualViewport` fired
`resize` at each pass, and `fit()` is bound to that event. It therefore re-ran
about 120 times a second for the life of the panel, re-pinning the list to the
bottom every time — which undid each attempt to scroll up *inside the same
frame*, before the `scroll` event that would have recorded it was delivered. The
source reads as correct: every pin is guarded by "only if the reader is at the
bottom", and the reader never got to stop being at the bottom.

    chat_scroll_probe.py

Prints JSON: an `idle` block (viewport-resize firings on a page nobody touches)
and a `steps` list (scroll position after each stage of a reading session).
"""
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 = 8747
RECORDS = []
socketserver.TCPServer.allow_reuse_address = True

# A bare document fires a couple of these while it settles. Anything above this
# is the panel driving itself.
IDLE_RESIZE_CEILING = 20

PAGE = r"""<!doctype html><html><head><meta charset=utf-8>
<link rel="stylesheet" href="/style.css"></head>
<body>
<div class="layout"><div class="main">
  <div class="group-header"><div><h2>a group</h2><p class="group-desc">a description</p></div></div>
  <div class="group-tabs"><button class="group-tab active">Chat</button></div>
  <div id="root"></div>
</div></div>
<script type="module">
import { html, render, useRef } from '/vendor/htm-preact.js';
import { ChatPanel } from '/chat-app.js';

const N = 120;
const now = Date.now() / 1000;
const messages = [];
for (let i = 0; i < N; i++) {
  messages.push({
    id: 'm' + i, sender_id: i % 3 ? 'someone' : 'me',
    sender_name: i % 3 ? 'someone' : 'me',
    // Every fifth message carries a link, so a preview card lands late and
    // grows the list the way the real thing does.
    payload: (i % 5 === 0 ? 'see https://example.invalid/p/' + i + ' ' : '')
             + 'message number ' + i + ', long enough to give the bubble a height',
    timestamp: now - (N - i) * 60, thread_id: null,
  });
}

// Held back until the reader has scrolled up: the cards must not be able to
// drag them back down.
let previewsAnswer = false;
const transport = {
  connected: true,
  onChat: null,
  async fetchChatHistory({ before }) {
    return before ? { messages: [], hasMore: false } : { messages, hasMore: true };
  },
  async sendChat() {},
  async uploadFile() { return {}; },
  fetchLinkPreview(url) {
    return new Promise(res => {
      const tick = () => previewsAnswer
        ? res({ ok: true, site_name: 'Example', title: 'A title for ' + url,
                description: 'A description long enough to add a couple of lines '
                             + 'to the card and grow the list content.' })
        : setTimeout(tick, 100);
      tick();
    });
  },
};

function Host() {
  const transportRef = useRef(transport);
  const gekRef = useRef(null);
  return html`<${ChatPanel} transportRef=${transportRef} gekRef=${gekRef}
    username="me" entries=${[]} status="connected" />`;
}
render(html`<${Host} />`, document.getElementById('root'));

const out = { steps: [] };
const list = () => document.querySelector('.chat-messages');

function snap(label) {
  const l = list();
  out.steps.push({
    label,
    scrollTop: Math.round(l.scrollTop),
    fromBottom: Math.round(l.scrollHeight - l.scrollTop - l.clientHeight),
    panelHeight: Math.round(
      document.querySelector('.chat-panel').getBoundingClientRect().height),
    // The jump button is the only outward sign that the panel noticed the
    // reader leave the bottom.
    jumpButton: !!document.querySelector('.chat-jump'),
  });
}

const wait = ms => new Promise(r => setTimeout(r, ms));

(async () => {
  await wait(1000);
  snap('arrived');

  // Nobody touches the page. A panel that resizes itself says so here.
  let resizes = 0;
  window.visualViewport?.addEventListener('resize', () => { resizes++; });
  await wait(1500);
  out.idle = { viewportResizes: resizes,
               documentOverflow: document.documentElement.scrollHeight - window.innerHeight };
  snap('after idle');

  // Scrolling up, over several frames, with no synthetic `scroll` event: the
  // browser fires the real one, a frame later, which is the whole point.
  const l = list();
  for (let i = 0; i < 6; i++) {
    l.scrollTop -= 120;
    await new Promise(r => requestAnimationFrame(r));
  }
  await wait(100);
  snap('scrolled up');

  // Now let the preview cards land: content grows above and below the reader.
  previewsAnswer = true;
  await wait(1200);
  snap('previews landed');

  transport.onChat({ id: 'live', sender_id: 'someone', sender_name: 'someone',
                     payload: 'a new message', timestamp: Date.now() / 1000 });
  await wait(400);
  snap('message arrived');

  window.dispatchEvent(new Event('resize'));
  await wait(400);
  snap('window resized');

  fetch('/log', { method: 'POST', body: JSON.stringify(out) });
})();
</script></body></html>"""


class H(http.server.BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass

    def do_POST(self):
        RECORDS.append(json.loads(
            self.rfile.read(int(self.headers["Content-Length"])).decode()))
        self.send_response(204)
        self.end_headers()

    def do_GET(self):
        if self.path == "/":
            body, ctype = PAGE.encode(), "text/html; charset=utf-8"
        else:
            path = (STATIC / self.path.lstrip("/")).resolve()
            if not str(path).startswith(str(STATIC)) or not path.is_file():
                self.send_response(404)
                self.end_headers()
                return
            body = path.read_bytes()
            ctype = ("text/css" if path.suffix == ".css"
                     else "text/javascript" if path.suffix == ".js"
                     else "application/octet-stream")
        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 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:
            # Real time, not `--virtual-time-budget`: the defect is a feedback
            # loop between layout and an event, and a virtual clock does not
            # run it.
            proc = subprocess.Popen(
                ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
                 f"--user-data-dir={profile}", "--window-size=1100,800",
                 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(RECORDS[0], indent=1))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())