Files
wild-pc/core/tests/test_toolchains.py
Paul Payne 6cf25ad2c7 core: evaluate node semver ranges instead of guessing the leading major
resolve_node_bin() could not parse a range, so it fell back to "pin by the
first number in the string" and took the newest installed version of that
major. For openclaw's engines pin `>=22.19.0 <23 || >=23.11.0` that picked
v22.17.0 — below the range's own floor — and baked it into the service's
unit PATH. The daemon refused to start on it and crash-looped 260k+ times
(~7.4s CPU per attempt, every ~5.5s, since before the journal's retention
window). StartLimitBurst never tripped because five attempts span ~27s,
outside the 10s StartLimitIntervalUSec.

Parse the pin properly: split on `||`, expand each comparator (>=, >, <=,
<, =, ^, ~, hyphen ranges, x/* wildcards, partial versions) into bounds
with node-semver's partial semantics, and pick the newest installed version
satisfying any alternative. openclaw now resolves to v24.18.0 via its
second alternative. A pin this parser cannot read at all still falls back
to newest-installed rather than guessing a version.

When nothing satisfies the pin it now fails with the installed versions
listed, so the next occurrence is diagnosable at apply time rather than
six days later in a restart loop.
2026-08-21 10:37:26 -07:00

202 lines
8.5 KiB
Python

"""Tests for toolchain (node) version resolution."""
from __future__ import annotations
from pathlib import Path
import pytest
from wildpc_core import toolchains
from wildpc_core.toolchains import (
ToolchainError,
read_node_pin,
resolve_node_bin,
)
def _install(root: Path, *versions: str) -> None:
"""Create fake nvm-style installs: <root>/vX.Y.Z/bin/node."""
for v in versions:
bindir = root / v / "bin"
bindir.mkdir(parents=True)
(bindir / "node").write_text("#!/bin/sh\n")
@pytest.fixture
def nvm(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
root = tmp_path / "nvm"
root.mkdir()
monkeypatch.setenv("WILDPC_NODE_VERSIONS_DIR", str(root))
return root
class TestReadNodePin:
def test_node_version_file(self, tmp_path: Path) -> None:
(tmp_path / ".node-version").write_text("24.14.1\n")
assert read_node_pin(tmp_path) == "24.14.1"
def test_nvmrc_when_no_node_version(self, tmp_path: Path) -> None:
(tmp_path / ".nvmrc").write_text("20\n")
assert read_node_pin(tmp_path) == "20"
def test_node_version_wins_over_nvmrc(self, tmp_path: Path) -> None:
(tmp_path / ".node-version").write_text("24\n")
(tmp_path / ".nvmrc").write_text("20\n")
assert read_node_pin(tmp_path) == "24"
def test_package_json_engines(self, tmp_path: Path) -> None:
(tmp_path / "package.json").write_text('{"engines": {"node": ">=22"}}')
assert read_node_pin(tmp_path) == ">=22"
def test_package_json_volta_fallback(self, tmp_path: Path) -> None:
(tmp_path / "package.json").write_text('{"volta": {"node": "18.20.0"}}')
assert read_node_pin(tmp_path) == "18.20.0"
def test_unpinned(self, tmp_path: Path) -> None:
assert read_node_pin(tmp_path) is None
def test_malformed_package_json(self, tmp_path: Path) -> None:
(tmp_path / "package.json").write_text("{not json")
assert read_node_pin(tmp_path) is None
class TestResolveNodeBin:
def test_unpinned_returns_none(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.1")
assert resolve_node_bin(tmp_path) is None
def test_none_source(self) -> None:
assert resolve_node_bin(None) is None
def test_exact_match(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.1", "v20.10.0")
(tmp_path / ".node-version").write_text("24.14.1")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_major_prefix_picks_newest(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.1.0", "v24.14.1", "v24.9.0", "v20.10.0")
(tmp_path / ".node-version").write_text("24")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_major_minor_prefix(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.1", "v24.14.9", "v24.9.0")
(tmp_path / ".node-version").write_text("24.14")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.9" / "bin"
def test_engines_range_matches_major(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.1", "v20.10.0")
(tmp_path / "package.json").write_text('{"engines": {"node": ">=24"}}')
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_caret_range_matches_major(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.1.0", "v24.14.1")
(tmp_path / "package.json").write_text('{"engines": {"node": "^24.1.0"}}')
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_alias_lts_picks_newest(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.1", "v20.10.0")
(tmp_path / ".nvmrc").write_text("lts/*")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_pinned_but_not_installed_raises(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v20.10.0")
(tmp_path / ".node-version").write_text("24")
with pytest.raises(ToolchainError, match="nvm install"):
resolve_node_bin(tmp_path)
def test_no_installs_raises_for_alias(self, tmp_path: Path, nvm: Path) -> None:
(tmp_path / ".nvmrc").write_text("node")
with pytest.raises(ToolchainError):
resolve_node_bin(tmp_path)
def test_missing_bin_not_counted(self, tmp_path: Path, nvm: Path) -> None:
# A version dir with no bin/node is not a usable install.
(nvm / "v24.14.1").mkdir(parents=True)
(tmp_path / ".node-version").write_text("24")
with pytest.raises(ToolchainError):
resolve_node_bin(tmp_path)
def test_default_dir_is_nvm(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("WILDPC_NODE_VERSIONS_DIR", raising=False)
assert (
toolchains.node_versions_dir() == Path.home() / ".nvm" / "versions" / "node"
)
class TestSemverRanges:
"""Range pins are evaluated, not reduced to their leading major.
The regression these guard: openclaw pins `>=22.19.0 <23 || >=23.11.0`, and a
leading-major heuristic picked the newest installed 22.x — v22.17.0, *below*
the range's own floor — baking a node into the systemd unit that the program
refuses to run on."""
def test_compound_range_uses_later_alternative(
self, tmp_path: Path, nvm: Path
) -> None:
_install(nvm, "v24.18.0", "v22.17.0", "v20.19.3")
(tmp_path / "package.json").write_text(
'{"engines": {"node": ">=22.19.0 <23 || >=23.11.0"}}'
)
assert resolve_node_bin(tmp_path) == nvm / "v24.18.0" / "bin"
def test_never_picks_below_lower_bound(self, tmp_path: Path, nvm: Path) -> None:
# Only a 22.x that fails the pin's own >=22.19.0 floor is installed.
_install(nvm, "v22.17.0", "v20.19.3")
(tmp_path / "package.json").write_text(
'{"engines": {"node": ">=22.19.0 <23 || >=23.11.0"}}'
)
with pytest.raises(ToolchainError, match="no installed version satisfies"):
resolve_node_bin(tmp_path)
def test_upper_bound_excludes_newer(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.18.0", "v22.19.1")
(tmp_path / "package.json").write_text('{"engines": {"node": ">=22.19.0 <23"}}')
assert resolve_node_bin(tmp_path) == nvm / "v22.19.1" / "bin"
def test_error_names_installed_versions(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v22.17.0")
(tmp_path / "package.json").write_text('{"engines": {"node": ">=24"}}')
with pytest.raises(ToolchainError, match=r"installed: 22\.17\.0"):
resolve_node_bin(tmp_path)
def test_tilde_pins_minor(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.15.0", "v24.14.9", "v24.14.1")
(tmp_path / ".nvmrc").write_text("~24.14.1")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.9" / "bin"
def test_caret_excludes_next_major(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v25.0.0", "v24.14.1")
(tmp_path / "package.json").write_text('{"engines": {"node": "^24.1.0"}}')
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_gt_partial_means_next_major(self, tmp_path: Path, nvm: Path) -> None:
# node-semver: ">22" is >=23.0.0, so an installed 22.19.0 does not match.
_install(nvm, "v23.1.0", "v22.19.0")
(tmp_path / "package.json").write_text('{"engines": {"node": ">22"}}')
assert resolve_node_bin(tmp_path) == nvm / "v23.1.0" / "bin"
def test_exact_pin_excludes_neighbours(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.14.9", "v24.14.1")
(tmp_path / ".node-version").write_text("=24.14.1")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_hyphen_range(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v24.0.0", "v23.5.0", "v22.1.0")
(tmp_path / "package.json").write_text(
'{"engines": {"node": "22.19.0 - 23.11.0"}}'
)
assert resolve_node_bin(tmp_path) == nvm / "v23.5.0" / "bin"
def test_x_wildcard_minor(self, tmp_path: Path, nvm: Path) -> None:
_install(nvm, "v25.0.0", "v24.14.1")
(tmp_path / ".node-version").write_text("24.x")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"
def test_unparseable_pin_falls_back_to_newest(
self, tmp_path: Path, nvm: Path
) -> None:
_install(nvm, "v24.14.1", "v20.10.0")
(tmp_path / ".nvmrc").write_text("lts/hydrogen")
assert resolve_node_bin(tmp_path) == nvm / "v24.14.1" / "bin"