diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
| commit | 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch) | |
| tree | b13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/tests/test_transport_contracts.py | |
| parent | 8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff) | |
| download | meshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz | |
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).
Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.
Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:
- group-page.js called onRefreshAuth on a stale-token handshake rejection,
but app.js never imported refreshAccessToken from hub-client.js — so a
brand new member (including a group's own creator) hit "Not a member of
this group" and the retry silently failed, throwing before it could
refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
it from i18n.js. Opening Chat on a group with real messages threw mid-
render; uncaught, that appears to wedge Preact's render scheduler, so
every button on the page stopped responding until reload.
Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/tests/test_transport_contracts.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_transport_contracts.py | 113 |
1 files changed, 73 insertions, 40 deletions
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index 5dc3b7b..e014518 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -23,6 +23,12 @@ import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" TRANSPORT = STATIC / "transport.js" APP = STATIC / "app.js" +# Chat's history paging/scroll-anchoring logic and GroupPage's own connect() +# effect moved out of app.js in the group-page refactor. +CHAT_APP = STATIC / "chat-app.js" +GROUP_PAGE = STATIC / "group-page.js" +SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js", + STATIC / "video-player.js", STATIC / "group-settings.js"] pytestmark = pytest.mark.skipif( not TRANSPORT.exists(), reason="the SPA sources are not available") @@ -38,6 +44,16 @@ def app(): return APP.read_text() +@pytest.fixture(scope="module") +def chat(): + return CHAT_APP.read_text() + + +@pytest.fixture(scope="module") +def group_page(): + return GROUP_PAGE.read_text() + + def test_chat_history_pages_backwards(transport): body = transport[transport.index("async fetchChatHistory"):] body = body[:body.index("\n }")] @@ -53,15 +69,15 @@ def test_chat_history_reports_whether_more_exists(transport): "without it the 'load older' control cannot know when to stop offering") -def test_the_browser_asks_for_the_newest_page_first(app): +def test_the_browser_asks_for_the_newest_page_first(chat): """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) + assert "fetchChatHistory({ limit: CHAT_PAGE })" in chat + assert re.search(r"const CHAT_PAGE\s*=\s*100", chat) + assert re.search(r"const CHAT_OLDER_PAGE\s*=\s*50", chat) -def test_older_pages_are_requested_with_a_cursor_not_an_offset(app): - assert "before: messages[0].id" in app, ( +def test_older_pages_are_requested_with_a_cursor_not_an_offset(chat): + assert "before: messages[0].id" in chat, ( "paging by offset repeats or skips messages when one arrives mid-scroll") @@ -88,20 +104,20 @@ def test_ping_can_time_out_sooner_than_a_transfer(transport): assert "timeoutMs" in body -def test_scroll_position_is_anchored_when_older_messages_are_prepended(app): +def test_scroll_position_is_anchored_when_older_messages_are_prepended(chat): """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, ( + assert "scrollHeight - list.scrollTop" in chat, "the anchor is measured from the bottom" + assert "list.scrollTop = list.scrollHeight - anchorRef.current" in chat + assert "useLayoutEffect" in chat, ( "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, ( +def test_the_view_only_follows_new_messages_when_already_at_the_bottom(chat): + assert "if (atBottomRef.current) list.scrollTop = list.scrollHeight" in chat, ( "scrolling unconditionally fights someone reading back through history") -def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(app): +def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(chat): """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 @@ -110,15 +126,15 @@ def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(app): """ # 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, ( + assert ".scrollIntoView(" not in chat + assert "list.scrollTo({ top: list.scrollHeight" in chat, ( "jumping to the latest should also land on the real bottom") -def test_messages_are_keyed_by_id_not_index(app): +def test_messages_are_keyed_by_id_not_index(chat): """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] + assert "key=${m.id}" in chat + assert "key=${i}" not in chat.split("function ChatPanel")[1].split("\n}")[0] def test_presence_has_three_states_and_a_label_for_each(app): @@ -137,9 +153,9 @@ def _string(source: str, key: str) -> str: return source[start:end] -def test_a_refusal_from_the_node_counts_as_present(app): +def test_a_refusal_from_the_node_counts_as_present(group_page): """The node answering "no" proves it is up; only silence proves nothing.""" - assert "err.reason ? 'online' : 'offline'" in app + assert "err.reason ? 'online' : 'offline'" in group_page # ── The create-group form ───────────────────────────────────────────────────── @@ -191,7 +207,7 @@ def test_the_form_starts_on_a_combination_the_api_accepts(app): # ── Dead references in the SPA ──────────────────────────────────────────────── -def test_no_setter_survives_the_state_it_belonged_to(app): +def test_no_setter_survives_the_state_it_belonged_to(): """A removed useState leaves its setter behind, and nothing complains. `setActionsOpen` outlived `actionsOpen` when the Actions dropdown became a @@ -202,28 +218,45 @@ def test_no_setter_survives_the_state_it_belonged_to(app): 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. + + Checked per file rather than on one concatenated blob: the group-page + refactor split what used to be one app.js into several, and a setter + defined in one (e.g. `setAuth`, imported from hub-client.js) must not be + mistaken for covering an orphan call of the same name in another. Lifting + state to the shared shell and passing its setter down as a prop is the + same idea one level lower — `FilesPanel`'s `setEntries` is real, just + declared in group-page.js's own `useState` rather than here — so a setter + named in a component's own destructured props is treated as defined too. """ 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"} - # A `setX` that is a plain function of this module is not an orphan setter: - # `setAuth` writes the session to localStorage and has no `useState` behind - # it by design. Without this the rule reports every such helper, and a rule - # that cries wolf is one someone eventually silences. - defined = set(re.findall(r"^(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M)) - defined |= set(re.findall(r"^\s*const\s+(set[A-Z]\w*)\s*=", app, re.M)) + for path in SPLIT_FILES: + app = path.read_text() + 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"} + # A `setX` that is a plain function of this module is not an orphan + # setter: `setAuth` writes the session to localStorage and has no + # `useState` behind it by design. Without this the rule reports every + # such helper, and a rule that cries wolf is one someone eventually + # silences. + defined = set(re.findall(r"^(?:async\s+)?function\s+(set[A-Z]\w*)\s*\(", app, re.M)) + defined |= set(re.findall(r"^\s*const\s+(set[A-Z]\w*)\s*=", app, re.M)) + # A setter named in a `function Component({ ..., setX, ... })` prop + # list is handed down from wherever it is really declared. + for params in re.findall(r"^function [A-Z]\w*\(\{([^}]*)\}", app, re.M): + defined.update(re.findall(r"\b(set[A-Z]\w*)\b", params)) - orphans = sorted(called - declared - imported - builtin - defined) - assert not orphans, ( - f"setter(s) called with no useState behind them: {orphans} — " - "each one is a ReferenceError the moment that code path runs") + orphans = sorted(called - declared - imported - builtin - defined) + assert not orphans, ( + f"{path.name}: setter(s) called with no useState behind them: " + f"{orphans} — each one is a ReferenceError the moment that code " + "path runs") # ── Parallel uploads ────────────────────────────────────────────────────────── |