diff options
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/chat_scroll_probe.py | 222 |
1 files changed, 222 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/chat_scroll_probe.py b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py new file mode 100644 index 0000000..7373976 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py @@ -0,0 +1,222 @@ +#!/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() + with tempfile.TemporaryDirectory() 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() + 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()) |