e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
186 lines
7.3 KiB
Python
186 lines
7.3 KiB
Python
# 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"
|