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>
This commit is contained in:
parent
e6b4480832
commit
9f39cc2c39
16 changed files with 2224 additions and 139 deletions
63
tests/sh/test_node_decision.sh
Normal file
63
tests/sh/test_node_decision.sh
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#!/bin/bash
|
||||
# Unit tests for decide_node_source() from studio/setup.sh.
|
||||
# Slices the pure function out of setup.sh and exercises the three outcomes:
|
||||
# system -- system Node + npm already satisfy Vite 8 (^20.19/22.12/>=23) + npm>=11
|
||||
# bundled -- otherwise install an isolated Node (the Discord-reported npm-only case)
|
||||
# skip -- UNSLOTH_SKIP_NODE_INSTALL=1 and the system is unsuitable
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SETUP_SH="$SCRIPT_DIR/../../studio/setup.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
_FUNC_FILE=$(mktemp)
|
||||
sed -n '/^decide_node_source()/,/^}/p' "$SETUP_SH" > "$_FUNC_FILE"
|
||||
if [ ! -s "$_FUNC_FILE" ]; then
|
||||
echo "FAIL: could not extract decide_node_source from $SETUP_SH"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
. "$_FUNC_FILE"
|
||||
|
||||
assert_decision() {
|
||||
_label="$1"; _node="$2"; _npm="$3"; _skip="$4"; _expected="$5"
|
||||
_actual="$(decide_node_source "$_node" "$_npm" "$_skip")"
|
||||
if [ "$_actual" = "$_expected" ]; then
|
||||
echo " PASS: $_label (node='$_node' npm='$_npm' skip='$_skip' -> $_actual)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $_label (node='$_node' npm='$_npm' skip='$_skip' expected '$_expected', got '$_actual')"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "decide_node_source"
|
||||
# system: both satisfy
|
||||
assert_decision "node22 + npm11" "v22.17.1" "11.13.0" "0" system
|
||||
assert_decision "node20.19 + npm11" "v20.19.0" "11.0.0" "0" system
|
||||
assert_decision "node24 + npm11" "v24.17.0" "11.13.0" "0" system
|
||||
assert_decision "node23 + npm11" "v23.5.0" "11.0.0" "0" system
|
||||
|
||||
# bundled: the reported bug -- fine Node, stale npm
|
||||
assert_decision "node22 + npm10 (bug)" "v22.17.1" "10.9.2" "0" bundled
|
||||
# bundled: node too old / wrong line
|
||||
assert_decision "node18" "v18.20.0" "11.0.0" "0" bundled
|
||||
assert_decision "node22.11 (<22.12)" "v22.11.0" "11.0.0" "0" bundled
|
||||
assert_decision "node20.18 (<20.19)" "v20.18.0" "11.0.0" "0" bundled
|
||||
assert_decision "node21 (odd)" "v21.7.0" "11.0.0" "0" bundled
|
||||
# bundled: missing entirely
|
||||
assert_decision "no node/npm" "" "" "0" bundled
|
||||
# bundled: garbage versions
|
||||
assert_decision "garbage versions" "vfoo" "bar" "0" bundled
|
||||
|
||||
# skip: unsuitable + skip flag
|
||||
assert_decision "npm10 + skip" "v22.17.1" "10.9.2" "1" skip
|
||||
assert_decision "missing + skip" "" "" "1" skip
|
||||
# skip flag does NOT override an already-good system
|
||||
assert_decision "good system + skip" "v22.17.1" "11.13.0" "1" system
|
||||
|
||||
rm -f "$_FUNC_FILE"
|
||||
echo ""
|
||||
echo "Passed: $PASS Failed: $FAIL"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
58
tests/sh/test_studio_home_node_dir.sh
Executable file
58
tests/sh/test_studio_home_node_dir.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression test: setup.sh installs the isolated Node under <UNSLOTH_STUDIO_HOME>
|
||||
# (or the STUDIO_HOME alias), matching node_runtime.managed_node_dir(). Extracts
|
||||
# the real STUDIO_HOME + NODE_DIR logic from setup.sh by content anchors (not line
|
||||
# numbers) and runs it against a hermetic fake HOME for each override case.
|
||||
set -u
|
||||
HERE="$(CDPATH= cd -P -- "$(dirname "$0")" && pwd -P)"
|
||||
SETUP="$HERE/../../studio/setup.sh"
|
||||
fails=0
|
||||
check() { # name expected actual
|
||||
if [ "$2" = "$3" ]; then printf ' PASS %s\n' "$1"
|
||||
else printf ' FAIL %s : expected [%s] got [%s]\n' "$1" "$2" "$3"; fails=$((fails+1)); fi
|
||||
}
|
||||
|
||||
# Block A: studio override -> STUDIO_HOME -> _STUDIO_HOME_IS_CUSTOM.
|
||||
blockA="$(awk '
|
||||
/^_studio_override_var=""/ {grab=1}
|
||||
grab {print}
|
||||
/_STUDIO_HOME_IS_CUSTOM=true/ {seen=1}
|
||||
seen && /^fi$/ {exit}
|
||||
' "$SETUP")"
|
||||
# Block B: _STUDIO_HOME_IS_CUSTOM -> _NODE_PARENT -> NODE_DIR.
|
||||
blockB="$(awk '
|
||||
/^if \[ "\$_STUDIO_HOME_IS_CUSTOM" = true \]; then/ {grab=1}
|
||||
grab {print}
|
||||
/^NODE_DIR="\$_NODE_PARENT\/node"/ {exit}
|
||||
' "$SETUP")"
|
||||
SNIP="$blockA"$'\n'"$blockB"$'\n''echo "$NODE_DIR"'
|
||||
|
||||
# Self-validate the extraction so a future setup.sh refactor fails loudly here.
|
||||
case "$blockA" in *"_STUDIO_HOME_IS_CUSTOM=true"*) : ;; *) echo "FAIL: blockA extraction broke"; exit 1 ;; esac
|
||||
case "$blockB" in *'NODE_DIR="$_NODE_PARENT/node"'*) : ;; *) echo "FAIL: blockB extraction broke"; exit 1 ;; esac
|
||||
|
||||
node_dir_for() { # HOME UNSLOTH_STUDIO_HOME STUDIO_HOME
|
||||
env -i HOME="$1" UNSLOTH_STUDIO_HOME="$2" STUDIO_HOME="$3" PATH="$PATH" \
|
||||
bash -c "$SNIP" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
T="$(mktemp -d)"
|
||||
trap 'rm -rf "$T"' EXIT
|
||||
mkdir -p "$T/custom" "$T/fakehome/.unsloth/studio"
|
||||
CUSTOM="$(CDPATH= cd -P -- "$T/custom" && pwd -P)"
|
||||
FAKEHOME="$(CDPATH= cd -P -- "$T/fakehome" && pwd -P)"
|
||||
LEGACY="$FAKEHOME/.unsloth/studio"
|
||||
|
||||
# 1. UNSLOTH_STUDIO_HOME = custom dir -> <custom>/node
|
||||
check "UNSLOTH_STUDIO_HOME=<custom> -> <custom>/node" "$CUSTOM/node" "$(node_dir_for "$FAKEHOME" "$CUSTOM" "")"
|
||||
# 2. STUDIO_HOME alias = custom dir -> <custom>/node
|
||||
check "STUDIO_HOME alias -> <custom>/node" "$CUSTOM/node" "$(node_dir_for "$FAKEHOME" "" "$CUSTOM")"
|
||||
# 3. UNSLOTH_STUDIO_HOME wins over STUDIO_HOME
|
||||
check "UNSLOTH_STUDIO_HOME wins over STUDIO_HOME" "$CUSTOM/node" "$(node_dir_for "$FAKEHOME" "$CUSTOM" "$T/fakehome")"
|
||||
# 4. Override = legacy default -> sibling ~/.unsloth/node
|
||||
check "legacy-valued override -> ~/.unsloth/node sibling" "$FAKEHOME/.unsloth/node" "$(node_dir_for "$FAKEHOME" "$LEGACY" "")"
|
||||
# 5. No override -> ~/.unsloth/node
|
||||
check "no override -> ~/.unsloth/node" "$FAKEHOME/.unsloth/node" "$(node_dir_for "$FAKEHOME" "" "")"
|
||||
|
||||
if [ "$fails" -ne 0 ]; then echo "$fails check(s) failed"; exit 1; fi
|
||||
echo "All checks passed"
|
||||
58
tests/sh/test_system_node_readonly.sh
Executable file
58
tests/sh/test_system_node_readonly.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression test: setup.sh's reuse (NODE_SOURCE=system) path is strictly
|
||||
# read-only. It runs no global npm install and sets no NPM_CONFIG_PREFIX, so
|
||||
# reusing a good system Node never mutates the user's Node/npm/NVM. Only the
|
||||
# isolated (bundled) path redirects npm into its own prefix and installs
|
||||
# anything global (and even then -g lands in the isolated prefix). Extraction is
|
||||
# anchored on setup.sh content, not line numbers, and self-validates so a
|
||||
# refactor fails loudly here.
|
||||
set -u
|
||||
HERE="$(CDPATH= cd -P -- "$(dirname "$0")" && pwd -P)"
|
||||
SETUP="$HERE/../../studio/setup.sh"
|
||||
fails=0
|
||||
fail() { printf ' FAIL %s\n' "$1"; fails=$((fails+1)); }
|
||||
pass() { printf ' PASS %s\n' "$1"; }
|
||||
|
||||
# Arm 1: the NODE_SOURCE=system branch body (reuse a good system Node).
|
||||
system_arm="$(awk '
|
||||
/^if \[ "\$NODE_SOURCE" = system \]; then/ {grab=1; next}
|
||||
/^elif \[ "\$NODE_SOURCE" = bundled \]; then/ {grab=0}
|
||||
grab {print}
|
||||
' "$SETUP")"
|
||||
# Arm 2: the NODE_SOURCE=bundled branch body (provision the isolated Node).
|
||||
bundled_arm="$(awk '
|
||||
/^elif \[ "\$NODE_SOURCE" = bundled \]; then/ {grab=1; next}
|
||||
grab && /^else$/ {grab=0}
|
||||
grab {print}
|
||||
' "$SETUP")"
|
||||
# The optional-bun block (the only global install, gated on the bundled path).
|
||||
bun_block="$(awk '
|
||||
/^if command -v bun &>\/dev\/null; then/ {grab=1}
|
||||
grab {print}
|
||||
grab && /^fi$/ {exit}
|
||||
' "$SETUP")"
|
||||
|
||||
# Self-validate extraction so a setup.sh refactor cannot silently void the test.
|
||||
[ -n "$system_arm" ] || { echo "FAIL: system arm extraction broke"; exit 1; }
|
||||
case "$bundled_arm" in *'NPM_CONFIG_PREFIX="$NODE_DIR"'*) : ;; *) echo "FAIL: bundled arm extraction broke"; exit 1 ;; esac
|
||||
case "$bun_block" in *'npm install -g bun'*) : ;; *) echo "FAIL: bun block extraction broke"; exit 1 ;; esac
|
||||
|
||||
# 1. system (reuse) arm performs no global npm install.
|
||||
case "$system_arm" in *"npm install -g"*) fail "system arm runs no 'npm install -g'" ;; *) pass "system arm runs no 'npm install -g'" ;; esac
|
||||
# 2. system (reuse) arm sets no npm prefix redirect (either casing of the var).
|
||||
case "$system_arm" in *NPM_CONFIG_PREFIX*|*npm_config_prefix*) fail "system arm sets no NPM_CONFIG_PREFIX" ;; *) pass "system arm sets no NPM_CONFIG_PREFIX" ;; esac
|
||||
# 3. system (reuse) arm does not rewrite PATH toward a managed Node dir.
|
||||
case "$system_arm" in *"export PATH="*) fail "system arm does not rewrite PATH" ;; *) pass "system arm does not rewrite PATH" ;; esac
|
||||
# 4. positive control: the bundled arm DOES pin the prefix (so 1-3 aren't vacuous).
|
||||
case "$bundled_arm" in *'NPM_CONFIG_PREFIX="$NODE_DIR"'*) pass "bundled arm pins NPM_CONFIG_PREFIX to the isolated dir" ;; *) fail "bundled arm pins NPM_CONFIG_PREFIX to the isolated dir" ;; esac
|
||||
# 5. the only global install (bun) is gated behind NODE_SOURCE=bundled.
|
||||
guard_at=$(printf '%s\n' "$bun_block" | grep -n 'elif \[ "\$NODE_SOURCE" = bundled \]; then' | head -1 | cut -d: -f1)
|
||||
bun_at=$(printf '%s\n' "$bun_block" | grep -n 'npm install -g bun' | head -1 | cut -d: -f1)
|
||||
if [ -n "$guard_at" ] && [ -n "$bun_at" ] && [ "$guard_at" -lt "$bun_at" ]; then
|
||||
pass "global bun install gated behind NODE_SOURCE=bundled"
|
||||
else
|
||||
fail "global bun install gated behind NODE_SOURCE=bundled"
|
||||
fi
|
||||
|
||||
if [ "$fails" -ne 0 ]; then echo "$fails check(s) failed"; exit 1; fi
|
||||
echo "All checks passed"
|
||||
477
tests/studio/install/test_install_node_prebuilt_logic.py
Normal file
477
tests/studio/install/test_install_node_prebuilt_logic.py
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
# 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"))
|
||||
185
tests/studio/install/test_managed_node_runtime.py
Normal file
185
tests/studio/install/test_managed_node_runtime.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tests for the runtime managed-Node resolver (studio/backend/utils/node_runtime.py).
|
||||
|
||||
The Studio frontend installer may provision an isolated Node under
|
||||
``<UNSLOTH_HOME>/node`` that is never added to the user's PATH. The backend OXC
|
||||
validator must still find a usable Node at runtime: a version-adequate system
|
||||
Node, else the managed isolated one. These tests pin that resolution and the
|
||||
version floor (kept in sync with the setup scripts' Node decision).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# node_runtime imports sibling backend packages by top-level name, so put
|
||||
# studio/backend on sys.path before importing it.
|
||||
_BACKEND = Path(__file__).resolve().parents[3] / "studio" / "backend"
|
||||
if str(_BACKEND) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND))
|
||||
|
||||
nr = importlib.import_module("utils.node_runtime")
|
||||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _clear_resolver_cache():
|
||||
nr._reset_resolved_node()
|
||||
yield
|
||||
nr._reset_resolved_node()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected",
|
||||
[
|
||||
("v20.19.0", True),
|
||||
("v20.18.9", False),
|
||||
("v21.7.0", False), # Node 21 (odd, non-LTS) is below the bar
|
||||
("v22.12.0", True),
|
||||
("v22.11.0", False),
|
||||
("v23.0.0", True),
|
||||
("v24.17.0", True),
|
||||
("v18.20.0", False),
|
||||
("not-a-version", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_version_floor_matches_setup_bar(version, expected):
|
||||
assert nr._version_meets_floor(version) is expected
|
||||
|
||||
|
||||
def test_managed_binary_layout_is_host_aware(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
binary = nr.managed_node_binary()
|
||||
if os.name == "nt":
|
||||
assert binary == tmp_path / "node" / "node.exe"
|
||||
else:
|
||||
assert binary == tmp_path / "node" / "bin" / "node"
|
||||
|
||||
|
||||
def test_managed_dir_uses_legacy_sibling_by_default(monkeypatch):
|
||||
# No env override -> ~/.unsloth/node (sibling of ~/.unsloth/studio).
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
|
||||
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
||||
assert nr.managed_node_dir() == Path.home() / ".unsloth" / "node"
|
||||
|
||||
|
||||
def _raise_oserror():
|
||||
raise OSError("simulated degraded import environment")
|
||||
|
||||
|
||||
def test_managed_dir_fallback_honors_override(monkeypatch, tmp_path):
|
||||
# If utils.paths cannot be loaded / studio_root() fails, the resolver must
|
||||
# still honor an explicit STUDIO_HOME override (not silently use legacy).
|
||||
import utils.paths.storage_roots as sr
|
||||
|
||||
monkeypatch.setattr(sr, "studio_root", _raise_oserror)
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
assert nr.managed_node_dir() == tmp_path / "node"
|
||||
|
||||
|
||||
def test_managed_dir_fallback_legacy_without_override(monkeypatch):
|
||||
import utils.paths.storage_roots as sr
|
||||
|
||||
monkeypatch.setattr(sr, "studio_root", _raise_oserror)
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
|
||||
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
||||
assert nr.managed_node_dir() == Path.home() / ".unsloth" / "node"
|
||||
|
||||
|
||||
def test_managed_dir_honors_studio_home_alias(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
|
||||
monkeypatch.setenv("STUDIO_HOME", str(tmp_path))
|
||||
assert nr.managed_node_dir() == tmp_path / "node"
|
||||
|
||||
|
||||
def test_managed_dir_unsloth_studio_home_wins_over_alias(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("STUDIO_HOME", str(tmp_path / "other"))
|
||||
assert nr.managed_node_dir() == tmp_path / "node"
|
||||
|
||||
|
||||
def test_managed_dir_legacy_valued_override_uses_sibling(monkeypatch):
|
||||
# An override set explicitly to the legacy default maps to the sibling
|
||||
# ~/.unsloth/node (matching setup.sh / setup.ps1), not ~/.unsloth/studio/node.
|
||||
legacy = Path.home() / ".unsloth" / "studio"
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(legacy))
|
||||
assert nr.managed_node_dir() == Path.home() / ".unsloth" / "node"
|
||||
|
||||
|
||||
def test_resolve_prefers_adequate_system_node(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
nr.shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None
|
||||
)
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: exe == "/usr/bin/node")
|
||||
assert nr.resolve_node_executable() == "/usr/bin/node"
|
||||
|
||||
|
||||
def test_resolve_falls_back_to_managed_when_no_system(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
managed = nr.managed_node_binary()
|
||||
managed.parent.mkdir(parents = True, exist_ok = True)
|
||||
managed.write_text("#!/bin/sh\necho v24.17.0\n")
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: None)
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: str(exe) == str(managed))
|
||||
assert nr.resolve_node_executable() == str(managed)
|
||||
|
||||
|
||||
def test_resolve_prefers_managed_over_unsuitable_system(monkeypatch, tmp_path):
|
||||
# System node present but too old; managed isolated Node is adequate.
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
managed = nr.managed_node_binary()
|
||||
managed.parent.mkdir(parents = True, exist_ok = True)
|
||||
managed.write_text("fake")
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: "/old/node")
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: str(exe) == str(managed))
|
||||
assert nr.resolve_node_executable() == str(managed)
|
||||
|
||||
|
||||
def test_resolve_returns_old_system_as_last_resort(monkeypatch, tmp_path):
|
||||
# Old system node, no managed install -> preserve pre-isolation behaviour.
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: "/old/node")
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: False)
|
||||
assert nr.resolve_node_executable() == "/old/node"
|
||||
|
||||
|
||||
def test_resolve_returns_none_when_nothing_available(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path)) # managed dir is empty
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: None)
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: False)
|
||||
assert nr.resolve_node_executable() is None
|
||||
|
||||
|
||||
def test_negative_result_is_not_cached(monkeypatch, tmp_path):
|
||||
# A Node that appears after the first (empty) probe must be picked up without
|
||||
# a restart, so None must not be memoized.
|
||||
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: None)
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: False)
|
||||
assert nr.resolve_node_executable() is None
|
||||
|
||||
managed = nr.managed_node_binary()
|
||||
managed.parent.mkdir(parents = True, exist_ok = True)
|
||||
managed.write_text("now-installed")
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: str(exe) == str(managed))
|
||||
assert nr.resolve_node_executable() == str(managed)
|
||||
|
||||
|
||||
def test_positive_result_is_cached(monkeypatch):
|
||||
monkeypatch.setattr(nr.shutil, "which", lambda name: "/usr/bin/node")
|
||||
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: True)
|
||||
assert nr.resolve_node_executable() == "/usr/bin/node"
|
||||
|
||||
# A cached positive result must not re-probe (shutil.which would now raise).
|
||||
def _boom(name):
|
||||
raise AssertionError("resolver re-probed despite a cached positive result")
|
||||
|
||||
monkeypatch.setattr(nr.shutil, "which", _boom)
|
||||
assert nr.resolve_node_executable() == "/usr/bin/node"
|
||||
83
tests/studio/test_node_decision.ps1
Normal file
83
tests/studio/test_node_decision.ps1
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env pwsh
|
||||
# Unit test for setup.ps1's Get-NodeDecision (the isolated-Node source picker:
|
||||
# system | bundled | skip). Pure helper, AST-extracted and run in-process -- no
|
||||
# Node/npm/network needed. Also serves as a setup.ps1 parse/syntax gate.
|
||||
# Run: pwsh -NoProfile -File tests/studio/test_node_decision.ps1
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$setupPath = [System.IO.Path]::Combine($PSScriptRoot, "..", "..", "studio", "setup.ps1")
|
||||
$setupPath = (Resolve-Path $setupPath).Path
|
||||
$source = Get-Content -Raw -Path $setupPath
|
||||
|
||||
$tokens = $null; $errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($setupPath, [ref]$tokens, [ref]$errors)
|
||||
if ($errors) { $errors | ForEach-Object { $_.ToString() }; throw "setup.ps1 has parse errors" }
|
||||
|
||||
$fn = $ast.FindAll({ param($n)
|
||||
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "Get-NodeDecision"
|
||||
}, $true)
|
||||
if ($fn.Count -ne 1) { throw "expected exactly one Get-NodeDecision in setup.ps1, found $($fn.Count)" }
|
||||
Invoke-Expression $fn[0].Extent.Text
|
||||
|
||||
$failures = 0
|
||||
function Check($name, $cond) {
|
||||
if ($cond) { Write-Host " PASS $name" }
|
||||
else { Write-Host " FAIL $name" -ForegroundColor Red; $script:failures++ }
|
||||
}
|
||||
|
||||
function D($node, $npm, $skip) { Get-NodeDecision -NodeVersion $node -NpmVersion $npm -SkipInstall $skip }
|
||||
|
||||
Write-Host "Get-NodeDecision"
|
||||
# system
|
||||
Check "node22 + npm11 -> system" ((D "v22.17.1" "11.13.0" "0") -eq "system")
|
||||
Check "node20.19 + npm11 -> system" ((D "v20.19.0" "11.0.0" "0") -eq "system")
|
||||
Check "node24 + npm11 -> system" ((D "v24.17.0" "11.13.0" "0") -eq "system")
|
||||
Check "node23 + npm11 -> system" ((D "v23.5.0" "11.0.0" "0") -eq "system")
|
||||
# bundled (the reported bug: fine Node, stale npm)
|
||||
Check "node22 + npm10 -> bundled" ((D "v22.17.1" "10.9.2" "0") -eq "bundled")
|
||||
Check "node18 -> bundled" ((D "v18.20.0" "11.0.0" "0") -eq "bundled")
|
||||
Check "node22.11 -> bundled" ((D "v22.11.0" "11.0.0" "0") -eq "bundled")
|
||||
Check "node20.18 -> bundled" ((D "v20.18.0" "11.0.0" "0") -eq "bundled")
|
||||
Check "node21 (odd) -> bundled" ((D "v21.7.0" "11.0.0" "0") -eq "bundled")
|
||||
Check "missing -> bundled" ((D "" "" "0") -eq "bundled")
|
||||
# skip flag
|
||||
Check "npm10 + skip -> skip" ((D "v22.17.1" "10.9.2" "1") -eq "skip")
|
||||
Check "missing + skip -> skip" ((D "" "" "1") -eq "skip")
|
||||
Check "good + skip -> system" ((D "v22.17.1" "11.13.0" "1") -eq "system")
|
||||
|
||||
# Structural guards: OXC can need Node when frontend is skipped, custom roots
|
||||
# must exist before NodeParent creation, bundled Node must isolate npm, and the
|
||||
# reuse (system) arm must touch nothing -- no prefix pin, no global install.
|
||||
$nodeSourceOffset = $source.IndexOf('$NodeSource = Get-NodeDecision')
|
||||
$skipFrontendBranchOffset = $source.IndexOf('} elseif ($SkipFrontend) {')
|
||||
$customHomeErrorOffset = $source.IndexOf('UNSLOTH_STUDIO_HOME/STUDIO_HOME=$NodeOverride does not exist')
|
||||
$nodeParentMkdirOffset = $source.IndexOf('New-Item -ItemType Directory -Force -Path $NodeParent')
|
||||
$npmPrefixOffset = $source.IndexOf('$env:NPM_CONFIG_PREFIX = $NodeDir')
|
||||
$nodePathClearOffset = $source.IndexOf('Remove-Item Env:NODE_PATH')
|
||||
$bundledBranchOffset = $source.IndexOf('} elseif ($NodeSource -eq "bundled") {')
|
||||
$systemArmOffset = $source.IndexOf('$SysNodeVersion | npm $SysNpmVersion (system)')
|
||||
$globalBunOffset = $source.IndexOf('npm install -g bun')
|
||||
Check "NodeSource initialized before SKIP_STUDIO_FRONTEND branch" (
|
||||
$nodeSourceOffset -ge 0 -and $skipFrontendBranchOffset -ge 0 -and $nodeSourceOffset -lt $skipFrontendBranchOffset
|
||||
)
|
||||
Check "custom Studio home validated before Node parent creation" (
|
||||
$customHomeErrorOffset -ge 0 -and $nodeParentMkdirOffset -ge 0 -and $customHomeErrorOffset -lt $nodeParentMkdirOffset
|
||||
)
|
||||
Check "bundled Node pins npm prefix and clears NODE_PATH" (
|
||||
$npmPrefixOffset -ge 0 -and $nodePathClearOffset -ge 0 -and $npmPrefixOffset -lt $nodePathClearOffset
|
||||
)
|
||||
# Symmetric to tests/sh/test_system_node_readonly.sh: the prefix pin and the only
|
||||
# global install (bun) sit between the bundled-branch marker and the system arm,
|
||||
# i.e. inside bundled, so reusing a good system Node mutates nothing.
|
||||
Check "npm prefix pin lives in the bundled branch, not the system arm" (
|
||||
$bundledBranchOffset -ge 0 -and $systemArmOffset -ge 0 -and
|
||||
$bundledBranchOffset -lt $npmPrefixOffset -and $npmPrefixOffset -lt $systemArmOffset
|
||||
)
|
||||
Check "global bun install lives in the bundled branch, not the system arm" (
|
||||
$bundledBranchOffset -ge 0 -and $systemArmOffset -ge 0 -and
|
||||
$bundledBranchOffset -lt $globalBunOffset -and $globalBunOffset -lt $systemArmOffset
|
||||
)
|
||||
|
||||
Write-Host ""
|
||||
if ($failures -gt 0) { Write-Host "$failures check(s) FAILED" -ForegroundColor Red; exit 1 }
|
||||
Write-Host "All checks passed" -ForegroundColor Green
|
||||
73
tests/studio/test_node_probe_guard.ps1
Normal file
73
tests/studio/test_node_probe_guard.ps1
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Regression test for the setup.ps1 system-node/npm probes. Under "Stop", a bare
|
||||
# `node -v` for an absent/broken node throws a terminating error `2>$null` cannot
|
||||
# swallow, which used to abort setup before the bundled-Node decision. The probes
|
||||
# are now guarded (Get-Command + try/catch); this runs the real probe lines with
|
||||
# node/npm absent or throwing and asserts setup would NOT terminate.
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:failures = 0
|
||||
function Check($name, $cond) {
|
||||
if ($cond) { Write-Host " PASS $name" }
|
||||
else { Write-Host " FAIL $name" -ForegroundColor Red; $script:failures++ }
|
||||
}
|
||||
|
||||
$setupPath = (Resolve-Path ([System.IO.Path]::Combine($PSScriptRoot, "..", "..", "studio", "setup.ps1"))).Path
|
||||
# Match specifically the two system-version probe assignments (not every
|
||||
# Get-Command node/npm in the file, e.g. the OXC-runtime npm guard).
|
||||
$probeLines = (Get-Content $setupPath) | Where-Object {
|
||||
$_ -match '\$Sys(Node|Npm)Version = try \{ if \(Get-Command (node|npm) -ErrorAction SilentlyContinue'
|
||||
}
|
||||
Check "setup.ps1 guards both node and npm probes with Get-Command" ($probeLines.Count -eq 2)
|
||||
|
||||
# Resolve pwsh by absolute path BEFORE scrubbing PATH, so we can launch a child
|
||||
# whose PATH has no node/npm while still invoking the interpreter.
|
||||
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue).Source
|
||||
if (-not $pwshExe) { $pwshExe = (Get-Command powershell).Source }
|
||||
$emptyDir = Join-Path ([System.IO.Path]::GetTempPath()) ("uns_probe_" + [guid]::NewGuid().ToString("N"))
|
||||
New-Item -ItemType Directory -Force -Path $emptyDir | Out-Null
|
||||
|
||||
function Invoke-WithoutNode([string]$body) {
|
||||
$script = "`$ErrorActionPreference = 'Stop'`n$body"
|
||||
$file = Join-Path $emptyDir ("probe_" + [guid]::NewGuid().ToString("N") + ".ps1")
|
||||
Set-Content -Path $file -Value $script -Encoding utf8
|
||||
$saved = $env:PATH
|
||||
try {
|
||||
$env:PATH = $emptyDir # node/npm guaranteed absent for the child
|
||||
$out = & $pwshExe -NoProfile -File $file 2>&1 | Out-String
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
$env:PATH = $saved
|
||||
}
|
||||
return [pscustomobject]@{ ExitCode = $code; Output = $out }
|
||||
}
|
||||
|
||||
# 1. The real guarded probes must NOT terminate, and must yield empty versions
|
||||
# (which Get-NodeDecision then maps to "bundled").
|
||||
$guarded = ($probeLines -join "`n") + "`nWrite-Output ""RESULT node=[`$SysNodeVersion] npm=[`$SysNpmVersion]"""
|
||||
$r = Invoke-WithoutNode $guarded
|
||||
Check "guarded probes do not terminate when node is absent (exit 0)" ($r.ExitCode -eq 0)
|
||||
Check "guarded probes yield empty node/npm versions" ($r.Output -match 'RESULT node=\[\] npm=\[\]')
|
||||
|
||||
# 2. Negative control: the OLD unguarded form DOES terminate -- proves this test
|
||||
# can actually distinguish the bug from the fix.
|
||||
$unguarded = "`$SysNodeVersion = (node -v 2>`$null)`nWrite-Output ""REACHED"""
|
||||
$n = Invoke-WithoutNode $unguarded
|
||||
Check "unguarded bare probe terminates under Stop (negative control)" ($n.ExitCode -ne 0 -and $n.Output -notmatch 'REACHED')
|
||||
|
||||
# 3. Present-but-broken shim: Get-Command finds it but invoking it throws (corrupt
|
||||
# Node / blocked npm.ps1). The try/catch must still degrade to empty, not abort.
|
||||
$throwShims = "function node { throw 'boom' }`nfunction npm { throw 'boom' }`n"
|
||||
$broken = $throwShims + ($probeLines -join "`n") + "`nWrite-Output ""RESULT node=[`$SysNodeVersion] npm=[`$SysNpmVersion]"""
|
||||
$b = Invoke-WithoutNode $broken
|
||||
Check "guarded probes do not terminate when a present shim throws (exit 0)" ($b.ExitCode -eq 0)
|
||||
Check "guarded probes yield empty versions when a present shim throws" ($b.Output -match 'RESULT node=\[\] npm=\[\]')
|
||||
|
||||
# 4. Negative control: the if-guard WITHOUT try/catch terminates when a present
|
||||
# command throws -- proves the try/catch (not just Get-Command) is load-bearing.
|
||||
$brokenUnguarded = "function node { throw 'boom' }`n`$SysNodeVersion = if (Get-Command node -ErrorAction SilentlyContinue) { (node -v 2>`$null) } else { '' }`nWrite-Output ""REACHED"""
|
||||
$bn = Invoke-WithoutNode $brokenUnguarded
|
||||
Check "if-guard without try/catch terminates on a throwing present command (negative control)" ($bn.ExitCode -ne 0 -and $bn.Output -notmatch 'REACHED')
|
||||
|
||||
Remove-Item -Recurse -Force $emptyDir -ErrorAction SilentlyContinue
|
||||
|
||||
if ($script:failures -gt 0) { Write-Host "$($script:failures) check(s) failed" -ForegroundColor Red; exit 1 }
|
||||
Write-Host "All checks passed"
|
||||
Loading…
Add table
Add a link
Reference in a new issue