aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness')
-rw-r--r--packages/meshbay-hub/tests/harness/chat_scroll_probe.py222
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())