Files
computer-playbook/tests/unit/python/utils/install/test_node.py
T
kevinveenbirkenbachandClaude Opus 5.5 30268cf552 fix(install): replace an end-of-life distro Node.js with the current LTS
Debian trixie ships nodejs 20.19, whose release line reached its end of
life on 2026-04-30. `@mermaid-js/mermaid-cli` 12 and its puppeteer
declare `node >=22.13.0`, so `make install-lint` failed in every fresh
Debian stack container with `All providers failed for
chrome-headless-shell` (#1088).

`ensure_node_present()` still installs the distro package first, then
reads the release line of `node` on PATH and looks it up in the
nodejs/Release schedule. A line past its `end` date is replaced by the
newest LTS from nodejs.org/dist/index.json: SHASUMS256.txt is verified
with gpgv against the active Node.js release keys from
nodejs/release-keys, the tarball against its SHA-256 entry, and the
release is unpacked into /usr/local, after which `node` on PATH must be
that release. Nothing pins a major: the next LTS line is picked up the
same way. Ubuntu 26.04's Node.js 22, supported until 2027-04-30, stays.

`ensure_npm_present()` calls it first, so a container that already holds
the end-of-life node is upgraded too rather than skipped because npm
exists. The call runs once per process.

Closes #1088

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 16:02:52 +02:00

159 lines
5.6 KiB
Python

"""Unit tests for :mod:`utils.install.node`."""
from __future__ import annotations
import datetime as dt
import hashlib
import tempfile
import unittest
import unittest.mock as mock
from pathlib import Path
from utils.install import node as node_mod
SCHEDULE = {
"v20": {"end": "2026-04-30"},
"v22": {"end": "2027-04-30"},
"v24": {"end": "2028-04-30"},
}
TODAY = dt.date(2026, 10, 21)
class TestLineIsSupported(unittest.TestCase):
def test_line_before_its_end_is_supported(self) -> None:
self.assertTrue(node_mod.line_is_supported("v22", SCHEDULE, TODAY))
def test_line_on_its_end_date_is_supported(self) -> None:
self.assertTrue(
node_mod.line_is_supported("v20", SCHEDULE, dt.date(2026, 4, 30))
)
def test_line_past_its_end_is_not_supported(self) -> None:
self.assertFalse(node_mod.line_is_supported("v20", SCHEDULE, TODAY))
def test_unknown_line_is_not_supported(self) -> None:
self.assertFalse(node_mod.line_is_supported("v18", SCHEDULE, TODAY))
class TestNewestLts(unittest.TestCase):
def test_skips_current_releases(self) -> None:
index = [
{"version": "v26.1.0", "lts": False},
{"version": "v24.22.1", "lts": "Krypton"},
{"version": "v22.30.0", "lts": "Jod"},
]
self.assertEqual(node_mod.newest_lts(index), "v24.22.1")
def test_raises_without_lts(self) -> None:
with self.assertRaises(RuntimeError):
node_mod.newest_lts([{"version": "v26.1.0", "lts": False}])
class TestTarballName(unittest.TestCase):
def test_maps_x86_64(self) -> None:
self.assertEqual(
node_mod.tarball_name("v24.22.1", "x86_64"),
"node-v24.22.1-linux-x64.tar.gz",
)
def test_maps_aarch64(self) -> None:
self.assertEqual(
node_mod.tarball_name("v24.22.1", "aarch64"),
"node-v24.22.1-linux-arm64.tar.gz",
)
def test_raises_for_unknown_machine(self) -> None:
with self.assertRaises(RuntimeError):
node_mod.tarball_name("v24.22.1", "riscv64")
class TestExpectedSha256(unittest.TestCase):
def test_finds_the_listed_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
shasums = Path(tmp) / "SHASUMS256.txt"
shasums.write_text(
"aaa node-v24.22.1-linux-arm64.tar.gz\n"
"bbb node-v24.22.1-linux-x64.tar.gz\n",
encoding="utf-8",
)
self.assertEqual(
node_mod.expected_sha256(shasums, "node-v24.22.1-linux-x64.tar.gz"),
"bbb",
)
def test_raises_for_an_unlisted_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
shasums = Path(tmp) / "SHASUMS256.txt"
shasums.write_text("aaa other.tar.gz\n", encoding="utf-8")
with self.assertRaises(RuntimeError):
node_mod.expected_sha256(shasums, "node-v24.22.1-linux-x64.tar.gz")
class TestInstallUpstreamLts(unittest.TestCase):
def test_refuses_a_tarball_whose_checksum_differs(self) -> None:
name = "node-v24.22.1-linux-x64.tar.gz"
def fake_download(url: str, output: str, **_kwargs: object) -> None:
path = Path(output)
if url.endswith("index.json"):
path.write_text(
'[{"version": "v24.22.1", "lts": "Krypton"}]', encoding="utf-8"
)
elif url.endswith("SHASUMS256.txt"):
listed = hashlib.sha256(b"published").hexdigest()
path.write_text(f"{listed} {name}\n", encoding="utf-8")
else:
path.write_bytes(b"tampered")
with (
mock.patch.object(node_mod, "download_file", side_effect=fake_download),
mock.patch.object(node_mod, "_verify_signature"),
mock.patch.object(node_mod.platform, "machine", return_value="x86_64"),
mock.patch.object(node_mod, "run_privileged") as extract,
self.assertRaises(RuntimeError),
):
node_mod.install_upstream_lts()
extract.assert_not_called()
class TestEnsureNodePresent(unittest.TestCase):
def setUp(self) -> None:
node_mod.ensure_node_present.cache_clear()
self.addCleanup(node_mod.ensure_node_present.cache_clear)
def _run(self, line: str) -> mock.MagicMock:
def fake_download(_url: str, output: str, **_kwargs: object) -> None:
Path(output).write_text(
'{"v20": {"end": "2026-04-30"}, "v22": {"end": "2099-04-30"}}',
encoding="utf-8",
)
with (
mock.patch.object(node_mod, "ensure_command_present") as ensure,
mock.patch.object(node_mod, "installed_line", return_value=line),
mock.patch.object(node_mod, "download_file", side_effect=fake_download),
mock.patch.object(node_mod, "install_upstream_lts") as upstream,
):
node_mod.ensure_node_present()
ensure.assert_called_once_with("node")
return upstream
def test_keeps_a_supported_distro_node(self) -> None:
self._run("v22").assert_not_called()
def test_replaces_an_end_of_life_distro_node(self) -> None:
self._run("v20").assert_called_once_with()
def test_propagates_the_hard_failure(self) -> None:
with (
mock.patch.object(
node_mod, "ensure_command_present", side_effect=RuntimeError("boom")
),
self.assertRaises(RuntimeError),
):
node_mod.ensure_node_present()
if __name__ == "__main__":
unittest.main()