""" The Windows installer (W4): a single per-user NSIS package carrying the Electron client and the frozen node daemon. Like test_packaging_units.py this reads the config rather than building anything — there is no electron-builder or PyInstaller run here. Weak evidence, and the right kind for the defects it guards against: a per-machine flag that would make the installer demand admin, a build step wired to the wrong file, the 150 MB node-runtime artifact slipping into git, the autostart seam between the NSIS uninstaller and meshbay_node.platform drifting apart. """ import json import re from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[3] CLIENT = ROOT / "packages" / "meshbay-client" PKG_JSON = CLIENT / "package.json" WIN = ROOT / "packaging" / "win" NSH = CLIENT / "build" / "installer.nsh" pytestmark = pytest.mark.skipif( not PKG_JSON.exists() or not WIN.exists(), reason="Windows packaging not present") def _pkg() -> dict: return json.loads(PKG_JSON.read_text(encoding="utf-8")) # ── electron-builder: Windows target ──────────────────────────────────────── def test_the_windows_target_is_nsis_with_the_committed_icon(): win = _pkg()["build"]["win"] assert win["target"] == "nsis" icon = CLIENT / win["icon"] assert icon.suffix == ".ico" and icon.exists(), f"{icon} is missing" def test_the_installer_is_per_user_and_never_asks_for_admin(): """ A logon-triggered scheduled task needs elevation (that is why W3 uses the Startup folder), and the whole desktop design is no-admin. perMachine or allowElevation here would undo that at install time. """ nsis = _pkg()["build"]["nsis"] assert nsis["oneClick"] is False assert nsis["perMachine"] is False assert nsis["allowElevation"] is False assert nsis["allowToChangeInstallationDirectory"] is True def test_the_node_runtime_is_carried_as_an_extraresource(): """ PyInstaller output lands in packages/meshbay-client/node-runtime/ and rides into the package under resources/node-runtime/. src/main.js:findNodeBinary resolves exactly that path (process.resourcesPath / node-runtime / meshbay-node.exe), so the two names must agree. """ extra = _pkg()["build"]["win"]["extraResources"] entry = next((e for e in extra if e.get("to") == "node-runtime"), None) assert entry, "no extraResources entry mapping to node-runtime" assert entry["from"] == "node-runtime" main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") assert "'node-runtime', 'meshbay-node.exe'" in main_js, ( "findNodeBinary no longer looks for the bundled daemon where " "extraResources puts it") def test_firewall_helper_is_carried_as_an_extraresource(): """packaging/win/firewall.ps1 must ride into resources/, at the fixed path installer.nsh invokes it from ($INSTDIR\\resources\\firewall.ps1).""" extra = _pkg()["build"]["win"]["extraResources"] entry = next((e for e in extra if e.get("to") == "firewall.ps1"), None) assert entry, "no extraResources entry mapping to firewall.ps1" assert entry["from"].endswith("packaging/win/firewall.ps1") assert (ROOT / "packaging" / "win" / "firewall.ps1").exists() def test_dist_win_delegates_to_the_build_script(): """`dist` (Linux) delegates to build-client.sh; `dist:win` is its counterpart and must not be a second inline electron-builder invocation.""" scripts = _pkg()["scripts"] assert "dist:win" in scripts assert "build-win.ps1" in scripts["dist:win"] # `dist` stays Linux-only and unchanged (test_desktop_shell.py guards it too). assert "win" not in scripts["dist"].lower() # ── the build scripts exist and point at real files ──────────────────────── @pytest.mark.parametrize("name", [ "build-win.ps1", "build-node-runtime.ps1", "meshbay-node.spec", "node-entry.py", "README.md", ]) def test_packaging_win_ships_its_scripts(name): assert (WIN / name).exists(), f"packaging/win/{name} is missing" def test_the_pyinstaller_entry_point_is_the_daemon_main(): src = (WIN / "node-entry.py").read_text(encoding="utf-8") assert "from meshbay_node.daemon import main" in src assert "main()" in src def test_the_spec_pulls_in_the_awkward_dependencies_whole(): """ The C/Rust-extension and dynamic-import packages are the ones PyInstaller's static pass drops. If someone trims collect_all to shrink the build, the frozen daemon fails at runtime, not at build time. """ spec = (WIN / "meshbay-node.spec").read_text(encoding="utf-8") for pkg in ("aiortc", "av", "aioquic", "pydantic_core", "uvicorn", "watchdog", "guessit", "blake3", "meshbay_node", "meshbay_common"): assert re.search(rf'["\']{re.escape(pkg)}["\']', spec), ( f"{pkg} dropped from the PyInstaller spec's collect list") def test_the_frozen_exe_carries_a_version_resource(): """ Without it the Windows Firewall prompt, Task Manager and Properties show a bare "meshbay-node". The version is read from the installed package so it tracks pyproject rather than being a second copy to update. """ spec = (WIN / "meshbay-node.spec").read_text(encoding="utf-8") assert "VSVersionInfo" in spec assert "version=_version_info" in spec, "EXE() is not given the version resource" assert 'StringStruct("ProductName", "MeshBay Node")' in spec assert '_pkg_version("meshbay-node")' in spec, "version is hardcoded, not read from the package" # ── the artifact never gets committed ────────────────────────────────────── def test_the_node_runtime_output_is_gitignored(): """It is ~150 MB of frozen Python. The `ui/` fork guard in test_desktop_shell.py exists for the same reason.""" gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8") assert "packages/meshbay-client/node-runtime/" in gitignore # ── the NSIS ↔ platform.py autostart seam ───────────────────────────────── def test_the_uninstaller_clears_the_autostart_launcher(): """ W3's `meshbay-node autostart install` drops a .vbs in the Startup folder (meshbay_node.platform._startup_vbs). After an uninstall it would point wscript at a deleted binary every sign-in, so customUnInstall must delete it — and at the path platform.py actually uses. """ from meshbay_node import platform as plat nsh = NSH.read_text(encoding="utf-8") assert "!macro customUnInstall" in nsh assert "taskkill /IM meshbay-node.exe /F" in nsh # The tail platform.py builds, made NSIS-relative ($APPDATA == %APPDATA%). tail = plat._startup_vbs() parts = tail.parts i = parts.index("Microsoft") rel = "\\".join(parts[i:]) # Microsoft\...\Startup\MeshBay Node.vbs assert rel in nsh, ( f"customUnInstall does not delete {rel!r} — the W3 autostart path " "changed and installer.nsh was not updated") def test_customInstall_stops_a_running_daemon_before_overwriting_it(): nsh = NSH.read_text(encoding="utf-8") body = nsh.split("!macro customInstall", 1)[1].split("!macroend", 1)[0] assert "taskkill /IM meshbay-node.exe /F" in body # ── the one-time elevated firewall step ───────────────────────────────────── def _macro_body(nsh: str, name: str) -> str: return nsh.split(f"!macro {name}", 1)[1].split("!macroend", 1)[0] def test_the_installer_offers_one_elevated_firewall_step_instead_of_two_dialogs(): """ Adding a firewall rule needs admin; the install itself never elevates (build.nsis allowElevation:false). So this must be opt-in (a Yes/No the user can decline) and skipped entirely in a silent install — an unattended `/S` install must never pop a UAC prompt on its own. """ nsh = NSH.read_text(encoding="utf-8") install = _macro_body(nsh, "customInstall") assert "${IfNot} ${Silent}" in install, ( "the firewall step is not guarded against silent installs") assert 'MessageBox MB_YESNO' in install assert 'ExecShellWait "runas"' in install assert 'firewall.ps1" add' in install def test_the_uninstaller_offers_to_remove_the_firewall_rules_default_no(): """Opt-in on the way out too, and defaulting to No: a stale allow-rule for a deleted exe is inert, so this should not nag.""" nsh = NSH.read_text(encoding="utf-8") uninstall = _macro_body(nsh, "customUnInstall") assert "${IfNot} ${Silent}" in uninstall assert "/SD IDNO" in uninstall, "the uninstall firewall prompt should default to No" assert 'firewall.ps1" remove' in uninstall def test_firewall_ps1_targets_both_executables_and_is_idempotent(): """One script, both rules — so installer.nsh only ever has to name it once on the way in and once on the way out.""" src = (ROOT / "packaging" / "win" / "firewall.ps1").read_text(encoding="utf-8") assert "MeshBay.exe" in src assert "node-runtime" in src and "meshbay-node.exe" in src # Remove-then-add: a re-run (reinstall, or install after a manual add) # must not leave duplicate rules. assert src.index("Remove-NetFirewallRule") < src.index("New-NetFirewallRule") def test_firewall_ps1_also_covers_lan_casting(): """ The WebRTC rules only reach MeshBay.exe / meshbay-node.exe; the cast HTTP relay (src/cast-relay.js, fixed TCP 19550-19553) and mDNS device discovery (src/cast-chromecast.js, bonjour-service, UDP 5353) are a separate surface on the client alone, and need their own ports and protocols. Ports here must agree with cast-relay.js's own constants and with the Linux definitions in packaging/firewall/*/meshbay-cast.xml — three descriptions of one port range that must not drift apart. """ src = (ROOT / "packaging" / "win" / "firewall.ps1").read_text(encoding="utf-8") assert "19550-19553" in src, "cast TCP range missing or does not match cast-relay.js" assert '"5353"' in src, "mDNS discovery port (UDP 5353) missing" relay = (CLIENT / "src" / "cast-relay.js").read_text(encoding="utf-8") assert "PORT_BASE = 19550" in relay and "PORT_COUNT = 4" in relay, ( "cast-relay.js's port range changed — update firewall.ps1 to match") firewalld = (ROOT / "packaging" / "firewall" / "firewalld" / "meshbay-cast.xml").read_text( encoding="utf-8") assert "19550-19553" in firewalld and "5353" in firewalld, ( "the Linux and Windows cast firewall definitions have drifted apart") def test_the_bundled_daemon_goes_on_the_user_path_and_comes_back_off(): """ The installer has no console entry point of its own; without this the operator has to `cd` into resources\\node-runtime\\ to run `meshbay-node`. The add uses stock WordFunc (no EnVar plugin — electron-builder's NSIS does not bundle it) and the same $INSTDIR-relative string on the way out. """ nsh = NSH.read_text(encoding="utf-8") assert "!insertmacro WordAdd" in nsh and "!insertmacro un.WordAdd" in nsh install = nsh.split("!macro customInstall", 1)[1].split("!macroend", 1)[0] uninstall = nsh.split("!macro customUnInstall", 1)[1].split("!macroend", 1)[0] assert 'HKCU "Environment" "Path"' in install assert "${WordAdd}" in install and '"+${MB_NODE_BIN}"' in install assert "${un.WordAdd}" in uninstall and '"-${MB_NODE_BIN}"' in uninstall # New shells need the broadcast to notice. assert "WM_WININICHANGE" in install and "WM_WININICHANGE" in uninstall # PATH points at the real .exe dir, so `where meshbay-node` resolves to the # binary the client and the W3 launcher use too — not a shim. assert 'MB_NODE_BIN "$INSTDIR\\resources\\node-runtime"' in nsh