File size: 7,308 Bytes
674d56a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | """Behavioral coverage for install_node's post-install version probe (#87460).
The probe used to be `installed_ver=$(node --version 2>/dev/null)` under
`set -e`: a Node binary that exists but cannot start (Node 26 linux-x64
links libatomic.so.1, missing on minimal Debian/Ubuntu) aborted the whole
installer at exit 127 with the loader's explanation discarded — installs
died mid-sentence with no output at all. The probe must degrade with a
clear error carrying the loader's message, and Debian/Ubuntu installs
must attempt libatomic1 up front.
"""
from __future__ import annotations
import io
import os
import re
import subprocess
import tarfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
def _extract_function(src: str, name: str) -> str:
match = re.search(rf"^{name}\(\) \{{\n(.*?)^\}}", src, re.S | re.M)
assert match is not None, f"{name}() not found in scripts/install.sh"
return match.group(1)
def _extract_install_node() -> str:
src = INSTALL_SH.read_text(encoding="utf-8")
return _extract_function(src, "install_node")
def _extract_install_node_line() -> str:
"""The line-walking refactor split the download/probe body into
install_node_line(); the sandboxed driver needs both functions."""
src = INSTALL_SH.read_text(encoding="utf-8")
return _extract_function(src, "install_node_line")
def _extract_node_satisfies_build() -> str:
src = INSTALL_SH.read_text(encoding="utf-8")
return _extract_function(src, "node_satisfies_build")
def _make_node_tarball(path: Path, node_body: str) -> None:
"""A .tar.xz-named gzip archive — both bsdtar and modern GNU tar sniff
compression by content, so the extension mismatch is irrelevant here."""
root = "node-v26.7.0-linux-x64"
with tarfile.open(path, "w:gz") as tar:
for name, body, mode in (
(f"{root}/bin/node", node_body, 0o755),
(f"{root}/bin/npm", "#!/bin/sh\nexit 0\n", 0o755),
(f"{root}/bin/npx", "#!/bin/sh\nexit 0\n", 0o755),
):
data = body.encode("utf-8")
info = tarfile.TarInfo(name=name)
info.size = len(data)
info.mode = mode
tar.addfile(info, io.BytesIO(data))
def _run_install_node(tmp_path: Path, node_body: str) -> tuple[int, str, str, list[str]]:
bin_dir = tmp_path / "bin"
home = tmp_path / "home"
link_dir = tmp_path / "links"
fixture = tmp_path / "fixture.tar.xz"
apt_log = tmp_path / "apt-calls"
bin_dir.mkdir()
home.mkdir()
link_dir.mkdir()
_make_node_tarball(fixture, node_body)
def _stub(name: str, body: str) -> None:
stub = bin_dir / name
stub.write_text(body, encoding="utf-8")
stub.chmod(0o755)
_stub(
"uname",
"#!/bin/sh\n"
'if [ "${1:-}" = "-m" ]; then echo x86_64; else echo Linux; fi\n',
)
_stub(
"curl",
"#!/bin/sh\n"
"# Called as: curl -fsSL <index-url> (stdout -> tarball name)\n"
"# curl -fsSL <download-url> -o <path>\n"
"if [ \"${3:-}\" = \"-o\" ]; then\n"
' cp "$FIXTURE" "$4"\n'
"else\n"
" echo 'node-v26.7.0-linux-x64.tar.xz'\n"
"fi\n",
)
_stub("sudo", "#!/bin/sh\nshift_if_env() { :; }\nexec env \"$@\"\n")
_stub(
"apt-get",
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$APT_LOG\"\nexit 0\n",
)
env = os.environ.copy()
env.update(
{
"FIXTURE": str(fixture),
"APT_LOG": str(apt_log),
"PATH": f"{bin_dir}:{env['PATH']}",
}
)
driver = tmp_path / "driver.sh"
driver.write_text(
"#!/usr/bin/env bash\n"
"set -euo pipefail\n"
"OS=linux\n"
"DISTRO=ubuntu\n"
"NODE_VERSION=26\n"
f"HERMES_HOME={home}\n"
"HAS_NODE=maybe\n"
"log_info() { printf 'INFO %s\\n' \"$*\"; }\n"
"log_success() { printf 'OK %s\\n' \"$*\"; }\n"
"log_warn() { printf 'WARN %s\\n' \"$*\"; }\n"
"log_error() { printf 'ERROR %s\\n' \"$*\" >&2; }\n"
f"get_command_link_dir() {{ echo {link_dir}; }}\n"
"configure_managed_node_npm_prefix() { return 0; }\n"
"node_satisfies_build() {\n"
f"{_extract_node_satisfies_build()}"
"}\n"
"install_node_line() {\n"
f"{_extract_install_node_line()}"
"}\n"
"install_node() {\n"
f"{_extract_install_node()}"
"}\n"
"install_node\n"
'printf "HAS_NODE=%s\\n" "$HAS_NODE"\n',
encoding="utf-8",
)
proc = subprocess.run(
["bash", str(driver)],
env=env,
capture_output=True,
text=True,
check=False,
)
apt_calls = (
apt_log.read_text(encoding="utf-8").splitlines()
if apt_log.exists()
else []
)
return proc.returncode, proc.stdout, proc.stderr, apt_calls
BROKEN_NODE = (
"#!/bin/sh\n"
"echo 'node: error while loading shared libraries: libatomic.so.1: "
"cannot open shared object file: No such file or directory' >&2\n"
"exit 127\n"
)
HEALTHY_NODE = "#!/bin/sh\necho v26.7.0\n"
def test_broken_node_degrades_with_clear_error(tmp_path: Path) -> None:
code, stdout, stderr, _ = _run_install_node(tmp_path, BROKEN_NODE)
# Old behavior: silent abort at exit 127 with no output (#87460).
# With the pre-release line-walk (#96601 salvage), a binary that cannot
# start now fails the pre-adoption probe (node_satisfies_build on the
# extracted tree), so it is rejected BEFORE replacing anything and the
# walker reports no usable line — strictly stronger than the old
# post-adoption cleanup, which this test previously pinned.
assert code == 0, stderr
combined = stdout + stderr
assert "cannot build native modules" in combined
assert "No usable Node.js release line found" in combined
assert "HAS_NODE=false" in stdout
assert "HAS_NODE=true" not in stdout
# The broken candidate must never be adopted: no managed tree, no links.
home = tmp_path / "home"
link_dir = tmp_path / "links"
assert not (home / "node").exists(), "broken managed Node tree left behind"
assert not (link_dir / "node").exists(), "broken node symlink left behind"
assert not (link_dir / "npm").exists(), "broken npm symlink left behind"
# AI-review follow-up: the broken tree and bin links must not linger
# — retries and later installer steps resolve `node` cleanly.
assert not (home / "node").exists(), "broken managed Node tree left behind"
assert not (link_dir / "node").exists(), "broken node symlink left behind"
assert not (link_dir / "npm").exists(), "broken npm symlink left behind"
def test_healthy_node_reports_success(tmp_path: Path) -> None:
code, stdout, stderr, _ = _run_install_node(tmp_path, HEALTHY_NODE)
assert code == 0, stderr
assert "Node.js v26.7.0 installed" in stdout
assert "HAS_NODE=true" in stdout
def test_libatomic1_is_preinstalled_on_ubuntu(tmp_path: Path) -> None:
_, _, _, apt_calls = _run_install_node(tmp_path, HEALTHY_NODE)
assert any("libatomic1" in call for call in apt_calls), apt_calls
|