diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 10:35:09 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 10:35:09 +0200 |
| commit | 2c0903c648e24b4e2adf20492398e8b67d033b49 (patch) | |
| tree | 0435f298010f0f946362f28baebbe88337ca8768 /packages/meshbay-hub/tests | |
| parent | 0ed078c92cabab1dab0f70f321562032ea549ce6 (diff) | |
| parent | eeda274d751c537f4ecef3087994a16a9517478f (diff) | |
| download | meshbay-2c0903c648e24b4e2adf20492398e8b67d033b49.tar.gz | |
Merge branch 'refactor/groups-phase1'
Groups refactor, phases 1-3.
The root model replaces the old `upload` flag and group-wide `member_upload`
with per-root `writable`/`removable`/`ejected`, carried by a `RootSet` that
both front doors — the loopback API and signed MNP — reach through the same
`ops` functions. MNP goes to 1.1, additively: the roots table now rides on
`index_delta`, so a root added, removed, ejected or plugged reaches every
connected client instead of only whoever reloaded.
The group UI becomes a plugin architecture: an application is a registry
entry in `apps.js` plus its own files, with directories stored generically
by `ops.set_app_directories` under whatever the app is called. A reference
application, hidden behind `?dev=1`, is what makes that claim testable —
adding it is what found the two places still naming apps by hand.
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.py | 383 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_css_variables.py | 78 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py | 159 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hook_ordering.py | 7 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py | 175 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py | 90 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_search_media_merge.py | 12 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_spa_imports.py | 87 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_spa_syntax.py | 85 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_transfers.py | 14 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_transport_contracts.py | 10 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_upload_controls_hidden.py | 263 |
12 files changed, 1294 insertions, 69 deletions
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py new file mode 100644 index 0000000..b8afc23 --- /dev/null +++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py @@ -0,0 +1,383 @@ +""" +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"} + # 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 - 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_an_older_node_still_fills_the_lists(): + """ + A node speaking MNP 1.0 sends `video_root`, not `video_directories`. + Reading the missing plural as "nothing configured" would empty a working + Videos tab on every group hosted by a node that has not been upgraded. + """ + page = GROUP_PAGE.read_text(encoding="utf-8") + block = page[page.index("setAppDirectories({"):] + block = block[:block.index("setChatDirectory")] + assert "ack.video_root" in block and "ack.audio_root" in block + assert "ack.photo_roots" in block + + +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('<div class="shared-directories-table">')[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 "<table" in b) + assert with_table.index("<table") < with_table.index("${message}"), ( + "the result is rendered above the table") + + +def test_a_refusal_does_not_look_like_a_footnote(): + """ + Every message here was a `settings-hint` — dim grey body text — so "two + roots would both be called uploads" read as an aside about the section + rather than as the reason nothing happened. + """ + table = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "SharedDirectoriesTable") + block = table[table.index("const message ="):] + block = block[:block.index("`;") + 2] + assert "error-msg" in block + assert "role=" in block and "alert" in block, ( + "a refusal that appears after a click has to be announced, not just " + "drawn") + + +# ── Saving, and being able to tell ────────────────────────────────────────── + +PANE_FLAGS = {"chat-app-settings.js": "dirty", + "video-app-settings.js": "dirsDirty", + "music-app-settings.js": "dirsDirty", + "photos-app-settings.js": "dirty"} + + +@pytest.mark.parametrize("pane", sorted(PANE_FLAGS)) +def test_save_is_a_button_and_not_dim_text(pane): + """ + It was `btn btn-small btn-secondary`, and there is no `.btn` rule in the + stylesheet at all — so it took `.btn-secondary`: no background, a + transparent border, dim grey text. Enabled it already looked like a + disabled control, and disabled it was that at 40% opacity. + + "You cannot always click Save, you do not notice, and it does not work" is + one sentence describing all of that. + """ + source = (STATIC / pane).read_text(encoding="utf-8") + assert 'class="app-save"' in source, f"{pane}'s Save is not the shared control" + assert "btn-secondary" not in source, ( + f"{pane}'s Save is still styled as dim text") + + +def test_the_disabled_state_is_visually_distinct(): + css = (STATIC / "style.css").read_text(encoding="utf-8") + rule = css[css.index(".app-save {"):] + rule = rule[:rule.index("}", rule.index(".app-save:disabled")) + 1] + assert "var(--accent)" in rule, "an enabled Save has no fill" + disabled = rule[rule.index(".app-save:disabled"):] + assert "background: none" in disabled and "--text-dim" in disabled, ( + "disabled differs from enabled by opacity alone, which is what made " + "it unreadable") + + +def test_a_saved_setting_reaches_the_page_that_renders_the_pane(): + """ + Why Chat was the systematic case. + + `_dispatch` resolves an admin ack against the pending request and returns — + right for an op whose caller already knows the value it chose. Chat's pane + calls `transport.setChatDirectory` itself, so nothing told `group-page` + anything: the node saved it, every *other* connected client learned it from + the broadcast, and the one that asked went on showing an unsaved-looking + draft. Clicking Save again just re-sent it. + """ + transport = TRANSPORT.read_text(encoding="utf-8") + block = transport[transport.index("const BROADCAST_ACK_TYPES"):] + block = block[:block.index("]);") + 3] + for ack in ("chat_directory_ack", "chat_link_preview_ack", + "app_directories_ack"): + assert ack in block, f"{ack} is swallowed by its own request" + + assert "_replayBroadcast" in transport + replay = transport[transport.index("function _replayBroadcast"):] + replay = replay[:replay.index("\n}") + 2] + for cb in ("_onChatDirectory", "_onChatLinkPreview", "_onAppDirectories"): + assert cb in replay, f"{cb} is never called for the requester" diff --git a/packages/meshbay-hub/tests/test_css_variables.py b/packages/meshbay-hub/tests/test_css_variables.py new file mode 100644 index 0000000..ec576d6 --- /dev/null +++ b/packages/meshbay-hub/tests/test_css_variables.py @@ -0,0 +1,78 @@ +""" +Every `var(--x)` names a variable this stylesheet defines. + +CSS fails silently and generously: an unknown custom property makes the whole +declaration invalid, and the rule around it still applies. So a panel written +`background: var(--bg-panel)` when the palette calls it `--bg-surface` does not +error, does not warn, and does not look obviously wrong in a diff — it just has +no background, and the page shows straight through the modal. + +That is not hypothetical. It shipped in the folder picker, and the same file +already carried one from before: `.notif-badge` asked for `--danger` where the +palette says `--error`, so the unread count was white text on nothing. Found by +a person looking at a screenshot, which is the only thing that was going to +find it. + +A fallback (`var(--x, #ef4444)`) is a lesser version of the same mistake: the +declaration is valid and renders, but the name is still fiction, and the next +reader is told a variable exists that does not. Those are reported separately. + +There was a third check here, comparing the dark palette against the light one +for anything a theme must not inherit. It fired on `--border-focus`, which is +a focus ring deliberately shared by both themes — correct code. A heuristic +that has to be explained away on its first run is worse than no test, so it is +gone rather than exempted. +""" + +import re +from pathlib import Path + +import pytest + +STYLE = (Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" + / "static" / "style.css") + +pytestmark = pytest.mark.skipif(not STYLE.exists(), + reason="the stylesheet is not in this checkout") + +# Where a custom property is defined. Two forms, and both had to be learned +# the hard way while writing this: a scoped one written inline +# (`.video-overview-wrap { --ov-lh: 1.5em; --ov-lines: 3; }`), which an +# anchored pattern misses, and one preceded by an explanatory comment, which a +# `[{;]`-prefixed pattern misses because the character before it is `/`. Either +# mistake reports correct code as broken, which is the fastest way to have a +# test like this ignored. +DEFINE = re.compile(r"(?:^|[{;])\s*(--[A-Za-z0-9_-]+)\s*:", re.M) +# `var(--name` and `var(--name, fallback`. +USE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)\s*(,)?") + + +def _text() -> str: + return STYLE.read_text(encoding="utf-8") + + +def test_every_variable_used_without_a_fallback_is_defined(): + source = _text() + defined = set(DEFINE.findall(source)) + assert defined, "no custom properties found — did the palette move?" + + missing = sorted({name for name, fallback in USE.findall(source) + if not fallback and name not in defined}) + assert not missing, ( + "used but never defined, so every declaration naming one of these is " + "invalid and silently does nothing:\n " + "\n ".join(missing)) + + +def test_a_fallback_does_not_excuse_an_unknown_name(): + """ + `var(--danger, #ef4444)` renders, so it is not the same bug — but it is the + same mistake, and it will read as intentional to the next person. Reported + so the name gets corrected rather than the fallback relied on. + """ + source = _text() + defined = set(DEFINE.findall(source)) + guessed = sorted({name for name, fallback in USE.findall(source) + if fallback and name not in defined}) + assert not guessed, ( + "used with a fallback but not defined anywhere — rename to the real " + "variable:\n " + "\n ".join(guessed)) diff --git a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py new file mode 100644 index 0000000..a1baf92 --- /dev/null +++ b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py @@ -0,0 +1,159 @@ +""" +The reference application, and what it is for. + +`docs/refactor-groups.md` claims that adding an application costs a registry +entry and the app's own files — no op, no MNP message, no route, no edit to the +pages that render it. Every other test of that claim reads source for the +*absence* of app names, which proves nobody wrote a special case for Videos. It +cannot prove that a genuinely new app works, because there was no new app. + +HelloWorld is one. It stores directories, appears as a tab, has a settings pane +and lists files, and the node has never heard its name outside one allow-list +entry. The assertions below are the claim, stated as things that must stay true +of a file that was not written for it. + +It ships hidden behind `?dev=1` (apps.js's `dev: true`). Registering it +normally would put a toy app in every operator's group; not registering it +would prove nothing, since registration is exactly the thing being claimed as +sufficient. + +**Two honest exceptions**, both found *by* adding it and both fixed by making +the code less app-specific rather than more: + +* `group-settings.js` fell back to the whole registry when a group had no + `enabled_apps` yet, which would have turned a hidden app on for everyone. It + asks `availableApps()` now. +* `group-page.js` wrote out `videoDirectories` / `musicDirectories` / + `photoDirectories` by hand. It derives `<key>Directories` from the registry + now, which is what made the claim true rather than nearly true. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +NODE_SRC = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src" + / "meshbay_node") + +APP = STATIC / "helloworld-app.js" +SETTINGS = STATIC / "helloworld-app-settings.js" + +pytestmark = pytest.mark.skipif(not APP.exists(), + reason="the reference app is not in this checkout") + + +def _code_only(source: str) -> str: + source = re.sub(r"/\*.*?\*/", "", source, flags=re.S) + return re.sub(r"^\s*//.*$", "", source, flags=re.M) + + +# ── The claim ──────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("name", [ + "group-page.js", "group-settings.js", "files-app.js", "transport.js", + "hub-client.js", "settings-ui.js", "folder-tree.js", +]) +def test_no_shared_client_file_mentions_it(name): + """ + The registry is where an app is named, and nowhere else. A branch on + `'helloworld'` in any of these would mean the architecture works for four + apps somebody wrote plumbing for. + """ + source = _code_only((STATIC / name).read_text(encoding="utf-8")) + assert "helloworld" not in source.lower(), ( + f"{name} names the reference app — adding an application is supposed " + f"to touch nothing here") + + +@pytest.mark.parametrize("name", [ + "ops.py", "roster.py", "config.py", "roots.py", +]) +def test_no_shared_node_module_mentions_it(name): + """ + Its directories are stored by `ops.set_app_directories`, which keys the row + by whatever the app is called. Nothing on the node knows what it is. + """ + source = (NODE_SRC / name).read_text(encoding="utf-8") + assert "helloworld" not in source.lower(), ( + f"{name} names the reference app; the generic path was supposed to " + f"cover it") + + +def test_the_node_names_it_once_and_only_in_the_allow_list(): + """ + `ALLOWED_APPS` is server-side enforcement — a client naming an app this + node does not know is refused — so an app absent from it could not + demonstrate anything. That entry plus the client's registry line is the + whole cost. + """ + source = (NODE_SRC / "transport" / "webrtc_server.py").read_text( + encoding="utf-8") + code = re.sub(r"^\s*#.*$", "", source, flags=re.M) + hits = [ln for ln in code.splitlines() if "helloworld" in ln.lower()] + assert len(hits) == 1, f"expected one mention, got: {hits}" + assert "ALLOWED_APPS" in hits[0] or "helloworld" in hits[0] + assert "ALLOWED_APPS" in code[:code.index("helloworld") + 200] + + +def test_the_registry_entry_is_ordinary(): + source = (STATIC / "apps.js").read_text(encoding="utf-8") + entry = source[source.index("key: 'helloworld'"):] + entry = entry[:entry.index("},") + 2] + for field in ("icon:", "labelKey:", "Component:", "Settings:"): + assert field in entry, f"the entry has no {field}" + assert "dev: true" in entry, "it would ship to every operator" + + +# ── And it is held to the same contract as the rest ────────────────────────── + +def test_its_settings_pane_takes_the_shared_props_and_no_others(): + m = re.search(r"function \w+Settings\(\{([^}]*)\}\)", + SETTINGS.read_text(encoding="utf-8")) + assert m + props = {p.strip() for p in m.group(1).split(",") if p.strip()} + assert props <= {"roots", "dirs", "settings", "saveDirectories", + "transport", "signFn"} + + +def test_it_reads_its_directories_under_its_own_key(): + """ + `<key>Directories` — the shape `group-page.js` derives for every registered + app. An app reading a name spelled anywhere else would need that place + edited too. + """ + for path in (APP, SETTINGS): + assert "helloworldDirectories" in path.read_text(encoding="utf-8") + + +def test_it_does_not_reach_for_the_transport(): + """ + It has no third-party service and no setting of its own, so it needs + neither — which is the case an app author most often starts from, and the + one the architecture has to make free. + """ + source = _code_only(SETTINGS.read_text(encoding="utf-8")) + assert "transport." not in source + assert "saveDirectories" in source + + +# ── Hidden, but genuinely registered ──────────────────────────────────────── + +def test_a_dev_app_is_filtered_out_by_default(): + source = (STATIC / "apps.js").read_text(encoding="utf-8") + assert "function availableApps()" in source + body = source[source.index("function availableApps()"):] + body = body[:body.index("\n}") + 2] + assert "devAppsShown()" in body and "a.dev" in body + + +def test_nothing_falls_back_to_the_unfiltered_registry(): + """ + A fallback of "every app in the registry" would enable a hidden one for the + whole group. This is the exception the reference app found. + """ + source = _code_only((STATIC / "group-settings.js").read_text(encoding="utf-8")) + assert "APPS.map(" not in source and "APPS.filter(" not in source, ( + "group-settings.js reads the raw registry; it should ask " + "availableApps()") diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py index f292b3e..01516bd 100644 --- a/packages/meshbay-hub/tests/test_hook_ordering.py +++ b/packages/meshbay-hub/tests/test_hook_ordering.py @@ -37,6 +37,13 @@ STATIC_FILES = [ "video-player.js", "video-app.js", "music-app.js", "music-player.js", "photos-app.js", "group-settings.js", + # The per-app settings architecture (docs/refactor-groups.md §3). Reached + # through the apps.js registry rather than imported by name, so a file + # left out of this list is one nothing checks — the failure is silent. + "settings-ui.js", "folder-tree.js", + "chat-app-settings.js", "video-app-settings.js", + "music-app-settings.js", "photos-app-settings.js", + "helloworld-app.js", "helloworld-app-settings.js", "auth-page.js", "explore-page.js", "create-group-page.js", ] 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 diff --git a/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py b/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py new file mode 100644 index 0000000..d126a05 --- /dev/null +++ b/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py @@ -0,0 +1,90 @@ +""" +`window.prompt` does not exist in the desktop client. + +The same `static/` tree is the web page and the application (CLAUDE.md's "one +UI source"), and Electron does not implement `prompt` — Chromium leaves it to +the embedder and Electron declines. It does not return null: it **throws**, +`Error: prompt() is not supported.` + +That made the Files toolbar's New folder button do nothing whatsoever. The call +sat above its own try, so the click produced no folder, no error, and nothing +on screen to react to — the failure looks exactly like a dead button, which is +what it was reported as. + +Measured rather than assumed, against this repo's own Electron 44: + + prompt('name?') -> Error: prompt() is not supported. + confirm('sure?') -> opens a real modal + alert('hi') -> opens a real modal + +So `confirm` and `alert` stay allowed and are used in a dozen places; only +`prompt` is banned. Anything that needs typed input needs a field. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif(not STATIC.exists(), + reason="SPA sources unavailable") + +# `prompt(` as a call, not `window.prompt` inside a comment or a longer +# identifier like `mkdir_prompt` / `promptForName`. +CALL = re.compile(r"(?<![\w.$])(?:window\.)?prompt\s*\(") + + +def _code_only(source: str) -> str: + source = re.sub(r"/\*.*?\*/", "", source, flags=re.S) + return re.sub(r"^\s*//.*$", "", source, flags=re.M) + + +def test_nothing_calls_prompt(): + offenders = [] + for path in sorted(STATIC.glob("*.js")): + if path.name == "sw.js": + continue + for i, line in enumerate(_code_only( + path.read_text(encoding="utf-8")).splitlines(), 1): + if CALL.search(line): + offenders.append(f"{path.name}:{i}: {line.strip()}") + + assert not offenders, ( + "prompt() throws in the desktop client, and the click that reaches it " + "does nothing at all:\n " + "\n ".join(offenders)) + + +def test_the_pattern_would_catch_a_real_call(): + """ + A guard that matches nothing passes over an empty set, which looks exactly + like success. Both spellings, and the near-misses it must not flag. + """ + assert CALL.search("const n = prompt('x');") + assert CALL.search("const n = window.prompt('x');") + assert not CALL.search("t('group.mkdir_prompt')") + assert not CALL.search("promptForName();") + assert not CALL.search("this.prompt(1);") + + +def test_creating_a_folder_uses_a_field(): + """ + The control the ban is about. A typed name needs somewhere to type it, and + an inline field can show the node's refusal beside the input rather than + after a dialog has closed. + """ + source = (STATIC / "files-app.js").read_text(encoding="utf-8") + assert "tb-mkdir-input" in source + assert "newDirName" in source + assert "group.mkdir_prompt" in source, "the field has no label or placeholder" + + +def test_leaving_the_folder_drops_a_half_typed_name(): + """ + Otherwise the folder is created where the person is no longer looking — + they navigated away, the draft came along, and the name lands in a + directory they were not thinking about. + """ + source = (STATIC / "files-app.js").read_text(encoding="utf-8") + assert "useEffect(() => { setNewDirName(null); }, [currentPath]);" in source diff --git a/packages/meshbay-hub/tests/test_search_media_merge.py b/packages/meshbay-hub/tests/test_search_media_merge.py index 0312c57..65f85aa 100644 --- a/packages/meshbay-hub/tests/test_search_media_merge.py +++ b/packages/meshbay-hub/tests/test_search_media_merge.py @@ -71,12 +71,12 @@ def pipeline(): return "\n".join([ "const t = (k) => k;", EXPORT.sub("", MERGE.read_text()), - _block(VIDEO_APP, "function underVideoRoot(entry, videoRoot) {"), + _block(VIDEO_APP, "function underVideoRoot(entry, directories) {"), _block(VIDEO_APP, "function buildSeasons(episodes) {"), - _block(VIDEO_APP, "function groupVideoEntries(entries, videoRoot) {"), + _block(VIDEO_APP, "function groupVideoEntries(entries, videoDirectories) {"), _block(MUSIC_APP, "function foldKey(s) {"), - _block(MUSIC_APP, "function underAudioRoot(entry, audioRoot) {"), - _block(MUSIC_APP, "function groupMusicEntries(entries, audioRoot) {"), + _block(MUSIC_APP, "function underAudioRoot(entry, directories) {"), + _block(MUSIC_APP, "function groupMusicEntries(entries, musicDirectories) {"), _block(PHOTOS_APP, "function underAnyPhotoRoot(entry, photoRoots) {"), _block(PHOTOS_APP, "function groupPhotoAlbums(entries, photoRoots) {"), _const("SEARCH_VIDEO_ROOT"), @@ -107,7 +107,7 @@ def _grid(tmp_path, pipeline, entries, salt="reader", local=()): salt: {json.dumps(salt)}, isLocal: (g) => local.has(g), }}); - const {{ movies, shows }} = groupVideoEntries(merged, SEARCH_VIDEO_ROOT); + const {{ movies, shows }} = groupVideoEntries(merged, [SEARCH_VIDEO_ROOT]); console.log(JSON.stringify({{ movies: movies.map((e) => ({{ id: e.id, title: e.display_title || e.name, groupId: e.groupId, @@ -136,7 +136,7 @@ def _albums(tmp_path, pipeline, entries, salt="reader", local=()): salt: {json.dumps(salt)}, isLocal: (g) => local.has(g), }}); - const {{ albums, tracks }} = groupMusicEntries(merged, SEARCH_AUDIO_ROOT); + const {{ albums, tracks }} = groupMusicEntries(merged, [SEARCH_AUDIO_ROOT]); console.log(JSON.stringify({{ albums: albums.map((a) => ({{ artist: a.artist, album: a.album, diff --git a/packages/meshbay-hub/tests/test_spa_imports.py b/packages/meshbay-hub/tests/test_spa_imports.py new file mode 100644 index 0000000..230f33f --- /dev/null +++ b/packages/meshbay-hub/tests/test_spa_imports.py @@ -0,0 +1,87 @@ +""" +Every import in the SPA points at a file that exports what it names. + +The failure this catches has a distinctive shape: nothing errors at build time, +because there is no build; the browser resolves the module graph at load, finds +a binding that is not there, and the page renders blank or the component simply +does not appear. `node --check` cannot see it — it parses one file at a time — +and neither can the source-reading guards, which look inside a file rather than +between two. + +Written after the settings-page split, which moved two shared components into a +new module and rewired eight files to import them from there. That is exactly +the change where a rename lands in one file and not the other. + +It is a static check, not a load: it says the name is exported, not that the +value is what the caller expects. `test_spa_syntax` covers parsing; +`test_hook_ordering` covers the ordering fault that also presents as a missing +component. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif(not STATIC.exists(), + reason="SPA sources unavailable") + +# `import { a, b as c } from './x.js';` / `import * as p from ...` / default. +IMPORT = re.compile( + r"^import\s+(?:\{([^}]*)\}|(\*\s+as\s+\w+)|(\w+))\s+from\s+'([^']+)';", re.M) +EXPORT_BLOCK = re.compile(r"^export\s+\{([^}]*)\};", re.M) +EXPORT_DECL = re.compile( + r"^export\s+(?:default\s+)?(?:async\s+)?(?:function|const|class|let|var)\s+(\w+)", + re.M) + + +def _exported(source: str) -> set[str]: + names = set(EXPORT_DECL.findall(source)) + for block in EXPORT_BLOCK.findall(source): + for raw in block.split(","): + name = raw.strip().split(" as ")[-1].strip() + if name: + names.add(name) + return names + + +def test_every_named_import_resolves(): + problems: list[str] = [] + for path in sorted(STATIC.glob("*.js")): + source = path.read_text(encoding="utf-8") + for names, star, default, spec in IMPORT.findall(source): + # vendor/ is third-party, bundled, and does not use a form this + # reads. Its exports are covered by the app failing to start. + if not spec.startswith("./") or "vendor/" in spec: + continue + target = (path.parent / spec[2:]).resolve() + if not target.exists(): + problems.append(f"{path.name}: imports {spec} — no such file") + continue + if star or default or not names.strip(): + continue + available = _exported(target.read_text(encoding="utf-8")) + for raw in names.split(","): + name = raw.strip().split(" as ")[0].strip() + if name and name not in available: + problems.append( + f"{path.name}: imports {{{name}}} from {spec}, " + f"which does not export it") + + assert not problems, "unresolved imports:\n" + "\n".join(problems) + + +def test_the_check_can_see_a_real_module(): + """ + Guard against the parser quietly matching nothing — a regex that stopped + finding imports would make the test above pass over an empty set, which + looks exactly like success. + """ + source = (STATIC / "apps.js").read_text(encoding="utf-8") + found = IMPORT.findall(source) + assert len(found) >= 5, ( + "the import pattern no longer matches apps.js — this test is then " + "checking nothing") + assert "configurableApps" in _exported(source) diff --git a/packages/meshbay-hub/tests/test_spa_syntax.py b/packages/meshbay-hub/tests/test_spa_syntax.py new file mode 100644 index 0000000..352f84b --- /dev/null +++ b/packages/meshbay-hub/tests/test_spa_syntax.py @@ -0,0 +1,85 @@ +""" +Every SPA module parses. + +This is the cheapest possible test and the suite did not have it, which is how +a `${/* ... */''}` — htm template syntax, pasted into a plain object literal — +reached a committed file. Nothing else here would catch it: the source-reading +guards (`test_hook_ordering`, `test_transport_contracts`, `test_spa_ordering`) +match patterns in text that parses or does not, and the browser harnesses only +load the few modules they need. + +**`node --check foo.js` is not the check.** It reports success on exactly the +file above: given a `.js` extension it makes its own decision about how to +parse, and a module-syntax error inside one can come back clean. Copying to +`.mjs` first is what forces the module parser, and it is the difference +between a green run and a real one — the same shape as the "a test that models +a fix agrees with it by construction" note in CLAUDE.md, one level lower. + +It says nothing about names, imports resolving, or hooks being in order. Those +have their own tests. This one only says the file is JavaScript. +""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not STATIC.exists(), + reason="node or the SPA sources are not available") + + +def _modules() -> list[Path]: + # vendor/ is third-party and shipped as-is; sw.js is a service worker, a + # classic script by definition, and transport.js is loaded with a plain + # <script> tag for the same historical reason (see its own header). + files = sorted(STATIC.glob("*.js")) + sorted((STATIC / "locales").glob("*.js")) + return [f for f in files if f.name not in ("sw.js",)] + + +def test_every_module_parses(tmp_path): + broken: list[str] = [] + for path in _modules(): + # The .mjs copy is the whole point — see this module's docstring. + copy = tmp_path / (path.stem + ".mjs") + copy.write_text(path.read_text(encoding="utf-8"), encoding="utf-8") + proc = subprocess.run(["node", "--check", str(copy)], + capture_output=True, text=True) + if proc.returncode != 0: + first = (proc.stderr or "").strip().splitlines() + detail = next((ln for ln in first if "Error" in ln), first[:1] and first[0] or "") + broken.append(f"{path.name}: {detail}") + + assert not broken, "SPA modules that do not parse:\n" + "\n".join(broken) + + +def test_the_check_would_notice_a_broken_file(tmp_path): + """ + The test above passing means nothing unless it can fail, and the way it + fails is the interesting part: this same content in a file called `.js` + is reported as fine. + """ + bad = "export const a = {\n ${/* not object syntax */''}\n b: 1,\n};\n" + + as_js = tmp_path / "sample.js" + as_js.write_text(bad, encoding="utf-8") + lenient = subprocess.run(["node", "--check", str(as_js)], + capture_output=True, text=True) + + as_mjs = tmp_path / "sample.mjs" + as_mjs.write_text(bad, encoding="utf-8") + strict = subprocess.run(["node", "--check", str(as_mjs)], + capture_output=True, text=True) + + assert strict.returncode != 0, ( + "the .mjs check no longer reports a module syntax error — this whole " + "test is then measuring nothing") + if lenient.returncode == 0: + # Recorded rather than asserted: this is a Node behaviour, and it + # improving would be good news, not a failure. The .mjs copy stays + # either way, because relying on the loose path is what let this + # through once already. + pass diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py index 86d58cd..3316615 100644 --- a/packages/meshbay-hub/tests/test_transfers.py +++ b/packages/meshbay-hub/tests/test_transfers.py @@ -208,9 +208,17 @@ def test_the_colour_is_defined_for_that_mark(): # ── The file list ─────────────────────────────────────────────────────────── def test_a_folder_name_carries_no_trailing_slash(): - """The folder icon in the cell beside it already says what it is.""" + """ + The folder icon in the cell beside it already says what it is. + + Anchored on `key=${full}`, which is the row that renders a directory. The + first `dir-row` in the file is the ".." row added later, whose only cell is + an ellipsis — slicing from there found no `${d}` and failed on a name it + had never looked at. + """ source = STATIC.joinpath("files-app.js").read_text(encoding="utf-8") - row = source[source.index('class="file-row dir-row"'):] - row = row[:row.index("</tr>")] + start = source.rindex("<tr", 0, source.index("key=${full}")) + row = source[start:source.index("</tr>", start)] + assert "dir-row" in row, "the anchor no longer lands on the directory row" assert "${d}/" not in row, "the folder name is rendered with a trailing slash" assert "${d}" in row diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index fee80bb..b462242 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -33,6 +33,16 @@ SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js", STATIC / "music-app.js", STATIC / "music-player.js", STATIC / "photos-app.js", STATIC / "group-settings.js", + # Same reason as test_hook_ordering's STATIC_FILES: these are + # reached through the registry, so leaving one out here means it + # is simply never checked. + STATIC / "settings-ui.js", STATIC / "folder-tree.js", + STATIC / "chat-app-settings.js", + STATIC / "video-app-settings.js", + STATIC / "music-app-settings.js", + STATIC / "photos-app-settings.js", + STATIC / "helloworld-app.js", + STATIC / "helloworld-app-settings.js", STATIC / "auth-page.js", STATIC / "explore-page.js", CREATE_GROUP] diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py index 94917d2..f6f476e 100644 --- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py +++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py @@ -1,14 +1,22 @@ """ -When the operator closes uploading, the controls go — both of them. +When a directory is read-only, the controls that write to it go — both of them. -There are two ways to put a file into a group and they are in different +There are two ways to put a file into a group and they live in different components: the Upload button in the Files toolbar, and the paperclip in the chat composer. Hiding one and forgetting the other is the obvious mistake, and -the second one is the easier to forget because it does not look like an upload. +the paperclip is the easier to forget because it does not look like an upload. Nothing here is a security property. **The node refuses the upload** — that is -`test_member_upload_policy.py` in the node package. This is about not offering -somebody a button whose only outcome is an error message. +`test_root_writable_policy.py` and `test_security_regressions.py` in the node +package. This is about not offering somebody a button whose only outcome is an +error message. + +What the RO/RW refactor changed: there is no group-wide answer any more. Files +uploads into *the root being browsed*, so its button follows that root's +`writable`. Chat has no folder on screen, so the shell picks one for it. The +two therefore read different things on purpose, and the tests below pin that +each reads the right one — a stronger claim than the old "both read one +boolean", which is why that assertion is gone rather than adapted. """ import re @@ -17,10 +25,6 @@ from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" -# The group-page refactor split what used to be one app.js into one file per -# "application" plus the group shell. mayUpload itself is still derived once, -# in the shell (group-page.js) — Files and Chat each moved to their own file -# and receive it as a prop, the same shape ChatPanel already took. APP = STATIC / "app.js" GROUP_PAGE = STATIC / "group-page.js" FILES_APP = STATIC / "files-app.js" @@ -36,45 +40,133 @@ def app() -> str: return GROUP_PAGE.read_text(encoding="utf-8") -def _component(app: str, name: str) -> str: - start = app.index(f"\nfunction {name}(") - end = app.find("\nfunction ", start + 1) - return app[start:end if end != -1 else len(app)] +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)] # ── Both controls ─────────────────────────────────────────────────────────── def test_the_files_toolbar_hides_its_upload_button(): + """ + Gated on the root being browsed, not on a group-wide answer: with one + writable root and one read-only one, a single boolean would offer the + button in both and produce a refusal in one of them. + """ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") toolbar = page[page.index("file-toolbar"):] toolbar = toolbar[:toolbar.index("breadcrumbs")] - assert "mayUpload &&" in toolbar, "the Upload button is offered regardless" + assert "currentRootWritable" in toolbar, ( + "the Upload button is offered regardless of the directory's own flag") + + +def test_the_files_upload_button_is_not_offered_at_the_top_of_a_group(): + """ + The top level is the set of roots, which is the operator's configuration + and not a directory on anyone's disk. There is nothing to upload *into* + there, and no root name to give the node. + """ + page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") + toolbar = page[page.index("file-toolbar"):] + toolbar = toolbar[:toolbar.index("breadcrumbs")] + assert "currentPath &&" in toolbar + + +def test_the_new_folder_button_follows_the_same_rule_as_upload(): + """ + Both write to the operator's disk, so both need a writable root — the node + refuses either otherwise. It used to require `isNodeAdmin`, which + contradicted the node ("a member who can add a file can organise where it + goes") and hid the control from everyone who could have used it. + """ + page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") + decl = page[page.index("const canCreateDir"):] + decl = decl[:decl.index(";") + 1] + assert "currentRootWritable" in decl + assert "currentPath" in decl, ( + "the top of a group is the set of roots, not a directory to create in") + assert "isNodeAdmin" not in decl + + +def test_an_icon_only_button_still_says_what_it_is(): + """ + The name moved into a tooltip to save toolbar width. A `title` is invisible + to a screen reader on a button with no text, so the label has to be there + as well — otherwise the control is simply unnamed for anyone not reading + with their eyes. The same goes for the field it opens, which has a + placeholder and no visible label. + """ + page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") + opener = page[page.index("canCreateDir && newDirName === null"):] + opener = opener[:opener.index("</button>")] + assert "title=" in opener and "aria-label=" in opener + assert "group.mkdir" in opener + + field = page[page.index("canCreateDir && newDirName !== null"):] + field = field[:field.index("</span>")] + assert "aria-label=" in field, ( + "the name field is labelled by a placeholder alone, which a screen " + "reader does not announce as a name") def test_the_chat_composer_hides_its_paperclip(): chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel") composer = chat[chat.index("chat-input-row"):] - assert "mayUpload &&" in composer, ( + assert "attachRoot ?" in composer, ( "the chat attachment is the second way in and is still offered") -def test_both_read_the_same_answer(app): - """Two derivations would eventually disagree, and the disagreement would - be one of them offering an upload the node refuses.""" - assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", app), ( - "mayUpload is no longer derived in one place") - # Files and Chat both receive it from the same `commonProps` object the - # shell spreads into whichever app tab is active — one derivation feeding - # one object, rather than two hand-written prop attributes that could - # drift apart. +def test_the_paperclip_says_why_rather_than_vanishing(): + """ + A control that disappears leaves the reader no way to find out what would + bring it back. A group with no writable directory is a state an operator + can fix, so it is worth naming. + """ + chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel") + composer = chat[chat.index("chat-input-row"):] + assert "chat.attach_read_only" in composer + + +# ── One derivation, in the shell ──────────────────────────────────────────── + +def test_the_attachment_directory_is_decided_once(app): + """ + Two derivations would eventually disagree, and the disagreement would be + one of them offering an upload the node refuses. + """ + assert re.search(r"const attachRoot = ", app), ( + "attachRoot is no longer derived in one place") props = app[app.index("const commonProps = {"):app.index("return html`")] - assert "mayUpload," in props or "mayUpload:" in props, ( - "mayUpload is not in the shared props object every app receives") + assert "attachRoot," in props or "attachRoot:" in props, ( + "attachRoot is not in the shared props object every app receives") -def test_the_operator_keeps_their_own_controls(app): - assert "memberUpload || isNodeAdmin" in app, ( - "turning uploads off would hide the operator's own upload button") +def test_an_unavailable_root_is_not_offered_as_a_destination(app): + """ + `writable` is configuration and stays true while a drive is unplugged or + ejected. Offering it anyway produces a refusal from the node with no + explanation on screen. + """ + block = app[app.index("const writableRoots"):] + block = block[:block.index("const attachRoot")] + assert "available" in block + + +def test_files_uploads_into_the_root_it_is_showing(): + """ + The client has to name the destination now, because the node cannot choose + between several writable roots without guessing — and a guess here means a + file landing in a directory nobody was looking at. + """ + page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel") + upload = page[page.index("const uploadFile"):] + upload = upload[:upload.index("const makeDirectory")] + assert "dir: uploadDir" in upload, "the node is left to choose the folder" + assert "const uploadDir = currentPath" in upload, ( + "the destination is not the folder on screen") + assert "root: uploadRoot" in upload, ( + "a node too old for `dir` reads `root`, and gets nothing without it") # ── Learning the answer ───────────────────────────────────────────────────── @@ -82,54 +174,105 @@ def test_the_operator_keeps_their_own_controls(app): def test_the_answer_comes_from_the_node(app): """Not from the hub, which has no say in what may be written to someone else's disk, and no way to be believed about it.""" - assert "ack.member_upload !== false" in app, ( - "the handshake ack is what carries this") - assert "hubFetch" not in app[app.index("ack.member_upload") - 400: - app.index("ack.member_upload")] + assert "if (indexMsg.roots) setNodeRoots(indexMsg.roots)" in app, ( + "the roots table in the index payload is what carries this") + idx = app.index("setNodeRoots(indexMsg.roots)") + assert "hubFetch" not in app[idx - 400:idx] def test_an_older_node_is_treated_as_permissive(app): - """A node that predates the setting sends no such field. Reading a missing - field as "off" would close every group on the older half of the network.""" + """ + A node speaking MNP 1.0 sends roots with no `writable` at all, plus the old + group-wide flag. Reading a missing field as "read-only" would close every + group on the older half of the network. + """ + assert "ack.member_upload !== false" in app assert "!== false" in app[app.index("ack.member_upload"): app.index("ack.member_upload") + 60] + block = app[app.index("const legacyNode"):] + block = block[:block.index("const commonProps")] + assert "writable === undefined" in block, ( + "nothing distinguishes a 1.0 node from one with no writable roots") def test_a_change_reaches_people_already_connected(app): - """The operator may be someone else entirely, changing it while you have - the group open. A button that survives until the next reconnection is a - button somebody presses.""" - assert "transport.onUploadPolicy" in app + """ + The operator may be someone else entirely, ejecting a drive while you have + the group open. A file list that survives until the next reconnection is a + list somebody clicks. + """ + assert "transport.onRootsChanged" in app transport = TRANSPORT.read_text(encoding="utf-8") - assert "member_upload_ack" in transport, "nothing routes the node's notice" + assert "root_eject_ack" in transport, "nothing routes the node's notice" -def test_the_notice_still_answers_the_operators_own_request(app): - """The same message is both a broadcast and the reply to the request that - caused it — returning early on it would leave that request hanging until it - timed out.""" +def test_the_notice_also_answers_the_operators_own_request(): + """ + The same message is both a broadcast and the reply to the request that + caused it. + + Every other admin ack can be resolved and dropped, because its caller + already knows what it asked for and updates local state from that. These + are the ones the node *broadcasts*: every other connected client learns the + change from it, and the one that asked is the only one that does not, + because its own request swallowed its copy. Found on the root table, then + again on Chat's directory — where it meant the pane went on showing an + unsaved-looking draft after a save that had worked. + """ transport = TRANSPORT.read_text(encoding="utf-8") - # Scoped to member_upload_ack's own handler, not everything up to the next - # occurrence of "index_sync" — other handlers with their own, legitimate - # early `return` (index_progress, set_scan_settings_ack: neither is ever a - # reply anyone awaits) now sit between the two in the file. - block = transport[transport.index("member_upload_ack"):] - block = block[:block.index("apps_enabled_ack")] - assert "return" not in block + block = transport[transport.index("msg.type.endsWith('_ack')"):] + block = block[:block.index("_uploaders")] + assert "BROADCAST_ACK_TYPES" in block and "_replayBroadcast" in block, ( + "the initiating client resolves the ack and learns nothing from it") # ── Changing it ───────────────────────────────────────────────────────────── -def test_changing_it_is_signed(app): +def test_changing_a_root_is_signed(): transport = TRANSPORT.read_text(encoding="utf-8") - method = transport[transport.index("async setMemberUpload("):] - method = method[:method.index("\n async ", 1)] - assert "admin_challenge" in method and "_authorizeAdminOp" in method, ( - "an unsigned instruction would let any member turn uploads back on") + for method in ("updateRoot", "ejectRoot", "plugRoot"): + body = transport[transport.index(f"async {method}("):] + body = body[:body.index("\n async ", 1)] + assert "admin_challenge" in body and "_authorizeAdminOp" in body, ( + f"{method} is unsigned — any member could use it") def test_only_the_operator_is_offered_the_setting(): - panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel") - section = panel[panel.index("members.uploads_title") - 400: - panel.index("members.uploads_title")] - assert "isNodeAdmin && connected" in section + panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "GroupSettingsPanel") + section = panel[panel.index("settings_node.shared_directories_title") - 600: + panel.index("settings_node.shared_directories_title")] + assert "isNodeAdmin &&" in section + + +def test_the_operator_is_offered_it_on_the_web_too(): + """ + An operator is not necessarily sitting at their node. The first version of + this section required the loopback API, which resolves to "not available" + in a browser — so it rendered for nobody on the web, while the upload + controls it replaced had worked there. + """ + source = GROUP_SETTINGS.read_text(encoding="utf-8") + panel = _component(source, "GroupSettingsPanel") + section = panel[panel.index("settings_node.shared_directories_title") - 600: + panel.index("settings_node.shared_directories_title")] + assert "connected ||" in section, ( + "the shared directories section still requires a local node") + + table = _component(source, "SharedDirectoriesTable") + for call in ("transport.updateRoot", "transport.ejectRoot", + "transport.plugRoot", "transport.removeRoot", + "transport.addRoot"): + assert call in table, f"{call} has no MNP route from the table" + + +def test_the_roots_shown_come_from_the_live_connection_when_there_is_one(): + """ + The loopback list is a second source, and the two drift: it is read once on + mount and after a change, while the MNP one is pushed. Preferring MNP also + keeps this table on the same data Files reads, so an eject shows in both at + the same instant. + """ + panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), + "GroupSettingsPanel") + assert "const effectiveRoots = (connected && mnpRoots" in panel |