diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 23:36:17 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 23:36:17 +0200 |
| commit | d6713a4c7b3f94a3b63e0c0f78e7939fa7eae4e8 (patch) | |
| tree | f0c00979232c1fd4c5bad0ddd726fbbde15eff1f /packages/meshbay-hub/tests | |
| parent | 799d87999c8324564dce5159191532e008dd93d2 (diff) | |
| parent | 32855a95e11032302f8d24036f6f2dd44b829368 (diff) | |
| download | meshbay-d6713a4c7b3f94a3b63e0c0f78e7939fa7eae4e8.tar.gz | |
Merge branch 'fix/chat-scroll-up'
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/chat_scroll_probe.py | 222 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_chat_scroll_up.py | 102 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_layout_responsive.py | 15 |
3 files changed, 335 insertions, 4 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()) diff --git a/packages/meshbay-hub/tests/test_chat_scroll_up.py b/packages/meshbay-hub/tests/test_chat_scroll_up.py new file mode 100644 index 0000000..6cafcdc --- /dev/null +++ b/packages/meshbay-hub/tests/test_chat_scroll_up.py @@ -0,0 +1,102 @@ +""" +Reading back through the conversation must work. + +The scroll-to-bottom on arrival has been patched five times, and the sixth +patch took the other side away: the panel re-pinned the list about 120 times a +second, so a wheel gesture was undone in the frame it happened in and the older +messages became unreachable. Every pin in the source is guarded by "only if the +reader is at the bottom" — the reader simply never got to stop being at the +bottom, because the `scroll` event that records it is delivered a frame after +the pin that erased it. + +None of that is visible in the source, which is why this measures the real +`ChatPanel` in a browser instead of reading `chat-app.js`. `test_chat_scroll_ +bottom.py` keeps the structural guards; this one keeps the behaviour. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "chat_scroll_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("google-chrome") is None or not (STATIC / "chat-app.js").exists(), + reason="Chrome or the SPA sources are not available") + +# A bare document fires a couple of these as it settles. Anything above this is +# the panel driving itself, which is what the loop looked like. +IDLE_RESIZE_CEILING = 20 + +# The harness scrolls up by six frames of 120px. Chrome's scroll anchoring +# moves the reader with the content when preview cards land above them, so the +# distance from the bottom is not expected to be exactly 720 afterwards — only +# to stay well clear of it. +SCROLLED_UP_FLOOR = 400 + + +@pytest.fixture(scope="module") +def probe(): + run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=180) + assert run.returncode == 0, run.stderr.decode()[-2000:] + data = json.loads(run.stdout.decode()) + return data, {s["label"]: s for s in data["steps"]} + + +def test_arrives_on_the_newest_message(probe): + """Opening the tab lands at the end of the conversation, after the late + layout and the panel sizing itself.""" + _, steps = probe + assert steps["arrived"]["fromBottom"] < 40, ( + "the chat must open on the newest message; it opened " + f"{steps['arrived']['fromBottom']}px above it") + + +def test_the_panel_does_not_resize_itself(probe): + """fit() runs on `resize` and must not produce one. When it did, it re-ran + every frame and re-pinned the scroll with it.""" + data, _ = probe + assert data["idle"]["viewportResizes"] <= IDLE_RESIZE_CEILING, ( + f"{data['idle']['viewportResizes']} viewport resizes on a page nobody " + "touched -- fit() is feeding the event it listens for, and every pass " + "re-pins the chat to the bottom") + assert data["idle"]["documentOverflow"] <= 0, ( + "the chat tab must not leave the document taller than the window") + + +def test_scrolling_up_holds(probe): + """The gesture must survive the frame it happened in.""" + _, steps = probe + assert steps["scrolled up"]["fromBottom"] >= SCROLLED_UP_FLOOR, ( + "scrolling up was undone: the list came back to " + f"{steps['scrolled up']['fromBottom']}px from the bottom") + assert steps["scrolled up"]["jumpButton"], ( + "the panel never noticed the reader leave the bottom -- the pin beat " + "the scroll event, so atBottom stayed true and no jump button appeared") + + +def test_late_content_does_not_drag_the_reader_down(probe): + """Link previews and thumbnails arrive over the following seconds and grow + the list. That is what the arrival pin is for, and it must be over by + now.""" + _, steps = probe + assert steps["previews landed"]["fromBottom"] >= SCROLLED_UP_FLOOR, ( + "preview cards landing pulled the reader back to the bottom") + + +def test_a_new_message_does_not_yank_the_reader_down(probe): + """Somebody writing while you read back marks the spot; it does not move + you.""" + _, steps = probe + assert steps["message arrived"]["fromBottom"] >= SCROLLED_UP_FLOOR, ( + "an incoming message pulled the reader away from what they were reading") + + +def test_a_resize_does_not_yank_the_reader_down(probe): + """A window resize, a rotation, or a phone's URL bar collapsing.""" + _, steps = probe + assert steps["window resized"]["fromBottom"] >= SCROLLED_UP_FLOOR, ( + "a resize pulled the reader back to the bottom") diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py index d7a680d..4ee07d0 100644 --- a/packages/meshbay-hub/tests/test_layout_responsive.py +++ b/packages/meshbay-hub/tests/test_layout_responsive.py @@ -145,12 +145,19 @@ def test_the_measurement_survives_a_scrolled_page(app): def test_the_panel_refits_when_the_viewport_changes(app): - # `fit` is wrapped by `fitAndPin` (which also keeps the view pinned to - # the bottom on a resize) — that is what the listeners bind to. + # `fit` is wrapped by `fitAndPin` (which also keeps the view pinned to the + # bottom on a resize), and on the window by `refit`, which additionally + # forgets the learnt space below the panel — a real viewport change can + # mean the page under it reflowed. `visualViewport` deliberately does not + # forget: a phone fires that event constantly, and re-deriving the leftover + # there is what made fit() oscillate and weld the reader to the bottom of + # the conversation (see test_chat_scroll_up.py). for event in ("resize", "orientationchange"): - assert f"addEventListener('{event}', fitAndPin)" in app + assert f"addEventListener('{event}', refit)" in app assert "visualViewport?.addEventListener('resize', fitAndPin)" in app - assert "removeEventListener('resize', fitAndPin)" in app, "the listener must be released" + assert "removeEventListener('resize', refit)" in app, "the listener must be released" + assert "el._chatFitBelow = 0" in app, ( + "a window resize must forget the learnt leftover and measure it again") def test_the_css_floor_does_not_fight_the_measurement(css, app): |