aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 10:35:09 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 10:35:09 +0200
commit2c0903c648e24b4e2adf20492398e8b67d033b49 (patch)
tree0435f298010f0f946362f28baebbe88337ca8768 /packages/meshbay-hub/tests
parent0ed078c92cabab1dab0f70f321562032ea549ce6 (diff)
parenteeda274d751c537f4ecef3087994a16a9517478f (diff)
downloadmeshbay-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.py383
-rw-r--r--packages/meshbay-hub/tests/test_css_variables.py78
-rw-r--r--packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py159
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py7
-rw-r--r--packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py175
-rw-r--r--packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py90
-rw-r--r--packages/meshbay-hub/tests/test_search_media_merge.py12
-rw-r--r--packages/meshbay-hub/tests/test_spa_imports.py87
-rw-r--r--packages/meshbay-hub/tests/test_spa_syntax.py85
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py14
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py10
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py263
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