• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 19068377358

04 Nov 2025 12:18PM UTC coverage: 92.46% (+12.2%) from 80.3%
19068377358

Pull #22816

github

web-flow
Merge a242f1805 into 89462b7ef
Pull Request #22816: Update Pants internal Python to 3.14

13 of 14 new or added lines in 12 files covered. (92.86%)

244 existing lines in 13 files now uncovered.

89544 of 96846 relevant lines covered (92.46%)

3.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.56
/src/python/pants/backend/javascript/subsystems/nodejs_test.py
1
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
1✔
5

6
import stat
1✔
7
from asyncio import Future
1✔
8
from pathlib import Path
1✔
9
from textwrap import dedent
1✔
10
from typing import NoReturn
1✔
11
from unittest.mock import MagicMock, Mock
1✔
12

13
import pytest
1✔
14

15
from pants.backend.javascript.subsystems import nodejs
1✔
16
from pants.backend.javascript.subsystems.nodejs import (
1✔
17
    CorepackToolDigest,
18
    CorepackToolRequest,
19
    NodeJS,
20
    NodeJSBinaries,
21
    NodeJSProcessEnvironment,
22
    _BinaryPathsPerVersion,
23
    _get_nvm_root,
24
    determine_nodejs_binaries,
25
    node_process_environment,
26
)
27
from pants.backend.javascript.target_types import JSSourcesGeneratorTarget
1✔
28
from pants.backend.python import target_types_rules
1✔
29
from pants.core.util_rules import config_files, source_files
1✔
30
from pants.core.util_rules.external_tool import DownloadedExternalTool, ExternalToolVersion
1✔
31
from pants.core.util_rules.search_paths import (
1✔
32
    VersionManagerSearchPaths,
33
    VersionManagerSearchPathsRequest,
34
)
35
from pants.core.util_rules.system_binaries import (
1✔
36
    BinaryNotFoundError,
37
    BinaryPath,
38
    BinaryPathRequest,
39
    BinaryPaths,
40
    BinaryShims,
41
    BinaryShimsRequest,
42
)
43
from pants.engine.env_vars import EnvironmentVars, EnvironmentVarsRequest
1✔
44
from pants.engine.fs import CreateDigest, Digest, MergeDigests, Snapshot
1✔
45
from pants.engine.internals.native_engine import EMPTY_DIGEST, EMPTY_FILE_DIGEST
1✔
46
from pants.engine.platform import Platform
1✔
47
from pants.engine.process import (
1✔
48
    Process,
49
    ProcessExecutionEnvironment,
50
    ProcessResult,
51
    ProcessResultMetadata,
52
)
53
from pants.testutil.option_util import create_subsystem
1✔
54
from pants.testutil.rule_runner import QueryRule, RuleRunner, run_rule_with_mocks
1✔
55
from pants.util.contextutil import temporary_dir
1✔
56

57

58
@pytest.fixture
1✔
59
def rule_runner() -> RuleRunner:
1✔
60
    rule_runner = RuleRunner(
1✔
61
        rules=[
62
            *nodejs.rules(),
63
            *source_files.rules(),
64
            *config_files.rules(),
65
            *target_types_rules.rules(),
66
            QueryRule(ProcessResult, [nodejs.NodeJSToolProcess]),
67
            QueryRule(NodeJSBinaries, ()),
68
            QueryRule(VersionManagerSearchPaths, (VersionManagerSearchPathsRequest,)),
69
            QueryRule(CorepackToolDigest, (CorepackToolRequest,)),
70
        ],
71
        target_types=[JSSourcesGeneratorTarget],
72
    )
73
    rule_runner.set_options([], env_inherit={"PATH"})
1✔
74
    return rule_runner
1✔
75

76

77
def get_snapshot(rule_runner: RuleRunner, digest: Digest) -> Snapshot:
1✔
78
    return rule_runner.request(Snapshot, [digest])
1✔
79

80

81
@pytest.mark.parametrize("package_manager", ["npm", "pnpm", "yarn"])
1✔
82
def test_corepack_without_explicit_version_contains_installation(
1✔
83
    rule_runner: RuleRunner, package_manager: str
84
):
85
    result = rule_runner.request(
1✔
86
        CorepackToolDigest, [CorepackToolRequest(package_manager, version=None)]
87
    )
88

89
    snapshot = get_snapshot(rule_runner, result.digest)
1✔
90

91
    assert f"._corepack_home/v1/{package_manager}" in snapshot.dirs
1✔
92

93

94
@pytest.mark.parametrize("package_manager", ["npm@7.0.0", "pnpm@2.0.0", "yarn@1.0.0"])
1✔
95
def test_corepack_with_explicit_version_contains_requested_installation(
1✔
96
    rule_runner: RuleRunner, package_manager: str
97
):
98
    binary, version = package_manager.split("@")
1✔
99

100
    result = rule_runner.request(CorepackToolDigest, [CorepackToolRequest(binary, version)])
1✔
101
    snapshot = get_snapshot(rule_runner, result.digest)
1✔
102

103
    assert f"._corepack_home/v1/{binary}/{version}" in snapshot.dirs
1✔
104

105

106
def test_npm_process(rule_runner: RuleRunner):
1✔
107
    rule_runner.set_options(["--nodejs-package-managers={'npm': '8.5.5'}"], env_inherit={"PATH"})
1✔
108
    result = rule_runner.request(
1✔
109
        ProcessResult,
110
        [nodejs.NodeJSToolProcess.npm(args=("--version",), description="Testing NpmProcess")],
111
    )
112

113
    assert result.stdout.strip() == b"8.5.5"
1✔
114

115

116
def test_npm_process_with_different_version(rule_runner: RuleRunner):
1✔
117
    rule_runner.set_options(["--nodejs-package-managers={'npm': '7.20.0'}"], env_inherit={"PATH"})
1✔
118
    result = rule_runner.request(
1✔
119
        ProcessResult,
120
        [nodejs.NodeJSToolProcess.npm(args=("--version",), description="Testing NpmProcess")],
121
    )
122

123
    assert result.stdout.strip() == b"7.20.0"
1✔
124

125

126
def test_pnpm_process(rule_runner: RuleRunner):
1✔
127
    result = rule_runner.request(
1✔
128
        ProcessResult,
129
        [
130
            nodejs.NodeJSToolProcess(
131
                tool="pnpm",
132
                tool_version="7.5.0",
133
                args=("--version",),
134
                description="Testing pnpm process",
135
            )
136
        ],
137
    )
138

139
    assert result.stdout.strip() == b"7.5.0"
1✔
140

141

142
def test_yarn_process(rule_runner: RuleRunner):
1✔
143
    result = rule_runner.request(
1✔
144
        ProcessResult,
145
        [
146
            nodejs.NodeJSToolProcess(
147
                tool="yarn",
148
                tool_version="1.22.19",
149
                args=("--version",),
150
                description="Testing yarn process",
151
            )
152
        ],
153
    )
154

155
    assert result.stdout.strip() == b"1.22.19"
1✔
156

157

158
def given_known_version(version: str) -> str:
1✔
159
    return f"{version}|linux_x86_64|1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd|333333"
1✔
160

161

162
@pytest.fixture
1✔
163
def mock_nodejs_subsystem() -> Mock:
1✔
164
    nodejs_subsystem = Mock(spec=NodeJS)
1✔
165
    future: Future[DownloadedExternalTool] = Future()
1✔
UNCOV
166
    future.set_result(DownloadedExternalTool(EMPTY_DIGEST, ""))
×
UNCOV
167
    nodejs_subsystem.download_known_version = MagicMock(return_value=future)
×
UNCOV
168
    return nodejs_subsystem
×
169

170

171
_SEMVER_1_1_0 = given_known_version("1.1.0")
1✔
172
_SEMVER_2_1_0 = given_known_version("2.1.0")
1✔
173
_SEMVER_2_2_0 = given_known_version("2.2.0")
1✔
174
_SEMVER_2_2_2 = given_known_version("2.2.2")
1✔
175
_SEMVER_3_0_0 = given_known_version("3.0.0")
1✔
176

177

178
@pytest.mark.parametrize(
1✔
179
    ("semver_range", "expected"),
180
    [
181
        pytest.param("1.x", _SEMVER_1_1_0, id="x_range"),
182
        pytest.param("2.0 - 3.0", _SEMVER_2_1_0, id="hyphen"),
183
        pytest.param(">2.2.0", _SEMVER_2_2_2, id="gt"),
184
        pytest.param("2.2.x", _SEMVER_2_2_0, id="x_range_patch"),
185
        pytest.param("~2.2.0", _SEMVER_2_2_0, id="thilde"),
186
        pytest.param("^2.2.0", _SEMVER_2_2_0, id="caret"),
187
        pytest.param("3.0.0", _SEMVER_3_0_0, id="exact"),
188
        pytest.param("=3.0.0", _SEMVER_3_0_0, id="exact_equals"),
189
        pytest.param("<3.0.0 >2.1", _SEMVER_2_2_0, id="and_range"),
190
        pytest.param(">2.1 || <2.1", _SEMVER_1_1_0, id="or_range"),
191
    ],
192
)
193
def test_node_version_from_semver_download(
1✔
194
    mock_nodejs_subsystem: Mock, semver_range: str, expected: str
195
) -> None:
UNCOV
196
    nodejs_subsystem = mock_nodejs_subsystem
×
UNCOV
197
    nodejs_subsystem.version = semver_range
×
UNCOV
198
    nodejs_subsystem.known_versions = [
×
199
        _SEMVER_1_1_0,
200
        _SEMVER_2_1_0,
201
        _SEMVER_2_2_0,
202
        _SEMVER_2_2_2,
203
        _SEMVER_3_0_0,
204
    ]
UNCOV
205
    run_rule_with_mocks(
×
206
        determine_nodejs_binaries,
207
        rule_args=(nodejs_subsystem, Platform.linux_x86_64, _BinaryPathsPerVersion()),
208
    )
209

UNCOV
210
    nodejs_subsystem.download_known_version.assert_called_once_with(
×
211
        ExternalToolVersion.decode(expected), Platform.linux_x86_64
212
    )
213

214

215
@pytest.mark.parametrize(
1✔
216
    ("semver_range", "expected_path"),
217
    [
218
        pytest.param("1.x", "1/1/0", id="x_range"),
219
        pytest.param("2.0 - 3.0", "2/1/0", id="hyphen"),
220
        pytest.param(">2.2.0", "2/2/2", id="gt"),
221
        pytest.param("2.2.x", "2/2/0", id="x_range_patch"),
222
        pytest.param("~2.2.0", "2/2/0", id="thilde"),
223
        pytest.param("^2.2.0", "2/2/0", id="caret"),
224
        pytest.param("3.0.0", "3/0/0", id="exact"),
225
        pytest.param("=3.0.0", "3/0/0", id="exact_equals"),
226
        pytest.param("<3.0.0 >2.1", "2/2/0", id="and_range"),
227
        pytest.param(">2.1 || <2.1", "1/1/0", id="or_range"),
228
    ],
229
)
230
def test_node_version_from_semver_bootstrap(
1✔
231
    mock_nodejs_subsystem: Mock, semver_range: str, expected_path: str
232
) -> None:
UNCOV
233
    nodejs_subsystem = mock_nodejs_subsystem
×
UNCOV
234
    nodejs_subsystem.version = semver_range
×
UNCOV
235
    nodejs_subsystem.known_versions = []
×
UNCOV
236
    discoverable_versions = _BinaryPathsPerVersion(
×
237
        {
238
            "1.1.0": (BinaryPath("1/1/0/node"),),
239
            "2.1.0": (BinaryPath("2/1/0/node"),),
240
            "2.2.0": (BinaryPath("2/2/0/node"),),
241
            "2.2.2": (BinaryPath("2/2/2/node"),),
242
            "3.0.0": (BinaryPath("3/0/0/node"),),
243
        }
244
    )
245

UNCOV
246
    def mock_download(*_) -> NoReturn:
×
247
        raise AssertionError("Should not run.")
×
248

UNCOV
249
    result = run_rule_with_mocks(
×
250
        determine_nodejs_binaries,
251
        rule_args=(nodejs_subsystem, Platform.linux_x86_64, discoverable_versions),
252
    )
253

UNCOV
254
    assert result.binary_dir == expected_path
×
255

256

257
def test_finding_no_node_version_is_an_error(mock_nodejs_subsystem: Mock) -> None:
1✔
UNCOV
258
    nodejs_subsystem = mock_nodejs_subsystem
×
UNCOV
259
    nodejs_subsystem.version = "*"
×
UNCOV
260
    nodejs_subsystem.known_versions = []
×
UNCOV
261
    discoverable_versions = _BinaryPathsPerVersion()
×
262

UNCOV
263
    def mock_download(*_) -> DownloadedExternalTool:
×
264
        return DownloadedExternalTool(EMPTY_DIGEST, "myexe")
×
265

UNCOV
266
    with pytest.raises(BinaryNotFoundError):
×
UNCOV
267
        run_rule_with_mocks(
×
268
            determine_nodejs_binaries,
269
            rule_args=(nodejs_subsystem, Platform.linux_x86_64, discoverable_versions),
270
        )
271

272

273
def mock_nodejs(version: str) -> str:
1✔
274
    """Return a bash script that emulates `node --version`."""
275
    return dedent(
1✔
276
        f"""\
277
        #!/bin/bash
278

279
        if [[ "$1" == '--version' ]]; then
280
            echo '{version}'
281
        fi
282
        """
283
    )
284

285

286
def test_find_valid_binary(rule_runner: RuleRunner) -> None:
1✔
287
    mock_binary = mock_nodejs("v3.0.0")
1✔
288
    with temporary_dir() as tmpdir:
1✔
289
        binary_dir = Path(tmpdir) / "bin"
1✔
290
        binary_dir.mkdir()
1✔
291
        binary_path = binary_dir / "node"
1✔
292
        binary_path.write_text(mock_binary)
1✔
293
        binary_path.chmod(binary_path.stat().st_mode | stat.S_IEXEC)
1✔
294

295
        rule_runner.set_options(
1✔
296
            [
297
                f"--nodejs-search-path=['{binary_dir}']",
298
                "--nodejs-known-versions=[]",
299
                "--nodejs-version=>2",
300
            ],
301
            env_inherit={"PATH"},
302
        )
303
        result = rule_runner.request(NodeJSBinaries, ())
1✔
304
    assert result.binary_dir == str(binary_dir)
1✔
305

306

307
@pytest.mark.parametrize(
1✔
308
    "env, expected_directory",
309
    [
310
        pytest.param({"NVM_DIR": "/somewhere/.nvm"}, "/somewhere/.nvm", id="explicit_nvm_dir"),
311
        pytest.param(
312
            {"HOME": "/somewhere-else", "XDG_CONFIG_HOME": "/somewhere"},
313
            "/somewhere/.nvm",
314
            id="xdg_config_home_set",
315
        ),
316
        pytest.param({"HOME": "/somewhere-else"}, "/somewhere-else/.nvm", id="home_dir_set"),
317
        pytest.param({}, None, id="no_dirs_set"),
318
    ],
319
)
320
def test_get_nvm_root(env: dict[str, str], expected_directory: str | None) -> None:
1✔
321
    def mock_environment_vars(_req: EnvironmentVarsRequest) -> EnvironmentVars:
1✔
322
        return EnvironmentVars(env)
1✔
323

324
    result = run_rule_with_mocks(
1✔
325
        _get_nvm_root,
326
        mock_calls={
327
            "pants.core.util_rules.env_vars.environment_vars_subset": mock_environment_vars,
328
        },
329
    )
330
    assert result == expected_directory
1✔
331

332

333
@pytest.mark.parametrize(
1✔
334
    "extra_environment, expected",
335
    [
336
        pytest.param(
337
            None,
338
            {
339
                "COREPACK_HOME": "{chroot}/._corepack_home",
340
                "PATH": "{chroot}/shim_cache:._corepack:__node/v18",
341
                "npm_config_cache": "npm_cache",
342
            },
343
            id="no_extra_environment",
344
        ),
345
        pytest.param(
346
            {},
347
            {
348
                "COREPACK_HOME": "{chroot}/._corepack_home",
349
                "PATH": "{chroot}/shim_cache:._corepack:__node/v18",
350
                "npm_config_cache": "npm_cache",
351
            },
352
            id="empty_extra_environment",
353
        ),
354
        pytest.param(
355
            {"PATH": "/usr/bin/"},
356
            {
357
                "COREPACK_HOME": "{chroot}/._corepack_home",
358
                "PATH": "{chroot}/shim_cache:._corepack:__node/v18:/usr/bin/",
359
                "npm_config_cache": "npm_cache",
360
            },
361
            id="extra_environment_extends_path",
362
        ),
363
        pytest.param(
364
            {"PATH": "/usr/bin/", "SOME_VAR": "VAR"},
365
            {
366
                "COREPACK_HOME": "{chroot}/._corepack_home",
367
                "PATH": "{chroot}/shim_cache:._corepack:__node/v18:/usr/bin/",
368
                "npm_config_cache": "npm_cache",
369
                "SOME_VAR": "VAR",
370
            },
371
            id="extra_environment_adds_to_environment",
372
        ),
373
        pytest.param(
374
            {"npm_config_cache": "I am ignored"},
375
            {
376
                "COREPACK_HOME": "{chroot}/._corepack_home",
377
                "PATH": "{chroot}/shim_cache:._corepack:__node/v18",
378
                "npm_config_cache": "npm_cache",
379
            },
380
            id="extra_environment_cannot_override_some_vars",
381
        ),
382
    ],
383
)
384
def test_node_process_environment_variables_are_merged(
1✔
385
    extra_environment: dict[str, str] | None, expected: dict[str, str]
386
) -> None:
387
    environment = NodeJSProcessEnvironment(
1✔
388
        NodeJSBinaries("__node/v18"),
389
        "npm_cache",
390
        BinaryShims(EMPTY_DIGEST, "shim_cache"),
391
        "._corepack_home",
392
        "._corepack",
393
        EnvironmentVars(),
394
    )
395

396
    assert environment.to_env_dict(extra_environment) == expected
1✔
397

398

399
def test_node_process_environment_with_tools(rule_runner: RuleRunner) -> None:
1✔
400
    def mock_get_binary_path(request: BinaryPathRequest) -> BinaryPaths:
1✔
401
        # These are the tools that are required by default for any nodejs process to work.
402
        default_required_tools = [
1✔
403
            "sh",
404
            "bash",
405
            "mkdir",
406
            "rm",
407
            "touch",
408
            "which",
409
            "sed",
410
            "dirname",
411
            "uname",
412
        ]
413
        if request.binary_name in default_required_tools:
1✔
414
            return BinaryPaths(
1✔
415
                request.binary_name, paths=[BinaryPath(f"/bin/{request.binary_name}")]
416
            )
417

418
        if request.binary_name == "real-tool":
1✔
419
            return BinaryPaths("real-tool", paths=[BinaryPath("/bin/a-real-tool")])
1✔
420

421
        return BinaryPaths(request.binary_name, ())
1✔
422

423
    def mock_get_binary_shims(request: BinaryShimsRequest) -> BinaryShims:
1✔
424
        return BinaryShims(EMPTY_DIGEST, "cache_name")
1✔
425

426
    def mock_environment_vars(request: EnvironmentVarsRequest) -> EnvironmentVars:
1✔
427
        return EnvironmentVars({})
1✔
428

429
    def mock_create_digest(request: CreateDigest) -> Digest:
1✔
430
        return EMPTY_DIGEST
1✔
431

432
    def mock_merge_digests(request: MergeDigests) -> Digest:
1✔
433
        return EMPTY_DIGEST
1✔
434

435
    def mock_enable_corepack_process_result(request: Process) -> ProcessResult:
1✔
436
        return ProcessResult(
1✔
437
            stdout=b"",
438
            stdout_digest=EMPTY_FILE_DIGEST,
439
            stderr=b"",
440
            stderr_digest=EMPTY_FILE_DIGEST,
441
            output_digest=EMPTY_DIGEST,
442
            metadata=ProcessResultMetadata(
443
                0,
444
                ProcessExecutionEnvironment(
445
                    environment_name=None,
446
                    platform=Platform.create_for_localhost().value,
447
                    docker_image=None,
448
                    remote_execution=False,
449
                    remote_execution_extra_platform_properties=[],
450
                    execute_in_workspace=False,
451
                    keep_sandboxes="never",
452
                ),
453
                "ran_locally",
454
                0,
455
            ),
456
        )
457

458
    def run(tools: list[str], optional_tools: list[str]) -> None:
1✔
459
        nodejs_binaries = NodeJSBinaries("__node/v18")
1✔
460
        nodejs_options = create_subsystem(
1✔
461
            NodeJS,
462
            tools=tools,
463
            optional_tools=optional_tools,
464
        )
465
        nodejs_options_env_aware = MagicMock(spec=NodeJS.EnvironmentAware)
1✔
466

467
        run_rule_with_mocks(
1✔
468
            node_process_environment,
469
            rule_args=[nodejs_binaries, nodejs_options, nodejs_options_env_aware],
470
            mock_calls={
471
                "pants.core.util_rules.env_vars.environment_vars_subset": mock_environment_vars,
472
                "pants.core.util_rules.system_binaries.create_binary_shims": mock_get_binary_shims,
473
                "pants.core.util_rules.system_binaries.find_binary": mock_get_binary_path,
474
                "pants.engine.intrinsics.create_digest": mock_create_digest,
475
                "pants.engine.intrinsics.merge_digests": mock_merge_digests,
476
                "pants.engine.process.fallible_to_exec_result_or_raise": mock_enable_corepack_process_result,
477
            },
478
        )
479

480
    run(tools=["real-tool"], optional_tools=[])
1✔
481

482
    with pytest.raises(BinaryNotFoundError, match="Cannot find `nonexistent-tool`"):
1✔
483
        run(tools=["real-tool", "nonexistent-tool"], optional_tools=[])
1✔
484

485
    # Optional non-existent tool should still succeed.
486
    run(tools=[], optional_tools=["real-tool", "nonexistent-tool"])
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc