From 84e778d5cdd1bb5cded8a7c0238797c17d48666c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 16 Aug 2026 15:29:11 +0200 Subject: feat(hub): chat, presence, a Profile page, and downloads that do not freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat opens on the newest hundred messages, loads fifty older on demand with the reading position anchored — the distance from the *bottom*, since everything above the viewport just grew — and follows new messages only when the reader was already at the end. Day separators, sender grouping, an unread marker, and a jump-to-latest pill. Messages are keyed by id: index keys plus prepending makes Preact reuse the wrong bubbles. A presence dot per group in the sidebar, three states, each backed by something: the hub's registry, or a connection this browser made or failed to make. Never colour alone — red and green are the pair colour-blind readers cannot separate — so each dot carries a title and an aria-label. Profile is split out of Settings: identity, node link, pinned node identities and account deletion. Mixing them put an irreversible button two scrolls under a theme picker. The create-group page loses its centred 520 px card, which left 190 px of margin either side, and its two button panels become a radio group — a button conveys no chosen state to a screen reader, and side by side they read as two independent actions rather than one either/or. The Files toolbar shows its actions as icon buttons the moment Select is on, disabled when they do not apply rather than appearing and vanishing. On a phone the right-hand group could not wrap and ran 130 px off the screen. Streamed downloads no longer freeze after one chunk. `registration.active` says a worker exists, not that this page is controlled by it — and an uncontrolled page's requests never reach its fetch handler, so the worker took the stream and was never asked for it, leaving `writer.write()` waiting on backpressure that would never lift. The page now requires control and the worker confirms it actually served the request before the sink is trusted. Fixed on the way: `setActionsOpen` outlived the state it belonged to and threw on every Files action; the chat scrollbar stopped short of the bottom; the owner's row sat lower than the rest; About showed a version hardcoded two releases ago. Co-Authored-By: Claude Opus 5 --- .../meshbay-hub/tests/test_transport_contracts.py | 218 +++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 packages/meshbay-hub/tests/test_transport_contracts.py (limited to 'packages/meshbay-hub/tests/test_transport_contracts.py') diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py new file mode 100644 index 0000000..fb92fcd --- /dev/null +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -0,0 +1,218 @@ +""" +Two wire contracts in transport.js that fail quietly when broken. + +Neither can be reached from Python, and `QE/deploy/e2e.py` is a second +implementation of the client rather than a test of this one, so these read the +source. That is weak evidence in general, and the right kind here: both defects +below produce a plausible screen rather than an error. + + - History paged forward from the oldest message, so a group with more than + 200 of them opened on its first screen and the recent conversation could + not be reached. Nothing threw; the wrong messages were simply shown. + - A reply that is not routed falls through to "resolve the oldest pending + request". Adding a ping made that dangerous: a pong handed to a waiting + history request satisfies it with a message that has no `messages` field, + and the conversation renders empty. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +TRANSPORT = STATIC / "transport.js" +APP = STATIC / "app.js" + +pytestmark = pytest.mark.skipif( + not TRANSPORT.exists(), reason="the SPA sources are not available") + + +@pytest.fixture(scope="module") +def transport(): + return TRANSPORT.read_text() + + +@pytest.fixture(scope="module") +def app(): + return APP.read_text() + + +def test_chat_history_pages_backwards(transport): + body = transport[transport.index("async fetchChatHistory"):] + body = body[:body.index("\n }")] + assert "before" in body, "the request must carry a backward cursor" + assert "since" not in body, ( + "`since` pages forward from the oldest message — that was the bug") + + +def test_chat_history_reports_whether_more_exists(transport): + body = transport[transport.index("async fetchChatHistory"):] + body = body[:body.index("\n }")] + assert "has_more" in body, ( + "without it the 'load older' control cannot know when to stop offering") + + +def test_the_browser_asks_for_the_newest_page_first(app): + """A group opens on the newest messages, not the oldest.""" + assert "fetchChatHistory({ limit: CHAT_PAGE })" in app + assert re.search(r"const CHAT_PAGE\s*=\s*100", app) + assert re.search(r"const CHAT_OLDER_PAGE\s*=\s*50", app) + + +def test_older_pages_are_requested_with_a_cursor_not_an_offset(app): + assert "before: messages[0].id" in app, ( + "paging by offset repeats or skips messages when one arrives mid-scroll") + + +def test_pong_is_routed_by_its_echoed_token(transport): + """Not left to the oldest-pending fallback, which would empty a chat.""" + assert "if (msg.type === 'pong')" in transport + routing = transport[transport.index("if (msg.type === 'pong')"):] + routing = routing[:routing.index("const oldest")] + assert "handler._key === key" in routing + assert "return;" in routing, ( + "a pong for a timed-out probe must stop here, not fall through") + + +def test_ping_requests_are_keyed(transport): + assert "`ping:${obj.token}`" in transport, ( + "an unkeyed ping cannot be matched to its pong") + + +def test_ping_can_time_out_sooner_than_a_transfer(transport): + """30 s is right for a chunk and useless for a liveness probe.""" + assert "_sendAndWait(obj, timeoutMs = 30000)" in transport + body = transport[transport.index(" async ping("):] + body = body[:body.index("\n }")] + assert "timeoutMs" in body + + +def test_scroll_position_is_anchored_when_older_messages_are_prepended(app): + """Everything above the viewport grows, so scrollTop alone is not enough.""" + assert "scrollHeight - list.scrollTop" in app, "the anchor is measured from the bottom" + assert "list.scrollTop = list.scrollHeight - anchorRef.current" in app + assert "useLayoutEffect" in app, ( + "correcting after paint shows the jump it is meant to prevent") + + +def test_the_view_only_follows_new_messages_when_already_at_the_bottom(app): + assert "if (atBottomRef.current) list.scrollTop = list.scrollHeight" in app, ( + "scrolling unconditionally fights someone reading back through history") + + +def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(app): + """scrollIntoView on a zero-height marker stops short of the true bottom. + + The list has padding and a flex gap below the last bubble, so aligning an + empty div to the viewport bottom left the bar a few pixels from the end — + visible on opening a group and again after sending a message. + """ + # The call, not the word: a comment explaining why it is gone should not + # be able to fail this. + assert ".scrollIntoView(" not in app + assert "list.scrollTo({ top: list.scrollHeight" in app, ( + "jumping to the latest should also land on the real bottom") + + +def test_messages_are_keyed_by_id_not_index(app): + """Index keys plus prepending makes Preact reuse the wrong bubbles.""" + assert "key=${m.id}" in app + assert "key=${i}" not in app.split("function ChatPanel")[1].split("\n}")[0] + + +def test_presence_has_three_states_and_a_label_for_each(app): + for state in ("online", "offline", "unknown"): + assert f"presence-{state}" in (STATIC / "style.css").read_text() + assert "t('presence.' + state)" in app, ( + "red and green are the pair colour-blind readers cannot separate, so " + "the dot needs a title and an aria-label, not just a colour") + + +def test_a_refusal_from_the_node_counts_as_present(app): + """The node answering "no" proves it is up; only silence proves nothing.""" + assert "err.reason ? 'online' : 'offline'" in app + + +# ── The create-group form ───────────────────────────────────────────────────── + +def test_choosing_public_settles_the_admission_question(app): + """Public implies open, so the policy selector has nothing left to ask. + + Enforced twice on purpose: the API refuses public+invite with a 422, and the + form never offers the combination. A form that can build a request the server + rejects is a form that produces an error message instead of a group. + """ + assert "setVisibility('public'); setJoinPolicy('open');" in app, ( + "picking Public must settle the policy, not leave the previous one") + assert "setVisibility('private'); setJoinPolicy('invite');" in app, ( + "going back to Private must not leave the group open by accident") + + form = app[app.index("function CreateGroupPage"):] + form = form[:form.index("\n}\n")] + selector = form.index("t('create_group.join_policy')") + guard = form.rindex("visibility === 'public'", 0, selector) + assert guard != -1, "the policy selector must sit behind a visibility guard" + assert "create_group.public_is_open" in form[guard:selector], ( + "a public group should say why there is nothing to choose") + + +def test_the_form_starts_on_a_combination_the_api_accepts(app): + form = app[app.index("function CreateGroupPage"):] + assert "useState('private')" in form[:form.index("return html")] + assert "useState('invite')" in form[:form.index("return html")] + + +# ── Dead references in the SPA ──────────────────────────────────────────────── + +def test_no_setter_survives_the_state_it_belonged_to(app): + """A removed useState leaves its setter behind, and nothing complains. + + `setActionsOpen` outlived `actionsOpen` when the Actions dropdown became a + row of buttons, and shipped: every action in the Files panel threw + ReferenceError on click. No Python test could see it and e2e.py does not + drive the SPA, so the shape is checked here instead. + + A grep for the state name does not find it — `setActionsOpen` does not + contain `actionsOpen`, the capital breaks the match. That is exactly how it + got through. + """ + import re + declared = set(re.findall(r"const \[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState", app)) + # Names brought in from another module are defined, just not here. + imported = set() + for names in re.findall(r"import\s*\{([^}]*)\}\s*from", app): + imported.update(n.strip().split(" as ")[-1].strip() for n in names.split(",")) + # A bare call only: `downloads.setMode(...)` and `view.setUint32(...)` belong + # to their object, not to this component. + called = set(re.findall(r"(?