336 lines
13 KiB
Python
336 lines
13 KiB
Python
"""Tests for hermes_cli.runtime_registry — exact pins, targets, facts.
|
|
|
|
Pure logic: no network, no real install. The pin table is EXACT by
|
|
design (no ranges, no "resolve latest"), so these assert the shape of
|
|
that contract and the eager validation that enforces it.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import runtime_registry as rr
|
|
|
|
|
|
def _pins(tools, schema=rr.PINS_SCHEMA_VERSION):
|
|
return {"schemaVersion": schema, "tools": tools}
|
|
|
|
|
|
def _entry(version="1.2.3", targets=("linux-x64",), sha=None):
|
|
return {
|
|
"version": version,
|
|
"files": {
|
|
t: {
|
|
"url": f"https://example.invalid/{t}/tool-{version}.tar.gz",
|
|
"sha256": sha or ("a" * 64),
|
|
}
|
|
for t in targets
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def pin_root(tmp_path):
|
|
"""Write a pin table and return the root it lives in."""
|
|
|
|
def _write(tools, schema=rr.PINS_SCHEMA_VERSION):
|
|
(tmp_path / rr.PINS_FILENAME).write_text(
|
|
json.dumps(_pins(tools, schema)), encoding="utf-8"
|
|
)
|
|
return tmp_path
|
|
|
|
return _write
|
|
|
|
|
|
class TestCurrentTarget:
|
|
def test_reports_a_pin_table_key_for_this_host(self):
|
|
target = rr.current_target()
|
|
|
|
platform, _, arch = target.partition("-")
|
|
assert platform in ("darwin", "linux", "win32")
|
|
assert arch in ("arm64", "x64")
|
|
|
|
def test_the_host_target_is_pinned_for_every_tool(self):
|
|
"""A platform Hermes runs on must have a download for every tool,
|
|
or provisioning silently degrades to system PATH there."""
|
|
target = rr.current_target()
|
|
|
|
for tool, entry in rr.load_pins().items():
|
|
assert rr.pinned_file(tool, target).url, f"{tool} has no {target} download"
|
|
|
|
|
|
class TestPinValidation:
|
|
def test_loads_a_well_formed_table(self, pin_root):
|
|
pins = rr.load_pins(pin_root({"node": _entry("26.7.0")}))
|
|
|
|
assert pins["node"]["version"] == "26.7.0"
|
|
|
|
def test_rejects_a_foreign_schema_version(self, pin_root):
|
|
with pytest.raises(ValueError, match="schemaVersion"):
|
|
rr.load_pins(pin_root({"node": _entry()}, schema=99))
|
|
|
|
def test_rejects_a_missing_version(self, pin_root):
|
|
entry = _entry()
|
|
del entry["version"]
|
|
|
|
with pytest.raises(ValueError, match="no exact version"):
|
|
rr.load_pins(pin_root({"node": entry}))
|
|
|
|
def test_rejects_a_tool_with_no_files(self, pin_root):
|
|
with pytest.raises(ValueError, match="no 'files' table"):
|
|
rr.load_pins(pin_root({"node": {"version": "1.0.0", "files": {}}}))
|
|
|
|
def test_rejects_a_non_https_url(self, pin_root):
|
|
entry = _entry()
|
|
entry["files"]["linux-x64"]["url"] = "http://example.invalid/x.tar.gz"
|
|
|
|
with pytest.raises(ValueError, match="https url"):
|
|
rr.load_pins(pin_root({"node": entry}))
|
|
|
|
def test_rejects_a_malformed_digest(self, pin_root):
|
|
"""A truncated sha256 must fail at LOAD, not halfway through a
|
|
user's first launch."""
|
|
with pytest.raises(ValueError, match="64 hex chars"):
|
|
rr.load_pins(pin_root({"node": _entry(sha="abc123")}))
|
|
|
|
def test_rejects_an_extends_edge_to_an_unpinned_tool(self, pin_root):
|
|
entry = _entry()
|
|
entry["extends"] = ["ghost"]
|
|
|
|
with pytest.raises(ValueError, match="which is not pinned"):
|
|
rr.load_pins(pin_root({"npm": entry}))
|
|
|
|
def test_rejects_a_self_extending_tool(self, pin_root):
|
|
entry = _entry()
|
|
entry["extends"] = ["npm"]
|
|
|
|
with pytest.raises(ValueError, match="extends itself"):
|
|
rr.load_pins(pin_root({"npm": entry}))
|
|
|
|
def test_rejects_an_extends_cycle_at_load(self, pin_root):
|
|
"""Both derived orders must terminate; a cycle is caught when the
|
|
table is read, not when a user's first launch hangs."""
|
|
a, b = _entry(), _entry()
|
|
a["extends"] = ["b"]
|
|
b["extends"] = ["a"]
|
|
|
|
with pytest.raises(ValueError, match="cycle"):
|
|
rr.load_pins(pin_root({"a": a, "b": b}))
|
|
|
|
def test_rejects_mixing_any_with_per_target_files(self, pin_root):
|
|
"""'any' claims one artifact serves every target. A table that
|
|
also names targets is stating two different things at once."""
|
|
entry = _entry(targets=("linux-x64",))
|
|
entry["files"]["any"] = {"url": "https://example.invalid/x.tgz",
|
|
"sha256": "b" * 64}
|
|
|
|
with pytest.raises(ValueError, match="mixes 'any'"):
|
|
rr.load_pins(pin_root({"npm": entry}))
|
|
|
|
|
|
class TestDerivedOrder:
|
|
"""Both orders come from one `extends` declaration, so they cannot
|
|
contradict each other the way two hand-kept lists could."""
|
|
|
|
def test_a_tool_installs_after_what_it_extends(self):
|
|
pins = {"node": {}, "npm": {"extends": ["node"]}}
|
|
|
|
assert rr.install_order(pins) == ["node", "npm"]
|
|
|
|
def test_a_tool_is_found_before_what_it_extends(self):
|
|
"""The whole point: npm exists to supersede the npm inside node."""
|
|
pins = {"node": {}, "npm": {"extends": ["node"]}}
|
|
|
|
assert rr.path_order(pins) == ["npm", "node"]
|
|
|
|
def test_a_declaration_written_out_of_order_still_resolves(self):
|
|
pins = {"npm": {"extends": ["node"]}, "node": {}}
|
|
|
|
assert rr.install_order(pins) == ["node", "npm"]
|
|
assert rr.path_order(pins) == ["npm", "node"]
|
|
|
|
def test_transitive_edges_chain(self):
|
|
pins = {"a": {}, "b": {"extends": ["a"]}, "c": {"extends": ["b"]}}
|
|
|
|
assert rr.install_order(pins) == ["a", "b", "c"]
|
|
assert rr.path_order(pins) == ["c", "b", "a"]
|
|
|
|
def test_unrelated_tools_keep_the_pin_table_order(self):
|
|
"""Between two tools with no edge the order is arbitrary, and
|
|
churning it would rewrite every PATH for no reason."""
|
|
pins = {"uv": {}, "git": {}, "gh": {}, "ripgrep": {}}
|
|
|
|
assert rr.install_order(pins) == ["uv", "git", "gh", "ripgrep"]
|
|
assert rr.path_order(pins) == ["uv", "git", "gh", "ripgrep"]
|
|
|
|
def test_both_orders_contain_every_tool_exactly_once(self):
|
|
pins = {"node": {}, "npm": {"extends": ["node"]}, "uv": {}, "gh": {}}
|
|
|
|
for order in (rr.install_order(pins), rr.path_order(pins)):
|
|
assert sorted(order) == sorted(pins)
|
|
|
|
|
|
class TestPinnedFile:
|
|
def test_resolves_url_version_and_digest_for_a_target(self, pin_root):
|
|
root = pin_root({"gh": _entry("2.97.0", ("linux-x64", "darwin-arm64"))})
|
|
|
|
pin = rr.pinned_file("gh", "darwin-arm64", install_root=root)
|
|
|
|
assert pin.version == "2.97.0"
|
|
assert pin.url.endswith("darwin-arm64/tool-2.97.0.tar.gz")
|
|
assert pin.sha256 == "a" * 64
|
|
|
|
def test_filename_comes_from_the_url(self, pin_root):
|
|
root = pin_root({"gh": _entry("2.97.0")})
|
|
|
|
assert rr.pinned_file("gh", "linux-x64", install_root=root).filename == (
|
|
"tool-2.97.0.tar.gz"
|
|
)
|
|
|
|
def test_unknown_tool_raises(self, pin_root):
|
|
with pytest.raises(KeyError, match="not in the pin table"):
|
|
rr.pinned_file("nope", "linux-x64", install_root=pin_root({"gh": _entry()}))
|
|
|
|
def test_unpinned_target_raises_rather_than_guessing(self, pin_root):
|
|
"""An unpinned platform is a gap in the table to fill, not a URL
|
|
to construct hopefully."""
|
|
root = pin_root({"gh": _entry(targets=("linux-x64",))})
|
|
|
|
with pytest.raises(KeyError, match="no pinned download for darwin-arm64"):
|
|
rr.pinned_file("gh", "darwin-arm64", install_root=root)
|
|
|
|
def test_an_any_artifact_resolves_for_every_target(self, pin_root):
|
|
"""A registry tarball's bytes do not vary by platform, so one
|
|
pinned artifact serves all six targets rather than six identical
|
|
rows nobody can keep honest."""
|
|
root = pin_root({"npm": _entry("12.0.2", ("any",))})
|
|
|
|
for target in ("linux-x64", "darwin-arm64", "win32-arm64"):
|
|
pin = rr.pinned_file("npm", target, install_root=root)
|
|
assert pin.version == "12.0.2"
|
|
assert pin.sha256 == "a" * 64
|
|
|
|
|
|
class TestRecordedPathOrder:
|
|
"""The facts file carries the derived order so both language readers
|
|
consume one answer instead of each restating a literal."""
|
|
|
|
def test_save_records_the_order_it_was_given(self, tmp_path):
|
|
facts = {
|
|
"node": rr.RuntimeFact(version="26.7.0", path="node/bin/node"),
|
|
"npm": rr.RuntimeFact(version="12.0.2", path="npm/bin/npm"),
|
|
}
|
|
rr.save_facts(facts, tmp_path, path_order=["npm", "node"])
|
|
|
|
assert rr.load_path_order(tmp_path) == ["npm", "node"]
|
|
|
|
def test_an_unprovisioned_tool_is_dropped_from_the_recorded_order(self, tmp_path):
|
|
"""The order names what to look for; a tool that failed to
|
|
provision has nothing to find."""
|
|
facts = {"node": rr.RuntimeFact(version="26.7.0", path="node/bin/node")}
|
|
rr.save_facts(facts, tmp_path, path_order=["npm", "node"])
|
|
|
|
assert rr.load_path_order(tmp_path) == ["node"]
|
|
|
|
def test_a_later_single_fact_update_keeps_the_recorded_order(self, tmp_path):
|
|
"""record_fact has no pin table in hand; it must not silently
|
|
reset the order to insertion order."""
|
|
rr.save_facts(
|
|
{
|
|
"node": rr.RuntimeFact(version="26.7.0", path="node/bin/node"),
|
|
"npm": rr.RuntimeFact(version="12.0.2", path="npm/bin/npm"),
|
|
},
|
|
tmp_path,
|
|
path_order=["npm", "node"],
|
|
)
|
|
|
|
rr.record_fact("node", "26.8.0", "node/bin/node", tmp_path)
|
|
|
|
assert rr.load_path_order(tmp_path) == ["npm", "node"]
|
|
|
|
def test_no_facts_file_means_no_order(self, tmp_path):
|
|
assert rr.load_path_order(tmp_path) == []
|
|
|
|
|
|
class TestRealPinTable:
|
|
"""The shipped table, as a contract rather than a snapshot."""
|
|
|
|
def test_every_tool_resolves_on_every_supported_target(self):
|
|
expected = {
|
|
"darwin-arm64",
|
|
"darwin-x64",
|
|
"linux-x64",
|
|
"linux-arm64",
|
|
"win32-x64",
|
|
"win32-arm64",
|
|
}
|
|
|
|
for tool in rr.load_pins():
|
|
for target in expected:
|
|
# Either a per-target row or a target-independent 'any'
|
|
# artifact — what matters is that nothing is unreachable.
|
|
assert rr.pinned_file(tool, target).url, f"{tool}/{target}"
|
|
|
|
def test_every_download_is_https_with_a_full_digest(self):
|
|
for tool, entry in rr.load_pins().items():
|
|
for target, spec in entry["files"].items():
|
|
assert spec["url"].startswith("https://"), f"{tool}/{target}"
|
|
assert len(spec["sha256"]) == 64, f"{tool}/{target}"
|
|
int(spec["sha256"], 16) # raises unless it is hex
|
|
|
|
def test_no_version_ranges_survive_anywhere(self):
|
|
"""Exact pins only: a range would make two builds of one commit
|
|
disagree and need a GitHub API call to resolve."""
|
|
for tool, entry in rr.load_pins().items():
|
|
version = entry["version"]
|
|
assert not version.endswith(".x"), tool
|
|
assert not version.startswith(">="), tool
|
|
|
|
def test_digests_are_unique_per_target(self):
|
|
"""Copy-paste is the likely failure when hand-editing 30 digests,
|
|
and a duplicated digest means one target downloads the wrong
|
|
file and fails verification. A tool pinning one 'any' artifact
|
|
has nothing to copy-paste wrong."""
|
|
for tool, entry in rr.load_pins().items():
|
|
digests = [spec["sha256"] for spec in entry["files"].values()]
|
|
assert len(digests) == len(set(digests)), tool
|
|
|
|
def test_every_extends_edge_names_a_pinned_tool(self):
|
|
"""A dangling edge would silently drop out of both derived
|
|
orders instead of failing."""
|
|
pins = rr.load_pins()
|
|
|
|
for tool, entry in pins.items():
|
|
for dep in entry.get("extends", []):
|
|
assert dep in pins, f"{tool} extends unpinned {dep}"
|
|
|
|
def test_npm_is_ordered_around_the_node_it_extends(self):
|
|
"""npm ships INSIDE node, so both derived orders have to place
|
|
it deliberately: installed after node (node unpacks it), found
|
|
before node (or node's bundled npm shadows it)."""
|
|
pins = rr.load_pins()
|
|
install = rr.install_order(pins)
|
|
path = rr.path_order(pins)
|
|
|
|
assert install.index("npm") > install.index("node")
|
|
assert path.index("npm") < path.index("node")
|
|
|
|
def test_git_ships_the_same_version_from_both_suppliers(self):
|
|
"""dugite-native (POSIX) and PortableGit (Windows) are different
|
|
builds of the same git. Letting them drift apart would make git
|
|
behaviour depend on the user's platform."""
|
|
git = rr.load_pins()["git"]
|
|
|
|
assert "dugite-native" in git["files"]["darwin-arm64"]["url"]
|
|
assert "PortableGit" in git["files"]["win32-x64"]["url"]
|
|
# One version field covers both — the table cannot express a skew.
|
|
assert git["version"] == "2.53.0"
|
|
|
|
def test_windows_git_is_portablegit_not_mingit(self):
|
|
"""MinGit omits bash.exe, which the desktop needs
|
|
(find-git-bash.ts). dugite's own windows build omits it too."""
|
|
for target in ("win32-x64", "win32-arm64"):
|
|
url = rr.load_pins()["git"]["files"][target]["url"]
|
|
assert "PortableGit" in url
|
|
assert "MinGit" not in url
|