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
|
"""
The desktop client refuses to start when the hub will no longer talk to it.
The SPA is served by the hub, so a browser picks up a new client on reload. The
desktop application **ships its own interface**, so on a flag day an un-updated
one can still sign in, still list groups, and then fail every connection with
`version_too_old` — a refusal in a protocol vocabulary, surfacing as a node that
will not talk, with nothing anyone can act on. §12.3 of
~/next/improve-downloads.md named this as the thing that had to exist before
MNP 3.0 could ship.
`compareVersions` and `refuseIfTooOld` are lifted out of `main.js` **as text**
and executed against a modelled environment, on the rule this repo follows
elsewhere: model the environment, never the code under test. The rest of
`test_desktop_shell.py` can only read the source, because there is no npm here
to launch Electron with; these two are ordinary functions and can be run.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client"
MAIN = CLIENT / "src" / "main.js"
pytestmark = pytest.mark.skipif(
shutil.which("node") is None or not MAIN.exists(),
reason="node or the desktop client sources are not available")
def _lift(name: str) -> str:
src = MAIN.read_text()
cut = src[src.index(name):]
return cut[:cut.index("\n}\n") + 2]
def _run(tmp_path, *, mine="1.1.0", hub_base="https://hub.example",
answer=None, status=200, throws=False):
"""Drive the gate against one hub.
`answer` is what `/v1/hub/version` returns; None means the field is absent
entirely, which is what an older hub sends.
"""
script = tmp_path / "gate.mjs"
script.write_text(f"""
const out = {{ dialogs: 0, opened: null }};
const config = {{ hubBase: {json.dumps(hub_base)} }};
const app = {{ getVersion: () => {json.dumps(mine)} }};
const dialog = {{
showMessageBox: async () => {{ out.dialogs += 1; return {{ response: 0 }}; }},
}};
const shell = {{ openExternal: async (u) => {{ out.opened = u; }} }};
globalThis.fetch = async () => {{
if ({json.dumps(throws)}) throw new Error('unreachable');
return {{ ok: {json.dumps(status)} === 200,
json: async () => ({json.dumps(answer)}) }};
}};
""" + _lift("function compareVersions") + _lift("async function refuseIfTooOld") + """
out.refused = await refuseIfTooOld();
out.compare = [
compareVersions('1.0.0', '1.1.0'),
compareVersions('1.1.0', '1.1.0'),
compareVersions('1.2.0', '1.1.0'),
compareVersions('1.10.0', '1.9.0'),
compareVersions('1.1', '1.1.0'),
compareVersions('nonsense', '1.1.0'),
];
console.log(JSON.stringify(out));
""")
proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
OK = {"client": {"minimum": "1.1.0", "recommended": "1.1.0"}}
# ── the comparison ──────────────────────────────────────────────────────────
def test_versions_compare_by_number_and_not_by_string(tmp_path):
"""`1.10.0` is newer than `1.9.0`, which string comparison gets backwards —
and that mistake locks out exactly the people who did update."""
assert _run(tmp_path, answer=OK)["compare"] == [-1, 0, 1, 1, 0, 0]
# ── the gate ────────────────────────────────────────────────────────────────
def test_a_client_older_than_the_minimum_is_stopped(tmp_path):
out = _run(tmp_path, mine="1.0.0", answer=OK)
assert out["refused"] is True
assert out["dialogs"] == 1, "it stopped without saying why"
assert out["opened"] == "https://hub.example", (
"the offer to download the update led nowhere")
def test_a_current_client_starts_normally(tmp_path):
out = _run(tmp_path, mine="1.1.0", answer=OK)
assert out["refused"] is False
assert out["dialogs"] == 0
def test_a_newer_client_is_not_stopped(tmp_path):
"""A development build ahead of the hub is not a reason to refuse to open
the application."""
assert _run(tmp_path, mine="2.0.0", answer=OK)["refused"] is False
def test_an_unreachable_hub_is_not_too_old(tmp_path):
"""A hub that is down, a laptop with no network, a captive portal. Treating
any of those as "you are out of date" would make an offline start
impossible for ever, and would do it at the worst moment."""
assert _run(tmp_path, mine="1.0.0", throws=True)["refused"] is False
assert _run(tmp_path, mine="1.0.0", status=503, answer=OK)["refused"] is False
def test_a_hub_that_states_no_minimum_stops_nothing(tmp_path):
"""An older hub answers without the field. Absent must read as "no opinion",
never as a refusal."""
assert _run(tmp_path, mine="0.0.1", answer={"hub": "1.2.3"})["refused"] is False
def test_a_first_run_with_no_hub_yet_is_not_stopped(tmp_path):
"""There is nothing to ask, and the first-run screen is where the address
gets typed."""
assert _run(tmp_path, mine="0.0.1", hub_base="", answer=OK)["refused"] is False
# ── where it is called ──────────────────────────────────────────────────────
def test_the_gate_runs_before_the_window_is_built():
"""A window that opens and then cannot connect is the failure this
replaces, so the order is the whole point."""
src = MAIN.read_text()
ready = src[src.index("app.whenReady().then("):]
ready = ready[:ready.index("createWindow();")]
assert "await refuseIfTooOld()" in ready, (
"the version check does not run before the window is created")
assert "app.quit()" in ready
|