diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_packaging_win.py | 220 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_platform.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_windows_node_lifecycle.py | 106 |
3 files changed, 258 insertions, 70 deletions
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index b5f6191..6f6388e 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -1263,35 +1263,41 @@ def test_create_group_page_falls_back_when_no_node_is_bundled(): # ------------------------------------------------------------------------ -# The "MSIX" target: same feature set as Full, packaged for Microsoft Store -# submission instead of NSIS. Unlike Light, this target keeps the node -# runtime and both service scripts -- what changes is packaging format, not -# what ships. +# The "MSIX" target: the bundled node, packaged for Microsoft Store +# submission instead of NSIS. What the NSIS build does with scripts (firewall +# rules, a sign-in launcher, a PATH entry) this package declares in its +# manifest, because everything a script writes names the versioned install +# folder each Store update deletes. At boot is the .exe installer's alone. # Weak, text-reading evidence throughout, same reasoning as the rest of # this file: there is no electron-builder/appx runner here either. # ------------------------------------------------------------------------ -def test_msix_config_is_standalone_and_keeps_the_full_bundle(): +def _yml_from_entries(yml: str) -> list[str]: + """The `from:` of every extraResources entry: directives, not the prose + around them, which names the very scripts it explains are absent.""" + return [ln.split("from:", 1)[1].strip() for ln in yml.splitlines() + if ln.strip().startswith("- from:")] + + +def test_msix_config_is_standalone_and_ships_the_node_not_the_scripts(): """ - Unlike Light, MSIX ships the same node-runtime/ffmpeg/service scripts as - Full -- an AppX install never elevating is not a reason to drop the - daemon, only to change how its two elevated operations get triggered - (see the two tests below). --config still - means this file is read alone (app-builder-lib's getConfig), so it - cannot silently inherit Full's package.json build.nsis or any signing - config meant for NSIS. + --config means this file is read alone (app-builder-lib's getConfig), so + it cannot silently inherit Full's package.json build.nsis or any signing + config meant for NSIS. It ships the node runtime and none of the scripts + whose output names the install folder: on a real Store install each of + them was stale after the next update. """ assert MSIX_YML.exists(), f"{MSIX_YML} is missing" yml = MSIX_YML.read_text(encoding="utf-8") - assert "appId: org.meshbay.client" in yml assert "target: appx" in yml assert "output: dist-msix" in yml - assert "node-runtime" in yml - assert "service.ps1" in yml - assert "service-mode.ps1" in yml - assert "firewall.ps1" in yml + sources = _yml_from_entries(yml) + assert "node-runtime" in sources + for script in ("firewall.ps1", "service.ps1", "service-mode.ps1", "ensure-node-path.ps1"): + assert not any(src.endswith(script) for src in sources), ( + f"{script} must not ship in the Store package (see this test's docstring)") def test_msix_identity_matches_the_partner_center_reservation(): @@ -1333,51 +1339,100 @@ def test_msix_declares_no_csc_on_purpose(): assert forbidden not in yml -def test_msix_declares_the_network_capabilities_firewall_ps1_would_add(): +MSIX_MANIFEST_XML = CLIENT / "build" / "appx-manifest.xml" + + +def test_msix_declares_the_firewall_rules_of_both_node_executables(): """ - Matches firewall.ps1's own rules, which are `-Profile Any` (private AND - public network). Whether Windows actually auto-exempts a full-trust - packaged app on the strength of these declarations is unverified until - sideloaded, but the declaration itself must at least match what the - elevated NSIS path grants today, or - an MSIX install would be silently narrower than Full/Light. + Declared rules are created at install without an administrator prompt, + follow the executable's versioned path on every update and go with the + package (all three measured on a real install). The capabilities + internetClientServer / privateNetworkClientServer were here before and + covered nothing: they make rules for sandboxed apps, which a full-trust + process is not, and a listener got the Windows prompt regardless. """ yml = MSIX_YML.read_text(encoding="utf-8") - caps_block = yml.split("capabilities:", 1)[1].split("customExtensionsPath", 1)[0] - assert "internetClientServer" in caps_block - assert "privateNetworkClientServer" in caps_block + assert "customManifestPath: build/appx-manifest.xml" in yml + assert MSIX_MANIFEST_XML.exists(), f"{MSIX_MANIFEST_XML} is missing" + xml = MSIX_MANIFEST_XML.read_text(encoding="utf-8") + package_level = xml.split("</Applications>", 1)[1] + for exe in ("meshbay-node.exe", "meshbay-nodew.exe"): + block = package_level.split(f'Executable="app\\resources\\node-runtime\\{exe}"', 1) + assert len(block) == 2, f"no firewall rules for {exe}" + rules = block[1].split("</desktop2:FirewallRules>", 1)[0] + for proto in ("TCP", "UDP"): + assert f'Direction="in" IPProtocol="{proto}" Profile="all"' in rules, (exe, proto) + assert 'Category="windows.firewallRules"' in package_level + assert 'xmlns:desktop2="http://schemas.microsoft.com/appx/manifest/desktop/windows10/2"' in xml -def test_msix_startup_task_targets_the_node_not_the_electron_shell(): +def test_msix_manifest_keeps_every_macro_of_electron_builders_template(): + """AppxTarget.js fills the custom template the way it fills its own. One + dropped from ours would leave a field electron-builder computes (identity, + version, languages) out; one it does not know fails the build, comments + included ("Macro macros is not defined", from a comment that said so).""" + xml = MSIX_MANIFEST_XML.read_text(encoding="utf-8") + stock = CLIENT / "node_modules" / "app-builder-lib" / "templates" / "appx" / "appxmanifest.xml" + if not stock.exists(): + pytest.skip("electron-builder is not installed (npm ci in packages/meshbay-client)") + macros = set(re.findall(r"\$\{([a-zA-Z0-9]+)\}", stock.read_text(encoding="utf-8"))) + assert macros == set(re.findall(r"\$\{([a-zA-Z0-9]+)\}", xml)) + + +def test_msix_manifest_fragments_are_valid_xml_comments_included(): + """makeappx refuses a manifest with "--" inside a comment ('>' expected), + and only at the very end of a build.""" + for path in (MSIX_MANIFEST_XML, MSIX_EXTENSIONS_XML): + for comment in re.findall(r"<!--(.*?)-->", path.read_text(encoding="utf-8"), re.S): + assert "--" not in comment, (path.name, comment[:60]) + + +def test_msix_startup_task_is_the_windowless_node_and_off_by_default(): """ - addAutoLaunchExtension's built-in windows.startupTask (app-builder-lib's - AppxTarget.js) always targets the package's own main executable -- the - Electron shell -- which is not what "starts at sign in" means today - (main.js's WIN_STARTUP_VBS launches meshbay-node.exe directly, keeping - the daemon running whether or not the UI is ever opened). This config - must NOT use addAutoLaunchExtension for that reason, and must instead - supply its own extension via customExtensionsPath pointing at the node - binary's in-package path -- app\\resources\\node-runtime\\meshbay-node.exe, - derived from AppxTarget.js's own `"app\\\\" + appOutDir-relative path` - mapping (build() in that file), which is not the same prefix - process.resourcesPath resolves to at runtime and easy to get wrong. + Windows runs a startup task's executable as is: the console build opened + a window whose close button stopped the node, so the task runs + meshbay-nodew.exe. Off by default -- it was on, and started the node at + every sign-in whatever mode the Node page said; the app switches it now. + Not addAutoLaunchExtension, which always starts the Electron shell. """ yml = MSIX_YML.read_text(encoding="utf-8") - # The comment explaining *why* addAutoLaunchExtension is not used - # necessarily names it -- check the directive, not the prose (the same - # "parse directives, not text" mistake CLAUDE.md's engineering lessons - # already record for a differently-shaped bug). lines = [ln.strip() for ln in yml.splitlines()] - assert not any(ln.startswith("addAutoLaunchExtension:") for ln in lines), ( - "must not set addAutoLaunchExtension -- it always targets the " - "Electron shell, not the node binary (see this test's docstring)") + assert not any(ln.startswith("addAutoLaunchExtension:") for ln in lines) assert "customExtensionsPath: build/appx-extensions.xml" in yml + ext = MSIX_EXTENSIONS_XML.read_text(encoding="utf-8") + task = ext.split('Category="windows.startupTask"', 1)[1].split("</desktop:Extension>", 1)[0] + assert 'Executable="app\\resources\\node-runtime\\meshbay-nodew.exe"' in task + assert 'TaskId="MeshBayNodeStartup"' in task and 'Enabled="false"' in task + # The id the CLI asks Windows for. + from meshbay_node import platform as plat + assert 'TaskId="' + plat.STARTUP_TASK_ID + '"' in task - assert MSIX_EXTENSIONS_XML.exists(), f"{MSIX_EXTENSIONS_XML} is missing" + +def test_msix_gives_the_cli_an_execution_alias_and_the_windows_it_needs(): + """`meshbay-node` in a terminal: an alias keeps one path across versions, + where the PATH entry the app used to add named the install folder. + Aliases need Windows 10 1709; the declared minimum is 1809.""" ext = MSIX_EXTENSIONS_XML.read_text(encoding="utf-8") - assert 'Category="windows.startupTask"' in ext - assert 'Executable="app\\resources\\node-runtime\\meshbay-node.exe"' in ext - assert 'EntryPoint="Windows.FullTrustApplication"' in ext + alias = ext.split('Category="windows.appExecutionAlias"', 1)[1] + assert 'Executable="app\\resources\\node-runtime\\meshbay-node.exe"' in alias + assert 'Alias="meshbay-node.exe"' in alias + yml = MSIX_YML.read_text(encoding="utf-8") + assert "minVersion: 10.0.17763.0" in yml + xml = MSIX_MANIFEST_XML.read_text(encoding="utf-8") + assert 'xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"' in xml + + +def test_the_node_runtime_has_a_windowless_daemon_beside_the_cli(): + spec = (WIN / "meshbay-node.spec").read_text(encoding="utf-8") + windowless = spec.split('name="meshbay-nodew"', 1) + assert len(windowless) == 2, "meshbay-node.spec must build meshbay-nodew.exe" + assert "console=False" in windowless[1].split(")", 1)[0] + coll = spec.split("COLLECT(", 1)[1].split(")", 1)[0] + assert "exe_windowless" in coll, "both executables share one _internal/" + assert '"meshbay-nodew.exe"' in (WIN / "build-node-runtime.ps1").read_text(encoding="utf-8") + # Started with no stdio at all, it must not die on a library's write. + entry = (WIN / "node-entry.py").read_text(encoding="utf-8") + assert "os.devnull" in entry and entry.index("os.devnull") < entry.index("import main") def test_msix_ships_the_four_required_tile_images(): @@ -1430,21 +1485,49 @@ def test_build_win_msix_points_electron_builder_at_the_system_sdk(): assert "makeappx.exe" in src -# ── main.js needs nothing new for this target ────────────────────────────── -# -# Unlike Light (which needed hasBundledNode/winCanElevateServiceMode/the -# create-group gate because the bundle itself is smaller), MSIX ships -# everything Full does, and the two elevation paths it cannot get from an -# installer already exist independently of installer.nsh: -# firewall.ps1's per-first-use Windows prompt (no admin needed for that -# fallback -- see firewall.ps1's own header) and main.js's -# winElevateServiceMode(), driven from the Node page ("the other door", -# already exercised by test_can_elevate_checks_service_mode_ps1_actually_ -# exists above) rather than from setup. There is deliberately no -# MSIX-specific test here pinning main.js: the Light-target tests above -# already pin that winCanElevateServiceMode() checks for service-mode.ps1's -# presence generically, which is exactly what makes it work for a third -# packaged target without being told about it. +# ── main.js in the Store package ────────────────────────────────────────── + +def test_main_js_switches_the_startup_task_in_the_store_package(): + """In the Store package "at sign-in" is the package's startup task, read + and switched through the node's CLI, which has the package's identity + where a powershell.exe the app started had none ("Element not found"). + Never the Startup-folder .vbs, whose path every update deletes.""" + src = MAIN_JS.read_text(encoding="utf-8") + assert "const WIN_STORE = process.platform === 'win32' && Boolean(process.windowsStore);" in src + task_fn = _fn_body(src, "async function winStartupTask(sub)") + assert "winNodeCli(['autostart', sub])" in task_fn + assert "powershell" not in task_fn.lower() + assert "STARTUP_TASK_TTL_MS" in _fn_body(src, "async function winSigninEnabled()"), ( + "the Node page polls; each uncached answer is a process") + signin = _fn_body(src, "async function winSigninEnabled()") + assert "if (!WIN_STORE) return winAutostartInstalled();" in signin + assert "winStartupTask('status')" in signin + assert "await winSigninEnabled()" in _fn_body(src, "async function winStartupMode()") + handler = src.split("handle('node:autostart'", 1)[1].split("\n });\n", 1)[0] + assert "winStartupTask('install')" in handler and "winStartupTask('remove')" in handler + # Every reader of the sign-in state asks the same function. + callers = [ln for ln in src.splitlines() if "winAutostartInstalled()" in ln + and not ln.strip().startswith("//") and "function winAutostartInstalled" not in ln] + assert len(callers) == 1, callers + + +def test_main_js_offers_no_service_mode_and_adds_no_path_entry_in_the_store_package(): + src = MAIN_JS.read_text(encoding="utf-8") + assert "!WIN_STORE" in _fn_body(src, "function winCanElevateServiceMode()") + status = _fn_body(src, "async function nodeServiceStatus()") + assert "store: WIN_STORE" in status + path_fn = src.split("function winEnsureNodeOnPath()", 1)[1].split("\n }", 1)[0] + assert "if (WIN_STORE) return;" in path_fn + page = (HUB_STATIC / "node-page.js").read_text(encoding="utf-8") + assert "info.store" in page and "node.startup_mode_service_store_hint" in page + + +def test_main_js_counts_the_windowless_daemon_as_a_node(): + """A node started at sign-in by the startup task is meshbay-nodew.exe: + Stop, the status and Quit must see it as they see meshbay-node.exe.""" + src = MAIN_JS.read_text(encoding="utf-8") + pids = _fn_body(src, "function winNodePids()") + assert "'IMAGENAME eq meshbay-node*'" in pids # ------------------------------------------------------------------------ @@ -1479,13 +1562,14 @@ def test_ensure_node_path_script_is_idempotent_and_unelevated(): "installer.nsh's own SendMessage") -def test_ensure_node_path_shipped_to_full_and_msix_not_light(): +def test_ensure_node_path_shipped_to_full_only(): pkg = _pkg() full_yml = json.dumps(pkg["build"]) assert "ensure-node-path.ps1" in full_yml - msix_yml = MSIX_YML.read_text(encoding="utf-8") - assert "ensure-node-path.ps1" in msix_yml + msix_sources = _yml_from_entries(MSIX_YML.read_text(encoding="utf-8")) + assert not any(src.endswith("ensure-node-path.ps1") for src in msix_sources), ( + "the Store package has an execution alias instead") light_yml = LIGHT_YML.read_text(encoding="utf-8") assert "ensure-node-path.ps1" not in light_yml, ( diff --git a/packages/meshbay-node/tests/test_platform.py b/packages/meshbay-node/tests/test_platform.py index 4805d8a..d9ce75c 100644 --- a/packages/meshbay-node/tests/test_platform.py +++ b/packages/meshbay-node/tests/test_platform.py @@ -301,7 +301,7 @@ def test_the_forced_stop_really_spares_its_caller(tmp_path): script = textwrap.dedent(f""" import sys; sys.path.insert(0, {str(src)!r}) from meshbay_node import platform as p - p.NODE_IMAGE = {image!r} + p.NODE_IMAGES = ({image!r},) p.autostart_end() print("survived") """) diff --git a/packages/meshbay-node/tests/test_windows_node_lifecycle.py b/packages/meshbay-node/tests/test_windows_node_lifecycle.py index 241f583..b85b6ea 100644 --- a/packages/meshbay-node/tests/test_windows_node_lifecycle.py +++ b/packages/meshbay-node/tests/test_windows_node_lifecycle.py @@ -173,7 +173,7 @@ def test_node_pids_finds_every_copy_but_its_caller(tmp_path, monkeypatch): shutil.copy(r"C:\Windows\System32\PING.EXE", exe) proc = subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - monkeypatch.setattr(plat, "NODE_IMAGE", "mbpidtest.exe") + monkeypatch.setattr(plat, "NODE_IMAGES", ("mbpidtest.exe",)) try: assert plat.node_pids() == [proc.pid] plat.kill_pid(proc.pid) @@ -184,6 +184,110 @@ def test_node_pids_finds_every_copy_but_its_caller(tmp_path, monkeypatch): proc.kill() +@pytest.mark.skipif(sys.platform != "win32", reason="lists and kills real processes") +def test_node_pids_counts_the_windowless_daemon_too(tmp_path, monkeypatch): + """The Store package's startup task runs meshbay-nodew.exe: a Stop that + looked for meshbay-node.exe alone would call that node gone.""" + import shutil + procs = [] + for name in ("mbpidtest.exe", "mbpidtestw.exe"): + shutil.copy(r"C:\Windows\System32\PING.EXE", tmp_path / name) + procs.append(subprocess.Popen([str(tmp_path / name), "-n", "300", "127.0.0.1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + monkeypatch.setattr(plat, "NODE_IMAGES", ("mbpidtest.exe", "mbpidtestw.exe")) + try: + assert sorted(plat.node_pids()) == sorted(p.pid for p in procs) + finally: + for p in procs: + p.kill() + assert set(plat.NODE_IMAGES) == {"mbpidtest.exe", "mbpidtestw.exe"} + monkeypatch.undo() + assert plat.NODE_IMAGES == ("meshbay-node.exe", "meshbay-nodew.exe") + + +def _current_package_family() -> str: + import ctypes + length = ctypes.c_uint32(0) + k32 = ctypes.windll.kernel32 + if k32.GetCurrentPackageFamilyName(ctypes.byref(length), None) != 122: + return "" + buf = ctypes.create_unicode_buffer(length.value) + k32.GetCurrentPackageFamilyName(ctypes.byref(length), buf) + return buf.value + + +@pytest.mark.skipif(sys.platform != "win32", reason="package identity is Windows'") +def test_only_meshbays_own_package_counts_as_the_store_install(monkeypatch): + """A terminal inside another packaged application (an IDE or an agent + from the Store) gives its processes that application's identity, and the + test suite itself may run in one: that is not MeshBay's package.""" + assert plat.in_store_package() is False + family = _current_package_family() + if family: + monkeypatch.setattr(plat, "STORE_PACKAGE_FAMILY_PREFIX", family.split("_")[0] + "_") + assert plat.in_store_package() is True + + +def test_the_store_package_switches_its_startup_task_not_a_launcher(monkeypatch, capsys): + """There a Startup-folder launcher or a boot task would name the versioned + WindowsApps path each update deletes (and right after sign-in Windows + refused the launcher that path): `autostart` switches the package's + startup task, and says so on a first line the desktop app reads.""" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(plat, "in_store_package", lambda: True) + monkeypatch.setattr(plat, "autostart_install", lambda *a: pytest.fail("wrote a launcher")) + monkeypatch.setattr(plat, "autostart_remove", lambda *a: pytest.fail("removed a launcher")) + monkeypatch.setattr(plat, "service_install", lambda *a: pytest.fail("made a boot task")) + monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""}) + asked = [] + answers = {"enable": "Enabled", "disable": "Disabled", "query": "Disabled"} + monkeypatch.setattr(plat, "startup_task", lambda a: asked.append(a) or answers[a]) + + class Args: + config = None + subcommand = "install" + for sub, action, state in (("install", "enable", "Enabled"), ("remove", "disable", "Disabled"), + ("status", "query", "Disabled")): + Args.subcommand = sub + lifecycle.autostart(Args) + assert asked[-1] == action + assert capsys.readouterr().out.splitlines()[0] == f"startup task {state}" + + # Switched off by the user in Windows Settings: theirs to switch back on. + answers["enable"] = "DisabledByUser" + Args.subcommand = "install" + with pytest.raises(SystemExit) as e: + lifecycle.autostart(Args) + assert e.value.code == 1 + assert "Settings > Apps > Startup" in capsys.readouterr().out + + # At boot is the .exe installer's. + with pytest.raises(SystemExit) as e: + lifecycle.service(Args) + assert e.value.code == 1 + assert ".exe" in capsys.readouterr().out + + +def test_a_refused_action_stays_on_the_node_page_until_the_next_action(): + """The status poll (every five seconds) cleared the one error state there + was, so a refusal vanished before it was read.""" + page = _js(STATIC / "node-page.js") + panel = page.split("function NodeServicePanel(", 1)[1].split("\nfunction ", 1)[0] + refresh = panel.split("const refresh = useCallback(", 1)[1].split("}, []);", 1)[0] + act = panel.split("const act = useCallback(", 1)[1].split("}, [", 1)[0] + assert "setActErr" not in refresh and "setPollErr('')" in refresh + assert "setActErr(platform.bridgeMessage(e))" in act + assert "const err = actErr || pollErr;" in panel + + +def test_the_startup_task_is_refused_outside_the_store_package(monkeypatch): + monkeypatch.setattr(plat, "in_store_package", lambda: False) + with pytest.raises(RuntimeError, match="Microsoft Store package"): + plat.startup_task("query") + with pytest.raises(ValueError): + plat.startup_task("toggle") + + # ── the desktop application: what it says, and when it sets the node up ────── def test_the_node_page_asks_the_node_not_only_the_task(): |