diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_transport_contracts.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_transport_contracts.py | 218 |
1 files changed, 218 insertions, 0 deletions
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"(?<![.\w])(set[A-Z]\w*)\s*\(", app)) + builtin = {"setTimeout", "setInterval"} + + orphans = sorted(called - declared - imported - builtin) + assert not orphans, ( + f"setter(s) called with no useState behind them: {orphans} — " + "each one is a ReferenceError the moment that code path runs") + + +# ── Parallel uploads ────────────────────────────────────────────────────────── + +def test_an_upload_refusal_names_the_file_it_is_about(transport): + """Reported 2026-08-16: a second upload started in parallel killed both. + + An error used to carry no filename, so the client could not tell whose it + was and failed every upload in flight — one name the node disliked took the + other file with it. The node names the file now, and only that upload stops. + """ + body = transport[transport.index("if (msg.type === 'error' && this._uploaders.size)"):] + body = body[:body.index("\n if (msg.type === 'chat_msg'")] + assert "this._uploaders.has(msg.filename)" in body, ( + "a named refusal must reach one uploader, not all of them") + assert "if (!msg.filename)" in body, ( + "an unnamed error from an older node must still stop everything — " + "guessing which upload it belongs to would be worse") + + +def test_uploads_are_tracked_per_file(transport): + """Acks interleave when two files are in flight.""" + assert "this._uploaders = new Map()" in transport + assert "this._uploaders.set(file.name" in transport |