Files
hermes-agent/tests/test_engines_satisfiable.py
Brooklyn Nicholson 63ff4b87b6 fix(install): sync the lockfile engines mirrors with the manifests
`hermes desktop` still failed with EBADENGINE demanding Node >=26 after
#76562, on a machine whose `apps/desktop/package.json` already said
`^20.19.0 || >=22.12.0`. #76562 fixed the manifest but not its mirror in
`package-lock.json`, and `npm ci` reads engines from the lockfile:

    package.json  apps/desktop -> {'node': '^20.19.0 || >=22.12.0'}
    package-lock  apps/desktop -> {'node': '>=26.0.0'}      <- what gated

Chasing that exposed a second, pre-existing problem: the floor #76562
declared was too generous. Running the real `npm ci` against the whole
workspace on Node 22.21.1 fails on a transitive dependency —

    npm error notsup Not compatible with your version of node/npm:
      react-router@8.3.0
    npm error notsup Required: {"node":">=22.22.0"}

react-router 8.3.0 (a direct dependency of both `apps/desktop` and `web`)
declares `>=22.22.0`, which is tighter than Vite's `^20.19 || >=22.12` and
excludes all of Node 20. So `>=20.0.0` promised support the tree cannot
deliver: an install on Node 20 or early 22 passed the installer's gate and
then died inside `npm ci` on someone else's package.

All four engine declarations now state the floor the dependency tree
actually has, `>=22.22.0`: root `package.json`, `apps/desktop/package.json`,
and both of their `package-lock.json` mirrors. The installer gates move with
them (`node_satisfies_build` in install.sh, `Test-NodeVersionOk` in
install.ps1) so a too-old system Node is replaced with the managed one
*before* npm runs, and the failure a user does see names hermes-agent rather
than a transitive package. NODE_VERSION stays 22 — latest-v22.x is 22.23.2,
comfortably above the floor.

The invariant test gains the case that would have caught the mirror drift on
its own: the desktop assertion now pins the tightest floor a dependency
actually declares, and the managed-runtime check compares majors, since
install.sh fetches latest-v{major}.x rather than {major}.0.0.

Verified with real `npm ci --dry-run` over the full workspace:
- node 22.23.2 (what install.sh provisions) -> 1258 packages
- node 26.5.1                               -> 1189 packages
- node 22.21.1 (below the floor)            -> EBADENGINE naming
  hermes-agent, i.e. our own manifest, not react-router
2026-08-01 23:28:28 -05:00

186 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The manifest's ``engines`` must be satisfiable by a toolchain we can actually ship.
`engine-strict=true` in `.npmrc` makes `engines` a hard gate on every
`npm ci` / `npm install` — the installer's workspace step, `hermes update`'s
dependency refresh, and CI alike. So a floor nobody's toolchain can meet is
not a strict-hygiene win; it is a total install outage.
That is exactly what happened: `engines.npm` was raised to `>=12.0.0` while
**no Node release bundles npm 12** (Node 26 ships 11.17.0, 24 ships 11.16.0,
22 ships 10.9.8). Every fresh install died at the first `npm ci`, and
`hermes update` left installs in a mixed state. These tests encode the
invariants that would have caught it.
Deliberately behavioral, not a snapshot: nothing here pins a version we
expect to change. Each test asserts a *relationship* — between the floor we
declare and the toolchain that has to satisfy it.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
# npm releases bundled with a Node major, newest-per-major. Not a catalog
# snapshot: the point is that *some* real, shipping toolchain must clear the
# floor, and these are the ones users actually arrive with.
_STOCK_NPM_BY_NODE_MAJOR = {
20: "10.8.2",
22: "10.9.8",
24: "11.16.0",
26: "11.17.0",
}
def _root_manifest() -> dict:
return json.loads((REPO_ROOT / "package.json").read_text())
def _parse_major_minor_patch(version: str) -> tuple[int, int, int]:
parts = version.split("-", 1)[0].split(".")
nums = [int(p) for p in parts[:3]]
while len(nums) < 3:
nums.append(0)
return nums[0], nums[1], nums[2]
def _satisfies_clause(version: str, clause: str) -> bool:
"""Evaluate one `>=x.y.z` / `<x.y.z` / `^x.y.z` comparator against *version*."""
clause = clause.strip()
if clause.startswith("^"):
bound = clause[1:].strip()
have = _parse_major_minor_patch(version)
want = _parse_major_minor_patch(bound)
# ^x.y.z allows >=x.y.z within the same major (x > 0).
return have[0] == want[0] and have >= want
for op in (">=", "<=", "<", ">", "="):
if clause.startswith(op):
bound = clause[len(op) :].strip()
break
else:
op, bound = "=", clause
have = _parse_major_minor_patch(version)
want = _parse_major_minor_patch(bound)
if op == ">=":
return have >= want
if op == "<=":
return have <= want
if op == "<":
return have < want
if op == ">":
return have > want
return have == want
def _satisfies_range(version: str, spec: str) -> bool:
"""Evaluate the `A || B` / space-joined-AND subset of semver we author."""
for alternative in spec.split("||"):
clauses = [c for c in alternative.strip().split() if c]
if clauses and all(_satisfies_clause(version, c) for c in clauses):
return True
return False
class TestEnginesAreSatisfiable:
def test_npm_floor_is_met_by_a_shipping_node(self):
"""Some stock Node must bundle an npm our floor accepts.
Without this, a fresh install cannot run `npm ci` at all: the
installer provisions a Node from nodejs.org and immediately uses the
npm that came with it.
"""
npm_range = _root_manifest()["engines"]["npm"]
satisfying = {
major: npm
for major, npm in _STOCK_NPM_BY_NODE_MAJOR.items()
if _satisfies_range(npm, npm_range)
}
assert satisfying, (
f"engines.npm is {npm_range!r}, which no shipping Node bundles "
f"(checked {_STOCK_NPM_BY_NODE_MAJOR}). With engine-strict=true "
"every fresh install fails at the first `npm ci`."
)
def test_node_floor_is_met_by_the_managed_runtime(self):
"""The Node major the installers provision must clear engines.node."""
node_range = _root_manifest()["engines"]["node"]
install_sh = (REPO_ROOT / "scripts" / "install.sh").read_text()
for line in install_sh.splitlines():
if line.startswith("NODE_VERSION="):
managed_major = int(line.split("=", 1)[1].strip().strip('"').strip("'"))
break
else: # pragma: no cover - install.sh always defines it
pytest.fail("install.sh does not define NODE_VERSION")
# install.sh fetches latest-v{major}.x, not {major}.0.0, so compare on
# the major: the newest release of that line must be able to clear the
# floor. A floor in a HIGHER major than we provision can never be met.
floor_majors = [
int(m.group(1))
for m in re.finditer(r">=\s*v?(\d+)", node_range)
]
assert floor_majors, f"cannot read a floor out of {node_range!r}"
assert managed_major >= min(floor_majors), (
f"engines.node is {node_range!r} but install.sh provisions Node "
f"{managed_major}.x. The runtime we ship must satisfy the floor we "
"declare, or the install we just performed cannot install deps."
)
def test_desktop_node_floor_is_not_stricter_than_its_toolchain(self):
"""apps/desktop must not demand more Node than its own build tools do.
Vite is the real constraint (it needs `node:util.styleText`). Raising
the desktop floor beyond it silently force-migrates every user's
toolchain for no dependency reason.
"""
desktop = json.loads((REPO_ROOT / "apps" / "desktop" / "package.json").read_text())
node_range = desktop["engines"]["node"]
# The tightest floor any dependency actually declares (react-router
# 8.3.0 -> >=22.22.0). If this legitimately rises, the assertion
# documents the reason for the bump rather than blocking it.
assert _satisfies_range("22.22.0", node_range), (
f"apps/desktop engines.node is {node_range!r}, which rejects Node "
"22.12 — stricter than Vite requires. A desktop floor above the "
"build toolchain's own floor replaces working user toolchains for "
"nothing."
)
class TestExcludedNpmBand:
"""npm 11.10–11.16 honor `min-release-age` but ignore `min-release-age-exclude`.
`.npmrc` sets both, so that band applies the 14-day age gate to packages
we deliberately exempted and installs fail with ETARGET. The floor must
keep excluding them.
"""
@pytest.mark.parametrize("bad_npm", ["11.10.0", "11.12.1", "11.16.0"])
def test_band_that_ignores_the_exclude_list_is_rejected(self, bad_npm):
npm_range = _root_manifest()["engines"]["npm"]
assert not _satisfies_range(bad_npm, npm_range), (
f"engines.npm {npm_range!r} accepts npm {bad_npm}, which supports "
"min-release-age but not min-release-age-exclude — it will fail "
"ETARGET on any freshly published dependency in .npmrc's exclude list."
)
@pytest.mark.parametrize("good_npm", ["10.9.8", "11.17.0", "12.0.2"])
def test_versions_handling_the_exclude_list_are_accepted(self, good_npm):
npm_range = _root_manifest()["engines"]["npm"]
assert _satisfies_range(good_npm, npm_range), (
f"engines.npm {npm_range!r} rejects npm {good_npm}, which handles "
".npmrc correctly and should be usable."
)
class TestManifestMirrors:
def test_lockfile_engines_match_the_manifest(self):
"""A stale lockfile mirror re-imposes the old floor on `npm ci`."""
manifest = _root_manifest()["engines"]
lock = json.loads((REPO_ROOT / "package-lock.json").read_text())
assert lock["packages"][""]["engines"] == manifest