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

pantsbuild / pants / 24604025132

18 Apr 2026 11:49AM UTC coverage: 92.478% (-0.4%) from 92.924%
24604025132

Pull #23268

github

web-flow
Merge c60f47029 into a92bc34b6
Pull Request #23268: perf: Remove python coroutine/trampoline overhead in awaits for ~22% faster `dependencies` goal

31 of 37 new or added lines in 4 files covered. (83.78%)

443 existing lines in 21 files now uncovered.

91210 of 98629 relevant lines covered (92.48%)

4.03 hits per line

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

90.48
/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 asyncio
1✔
7
import stat
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
    with asyncio.Runner():
1✔
165
        nodejs_subsystem = Mock(spec=NodeJS)
1✔
166
        future: asyncio.Future[DownloadedExternalTool] = asyncio.Future()
1✔
167
        future.set_result(DownloadedExternalTool(EMPTY_DIGEST, ""))
1✔
168
        nodejs_subsystem.download_known_version = MagicMock(return_value=future)
1✔
169
        return nodejs_subsystem
1✔
170

171

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

178

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

211
    nodejs_subsystem.download_known_version.assert_called_once_with(
1✔
212
        ExternalToolVersion.decode(expected), Platform.linux_x86_64
213
    )
214

215

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

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

250
    result = run_rule_with_mocks(
1✔
251
        determine_nodejs_binaries,
252
        rule_args=(nodejs_subsystem, Platform.linux_x86_64, discoverable_versions),
253
    )
254

255
    assert result.binary_dir == expected_path
1✔
256

257

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

264
    def mock_download(*_) -> DownloadedExternalTool:
1✔
265
        return DownloadedExternalTool(EMPTY_DIGEST, "myexe")
×
266

267
    with pytest.raises(BinaryNotFoundError):
1✔
268
        run_rule_with_mocks(
1✔
269
            determine_nodejs_binaries,
270
            rule_args=(nodejs_subsystem, Platform.linux_x86_64, discoverable_versions),
271
        )
272

273

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

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

286

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

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

307

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

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

333

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

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

399

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

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

UNCOV
422
        return BinaryPaths(request.binary_name, ())
×
423

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

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

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

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

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

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

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

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

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

486
    # Optional non-existent tool should still succeed.
UNCOV
487
    run(tools=[], optional_tools=["real-tool", "nonexistent-tool"])
×
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

© 2026 Coveralls, Inc