""" 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: * 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_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")