aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_transport_contracts.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
commit9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch)
treeb13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/tests/test_transport_contracts.py
parent8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff)
downloadmeshbay-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.py113
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 ──────────────────────────────────────────────────────────