aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-06 19:27:38 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-06 19:27:38 +0200
commit005f3cf83559eaf84fd307584477c40676be1dd3 (patch)
treead1668e1e99f3e0cb03f559cfdec6d27cf742d5b /packages/meshbay-hub/tests
parent36ef1b76dce56a40650f890156eb137536ef015d (diff)
downloadmeshbay-005f3cf83559eaf84fd307584477c40676be1dd3.tar.gz
fix(client): degrade against a node still speaking MNP 1.0
The SPA is served by the hub, so deploying the hub puts this client in front of every node — including the ones not updated yet. That window is the normal state for as long as an operator takes, and for a node someone else runs it may be indefinite. Three controls were broken across it, and the failure mode is quiet: an unknown message type is logged by the node and never answered, so the click produces a thirty-second wait ending in a timeout with nothing on screen to say the node simply cannot do this. Files' Upload button read `root.writable`, which a 1.0 node does not send — it says `upload`, the same answer under the older name. The button disappeared on every un-upgraded node. It reads both now, and still respects an explicit `writable: false` rather than falling through to the legacy flag. The per-app folder pickers spoke `app_directories`. Videos, Music and Photos each had their own message before that and those still work, so the page chooses by version: an operator on an older node keeps the ability they had. `video_root` and `audio_root` hold one folder, so several are refused with a reason rather than stored as the first and silently truncated. Root management — writable, removable, eject, plug — has no older equivalent to route to, so the table goes read-only with a line saying why and pointing at the `meshbay-node root` commands. Chat's two settings are new with nothing before them and are hidden the same way. None of this was inferred from a payload's shape: `_checkNodeVersion` already parsed the node's version and threw it away, and it is kept now. Coupling a capability to whether some field happens to be present is how a flag flips because an unrelated payload changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_app_settings_plugin.py7
-rw-r--r--packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py175
2 files changed, 180 insertions, 2 deletions
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py
index 00a07b0..1837c28 100644
--- a/packages/meshbay-hub/tests/test_app_settings_plugin.py
+++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py
@@ -226,9 +226,12 @@ def test_the_page_performs_exactly_one_app_specific_operation():
# The page's own settings, which belong to no app: which apps are enabled
# at all, and how hard the node works watching its disk.
page_level = {"setAppsEnabled", "setScanSettings"}
- assert calls - page_level == {"setAppDirectories"}, (
+ # Both are the same generic operation; the second is what a node too old
+ # for it understands, chosen by version rather than by app.
+ generic = {"setAppDirectories", "setAppDirectoriesLegacy"}
+ assert calls - page_level == generic, (
f"the settings page performs app-specific operations: "
- f"{sorted(calls - page_level - {'setAppDirectories'})}")
+ f"{sorted(calls - page_level - generic)}")
# ── The apps read a list ────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
new file mode 100644
index 0000000..63390af
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
@@ -0,0 +1,175 @@
+"""
+The page ships before the nodes do.
+
+The SPA is served by the hub, so deploying the hub puts this version of the
+client in front of *every* node, including the ones still running MNP 1.0. That
+window is not a corner case — it is the normal state for as long as it takes an
+operator to update, and for a node someone else runs it may be indefinite.
+
+The failure mode is specific and quiet: a node logs an unknown message type and
+sends **nothing back**, so a control that speaks MNP 1.1 to it produces a
+thirty-second wait ending in a timeout, with nothing on screen to say the node
+simply cannot do this. Three of them were like that before these tests:
+
+* Files' Upload button read `root.writable`, which a 1.0 node does not send —
+ it says `upload`. The button disappeared on every un-upgraded node.
+* The shared-directories toggles, eject and plug have no older equivalent at
+ all.
+* The per-app folder pickers spoke `app_directories`, where a 1.0 node
+ understands `video_root` / `audio_root` / `photo_roots`.
+
+Source-reading, like the other SPA guards. What it cannot check is that the
+degraded path is pleasant; what it does check is that each of the three exists.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+FILES_APP = STATIC / "files-app.js"
+GROUP_PAGE = STATIC / "group-page.js"
+GROUP_SETTINGS = STATIC / "group-settings.js"
+
+pytestmark = pytest.mark.skipif(not TRANSPORT.exists(),
+ reason="SPA sources unavailable")
+
+
+def _component(source: str, name: str) -> str:
+ start = source.index(f"\nfunction {name}(")
+ end = source.find("\nfunction ", start + 1)
+ return source[start:end if end != -1 else len(source)]
+
+
+# ── Knowing which node you are talking to ───────────────────────────────────
+
+def test_the_client_keeps_the_version_it_checked():
+ """
+ `_checkNodeVersion` parsed the node's version and threw it away, so nothing
+ downstream could ask. Refusing to connect is not the only thing a version
+ is good for.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ assert "this._nodeVersion = String(reply.v" in source
+ assert "get supportsAppOps()" in source
+
+
+def test_the_capability_reads_the_version_rather_than_guessing():
+ """
+ Inferring it from whether some field happens to be present is how two
+ unrelated things end up coupled — the flag would flip because a payload
+ changed shape for another reason entirely.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ getter = source[source.index("get supportsAppOps()"):]
+ getter = getter[:getter.index("\n }") + 4]
+ assert "_nodeVersion" in getter
+ assert "1" in getter, "no version comparison in the capability check"
+
+
+# ── The three degraded paths ────────────────────────────────────────────────
+
+def test_the_upload_button_reads_the_older_flag_too():
+ """
+ A 1.0 node's roots carry `upload`; `writable` is the same answer renamed.
+ Reading only the new name hides the Upload button on every node that has
+ not been updated, which on the day the page ships is all of them.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ decl = page[page.index("const currentRootWritable"):]
+ decl = decl[:decl.index(";") + 1]
+ assert "currentRoot.upload" in decl, (
+ "the Upload button ignores the flag an older node actually sends")
+ assert "writable !== undefined" in decl, (
+ "a root that is explicitly writable=false must stay read-only — "
+ "falling through to `upload` there would reopen it")
+
+
+def test_app_directories_fall_back_to_the_three_older_messages():
+ """
+ Videos, Music and Photos each had their own message before the generic op,
+ and those still work — so an operator on an un-upgraded node keeps the
+ ability they had rather than being handed a control that times out.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ legacy = source[source.index("async setAppDirectoriesLegacy("):]
+ legacy = legacy[:legacy.index("\n async ", 1)]
+ for call in ("setPhotoRoots", "setVideoRoot", "setAudioRoot"):
+ assert call in legacy, f"{call} is not reachable on the older path"
+
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "transport.supportsAppOps" in panel, (
+ "the settings page sends the 1.1 message unconditionally")
+
+
+def test_the_older_path_refuses_what_it_cannot_carry():
+ """
+ `video_root` and `audio_root` hold one folder. Sending several would store
+ the first and drop the rest silently, which is worse than refusing — the
+ operator would see a saved setting that is not what they chose.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ legacy = source[source.index("async setAppDirectoriesLegacy("):]
+ legacy = legacy[:legacy.index("\n async ", 1)]
+ assert "clean.length > 1" in legacy
+ assert "throw new Error" in legacy
+
+
+def test_root_management_is_read_only_against_an_older_node():
+ """
+ Unlike the app directories, `writable`, `removable`, eject and plug have no
+ older equivalent to route to. The controls are shown without being
+ offered, with the reason, rather than accepting a click that goes nowhere.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ table_call = panel[panel.index("<${SharedDirectoriesTable}"):]
+ table_call = table_call[:table_call.index("/>")]
+ assert "readOnly=" in table_call
+ assert "nodeSupportsAppOps" in table_call
+ assert "settings_node.roots_node_too_old" in panel, (
+ "nothing says why the controls are inert")
+
+
+def test_chat_settings_are_hidden_rather_than_routed():
+ """
+ Chat's directory and link-preview switch are new in 1.1 with nothing
+ before them, so there is no older message to fall back to.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "settings_node.app_node_too_old" in panel
+
+
+# ── Reading an older node's handshake ───────────────────────────────────────
+
+def test_the_ack_is_read_in_both_shapes(app=None):
+ """
+ A 1.0 ack has `video_root` and no `video_directories`, and no
+ `chat_link_preview` at all. Reading a missing plural as "nothing
+ configured" empties a working Videos tab; reading a missing switch as off
+ silently changes what a group's chat does.
+ """
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ block = page[page.index("setAppDirectories({"):]
+ block = block[:block.index("setNodeSupportsAppOps")]
+ for legacy in ("ack.video_root", "ack.audio_root", "ack.photo_roots"):
+ assert legacy in block, f"{legacy} is not read as a fallback"
+ assert "ack.chat_link_preview !== false" in page, (
+ "an absent link-preview switch must read as on, not off")
+
+
+def test_the_attachment_root_falls_back_to_the_older_answer():
+ """
+ A 1.0 node's roots carry no `writable`, so nothing looks writable and the
+ paperclip would vanish. The group-wide `member_upload` flag is the only
+ answer such a node gives, and it is what gets used.
+ """
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ block = page[page.index("const writableRoots"):]
+ block = block[:block.index("const commonProps")]
+ assert "legacyNode" in block and "memberUpload" in block
+ assert "writable === undefined" in block