diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_packaging_win.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_packaging_win.py | 249 |
1 files changed, 249 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index 7421a8c..b223156 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -21,6 +21,20 @@ CLIENT = ROOT / "packages" / "meshbay-client" PKG_JSON = CLIENT / "package.json" WIN = ROOT / "packaging" / "win" NSH = CLIENT / "build" / "installer.nsh" +MAIN_JS = CLIENT / "src" / "main.js" +PRELOAD_JS = CLIENT / "src" / "preload.js" + +# The "Light" target: Electron client + UI, no bundled node. See +# C:\Users\admin\devel\light-client.md for the evaluation this implements. +LIGHT_NSH = CLIENT / "build" / "installer-light.nsh" +LIGHT_YML = WIN / "electron-builder.light.yml" +BUILD_WIN_LIGHT = WIN / "build-win-light.ps1" +BUILD_WIN_COMMON = WIN / "build-win-common.ps1" + +# sync-ui.js copies this into CLIENT/ui/ verbatim -- read the source of +# truth, same as every other cross-package check in this file already does +# for meshbay-client's own src/. +HUB_STATIC = ROOT / "packages" / "meshbay-hub" / "src" / "meshbay_hub" / "static" pytestmark = pytest.mark.skipif( not PKG_JSON.exists() or not WIN.exists(), @@ -659,3 +673,238 @@ def test_ffmpeg_bundling_is_the_default_not_opt_in(): win_src = (WIN / "build-win.ps1").read_text(encoding="utf-8") assert "SkipFfmpeg" in win_src assert "FfmpegDir" not in win_src + + +# ------------------------------------------------------------------------ +# The "Light" target: Electron client + UI, no bundled node. See +# C:\Users\admin\devel\light-client.md for the evaluation. Weak, text- +# reading evidence throughout, same reasoning as the rest of this file: +# there is no electron-builder/PowerShell/NSIS runner here, and it is the +# right kind of evidence for what these guard against -- a config drifting +# back to carrying the node-runtime it must not, or the two orchestrators' +# shared steps diverging silently. +# ------------------------------------------------------------------------ + +def test_light_config_is_standalone_and_ships_no_node(): + """ + electron-builder.light.yml is passed via --config, which (per + app-builder-lib/out/util/config/load.js's getConfig) makes electron- + builder read ONLY that file -- package.json's own build field, and + therefore its node-runtime/service*.ps1 extraResources, is never even + loaded. Pins the config's own content regardless: it must name neither + the node runtime nor the two service scripts, and must still carry + firewall.ps1 (the one thing Light still needs -- packaging/win/ + firewall.ps1's header explains why the client needs an inbound rule too, + not only a node). + """ + assert LIGHT_YML.exists(), f"{LIGHT_YML} is missing" + yml = LIGHT_YML.read_text(encoding="utf-8") + + assert "appId: org.meshbay.client.light" in yml + assert "productName: MeshBay Light" in yml + assert "output: dist-light" in yml + + assert "node-runtime" not in yml + assert "service.ps1" not in yml + assert "service-mode.ps1" not in yml + assert "firewall.ps1" in yml + + assert "include: installer-light.nsh" in yml, ( + "nsis.include must point at the Light NSIS customisation, not the " + "default build/installer.nsh (Full's)") + + +def test_light_config_keeps_the_same_no_admin_nsis_policy(): + """Per-user, no elevation at install time -- identical policy to Full's + package.json build.nsis, for the same reason (MESHBAY_DESIGN.md + 11.2/7.5: the DPAPI-protected hub device key is account-bound).""" + yml = LIGHT_YML.read_text(encoding="utf-8") + assert "oneClick: false" in yml + assert "perMachine: false" in yml + assert "allowElevation: false" in yml + + +def test_dist_win_light_delegates_to_the_light_orchestrator(): + pkg = _pkg() + scripts = pkg["scripts"] + assert "dist:win:light" in scripts + assert "build-win-light.ps1" in scripts["dist:win:light"] + assert "dist:win" in scripts + assert "build-win.ps1" in scripts["dist:win"] + assert "build-win-light.ps1" not in scripts["dist:win"] + + +def test_build_win_light_skips_the_node_runtime_step(): + """The entire point of Light: no PyInstaller freeze, no ffmpeg fetch.""" + assert BUILD_WIN_LIGHT.exists(), f"{BUILD_WIN_LIGHT} is missing" + src = BUILD_WIN_LIGHT.read_text(encoding="utf-8") + assert "build-node-runtime.ps1" not in src + assert "electron-builder.light.yml" in src + assert "--config" in src + + +def test_build_orchestrators_share_the_common_steps_not_a_copy(): + """ + Node check / npm ci / Electron bump / sync-ui must live in exactly one + place (build-win-common.ps1), dot-sourced by both -- a copy would let + the two drift the way the installer flow itself once did (the W3 + one-shot dialog bug). Both orchestrators must call the shared + functions, neither may inline its own npm ci / Electron-bump logic. + """ + assert BUILD_WIN_COMMON.exists(), f"{BUILD_WIN_COMMON} is missing" + common = BUILD_WIN_COMMON.read_text(encoding="utf-8") + for fn in ("Assert-NodeVersion", "Invoke-NpmCi", "Invoke-ElectronBump", "Invoke-SyncUi"): + assert f"function {fn}" in common, f"{fn} is not defined in build-win-common.ps1" + + full = (WIN / "build-win.ps1").read_text(encoding="utf-8") + light = BUILD_WIN_LIGHT.read_text(encoding="utf-8") + for src, name in ((full, "build-win.ps1"), (light, "build-win-light.ps1")): + assert '. (Join-Path $WinDir "build-win-common.ps1")' in src, ( + f"{name} does not dot-source build-win-common.ps1") + for fn in ("Assert-NodeVersion", "Invoke-NpmCi", "Invoke-ElectronBump", "Invoke-SyncUi"): + assert fn in src, f"{name} does not call {fn}" + assert "npm ci --ignore-scripts" not in src, ( + f"{name} must not re-implement npm ci inline") + + +def test_firewall_ps1_skips_the_node_rule_when_the_exe_is_missing(): + """ + The one behaviour Light structurally depends on: firewall.ps1 is + shipped unforked (test_light_config_is_standalone_and_ships_no_node + above), relying on `add` already skipping any rule whose target .exe + does not exist -- true for "MeshBay Node" in a Light install, where + node-runtime\\meshbay-node.exe is never there. If this guard were ever + removed, New-NetFirewallRule would be pointed at a path that does not + exist and Light's one firewall elevation would start failing. + """ + src = (WIN / "firewall.ps1").read_text(encoding="utf-8") + add_block = src.split('if ($Action -eq "add")', 1)[1] + assert "if (-not (Test-Path $r.Path))" in add_block + skip_stanza = add_block.split("if (-not (Test-Path $r.Path))", 1)[1].split("}", 1)[0] + assert "continue" in skip_stanza + + +def test_light_installer_forces_per_user_and_elevates_firewall_once(): + assert LIGHT_NSH.exists(), f"{LIGHT_NSH} is missing" + nsh = LIGHT_NSH.read_text(encoding="utf-8") + install = _macro_body(nsh, "customInstall") + mode = _macro_body(nsh, "customInstallMode") + + assert 'StrCpy $isForceCurrentInstall "1"' in mode + + assert "${IfNot} ${Silent}" in install + i_check = install.index('firewall.ps1" check') + i_runas = install.index('ExecShellWait "runas"') + assert i_check < i_runas, "the unelevated check must run before any elevation" + assert 'firewall.ps1" add' in install + + assert "MB_AutoMode" not in nsh, "there is no autostart choice for Light" + assert "Page custom" not in nsh + assert "MessageBox MB_YESNO" not in install, ( + "customInstall itself must ask nothing -- only customUnInstall's " + "opt-in firewall-removal prompt uses MessageBox") + + +def test_light_installer_never_touches_a_co_installed_full_clients_node(): + """ + Full and Light can be installed side by side (distinct appId/ + productName/install dir -- test_light_config_is_standalone_and_ships_ + no_node above). Light's installer/uninstaller must be a complete no-op + with respect to anything a co-installed Full client owns: its node + process, its Startup .vbs, its Scheduled Task, its own firewall rule. + The easy mistake here is copy-pasting installer.nsh and trimming it, + leaving one of these in by accident. + """ + nsh = LIGHT_NSH.read_text(encoding="utf-8") + for forbidden in ("taskkill", 'HKCU "Environment"', "service.ps1", + "service-mode.ps1", ".vbs", "MeshBay Node.vbs"): + assert forbidden not in nsh, ( + f"installer-light.nsh must not reference {forbidden!r} -- that " + "belongs to a co-installed Full client, not to Light") + + +def test_light_uninstaller_offers_to_remove_the_firewall_rules_default_no(): + nsh = LIGHT_NSH.read_text(encoding="utf-8") + uninstall = _macro_body(nsh, "customUnInstall") + assert "${IfNot} ${Silent}" in uninstall + assert "/SD IDNO" in uninstall + assert 'firewall.ps1" remove' in uninstall + assert 'ExecShellWait "runas"' in uninstall + + +# ── the app-side gaps a bundle-less build would otherwise hit ────────────── + +def test_has_bundled_node_is_windows_only_and_packaged_only(): + """ + Off win32, or unpackaged (dev), this must stay true unconditionally -- + only a packaged Windows build can even be Light, so nothing here may + change today's behaviour for Linux, macOS or `npm start`. + """ + src = MAIN_JS.read_text(encoding="utf-8") + assert "function hasBundledNode()" in src + body = src.split("function hasBundledNode()", 1)[1].split("\n }", 1)[0] + assert "process.platform === 'win32'" in body + assert "app.isPackaged" in body + assert "return true" in body, ( + "must fall back to true (today's behaviour) off win32 / unpackaged") + + +def test_node_bundled_ipc_channel_exists_end_to_end(): + """main.js handles it, preload.js exposes it, platform.js wraps it -- + the same three-layer shape every other node.* capability already has.""" + main = MAIN_JS.read_text(encoding="utf-8") + assert "ipcMain.handle('node:bundled'" in main + assert "hasBundledNode()" in main + + preload = PRELOAD_JS.read_text(encoding="utf-8") + assert "ipcRenderer.invoke('node:bundled')" in preload + + platform_js = (HUB_STATIC / "platform.js").read_text(encoding="utf-8") + assert "bridge.node.bundled()" in platform_js + + +def test_can_elevate_checks_service_mode_ps1_actually_exists(): + """ + app.isPackaged alone used to gate canElevate -- true for Light too, + where service-mode.ps1 is never shipped, so the Node page would offer + "background service" and only fail when clicked. Both nodeServiceStatus + branches (service installed, and the per-user Startup branch) must go + through the same helper rather than reimplementing the check. + """ + src = MAIN_JS.read_text(encoding="utf-8") + assert "function winCanElevateServiceMode()" in src + helper = src.split("function winCanElevateServiceMode()", 1)[1].split("\n }", 1)[0] + assert "app.isPackaged" in helper + assert "'service-mode.ps1'" in helper + assert src.count("canElevate: winCanElevateServiceMode()") == 2, ( + "both nodeServiceStatus branches must use the helper, not a bare " + "app.isPackaged") + assert "canElevate: app.isPackaged," not in src + + +def test_startup_mode_is_null_not_a_lie_when_no_node_is_found(): + """ + The per-user Startup branch used to report mode: 'startup' unconditionally, + so a Light install with no node anywhere (bundled or on PATH) showed a + working-looking autostart dropdown for a node that did not exist -- + node-page.js's showStartupRow is gated on typeof info.mode === 'string'. + """ + src = MAIN_JS.read_text(encoding="utf-8") + assert "mode: bin ? 'startup' : null," in src + assert "mode: 'startup'," not in src + + +def test_create_group_page_falls_back_when_no_node_is_bundled(): + """ + The wizard's node-linking step is exactly the known "Detecting local + node..." hang when nothing ever answers. Gating it on `bundled` (not + just `available`, which is true for Light too -- both are Electron) + routes a Light install to CreateGroupFormSimple: not a lesser feature, + but the same node-free form a plain browser member already uses. + """ + src = (HUB_STATIC / "create-group-page.js").read_text(encoding="utf-8") + body = src.split("export function CreateGroupPage(props) {", 1)[1] \ + .split("\nfunction CreateGroupFormSimple", 1)[0] + assert "platform.node.bundled()" in body + assert "platform.node.available && bundled" in body + assert "CreateGroupWizard" in body and "CreateGroupFormSimple" in body |