diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-12 15:48:03 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-12 15:48:22 +0200 |
| commit | 9cc2909cb4a360c81b471ceab1d9578a7655a88e (patch) | |
| tree | da6da7ff43eed10382e72247ef7c84b142a42ca5 /packages/meshbay-node | |
| parent | d8885c8df17c60927cb8d1f77ce1745814c6d3b4 (diff) | |
| download | meshbay-9cc2909cb4a360c81b471ceab1d9578a7655a88e.tar.gz | |
fix(packaging): three MSIX first-run regressions found by a real sideload
A second-machine sideload of the MSIX target surfaced three things the
earlier verification round (which only proved the package installs and
runs) had missed:
1. meshbay-node missing from PATH. installer.nsh's customInstall adds
node-runtime\ to HKCU\Environment at install time -- an unelevated
per-user write, never blocked by MSIX's no-elevation rule, only by the
more basic fact that an AppX/MSIX install runs no custom code at all.
packaging/win/ensure-node-path.ps1 (idempotent, no admin verb) plus
main.js's winEnsureNodeOnPath() do it from the app itself instead, once
per launch, shipped to Full and MSIX (not Light, nothing to add there).
Verified live via the Node inspector protocol: the entry was in
HKCU\Environment\Path after a launch, absent before.
2. A daemon that crashes on startup failed silently. spawnNodeDetached()
used stdio: 'ignore', so a real crash reproduced live (a second instance
colliding with the first on 127.0.0.1:18000) left waitForNode()'s
generic 60s timeout as the only failure ever shown. spawnNodeDetachedWatched()
pipes stdio and watches ~2.5s, rejecting immediately with the daemon's
own stderr on an early exit; a survivor has its streams released and
runs fully detached exactly as before. First version bounded the
captured text by line count and a live test showed that cut the actual
OSError line -- two uvicorn/asyncio tracebacks followed it in the real
capture -- so it is bounded by characters instead.
3. No hint that a startup-mode choice exists. The install-time radio page
was the only place this was ever offered, and nothing replaces it now
that no install-time page can exist at all. SetupWelcome (the existing
first-run banner) grew a conditional hint, shown only while a bundled
node is present and neither autostart nor service mode is configured
yet. Considered and rejected: linking straight to the Node page -- its
route is gated on a linked hub node key, false on the exact fresh-install
screen this hint targets, so the link would have been dead on arrival.
New key setup.node_startup_hint, added to all ten locale catalogues.
test_packaging_win.py gained six tests pinning all three (69 total).
Full plan and verification detail: C:\Users\admin\devel\msix-installer.md
section 13 (out of repo).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/tests/test_packaging_win.py | 114 |
1 files changed, 114 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index 2cc8f12..6f7573b 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -1102,3 +1102,117 @@ def test_build_win_msix_points_electron_builder_at_the_system_sdk(): # 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. + + +# ------------------------------------------------------------------------ +# Three gaps a real sideload install found (2026-09-12) that reading the +# manifest and launching the app once had missed: no install-time hook means +# no equivalent of installer.nsh's PATH write either, an immediate daemon +# crash was silently discarded instead of surfacing to the user, and nobody +# told a first-time MSIX user the startup-mode choice existed at all. +# Confirmed live: process.resourcesPath/spawn work correctly under the +# installed AppX path, PATH actually gained the entry on next launch, and a +# real port-18000 collision now rejects in ~2s with the daemon's own stderr +# instead of a 60s generic timeout. +# ------------------------------------------------------------------------ + +ENSURE_NODE_PATH_PS1 = WIN / "ensure-node-path.ps1" + + +def test_ensure_node_path_script_is_idempotent_and_unelevated(): + """ + No admin verb, no elevation helper -- a per-user HKCU write never needed + elevation in the first place (installer.nsh's customInstall already did + this one unelevated); what MSIX lacks is an install-time hook to run + anything from, not the right to make this specific change. + """ + assert ENSURE_NODE_PATH_PS1.exists(), f"{ENSURE_NODE_PATH_PS1} is missing" + src = ENSURE_NODE_PATH_PS1.read_text(encoding="utf-8") + assert "HKEY_CURRENT_USER\\Environment" in src + assert "already present" in src, "must be a no-op when the entry already exists" + assert "RunAs" not in src and "Verb" not in src + assert "WM_SETTINGCHANGE" in src or "SendMessageTimeout" in src, ( + "must broadcast the change so already-open shells notice, same as " + "installer.nsh's own SendMessage") + + +def test_ensure_node_path_shipped_to_full_and_msix_not_light(): + 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 + + light_yml = LIGHT_YML.read_text(encoding="utf-8") + assert "ensure-node-path.ps1" not in light_yml, ( + "Light has no bundled node-runtime to add to PATH") + + +def test_main_js_calls_ensure_node_path_on_every_launch(): + src = MAIN_JS.read_text(encoding="utf-8") + assert "function winEnsureNodeOnPath()" in src + body = src.split("function winEnsureNodeOnPath()", 1)[1].split("\n }", 1)[0] + assert "hasBundledNode()" in body, "must not run at all for a Light install" + assert "ensure-node-path.ps1" in body + # Actually invoked, not just defined -- registerBridge() calls it once, + # unconditionally, on every launch (idempotent, so Full's already-set + # PATH is just a fast no-op query each time). + assert src.count("winEnsureNodeOnPath()") >= 2, ( + "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. + """ + 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") + + +def test_setup_welcome_hints_at_the_node_startup_choice(): + """ + build/installer.nsh's radio page was the only place this choice was ever + offered, and an AppX/MSIX install has no install-time page at all to + replace it with -- a first-time user of a build with a bundled node + otherwise has no reason to ever find the Node page's startup-mode + control. Shown only while neither mode is configured yet (so it + disappears on its own once one is, or never appears for Light/non- + Windows/browser, where platform.node.service.available is false). + """ + src = (HUB_STATIC / "app.js").read_text(encoding="utf-8") + fn = src.split("function SetupWelcome(", 1)[1].split("\nfunction ", 1)[0] + assert "platform.node.bundled()" in fn + assert "platform.node.service.status()" in fn + assert "mode === 'service'" in fn and "autostart" in fn + assert "setup.node_startup_hint" in fn + + +def test_node_startup_hint_key_exists_in_all_ten_locales(): + for name in ("en", "fr", "es", "pt-BR", "zh-CN", "ja", "de", "it", "nl", "pl"): + cat = (HUB_STATIC / "locales" / f"{name}.js").read_text(encoding="utf-8") + assert "'setup.node_startup_hint':" in cat, f"{name}.js is missing the key" |