""" Adding an application must not mean editing the pages that render it. That is the whole claim of the plugin architecture, and it is the kind of claim that decays silently: the first special case for one app reads as harmless, and by the third the loop is a lookup table with a default branch. These tests are what makes the claim checkable. They are source-reading, which is weak evidence and the only kind available for the SPA. Where a stronger check exists it is used instead — `test_spa_syntax` parses every module, and `test_hook_ordering` catches the temporal-dead-zone fault this refactor's new import graph could otherwise reintroduce. """ import re from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" APPS = STATIC / "apps.js" GROUP_SETTINGS = STATIC / "group-settings.js" GROUP_PAGE = STATIC / "group-page.js" SETTINGS_UI = STATIC / "settings-ui.js" FOLDER_TREE = STATIC / "folder-tree.js" TRANSPORT = STATIC / "transport.js" # parents[2] is `packages/` — the tests live at # packages/meshbay-hub/tests/, so [0] is tests, [1] the package, [2] packages. # Got this wrong once and the two cross-package checks below skipped silently, # which is worse than not having them: a green run that measured nothing. NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" / "meshbay_node" / "transport" / "webrtc_server.py") PANES = ["chat-app-settings.js", "video-app-settings.js", "music-app-settings.js", "photos-app-settings.js"] pytestmark = pytest.mark.skipif(not APPS.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)] def _code_only(source: str) -> str: """ The same source with comments removed. A prose explanation of what moved out of a file is not the file naming an app — and the note recording *why* TMDB is no longer here is exactly the kind of comment this codebase wants kept. Crude on purpose: it does not understand strings containing `//`, which for these files is fine and for a parser would be a second implementation of one. """ source = re.sub(r"/\*.*?\*/", "", source, flags=re.S) return re.sub(r"^\s*//.*$", "", source, flags=re.M) # ── The registry is the only place an app is named ────────────────────────── def test_the_settings_page_names_no_application(): """ The apps loop renders whatever the registry holds. A branch on `'video'` here is the first step back to the 1338-line page this replaced, where every app's settings were inlined and the file grew with each one. """ panel = _code_only(_component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel")) for app in ("'video'", "'music'", "'photo'", "tmdb", "musicbrainz", "TMDB"): assert app not in panel, ( f"group-settings.js still names {app} — an app's own settings " f"belong in its settings file") def test_the_settings_page_renders_the_registry(): panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel") assert "configurableApps()" in panel assert "app.Settings" in panel, "the registry's component is not rendered" def test_every_registered_app_has_a_key_the_node_would_accept(): """ The registry key is the identifier everywhere: the tab, `apps_enabled`, the `app_directories` op, and the roster row the directories live in. A key here that `ALLOWED_APPS` does not have is an app whose settings are refused by the node with no clue why. """ node = NODE_SERVER if not node.exists(): pytest.skip("the node package is not in this checkout") m = re.search(r"ALLOWED_APPS = frozenset\(\{([^}]*)\}\)", node.read_text(encoding="utf-8")) assert m, "ALLOWED_APPS moved" allowed = set(re.findall(r"'([^']+)'|\"([^\"]+)\"", m.group(1))) allowed = {a or b for a, b in allowed} keys = set(re.findall(r"\{ key: '([^']+)'", APPS.read_text(encoding="utf-8"))) assert keys, "no app keys found — did the registry's shape change?" assert keys <= allowed, ( f"registered apps the node would refuse: {sorted(keys - allowed)}") # ── One contract, every pane ──────────────────────────────────────────────── @pytest.mark.parametrize("pane", PANES) def test_every_pane_takes_the_same_props(pane): """ A pane that reached for something else would make the loop that renders them conditional, which is the same thing as the page naming apps again. """ source = (STATIC / pane).read_text(encoding="utf-8") m = re.search(r"function \w+Settings\(\{([^}]*)\}\)", source) assert m, f"{pane}: no settings component with a destructured props object" props = {p.strip() for p in m.group(1).split(",") if p.strip()} allowed = {"roots", "dirs", "settings", "saveDirectories", "transport", "signFn"} assert props <= allowed, ( f"{pane} takes props outside the shared contract: " f"{sorted(props - allowed)}") @pytest.mark.parametrize("pane", PANES) def test_no_pane_imports_the_page_that_renders_it(pane): """ `group-settings` → `apps` → a pane → `group-settings` is a cycle, and ES modules answer it with a temporal-dead-zone ReferenceError at first render rather than an import error — the component simply does not appear. That is why the shared widgets live in `settings-ui.js`. """ source = (STATIC / pane).read_text(encoding="utf-8") assert "group-settings.js" not in source, ( f"{pane} imports the page that renders it — that is an import cycle") @pytest.mark.parametrize("pane", PANES) def test_every_pane_owns_its_own_busy_state(pane): """ One shared flag would disable every section while any one of them saves, and attribute one section's error message to another. """ source = (STATIC / pane).read_text(encoding="utf-8") assert "useSaver()" in source # ── The folder picker ─────────────────────────────────────────────────────── def test_the_picker_asks_the_node_for_nothing(): """ The tree is built from paths the client already holds. A fetch here would be a folder-browsing protocol, which this deliberately is not: what it shows is what the group's index contains, and a folder the node never indexed does not exist as far as the group is concerned. """ source = FOLDER_TREE.read_text(encoding="utf-8") for forbidden in ("hubFetch", "fetch(", "platform.node", "transport."): assert forbidden not in source, ( f"folder-tree.js reaches for {forbidden} — it is meant to be " f"derived from the index the client already has") def test_a_read_only_root_cannot_be_chosen_as_a_destination(): """ Chat's attachment folder is the one directory that gets written to, and the node refuses a read-only root for it. Letting the picker offer one would move that refusal to the moment somebody sends a file. """ source = FOLDER_TREE.read_text(encoding="utf-8") picker = _component(source, "FolderTreePicker") assert "requireWritable" in picker assert "root.writable" in picker, ( "writability is not consulted when deciding what is selectable") chat = (STATIC / "chat-app-settings.js").read_text(encoding="utf-8") assert "requireWritable=${true}" in chat, ( "Chat's directory picker does not require a writable root") def test_the_picker_can_clear_a_selection(): """ Confirming with nothing chosen is how an app's directories are unset, and an OK disabled on an empty selection would make that impossible without another control. """ picker = _component(FOLDER_TREE.read_text(encoding="utf-8"), "FolderTreePicker") ok = picker[picker.index("folder_tree.confirm") - 400: picker.index("folder_tree.confirm")] assert "disabled" not in ok # ── The generic op ────────────────────────────────────────────────────────── def test_the_directory_op_is_signed_and_names_its_app(): """ An operator shown "Media/Films" alone cannot tell which application is about to be pointed at it, and two apps' challenges would be indistinguishable — so the app is in the signed subject, and both sides build it the same way. """ transport = TRANSPORT.read_text(encoding="utf-8") body = transport[transport.index("async setAppDirectories("):] body = body[:body.index("\n async ", 1)] assert "admin_challenge" in body and "_authorizeAdminOp" in body assert "${appKey}:${clean.join(',')}" in body node = NODE_SERVER if node.exists(): assert 'f"{app}:{\',\'.join(clean)}"' in node.read_text(encoding="utf-8"), ( "the node builds a different subject than the client signs") def test_the_page_performs_exactly_one_app_specific_operation(): """ Pointing an app at folders is what every app has, so the page does it. Anything one app alone needs — a TMDB key, a link-preview switch — the pane does with the transport it is given. An app that only wants directories therefore touches neither file. """ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel") calls = set(re.findall(r"transport\.(set\w+)\(", panel)) # 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"} # One generic operation, keyed by the app's own name: adding an app adds # no message type and no call site here. generic = {"setAppDirectories"} assert calls - page_level == generic, ( f"the settings page performs app-specific operations: " f"{sorted(calls - page_level - generic)}") # ── The apps read a list ──────────────────────────────────────────────────── @pytest.mark.parametrize("app,prop", [ ("video-app.js", "videoDirectories"), ("music-app.js", "musicDirectories"), ("photos-app.js", "photoDirectories"), ]) def test_each_app_takes_a_list_of_directories(app, prop): """ Videos and Music took a single folder, so a library spread over two drives could not be described at all — the operator's only recourse was to point the app at a parent containing both, which pulls in everything else too. """ source = (STATIC / app).read_text(encoding="utf-8") assert prop in source for singular in ("videoRoot", "audioRoot"): assert singular not in source, ( f"{app} still reads {singular} — one shape per idea") def test_the_lists_are_read_under_one_name_each(): """ One name per app on the ack — `_directories`, always a list — and the shell names none of them. It read three by hand until 2026-09-10, and `helloworld` was not among them: the app that exists to prove a new one needs no special-casing had its directories dropped on arrival, which made the plugin claim false exactly where it is demonstrated. An app the node knows about must be one this page already handles, and the only way to guarantee that is to read the ack's own keys instead of a list kept here. A second *name* for the same answer would be the older fault: a page reading whichever it thinks of first, and a node filling only the other one emptying a working tab. """ page = GROUP_PAGE.read_text(encoding="utf-8") block = page[page.index("setAppDirectories("):] block = block[:block.index("setChatDirectory")] assert "endsWith('_directories')" in block, ( "the shell must derive the map from the ack's keys, not name apps") for named in ("ack.video_directories", "ack.music_directories", "ack.photo_directories"): assert named not in block, f"{named} is an application named by the shell" for gone in ("ack.video_root", "ack.audio_root", "ack.photo_roots"): assert gone not in block, f"{gone} is a second name for the same answer" def test_the_search_cache_reads_both_shapes(): """ The cross-group index cache lives in IndexedDB and outlives a deploy, so a reader opening Search after this ships still has entries written by the previous version. Reading only the new shape empties their results with nothing to distinguish it from "nothing matched". """ source = (STATIC / "search-page.js").read_text(encoding="utf-8") fn = _component(source, "cachedDirs") assert "legacyKey" in fn assert "videoRoot" in source and "audioRoot" in source and "photoRoots" in source # ── Where a result is reported ────────────────────────────────────────────── def test_the_directory_result_is_reported_below_the_controls(): """ It was rendered first, between the section's intro and the table header — above everything the eye had already moved past by the time it appeared. It belongs under the button that caused it, which is the last thing in the section. """ table = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "SharedDirectoriesTable") # Two of them: the early return for a group with no directories yet, and # the real one. Both report, and both must report last — keyed on the # wrapper rather than on `return`, of which there are more. blocks = table.split('
')[1:] assert len(blocks) == 2, "the section's shape changed; this test is stale" for block in blocks: assert block.index("${addControls}") < block.index("${message}"), ( "the result is rendered above the Add button rather than below it") with_table = next(b for b in blocks if "