""" 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