diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_packaging_win.py | 472 |
1 files changed, 403 insertions, 69 deletions
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index aa77c29..cc9f8cc 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -10,8 +10,11 @@ node-runtime artifact slipping into git, the autostart seam between the NSIS uninstaller and meshbay_node.platform drifting apart. """ +import inspect import json +import os import re +import sys from pathlib import Path import pytest @@ -196,7 +199,6 @@ def test_the_uninstaller_clears_the_autostart_launcher(): 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() @@ -208,10 +210,244 @@ def test_the_uninstaller_clears_the_autostart_launcher(): "changed and installer.nsh was not updated") -def test_customInstall_stops_a_running_daemon_before_overwriting_it(): +# ── an upgrade must replace the node it is upgrading ─────────────────────── +# +# The test this replaced asserted a `taskkill` in customInstall, under the name +# "stops a running daemon before overwriting it". Both halves were false: +# electron-builder runs customInstall AFTER it has copied the files, and an +# unelevated taskkill cannot reach a service-mode daemon (S4U session, "Access +# is denied"). The copy of the locked meshbay-node.exe failed, electron-builder's +# last-resort extract ignored the failure, and an upgraded install went on +# running the previous version's node -- whose code is embedded in that exe -- +# against the new client and hub. These read electron-builder's own template +# rather than restating what it was assumed to do. + +EB_NSIS = CLIENT / "node_modules" / "app-builder-lib" / "templates" / "nsis" + + +def _eb_template(rel: str) -> str: + path = EB_NSIS / rel + if not path.exists(): + pytest.skip("electron-builder is not installed (npm ci in packages/meshbay-client)") + return path.read_text(encoding="utf-8") + + +def test_electron_builder_checks_for_running_apps_before_it_copies_anything(): + section = _eb_template("installSection.nsh") + i_check = section.index("!insertmacro CHECK_APP_RUNNING") + i_uninstall_old = section.index("!insertmacro uninstallOldVersion") + i_copy = section.index("!insertmacro installApplicationFiles") + i_custom = section.index("!insertmacro customInstall") + assert i_check < i_uninstall_old < i_copy < i_custom, ( + "electron-builder's install order changed: the node must be stopped " + "before uninstallOldVersion and installApplicationFiles, and " + "customInstall is only reached after both") + + uninstaller = _eb_template("uninstaller.nsh") + section_body = uninstaller.split('Section "un.', 1)[1] + assert section_body.index("call un.checkAppRunning") < \ + section_body.index("!insertmacro customUnInstall") + + +def test_electron_builder_hands_the_running_app_check_to_customCheckAppRunning(): + helper = _eb_template("include/allowOnlyOneInstallerInstance.nsh") + check = helper.split("!macro CHECK_APP_RUNNING", 1)[1].split("!macroend", 1)[0] + assert "!insertmacro customCheckAppRunning" in check + # ...and defining it drops what its own check needs; installer.nsh supplies them. + guarded = helper.split("!ifmacrondef customCheckAppRunning", 1)[1].split("!endif", 1)[0] + assert '!include "getProcessInfo.nsh"' in guarded + assert "Var pid" in guarded + + +STOP_NODE_PS1 = CLIENT / "build" / "stop-node.ps1" + + +def test_the_node_is_stopped_before_any_file_is_copied(): nsh = NSH.read_text(encoding="utf-8") - body = _macro_body(nsh, "customInstall") - assert "taskkill /IM meshbay-node.exe /F" in body + check = _macro_body(nsh, "customCheckAppRunning") + # Embedded and run from the plugins dir: the installed copy may be the one + # being replaced. + assert r'"${BUILD_RESOURCES_DIR}\stop-node.ps1"' in check + assert r'-File "$PLUGINSDIR\mb-stop-node.ps1"' in check + assert check.index("InitPluginsDir") < check.index("File ") + # A node that will not stop fails the install loudly, never half-upgrades it. + assert "Quit" in check and "/SD IDCANCEL" in check + # electron-builder's own "close MeshBay" check still runs, with its prerequisites. + assert check.index("IS_POWERSHELL_AVAILABLE") < check.index("_CHECK_APP_RUNNING") + assert '!include "getProcessInfo.nsh"' in nsh + assert re.search(r"^Var pid\s*$", nsh, re.M) + + +def test_the_installer_asks_the_node_to_stop_before_forcing_it(): + src = STOP_NODE_PS1.read_text(encoding="utf-8") + i_api = src.index("/api/shutdown") + i_task = src.index("schtasks /end /tn \"MeshBay Node\"") + i_kill = src.index("Stop-Process -Force") + assert i_api < i_task < i_kill, ( + "graceful through the control API, then Task Scheduler, then taskkill") + assert "ui_port" in src and r"data\ui-token" in src + + +def test_no_taskkill_is_left_to_pretend_it_stops_the_node(): + nsh = NSH.read_text(encoding="utf-8") + for macro in ("customInstall", "customUnInstall"): + assert "taskkill" not in _macro_body(nsh, macro), ( + f"{macro} runs after the files are touched, and taskkill cannot reach " + "a service-mode daemon anyway") + + +def test_setup_starts_the_service_node_it_just_installed(): + nsh = NSH.read_text(encoding="utf-8") + install = _macro_body(nsh, "customInstall") + tail = install.split("mb_auto_done:", 1)[1] + run = 'schtasks /run /tn "MeshBay Node"' + assert run in tail, "the node stopped before the copy must be started again" + # Outside the ${IfNot} ${Silent} block: a silent upgrade needs its node too. + assert tail.index("${EndIf}") < tail.index(run) + last_if = tail.rindex("${If} $MB_AutoMode", 0, tail.index(run)) + assert tail[last_if:].startswith('${If} $MB_AutoMode == "2"') + + +def test_a_stale_service_task_is_registered_again(): + src = (WIN / "service.ps1").read_text(encoding="utf-8") + status = src.split('"status" {', 1)[1].split('"run" {', 1)[0] + assert "exit 2" in status and "INSTALLED_STALE" in status + for probe in ("$exe -ne $node", '"PT0S"', "DisallowStartIfOnBatteries", + "StopIfGoingOnBatteries"): + assert probe in status, f"service.ps1 status does not check {probe}" + install = _macro_body(NSH.read_text(encoding="utf-8"), "customInstall") + assert "${If} $R1 == 0" in install, "only a current task (0) may skip the elevation" + + +def test_the_service_task_is_not_ended_after_72_hours_or_on_battery(): + from meshbay_node import platform as plat + + flags = ("-ExecutionTimeLimit ([TimeSpan]::Zero)", "-AllowStartIfOnBatteries", + "-DontStopIfGoingOnBatteries", "-MultipleInstances IgnoreNew") + ps1 = (WIN / "service.ps1").read_text(encoding="utf-8") + install = ps1.split('"install" {', 1)[1].split('"remove" {', 1)[0] + assert "-Settings $taskSettings" in install + for flag in flags: + assert flag in install, f"service.ps1 install lacks {flag}" + assert flag in plat.SERVICE_TASK_SETTINGS, f"platform.py lacks {flag}" + assert "-Settings $s" in inspect.getsource(plat.service_install) + + +def test_a_service_restart_waits_for_the_old_instance_before_starting_one(): + """/end returns before the task leaves Running; a /run in that window is + dropped (MultipleInstances IgnoreNew) and leaves no node at all. And the + restart reports the new instance, not the one still shutting down.""" + from meshbay_node.cli import lifecycle + stop = inspect.getsource(lifecycle._stop_node) + i_end = stop.index("service_end()") + assert 'service_state().lower() == "running"' in stop[i_end:] + start = inspect.getsource(lifecycle._start_and_confirm) + assert start.index("_stop_node(cfg)") < start.index("since = time.time()") \ + < start.index("service_run()") + assert "_await_daemon(cfg, since)" in start + + +def test_node_start_refuses_a_node_of_another_version(): + main_js = MAIN_JS.read_text(encoding="utf-8") + body = main_js.split("ipcMain.handle('node:start'", 1)[1] + body = body[:body.index("process.platform !== 'linux'")] + assert "p.version !== app.getVersion()" in body + assert body.index("waitForNode(") < body.index("p.version !== app.getVersion()") \ + < body.index("linkNodeKeyAndAwaitRunning(") + # A node that stopped answering is not reported as "started but not linked". + assert "if (!ready)" in body and "nodeLogHint()" in body + + +def test_the_build_starts_the_frozen_daemon_not_only_its_help(): + build = (WIN / "build-node-runtime.ps1").read_text(encoding="utf-8") + assert "smoke-node-runtime.ps1" in build + smoke = (WIN / "smoke-node-runtime.ps1").read_text(encoding="utf-8") + assert "/api/status" in smoke and "$status.version -ne $ExpectVersion" in smoke + assert "state\\node.log" in smoke + # Never the developer's node or a real hub. + assert "$env:LOCALAPPDATA = $profileDir" in smoke + assert 'url = "http://127.0.0.1:1"' in smoke + + +def _run_stop_node(tmp_path, dummy_name, localappdata): + """stop-node.ps1 as setup runs it, with the process and task names swapped + for ones that belong to this test -- the real ones would stop the + developer's own node.""" + import shutil + import subprocess + + script = (STOP_NODE_PS1.read_text(encoding="utf-8") + .replace('"MeshBay Node"', '"MeshBay Node stop-test"') + .replace("meshbay-node", dummy_name)) + assert "meshbay-node" not in script + copy = tmp_path / "stop-node-test.ps1" + copy.write_text(script, encoding="utf-8") + pwsh = (shutil.which("powershell") + or r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe") + env = {**os.environ, "LOCALAPPDATA": str(localappdata)} + return subprocess.run([pwsh, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(copy)], + capture_output=True, text=True, timeout=90, env=env) + + +def _dummy_node(tmp_path, name): + import shutil + import subprocess + exe = tmp_path / f"{name}.exe" + shutil.copy(r"C:\Windows\System32\PING.EXE", exe) + return subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +@pytest.mark.skipif(sys.platform != "win32", reason="runs the installer's PowerShell for real") +def test_setup_stops_a_node_that_answers_through_its_control_api(tmp_path): + """The graceful path: the script finds port and token where the node keeps + them, POSTs /api/shutdown, and waits for the process to go -- here a stand-in + that exits when asked, as the daemon does.""" + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + + proc = _dummy_node(tmp_path, "mbstopgraceful") + asked = [] + + class Api(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + asked.append(self.path) + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"{}") + proc.kill() # "shuts down" + + def log_message(self, *a): + pass + + server = HTTPServer(("127.0.0.1", 0), Api) + threading.Thread(target=server.serve_forever, daemon=True).start() + home = tmp_path / "home" / "meshbay" + (home / "data").mkdir(parents=True) + (home / "node.toml").write_text(f"[node]\nui_port = {server.server_address[1]}\n", + encoding="utf-8") + (home / "data" / "ui-token").write_text("tok", encoding="utf-8") + try: + r = _run_stop_node(tmp_path, "mbstopgraceful", tmp_path / "home") + assert r.returncode == 0, r.stdout + r.stderr + assert asked == ["/api/shutdown?t=tok"] + finally: + server.shutdown() + if proc.poll() is None: + proc.kill() + + +@pytest.mark.skipif(sys.platform != "win32", reason="runs the installer's PowerShell for real") +def test_setup_forces_a_node_that_does_not_answer(tmp_path): + proc = _dummy_node(tmp_path, "mbstopforced") + try: + r = _run_stop_node(tmp_path, "mbstopforced", tmp_path / "nothing-here") + assert r.returncode == 0, r.stdout + r.stderr + assert proc.wait(timeout=10) is not None + finally: + if proc.poll() is None: + proc.kill() # ── the autostart choice + the one-time elevated firewall step ────────────── @@ -330,11 +566,59 @@ def test_the_uninstaller_offers_to_remove_everything_privileged_default_no(): uninstall = _macro_body(nsh, "customUnInstall") assert "${IfNot} ${Silent}" in uninstall + assert "${AndIfNot} ${isUpdated}" in uninstall, "not while an upgrade replaces it" assert "/SD IDNO" in uninstall, "the uninstall prompt should default to No" - assert 'service-mode.ps1" -Action remove' in uninstall + # uninstall, not remove: remove keeps the firewall rules (a mode switch). + assert 'service-mode.ps1" -Action uninstall' in uninstall assert 'ExecShellWait "runas"' in uninstall +def test_an_upgrade_keeps_the_sign_in_launcher(): + """The previous version's uninstaller runs during an upgrade; deleting the + launcher there left upgraded "at sign-in" installs with no autostart at all.""" + uninstall = _macro_body(NSH.read_text(encoding="utf-8"), "customUnInstall") + i_guard = uninstall.index("${IfNot} ${isUpdated}") + assert i_guard < uninstall.index("MeshBay Node.vbs") + + +def test_setup_preselects_the_mode_this_machine_already_runs(): + init = _macro_body(NSH.read_text(encoding="utf-8"), "customInit") + i_task = init.index('schtasks /query /tn "MeshBay Node"') + i_vbs = init.index("MeshBay Node.vbs") + i_prev = init.index("$INSTDIR\\${APP_EXECUTABLE_FILENAME}") + assert i_task < i_vbs < i_prev + assert 'StrCpy $MB_AutoMode "1"' in init[i_vbs:i_prev] + assert 'StrCpy $MB_AutoMode "0"' in init[i_prev:] + + +def test_setup_leaves_the_launcher_and_the_node_as_the_mode_wants_them(): + install = _macro_body(NSH.read_text(encoding="utf-8"), "customInstall") + tail = install.split("mb_auto_done:", 1)[1] + silent_end = tail.index("${EndIf}") + # Outside ${IfNot} ${Silent}: a silent upgrade needs them as much. + assert silent_end < tail.index("autostart install") < tail.index("autostart remove") + start = tail.split('${FileExists} "$LOCALAPPDATA\\meshbay\\node.toml"', 1)[1] + assert 'schtasks /run /tn "MeshBay Node"' in start + assert 'meshbay-node.exe" autostart start' in start + + +def test_choosing_another_mode_takes_out_a_leftover_boot_task(): + install = _macro_body(NSH.read_text(encoding="utf-8"), "customInstall") + modes01 = install.split("; Modes 0 and 1.", 1)[1].split("mb_auto_done:", 1)[0] + assert 'service-mode.ps1" -Action remove' in modes01 + + +def test_leaving_service_mode_keeps_the_firewall_rules(): + """Switching from the Node page removed them too, and the node silently + stopped accepting connections.""" + src = (WIN / "service-mode.ps1").read_text(encoding="utf-8") + assert '[ValidateSet("install", "remove", "uninstall")]' in src + fw = src.split('if ($Action -ne "remove")', 1) + assert len(fw) == 2 and "firewall.ps1" in fw[1], "remove must skip the firewall step" + # Before the task goes, or a service node runs on with nothing to stop it. + assert src.index("Stop-Process -Force") < src.index('"service.ps1") $serviceAction') + + # ── service mode itself (packaging/win/service.ps1, service-mode.ps1) ────── def test_service_ps1_and_service_mode_ps1_are_extraresources(): @@ -494,18 +778,47 @@ def test_the_help_smoke_test_joins_multiline_output_before_matching(): assert '(& $exe --help 2>&1) -join' in body -def test_main_js_drives_the_service_task_for_all_three_actions(): - """The hard requirement: Start/Stop/Restart from the Node page must - control the Scheduled Task when service mode is active, not just spawn a - detached process that has nothing to do with it.""" - main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") - for handler in ("node:service-stop", "node:service-restart", "node:start"): - body = main_js.split(f"ipcMain.handle('{handler}'", 1)[1] - body = body[:body.index("ipcMain.handle(")] - assert "winServiceTaskStatus" in body, f"{handler} never checks for the service task" +def _fn_body(src: str, signature: str) -> str: + return src.split(signature, 1)[1].split("\n }\n", 1)[0] + + +def test_every_start_stop_and_restart_goes_through_the_cli(): + """One implementation behind every front door. main.js kept its own copy: + it ended the service with schtasks first (a TerminateProcess), started nodes + as children of Electron (which inherited Electron's sockets), and reported a + node it could not reach from its session as stopped.""" + main_js = MAIN_JS.read_text(encoding="utf-8") + assert "winNodeCli(['autostart', 'stop'])" in _fn_body( + main_js, "async function killNodeProcesses()") + assert "killNodeProcesses()" in _fn_body(main_js, "async function nodeServiceStop()") + assert "winNodeStartVia(['restart-daemon'])" in _fn_body( + main_js, "async function nodeServiceRestart()") + start = main_js.split("ipcMain.handle('node:start'", 1)[1] + start = start.split("process.platform !== 'linux'", 1)[0] + assert "winNodeStartVia(['restart-daemon'])" in start + for gone in ("spawnNodeDetached", "winServiceTaskEnd", "winServiceTaskRun"): + assert gone not in main_js, gone + # The CLI stops gracefully first, whatever the session, then forces. + from meshbay_node.cli import lifecycle + stop = inspect.getsource(lifecycle._stop_node) + assert stop.index("request_graceful_stop") < stop.index("service_end()") \ + < stop.index("autostart_end()") -def test_node_start_provisions_before_it_ever_touches_the_service_task(): +def test_nothing_treats_find_node_binary_as_always_a_promise(): + """It returns the bundled path as a plain string in a packaged build: a + `.then` on it failed every start and stop in the installed app only -- + found by launching the installed app, invisible from a dev run.""" + main_js = MAIN_JS.read_text(encoding="utf-8") + assert not re.search(r"findNodeBinary\(\)\s*\.\s*then", main_js) + + +def test_a_stop_that_leaves_the_node_answering_says_so(): + stop = _fn_body(MAIN_JS.read_text(encoding="utf-8"), "async function nodeServiceStop()") + assert "if (await probeNode())" in stop and "throw new Error" in stop + + +def test_node_start_provisions_before_it_starts_anything(): """ On a fresh install with service mode chosen, the Scheduled Task exists before anything is provisioned (node.toml is written by the wizard, not @@ -525,11 +838,10 @@ def test_node_start_provisions_before_it_ever_touches_the_service_task(): body = body[:body.index("ipcMain.handle(")] provision_at = body.index("provisionNode(") - service_check_at = body.index("winServiceTaskStatus") - assert provision_at < service_check_at, ( - "node:start checks the service task before provisioning — a fresh " - "install's first Start would run/query the daemon before node.toml " - "exists for it to read") + start_at = body.index("winNodeStartVia(") + assert provision_at < start_at, ( + "node:start starts the node before provisioning — a fresh install's " + "first Start would run the daemon before node.toml exists for it to read") def test_node_start_links_the_node_key_on_windows_not_only_linux(): @@ -556,7 +868,29 @@ def test_node_start_links_the_node_key_on_windows_not_only_linux(): helper = main_js.split("async function linkNodeKeyAndAwaitRunning", 1)[1] helper = helper[:2000] assert "/v1/users/me/node_key" in helper - assert "waiting_for_account" in helper and "waiting_for_node_key" in helper + # Linked whatever the node is waiting for -- a node backing off a 429 + # ('waiting_for_hub') needs its key as much as one at 'waiting_for_account'. + assert "last.status !== 'starting'" in helper + + +def test_a_node_backing_off_the_hub_is_still_a_node(): + """'waiting_for_hub' (429 or hub restart) was missing from probeNode's list, + so node:start treated a live node as absent, never linked it, and said + "started but could not link" -- reproduced against a local hub.""" + main_js = MAIN_JS.read_text(encoding="utf-8") + probe = main_js.split("async function probeNode()", 1)[1][:1500] + ready = probe.split("const READY = [", 1)[1].split("];", 1)[0] + for status in ("running", "waiting_for_node_key", "waiting_for_account", + "waiting_for_hub", "starting"): + assert f"'{status}'" in ready, status + daemon_py = (ROOT / "packages" / "meshbay-node" / "src" / "meshbay_node" + / "daemon.py").read_text(encoding="utf-8") + daemon_states = set(re.findall(r'self\._state\["status"\] = "(\w+)"', daemon_py)) + not_a_node = {"stopping"} # shutting down: nothing to link or wait for + assert daemon_states - not_a_node <= {s.strip(" '\n") for s in ready.split(",")}, ( + "the daemon has a status probeNode does not recognise") + wizard = (HUB_STATIC / "create-group-page.js").read_text(encoding="utf-8") + assert "'waiting_for_hub'" in wizard def test_firewall_ps1_targets_both_executables_and_is_idempotent(): @@ -1159,36 +1493,13 @@ def test_main_js_calls_ensure_node_path_on_every_launch(): "must be both defined and called") -def test_node_start_surfaces_an_immediate_daemon_crash_instead_of_a_60s_timeout(): - """ - Reproduced live: a daemon that exits within ~1s (a port already bound, - reproduced with a second instance colliding on 127.0.0.1:18000) used to - be indistinguishable from one that simply never started -- spawn()'s - stdio was 'ignore', discarding the exact stderr line that named the real - problem, and waitForNode()'s 60s generic timeout was the only failure - path left. spawnNodeDetachedWatched watches for an early exit and - rejects with the daemon's own tail of stderr instead. - """ +def test_a_node_that_fails_to_start_is_reported_with_its_own_words_and_log(): + """A daemon that exits at once used to look like one that never started. + The CLI reports what happened; the app passes that on with where the log + is -- the only place a node with no console writes why.""" src = MAIN_JS.read_text(encoding="utf-8") - assert "function spawnNodeDetachedWatched(" in src - body = src.split("function spawnNodeDetachedWatched(", 1)[1].split("\n }", 1)[0] - assert "stdio: ['ignore', 'pipe', 'pipe']" in body - assert "exited immediately" in body - assert "NODE_CRASH_WATCH_MS" in body - # The tail must be bounded by length, not by a line count -- a real - # capture had the actual OSError line pushed out by two uvicorn/asyncio - # tracebacks that followed it, which a short "last N lines" cut before - # this was fixed to bound by characters instead. - assert "split(/\\r?\\n/).slice(" not in body, ( - "a line-count tail can cut the one line that names the real error " - "-- bound by characters instead (reproduced live, see the comment " - "above this constant)") - assert "4000" in body - - async_fn = src.split("async function spawnNodeDetached()", 1)[1].split("\n }", 1)[0] - assert "spawnNodeDetachedWatched" in async_fn, ( - "spawnNodeDetached must actually use the watched spawn, not the old " - "fire-and-forget one") + body = _fn_body(src, "async function winNodeStartVia(args)") + assert "r.out" in body and "nodeLogHint()" in body and "throw new Error" in body def test_setup_welcome_hints_at_the_node_startup_choice(): @@ -1265,25 +1576,48 @@ def test_node_start_ends_a_service_mode_daemon_via_task_scheduler_not_taskkill() until a reboot. nodeServiceStop/nodeServiceRestart already route through winServiceTaskEnd() first for exactly this reason -- node:start must too. """ + # Now the CLI's (test_every_start_stop_and_restart_goes_through_the_cli): + # its stop reaches a session-0 node through the node's own control API, and + # still ends the task when that does not answer. + from meshbay_node.cli import lifecycle + stop = inspect.getsource(lifecycle._stop_node) + assert "request_graceful_stop" in stop and "service_end()" in stop + + +def test_switching_modes_stops_the_node_first_and_brings_it_back(): + """Removing the service left its node running in session 0, unstoppable; + installing it started a second node that found the port taken and quit.""" main_js = MAIN_JS.read_text(encoding="utf-8") - body = main_js.split("ipcMain.handle('node:start'", 1)[1] - body = body[:body.index("ipcMain.handle(")] - win_branch = body.split("process.platform === 'win32'", 1)[1] - win_branch = win_branch[:win_branch.index("process.platform !== 'linux'")] + body = main_js.split("ipcMain.handle('node:service-mode'", 1)[1].split("ipcMain.handle(", 1)[0] + assert body.index("killNodeProcesses()") < body.index("winElevateServiceMode(action)") + after = body.split("winElevateServiceMode(action)", 1)[1] + assert "wasRunning" in after and "winNodeStartVia(['autostart', 'start'])" in after + autostart = main_js.split("ipcMain.handle('node:autostart'", 1)[1] + autostart = autostart.split("ipcMain.handle(", 1)[0] + assert "winServiceTaskStatus()).installed" in autostart, ( + "the sign-in launcher must refuse while the boot task exists") + - svc_installed = win_branch.split("if (svc.installed) {", 1)[1] - svc_installed = svc_installed[:svc_installed.index("} else {")] - assert "winServiceTaskEnd" in svc_installed, ( - "the service-mode branch of node:start never calls winServiceTaskEnd() " - "-- a stuck S4U-session daemon can't be reached by killNodeProcesses() " - "(Access is denied, confirmed live) so it never actually gets replaced") - assert svc_installed.index("winServiceTaskEnd") < svc_installed.index("winServiceTaskRun"), ( - "winServiceTaskEnd() must run before winServiceTaskRun() -- ending " - "second would stop the fresh instance right after starting it") +def test_a_declined_prompt_leaves_the_node_as_it_was(): + """The node is stopped before the prompt; a "No" -- or a prompt nobody + answered for two minutes -- used to leave it stopped, groups offline, with + the mode unchanged. Found by letting the prompt time out on a real install.""" + main_js = MAIN_JS.read_text(encoding="utf-8") + body = main_js.split("ipcMain.handle('node:service-mode'", 1)[1].split("ipcMain.handle(", 1)[0] + guarded = body.split("await winElevateServiceMode(action);", 1)[1] + catch = guarded.split("} catch (err) {", 1)[1].split("throw err;", 1)[0] + assert "wasRunning" in catch and "winNodeStartVia(['restart-daemon'])" in catch + elevate = _fn_body(main_js, "function winElevateServiceMode(action)") + assert "/cancel/i.test(" in elevate, "a decline is not reported as a failure" - svc_else = win_branch.split("} else {", 1)[1] - svc_else = svc_else[:svc_else.index("const p = await waitForNode")] - assert "killNodeProcesses" in svc_else, ( - "the non-service branch (Startup mode / only-while-open) should still " - "use killNodeProcesses() -- that daemon runs in this same session, " - "where taskkill/CTRL_BREAK actually work") + +def test_only_while_open_starts_with_the_app_and_stops_at_quit(): + main_js = MAIN_JS.read_text(encoding="utf-8") + start = _fn_body(main_js, "async function winStartNodeWithApp()") + assert "winStartupMode()) !== 'open'" in start and "nodeProvisioned()" in start + assert "winNodeStartVia(['autostart', 'start'])" in start + ready = main_js.split("app.whenReady().then(", 1)[1] + assert ready.index("createWindow();") < ready.index("nodeWithApp.start()") + quit_ = main_js.split("app.on('before-quit', (event) => {", 1)[1].split("\n });", 1)[0] + assert "!nodeStartedByApp" in quit_ and "event.preventDefault()" in quit_ + assert "mode === 'open' ? killNodeProcesses()" in quit_ |