1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
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"}
# One generic operation, keyed by the app's own name: adding an app adds
# no message type and no call site here.
generic = {"setAppDirectories"}
assert calls - page_level == generic, (
f"the settings page performs app-specific operations: "
f"{sorted(calls - page_level - generic)}")
# ── The apps read a list ────────────────────────────────────────────────────
@pytest.mark.parametrize("app,prop", [
("video-app.js", "videoDirectories"),
("music-app.js", "musicDirectories"),
("photos-app.js", "photoDirectories"),
])
def test_each_app_takes_a_list_of_directories(app, prop):
"""
Videos and Music took a single folder, so a library spread over two drives
could not be described at all — the operator's only recourse was to point
the app at a parent containing both, which pulls in everything else too.
"""
source = (STATIC / app).read_text(encoding="utf-8")
assert prop in source
for singular in ("videoRoot", "audioRoot"):
assert singular not in source, (
f"{app} still reads {singular} — one shape per idea")
def test_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"
|