""" 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. Refusing at the handshake is only honest if something tells the person *before* they get there, and this is that something. `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