diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 23:34:49 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 23:34:49 +0200 |
| commit | 32855a95e11032302f8d24036f6f2dd44b829368 (patch) | |
| tree | f0c00979232c1fd4c5bad0ddd726fbbde15eff1f /packages/meshbay-hub/tests/harness/chat_scroll_probe.py | |
| parent | 799d87999c8324564dce5159191532e008dd93d2 (diff) | |
| download | meshbay-32855a95e11032302f8d24036f6f2dd44b829368.tar.gz | |
fix(hub): let the reader scroll up in the chat again
The chat could not be read back: any wheel gesture was undone in the frame
it happened in, and the "jump to latest" button never appeared.
None of the pins in ChatPanel are at fault -- every one of them is guarded
by "only if the reader is at the bottom". The reader never got to stop
being at the bottom.
fit() set the panel's height, read documentElement.scrollHeight back and
subtracted the overflow, so the document alternately did and did not
overflow the window. The page scrollbar appeared and vanished with it and
visualViewport fired resize at every pass -- the event fit() is bound to.
It therefore re-entered itself for the life of the panel: measured at 240
firings in two seconds on a page nobody was touching, against 2 for a bare
document. Each pass ran fitAndPin, which re-pinned the list to the bottom
before the scroll event that would have recorded the gesture was delivered
a frame later, so atBottomRef never went false.
- fit() learns the space below the panel once and remembers it on the
element instead of re-deriving it by writing and measuring back. At the
steady state it writes nothing, so it produces no resize. A real window
resize or an orientation change forgets the learnt value and measures
again (the page under the panel may have reflowed); visualViewport
deliberately does not, since a phone fires it constantly.
- The scroll-to-bottom is now scoped to *arrival*, which is all it was ever
for: opening the group, or coming back to the Chat tab, including the
thumbnails and link-preview cards that keep growing the list for a second
afterwards. It ends when the reader takes hold of the scroll, and the
ResizeObserver disconnects there.
- That release is recorded from the gesture (wheel/touchmove/pointerdown/
keydown), not from the scroll event, which arrives too late to protect
anything.
Unchanged: landing on the newest message, following new messages while
already at the bottom, the "load older" anchor and the unread marker.
tests/harness/chat_scroll_probe.py mounts the real ChatPanel in a browser
and reads a conversation back; test_chat_scroll_up.py asserts against it.
With the fix reverted, five of its six tests fail and the sixth -- landing
on the newest message -- still passes, which is the property that must not
have been traded away. A structural test cannot see any of this, which is
why it is measured.
test_layout_responsive.py pinned the listener's name and follows the
rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
Diffstat (limited to 'packages/meshbay-hub/tests/harness/chat_scroll_probe.py')
| -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()) |