aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_app_settings_plugin.py
blob: a56d70f97fc312ce849b3cd8cc219d784bd6812c (plain) (blame)
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
384
385
386
387
388
389
390
391
392
393
"""
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 node_tree
import pytest
from spa_source import transport_source

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"

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.
    """
    if not node_tree.available():
        pytest.skip("the node package is not in this checkout")
    m = re.search(r"ALLOWED_APPS = frozenset\(\{([^}]*)\}\)",
                  node_tree.webrtc_source())
    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_source()
    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

    if node_tree.available():
        assert 'f"{app}:{\',\'.join(clean)}"' in node_tree.webrtc_source(), (
            "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, how hard the node works watching its disk, and whether members'
    # cross-group Search lists the group.
    page_level = {"setAppsEnabled", "setScanSettings", "setSearchListed"}
    # One generic operation, keyed by the app's own name: adding an app adds
    # no message type and no call site here.
    generic = {"setAppDirectories"}
    assert calls - page_level == generic, (
        f"the settings page performs app-specific operations: "
        f"{sorted(calls - page_level - generic)}")


# ── The apps read a list ────────────────────────────────────────────────────

@pytest.mark.parametrize("app,prop", [
    ("video-app.js", "videoDirectories"),
    ("music-app.js", "musicDirectories"),
    ("photos-app.js", "photoDirectories"),
])
def test_each_app_takes_a_list_of_directories(app, prop):
    """
    Videos and Music took a single folder, so a library spread over two drives
    could not be described at all — the operator's only recourse was to point
    the app at a parent containing both, which pulls in everything else too.
    """
    source = (STATIC / app).read_text(encoding="utf-8")
    assert prop in source
    for singular in ("videoRoot", "audioRoot"):
        assert singular not in source, (
            f"{app} still reads {singular} — one shape per idea")


def test_the_lists_are_read_under_one_name_each():
    """
    One name per app on the ack — `<app>_directories`, always a list — and the
    shell names none of them.

    It read three by hand until 2026-09-10, and `helloworld` was not among them:
    the app that exists to prove a new one needs no special-casing had its
    directories dropped on arrival, which made the plugin claim false exactly
    where it is demonstrated. An app the node knows about must be one this page
    already handles, and the only way to guarantee that is to read the ack's own
    keys instead of a list kept here.

    A second *name* for the same answer would be the older fault: a page reading
    whichever it thinks of first, and a node filling only the other one emptying
    a working tab.
    """
    page = GROUP_PAGE.read_text(encoding="utf-8")
    block = page[page.index("setAppDirectories("):]
    block = block[:block.index("setChatDirectory")]
    assert "endsWith('_directories')" in block, (
        "the shell must derive the map from the ack's keys, not name apps")
    for named in ("ack.video_directories", "ack.music_directories",
                  "ack.photo_directories"):
        assert named not in block, f"{named} is an application named by the shell"
    for gone in ("ack.video_root", "ack.audio_root", "ack.photo_roots"):
        assert gone not in block, f"{gone} is a second name for the same answer"


def test_the_search_cache_reads_both_shapes():
    """
    The cross-group index cache lives in IndexedDB and outlives a deploy, so a
    reader opening Search after this ships still has entries written by the
    previous version. Reading only the new shape empties their results with
    nothing to distinguish it from "nothing matched".
    """
    source = (STATIC / "search-page.js").read_text(encoding="utf-8")
    fn = _component(source, "cachedDirs")
    assert "legacyKey" in fn
    assert "videoRoot" in source and "audioRoot" in source and "photoRoots" in source


# ── Where a result is reported ──────────────────────────────────────────────

def test_the_directory_result_is_reported_below_the_controls():
    """
    It was rendered first, between the section's intro and the table header —
    above everything the eye had already moved past by the time it appeared.
    It belongs under the button that caused it, which is the last thing in the
    section.
    """
    table = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
                       "SharedDirectoriesTable")
    # Two of them: the early return for a group with no directories yet, and
    # the real one. Both report, and both must report last — keyed on the
    # wrapper rather than on `return`, of which there are more.
    blocks = table.split('<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_source()
    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"