unsloth/tests/studio/install/test_install_node_prebuilt_logic.py
Daniel Han 9f39cc2c39
Studio: use an isolated Node.js for the frontend build instead of replacing the system Node/npm (#6533)
* Studio: use an isolated Node.js for the frontend build instead of replacing the system Node/npm

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: address Node isolation review (no-Node probe crash, PATH refresh, OXC provisioning, venv python, runtime node resolver)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix/adjust Node isolation for PR #6533

* Studio Node: don't cache a negative node resolution; accept Node metadata in setup.sh ownership guard

- node_runtime: memoize only a version-adequate executable so a Node installed
  by a separate-process 'studio update' is picked up without a backend restart.
- setup.sh: _studio_owned_adoptable also accepts UNSLOTH_NODE_PREBUILT_INFO.json,
  matching the setup.ps1 Node ownership guard (custom-home parity).

* Studio setup.ps1: skip OXC npm install gracefully when npm is absent

Mirror setup.sh's `command -v npm` guard so a pip-installed Studio with no
system Node skips the OXC runtime install (validator degrades at runtime) instead
of exit 1 aborting the whole setup. Tighten test_node_probe_guard.ps1's probe
regex so it only matches the two system-version probes, not this new npm guard.

* Wire test_node_probe_guard.ps1 into Windows CI for PR #6533

* Harden isolated Node install and probes for PR #6533

- install_node_prebuilt.py: keep an existing, still-usable isolated Node
  when nodejs.org's dist index is unreachable instead of aborting the
  update on a transient outage (existing_install_usable + tolerant fetch).
- install_node_prebuilt.py: pin NPM_CONFIG_PREFIX/npm_config_prefix and
  drop NODE_PATH in _run_node so any npm -g stays inside the isolated
  prefix; Windows npm otherwise writes to %APPDATA%\npm.
- install_node_prebuilt.py: resolve tar hard-link targets against the
  archive root (symlink targets stay link-parent relative).
- setup.ps1: wrap the system node/npm probes in try/catch so a present
  but broken shim degrades to the bundled Node instead of aborting setup.
- setup.ps1: run the isolated Node install with the handed-off/venv Python
  (ReusedSetupPython); the main resolver runs later and bare python may be
  a Store stub this early.
- setup.sh: log when the OXC validator runtime is skipped for missing npm,
  matching setup.ps1.
- node_runtime.py: move the version-floor comment onto _version_meets_floor.
- Tests for the offline-reuse and broken-shim paths.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Trim verbose comments across the Studio Node installer for PR #6533

Comments-only pass: collapse the multi-line section banners to single lines,
drop comments that restate obvious code, and tighten the remaining docstrings
and "why" notes without losing intent. No code changes (verified with an AST
comment-only check on the Python files and a non-comment-diff scan on setup.sh
and setup.ps1). Net 109 fewer lines; the install, decision, and probe-guard
suites stay green.

* Harden Node install from review: validated Python, version floor, legacy home, lock race

For PR #6533, addressing the latest review pass:

- setup.ps1: run the isolated Node install with the validated reused/venv Python.
  An incompatible reused interpreter (old venv, conda, stale UNSLOTH_SETUP_PYTHON)
  is no longer used; fall back to the resolved python instead.
- setup.ps1: a STUDIO_HOME/UNSLOTH_STUDIO_HOME override equal to the legacy default
  now uses the legacy sibling node dir (~/.unsloth/node), matching the runtime
  resolver and setup.sh, so OXC can find the Node it installed.
- install_node_prebuilt.py: reject an explicit --node-version below the floor
  (^20.19 || >=22.12 || >=23) instead of installing a Node the build cannot use.
- install_node_prebuilt.py: atomically rename a stale install lock before unlinking
  so two concurrent runs without filelock cannot both acquire it.

Tests added for the version floor (parametrized + explicit-below-floor rejection).
Full install suite: 937 passed, 1 skipped; setup.ps1 parses; decision tests green.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address latest review: armv7l + later-fetch offline reuse for PR #6533

- install_node_prebuilt.py: reject 32-bit ARM (armv7l) up front. Node 24 LTS
  ships no linux-armv7l build, so the old path failed late with a confusing
  "no sha256"; it now fails fast with a clear unsupported-architecture error.
- install_node_prebuilt.py: extend the offline-reuse fallback to the SHASUMS and
  archive fetches. If index.json resolves a newer Node but a later download fails
  and a usable isolated Node is already on disk, keep it instead of aborting a
  non-force update.

Tests added: armv7l/armhf are unsupported; a SHASUMS failure keeps an existing
usable Node and re-raises when none is present. Full install suite: 941 passed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add UNSLOTH_STUDIO_HOME node-dir tests (install side + resolver) for PR #6533

* Add regression tests pinning the reuse path read-only and isolating installer writes

Lock in the two invariants behind the isolated-Node design: reusing a good
system Node never mutates the user's Node/npm, and the installer's own npm
calls only ever write inside its install_dir.

- tests/studio/install/test_install_node_prebuilt_logic.py: assert _run_node
  redirects NPM_CONFIG_PREFIX/npm_config_prefix into install_dir and drops an
  inherited NODE_PATH; assert _ensure_npm_floor scopes the npm self-upgrade to
  install_dir (never -g against the system) and is a no-op once npm meets the floor.
- tests/sh/test_system_node_readonly.sh (new, wired into studio-backend-ci.yml):
  the setup.sh NODE_SOURCE=system arm runs no global install and sets no
  NPM_CONFIG_PREFIX, with a positive control that the bundled arm does.
- tests/studio/test_node_decision.ps1: symmetric structural guard that the prefix
  pin and the only global install (bun) live in the bundled branch, not the system arm.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
2026-06-21 21:17:29 -07:00

477 lines
18 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Logic tests for studio/install_node_prebuilt.py -- the isolated Node installer.
# No network/GPU: downloads are monkeypatched and archives are built in-memory.
import importlib.util
import io
import json
import os
import sys
import tarfile
import types
import zipfile
from pathlib import Path
import pytest
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
MODULE_PATH = PACKAGE_ROOT / "studio" / "install_node_prebuilt.py"
SPEC = importlib.util.spec_from_file_location("studio_install_node_prebuilt", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
M = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = M
SPEC.loader.exec_module(M)
HostInfo = M.HostInfo
PrebuiltFallback = M.PrebuiltFallback
def _host(node_os: str, node_arch: str) -> HostInfo:
ext = ".zip" if node_os == "win" else ".tar.gz"
return HostInfo(
system = {"linux": "Linux", "darwin": "Darwin", "win": "Windows"}[node_os],
machine = node_arch,
node_os = node_os,
node_arch = node_arch,
archive_ext = ext,
is_windows = node_os == "win",
)
# ── Host detection (per OS/arch) ──
@pytest.mark.parametrize(
"system,machine,exp_os,exp_arch,exp_ext",
[
("Linux", "x86_64", "linux", "x64", ".tar.gz"),
("Linux", "aarch64", "linux", "arm64", ".tar.gz"),
("Darwin", "x86_64", "darwin", "x64", ".tar.gz"),
("Darwin", "arm64", "darwin", "arm64", ".tar.gz"),
("Windows", "AMD64", "win", "x64", ".zip"),
("Windows", "ARM64", "win", "arm64", ".zip"),
],
)
def test_detect_host(monkeypatch, system, machine, exp_os, exp_arch, exp_ext):
monkeypatch.setattr(M.platform, "system", lambda: system)
monkeypatch.setattr(M.platform, "machine", lambda: machine)
host = M.detect_host()
assert (host.node_os, host.node_arch, host.archive_ext) == (exp_os, exp_arch, exp_ext)
assert host.is_windows == (exp_os == "win")
@pytest.mark.parametrize(
"system,machine",
[("Plan9", "x86_64"), ("Linux", "sparc64"), ("Linux", "armv7l"), ("Linux", "armhf")],
)
def test_detect_host_unsupported(monkeypatch, system, machine):
monkeypatch.setattr(M.platform, "system", lambda: system)
monkeypatch.setattr(M.platform, "machine", lambda: machine)
with pytest.raises(PrebuiltFallback):
M.detect_host()
# ── URL / asset construction (pure) ──
def test_asset_and_url_linux():
host = _host("linux", "x64")
assert M.node_asset_name("24.17.0", host) == "node-v24.17.0-linux-x64.tar.gz"
assert (
M.node_download_url("24.17.0", M.node_asset_name("24.17.0", host))
== "https://nodejs.org/dist/v24.17.0/node-v24.17.0-linux-x64.tar.gz"
)
def test_asset_windows_is_zip():
host = _host("win", "x64")
assert M.node_asset_name("24.17.0", host) == "node-v24.17.0-win-x64.zip"
def test_shasums_url():
assert M.node_shasums_url("24.17.0") == "https://nodejs.org/dist/v24.17.0/SHASUMS256.txt"
def test_binary_layout_is_host_aware():
# Windows ships node.exe + node_modules\npm at the root; Unix uses bin/ + lib/.
win = _host("win", "x64")
nix = _host("linux", "x64")
assert M.node_binary_path(Path("/n"), win) == Path("/n/node.exe")
assert M.node_binary_path(Path("/n"), nix) == Path("/n/bin/node")
assert M.npm_cli_path(Path("/n"), win) == Path("/n/node_modules/npm/bin/npm-cli.js")
assert M.npm_cli_path(Path("/n"), nix) == Path("/n/lib/node_modules/npm/bin/npm-cli.js")
# ── SHASUMS256.txt parsing ──
def test_expected_sha256_for():
asset = "node-v24.17.0-linux-x64.tar.gz"
good = "a" * 64
text = (
f"{'b' * 64} node-v24.17.0-linux-arm64.tar.gz\n"
f"{good} {asset}\n"
f"{'c' * 64} node-v24.17.0-win-x64.zip\n"
)
assert M.expected_sha256_for(text, asset) == good
assert M.expected_sha256_for(text, "node-v24.17.0-darwin-x64.tar.gz") is None
def test_expected_sha256_rejects_malformed():
asset = "node-v24.17.0-linux-x64.tar.gz"
assert M.expected_sha256_for(f"notahex {asset}\n", asset) is None
# ── Version selection from index.json ──
INDEX = [
{"version": "v26.3.1", "lts": False},
{"version": "v24.17.0", "lts": "Krypton"},
{"version": "v24.9.0", "lts": "Krypton"},
{"version": "v22.20.0", "lts": "Jod"},
{"version": "v20.19.0", "lts": "Iron"},
]
def test_select_lts_respects_min_major():
# Newest LTS at/above 24 -> 24.17.0 (22.x LTS is below the floor).
assert M.select_node_version(INDEX, channel = "lts", min_major = 24) == "24.17.0"
def test_select_latest_overall():
assert M.select_node_version(INDEX, channel = "latest", min_major = 24) == "26.3.1"
def test_select_explicit_passthrough():
assert M.select_node_version(INDEX, channel = "v24.5.0", min_major = 24) == "24.5.0"
def test_select_no_candidate_raises():
with pytest.raises(PrebuiltFallback):
M.select_node_version(INDEX, channel = "lts", min_major = 99)
# ── Archive extraction (zip + tar.gz with the npm-style symlink), traversal guard ──
def _add_file(
tar: tarfile.TarFile,
name: str,
data: bytes,
mode: int = 0o644,
):
info = tarfile.TarInfo(name)
info.size = len(data)
info.mode = mode
tar.addfile(info, io.BytesIO(data))
def _add_symlink(tar: tarfile.TarFile, name: str, target: str):
info = tarfile.TarInfo(name)
info.type = tarfile.SYMTYPE
info.linkname = target
tar.addfile(info)
@pytest.mark.skipif(
os.name == "nt",
reason = "Node ships a .zip (no symlinks) on Windows; the tar+symlink path is Unix-only",
)
def test_extract_tar_gz_with_npm_symlink(tmp_path: Path):
# Mirrors the real Node tarball: bin/npm -> ../lib/node_modules/npm/bin/npm-cli.js
archive = tmp_path / "node.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
_add_file(tar, "node-v24/bin/node", b"#!/bin/sh\necho v24.17.0\n", mode = 0o755)
_add_file(tar, "node-v24/lib/node_modules/npm/bin/npm-cli.js", b"// npm")
_add_symlink(tar, "node-v24/bin/npm", "../lib/node_modules/npm/bin/npm-cli.js")
dest = tmp_path / "out"
M.extract_archive(archive, dest)
npm_link = dest / "node-v24" / "bin" / "npm"
assert npm_link.is_symlink()
assert (dest / "node-v24" / "bin" / "node").exists()
# executable bit preserved
assert (dest / "node-v24" / "bin" / "node").stat().st_mode & 0o111
def test_extract_zip(tmp_path: Path):
archive = tmp_path / "node.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("node-v24-win-x64/node.exe", b"MZ")
zf.writestr("node-v24-win-x64/npm.cmd", b"@echo off")
dest = tmp_path / "out"
M.extract_archive(archive, dest)
assert (dest / "node-v24-win-x64" / "node.exe").exists()
def test_extract_rejects_path_traversal(tmp_path: Path):
archive = tmp_path / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
_add_file(tar, "../escape.txt", b"pwn")
with pytest.raises(PrebuiltFallback):
M.extract_archive(archive, tmp_path / "out")
# ── Checksum-verified download (accept + reject) ──
def test_download_file_verified_accepts_match(tmp_path: Path, monkeypatch):
payload = b"real-node-archive"
sha = M.hashlib.sha256(payload).hexdigest()
def fake_download(url: str, destination: Path):
destination.write_bytes(payload)
monkeypatch.setattr(M, "download_file", fake_download)
dest = tmp_path / "a.tar.gz"
M.download_file_verified("http://x/a.tar.gz", dest, expected_sha256 = sha, label = "a")
assert dest.read_bytes() == payload
def test_download_file_verified_rejects_mismatch(tmp_path: Path, monkeypatch):
def fake_download(url: str, destination: Path):
destination.write_bytes(b"tampered")
monkeypatch.setattr(M, "download_file", fake_download)
with pytest.raises(PrebuiltFallback):
M.download_file_verified("http://x/a", tmp_path / "a", expected_sha256 = "0" * 64, label = "a")
# ── Lock liveness probe (Windows must not use os.kill(pid, 0)) ──
def test_pid_is_alive_windows_uses_tasklist_not_os_kill(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
def fail_kill(pid, sig):
raise AssertionError("Windows liveness must not call os.kill(pid, 0)")
def fake_run(cmd, **kwargs):
assert cmd[:2] == ["tasklist", "/FI"]
assert "PID eq 1234" in cmd
return types.SimpleNamespace(stdout = '"node.exe","1234","Console","1","12,345 K"\n')
monkeypatch.setattr(M.os, "kill", fail_kill)
monkeypatch.setattr(M.subprocess, "run", fake_run)
assert M._pid_is_alive(1234) is True
def test_pid_is_alive_windows_false_when_tasklist_omits_pid(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
monkeypatch.setattr(
M.subprocess,
"run",
lambda *a, **k: types.SimpleNamespace(
stdout = "INFO: No tasks are running which match the specified criteria.\n"
),
)
assert M._pid_is_alive(1234) is False
def test_pid_is_alive_windows_assumes_alive_when_tasklist_fails(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
def boom(*args, **kwargs):
raise OSError("tasklist unavailable")
monkeypatch.setattr(M.subprocess, "run", boom)
assert M._pid_is_alive(1234) is True
def test_pid_is_alive_posix_signal_zero(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "linux")
calls = []
def fake_kill(pid, sig):
calls.append((pid, sig))
if pid == 9999:
raise ProcessLookupError
monkeypatch.setattr(M.os, "kill", fake_kill)
assert M._pid_is_alive(1234) is True
assert M._pid_is_alive(9999) is False
assert calls == [(1234, 0), (9999, 0)]
# ── existing_install_matches + install_prebuilt short-circuit ──
def test_existing_install_matches_false_without_metadata(tmp_path: Path):
host = _host("linux", "x64")
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is False
def test_existing_install_matches_true_when_version_and_runtime_ok(tmp_path: Path, monkeypatch):
host = _host("linux", "x64")
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is True
# npm too old -> not a match
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is False
def test_install_prebuilt_short_circuits_when_version_matches(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
def boom(*a, **k):
raise AssertionError("must not download when the install already matches")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_existing_install_usable_is_version_agnostic(tmp_path: Path, monkeypatch):
host = _host("linux", "x64")
assert M.existing_install_usable(tmp_path, host) is False # no metadata
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
assert M.existing_install_usable(tmp_path, host) is True
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
assert M.existing_install_usable(tmp_path, host) is False # npm below floor
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: None)
assert M.existing_install_usable(tmp_path, host) is False # node does not run
def _offline(*a, **k):
raise OSError("nodejs.org unreachable")
def test_install_prebuilt_keeps_existing_when_index_unreachable(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "fetch_json", _offline)
def boom(*a, **k):
raise AssertionError("must not download when keeping the existing install")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_install_prebuilt_reraises_when_index_unreachable_and_no_install(
tmp_path: Path, monkeypatch
):
install_dir = tmp_path / "node" # nothing on disk to fall back to
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
def test_install_prebuilt_force_does_not_keep_existing_offline(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "fetch_json", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = True)
@pytest.mark.parametrize(
"ver,ok",
[
("20.19.0", True),
("20.18.9", False),
("22.12.0", True),
("22.11.5", False),
("23.0.0", True),
("24.4.1", True),
("21.7.3", False),
("24", True),
("20", False),
],
)
def test_meets_node_floor(ver, ok):
assert M._meets_node_floor(ver) is ok
def test_install_prebuilt_rejects_explicit_below_floor(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
def boom(*a, **k):
raise AssertionError("must not download a below-floor Node")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
with pytest.raises(PrebuiltFallback):
M.install_prebuilt(install_dir, channel = "20.18.0", min_major = 24, force = False)
def test_install_prebuilt_keeps_existing_when_shasums_fetch_fails(tmp_path: Path, monkeypatch):
# index.json resolves a newer version, but the later SHASUMS fetch fails and a
# usable older isolated Node is on disk -> keep it instead of aborting.
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.9.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # newest LTS = 24.17.0
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.9.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "download_bytes", _offline) # SHASUMS fetch fails
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_install_prebuilt_reraises_shasums_failure_without_existing(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node" # nothing usable on disk
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
monkeypatch.setattr(M, "download_bytes", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
# ── Isolation invariant: the installer only writes inside its own install_dir ──
def test_run_node_pins_npm_prefix_to_install_dir(tmp_path: Path, monkeypatch):
# Every node/npm call the installer makes redirects npm's global prefix into
# the isolated install_dir and drops an inherited NODE_PATH, so a stray `npm
# -g` can never write to the user's system Node/npm.
install_dir = tmp_path / "node"
monkeypatch.setenv("NPM_CONFIG_PREFIX", "/usr/local") # user's own global prefix
monkeypatch.setenv("NODE_PATH", "/usr/lib/node_modules")
captured = {}
def fake_run(cmd, **kw):
captured["env"] = kw["env"]
return types.SimpleNamespace(returncode = 0, stdout = "v24.17.0\n", stderr = "")
monkeypatch.setattr(M.subprocess, "run", fake_run)
assert M._run_node(install_dir, _host("linux", "x64"), ["-v"]) == "v24.17.0"
env = captured["env"]
assert env["NPM_CONFIG_PREFIX"] == str(install_dir)
assert env["npm_config_prefix"] == str(install_dir)
assert "NODE_PATH" not in env # inherited NODE_PATH is dropped, not leaked in
def test_ensure_npm_floor_scopes_upgrade_to_install_dir(tmp_path: Path, monkeypatch):
# A pinned build shipping npm < 11 self-upgrades, but only inside the isolated
# prefix: it goes through _run_node against install_dir, never the system.
install_dir = tmp_path / "node"
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
calls = []
monkeypatch.setattr(M, "_run_node", lambda d, h, args, **kw: calls.append((d, args)) or "")
M._ensure_npm_floor(install_dir, _host("linux", "x64"))
assert len(calls) == 1
target_dir, args = calls[0]
assert target_dir == install_dir # upgrade scoped to the isolated dir
assert args[-3:] == ["install", "-g", f"npm@^{M.NPM_MIN_MAJOR}"]
def test_ensure_npm_floor_noop_when_npm_meets_bar(tmp_path: Path, monkeypatch):
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: M.NPM_MIN_MAJOR)
def boom(*a, **k):
raise AssertionError("must not run an npm upgrade when npm already meets the floor")
monkeypatch.setattr(M, "_run_node", boom)
M._ensure_npm_floor(tmp_path / "node", _host("linux", "x64"))