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
|
"""
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
|