diff --git a/.github/workflows/installer-tests.yml b/.github/workflows/installer-tests.yml index 66f249ffa2..109db526ff 100644 --- a/.github/workflows/installer-tests.yml +++ b/.github/workflows/installer-tests.yml @@ -36,3 +36,11 @@ jobs: - name: 8.3 short-path normalization (Windows PowerShell 5.1) shell: powershell run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1 + + - name: System Node and npm compatibility (pwsh 7) + shell: pwsh + run: pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-node-compatibility.ps1 + + - name: System Node and npm compatibility (Windows PowerShell 5.1) + shell: powershell + run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-node-compatibility.ps1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f3f5c9756a..06d9dd6727 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -934,6 +934,88 @@ function Get-NpmRange { return $NpmRange } +# Convert the numeric core of an npm version or range operand into a stable +# three-component System.Version. npm reports semantic versions, but the +# installer only needs the numeric core for the comparator ranges authored in +# package.json (for example, <11.10.0 || >=11.17.0). +function ConvertTo-NpmVersion { + param([string]$Version) + + if (-not $Version) { return $null } + + $core = ($Version.Trim() -replace '^v', '' -replace '-.*$', '') + $parts = @($core -split '\.') + if ($parts.Count -lt 1 -or $parts.Count -gt 3) { return $null } + foreach ($part in $parts) { + if ($part -notmatch '^\d+$') { return $null } + } + while ($parts.Count -lt 3) { $parts += '0' } + + try { + return [version]($parts -join '.') + } catch { + return $null + } +} + +# Evaluate the comparator-only npm ranges used by the root manifest and the +# pre-clone fallback. Alternatives are separated with || and each alternative +# may contain one or more whitespace-separated <, <=, >, or >= comparators. +# Unknown range syntax fails closed so an incompatible system npm cannot reach +# npm ci and fail later with EBADENGINE. +function Test-NpmVersionOk { + param( + [string]$Version, + [string]$Range = (Get-NpmRange) + ) + + $actual = ConvertTo-NpmVersion $Version + if (-not $actual -or -not $Range) { return $false } + + foreach ($alternative in @($Range -split '\s*\|\|\s*')) { + $clause = $alternative.Trim() + if (-not $clause) { continue } + + $comparators = [regex]::Matches( + $clause, + '(?:^|\s)(<=|>=|<|>)\s*(\d+(?:\.\d+){0,2})(?=\s|$)' + ) + if ($comparators.Count -eq 0) { continue } + + $remainder = [regex]::Replace( + $clause, + '(?:^|\s)(?:<=|>=|<|>)\s*\d+(?:\.\d+){0,2}(?=\s|$)', + '' + ).Trim() + if ($remainder) { continue } + + $matchesClause = $true + foreach ($comparator in $comparators) { + $target = ConvertTo-NpmVersion $comparator.Groups[2].Value + if (-not $target) { + $matchesClause = $false + break + } + + $matchesComparator = switch ($comparator.Groups[1].Value) { + '<' { $actual -lt $target } + '<=' { $actual -le $target } + '>' { $actual -gt $target } + '>=' { $actual -ge $target } + default { $false } + } + if (-not $matchesComparator) { + $matchesClause = $false + break + } + } + + if ($matchesClause) { return $true } + } + + return $false +} + # Upgrade the Hermes-managed Node tree's bundled npm into $NpmRange. # # The nodejs.org zip ships whatever npm that Node major bundles -- Node 26.5.1 @@ -1587,20 +1669,55 @@ function Test-NodeVersionOk { return ($v.Major -gt 22) } +# Accept a system Node only when its companion npm also satisfies the same +# range used to provision the Hermes-managed tree. Keeping this probe separate +# lets the initial PATH check and the post-winget check share one authority. +function Test-SystemNodeReady { + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { return $false } + + $version = node --version + if (-not (Test-NodeVersionOk $version)) { + Write-Warn "Node.js $version is too old (Hermes requires Node >=22.22.0)" + return $false + } + + Ensure-NodeExeOnPath | Out-Null + $npmRange = Get-NpmRange + $npmCmd = Get-Command npm.cmd -ErrorAction SilentlyContinue + if (-not $npmCmd) { + $npmCmd = Get-Command npm -ErrorAction SilentlyContinue + } + + $npmVersion = $null + if ($npmCmd) { + try { + $npmVersion = (& $npmCmd --version 2>$null | Select-Object -First 1) + } catch { } + } + + if ($npmVersion -and (Test-NpmVersionOk $npmVersion $npmRange)) { + Write-Success "Node.js $version with npm $npmVersion found" + return $true + } + + if ($npmVersion) { + Write-Warn "Node.js $version uses npm $npmVersion, which does not satisfy Hermes requirement $npmRange" + } else { + Write-Warn "Node.js $version was found, but npm is missing or could not report its version" + } + return $false +} + function Test-Node { Write-Info "Checking Node.js (for browser tools)..." - if (Get-Command node -ErrorAction SilentlyContinue) { - $version = node --version - if (Test-NodeVersionOk $version) { - Ensure-NodeExeOnPath | Out-Null - Write-Success "Node.js $version found" - $script:HasNode = $true - return $true - } - Write-Warn "Node.js $version is too old (Hermes requires Node >=26)" + if (Test-SystemNodeReady) { + $script:HasNode = $true + return $true } + Write-Info "Using a Hermes-managed Node.js installation instead..." + # Prefer a Hermes-managed Node from a previous run over a too-old system one. $managedNode = "$HermesHome\node\node.exe" if ((Test-Path $managedNode) -and (Test-NodeVersionOk (& $managedNode --version))) { @@ -1774,9 +1891,7 @@ function Test-Node { $ErrorActionPreference = $prevEAP # Refresh PATH $env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") - if (Get-Command node -ErrorAction SilentlyContinue) { - $version = node --version - Write-Success "Node.js $version installed via winget" + if (Test-SystemNodeReady) { $script:HasNode = $true return $true } diff --git a/scripts/tests/test-install-ps1-node-compatibility.ps1 b/scripts/tests/test-install-ps1-node-compatibility.ps1 new file mode 100644 index 0000000000..abdb06a823 --- /dev/null +++ b/scripts/tests/test-install-ps1-node-compatibility.ps1 @@ -0,0 +1,142 @@ +# Behavioral tests for install.ps1 system Node/npm compatibility selection. +# +# The installer itself is not executed. The real shipped functions are lifted +# through the PowerShell AST, then external commands and downloads are replaced +# with deterministic in-process stubs. This exercises the actual range parser +# and Test-Node acceptance gate without changing PATH, installing software, or +# touching the user's Hermes home. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)) +$installScript = Join-Path $repoRoot 'scripts\install.ps1' +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $installScript, [ref]$tokens, [ref]$parseErrors +) +if ($parseErrors.Count -gt 0) { + throw "install.ps1 has parse errors: $($parseErrors -join '; ')" +} + +foreach ($name in @( + 'ConvertTo-NpmVersion', + 'Test-NpmVersionOk', + 'Test-NodeVersionOk', + 'Test-SystemNodeReady', + 'Test-Node' +)) { + $fn = $ast.FindAll( + { + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $name + }, + $true + ) | Select-Object -First 1 + if (-not $fn) { throw "$name not found in install.ps1" } + . ([scriptblock]::Create($fn.Extent.Text)) +} + +$script:Failures = 0 +function Assert-Equal { + param($Expected, $Actual, [string]$Label) + if ($Expected -ceq $Actual) { + Write-Host "PASS: $Label" + } else { + Write-Host "FAIL: $Label" + Write-Host " expected: [$Expected]" + Write-Host " actual: [$Actual]" + $script:Failures++ + } +} + +Write-Host '-- npm range evaluation --' +$supportedRange = '<11.10.0 || >=11.17.0' +Assert-Equal $true (Test-NpmVersionOk '11.9.9' $supportedRange) 'lower alternative is accepted' +Assert-Equal $false (Test-NpmVersionOk '11.10.0' $supportedRange) 'excluded band starts at 11.10.0' +Assert-Equal $false (Test-NpmVersionOk '11.16.0' $supportedRange) 'reported npm 11.16.0 is rejected' +Assert-Equal $true (Test-NpmVersionOk '11.17.0' $supportedRange) 'upper alternative starts at 11.17.0' +Assert-Equal $false (Test-NpmVersionOk '11.17.0' '>=12.0.0') 'pre-clone npm floor rejects 11.x' +Assert-Equal $true (Test-NpmVersionOk '12.0.0' '>=12.0.0') 'pre-clone npm floor accepts 12.0.0' +Assert-Equal $false (Test-NpmVersionOk 'not-a-version' $supportedRange) 'malformed version fails closed' +Assert-Equal $false (Test-NpmVersionOk '12.0.0' '^12.0.0') 'unsupported range syntax fails closed' + +# Controlled command surface used by the lifted Test-Node function. +$script:FakeNpmAvailable = $true +$script:FakeNpmVersion = '11.16.0' +$script:FakeNpmRange = $supportedRange +$script:DownloadAttempts = 0 +$script:HasNode = $null +$HermesHome = Join-Path $env:TEMP ("hermes-node-compatibility-test-" + [Guid]::NewGuid().ToString('N')) +$NodeVersion = '22' + +function node { 'v24.18.0' } +function npm.cmd { $script:FakeNpmVersion } +function Get-Command { + [CmdletBinding()] + param([string]$Name) + + switch ($Name) { + 'node' { + return Microsoft.PowerShell.Core\Get-Command node -CommandType Function + } + 'npm.cmd' { + if ($script:FakeNpmAvailable) { + return Microsoft.PowerShell.Core\Get-Command npm.cmd -CommandType Function + } + return $null + } + 'npm' { return $null } + 'winget' { return $null } + default { return $null } + } +} +function Get-NpmRange { $script:FakeNpmRange } +function Ensure-NodeExeOnPath { $true } +function Get-WindowsArch { 'x64' } +function Invoke-WebRequest { + $script:DownloadAttempts++ + throw 'network disabled by test' +} +function Write-Info { param([string]$Message) } +function Write-Warn { param([string]$Message) } +function Write-Success { param([string]$Message) } + +function Invoke-SystemNodeProbe { + param([string]$NpmVersion, [bool]$NpmAvailable = $true) + + $script:FakeNpmVersion = $NpmVersion + $script:FakeNpmAvailable = $NpmAvailable + $script:DownloadAttempts = 0 + $script:HasNode = $null + [void](Test-Node) + return [pscustomobject]@{ + HasNode = $script:HasNode + DownloadAttempts = $script:DownloadAttempts + } +} + +Write-Host '' +Write-Host '-- system Node acceptance --' +$result = Invoke-SystemNodeProbe '11.17.0' +Assert-Equal $true $result.HasNode 'compatible system Node/npm is accepted' +Assert-Equal 0 $result.DownloadAttempts 'compatible system npm avoids managed download' + +$result = Invoke-SystemNodeProbe '11.16.0' +Assert-Equal $false $result.HasNode 'incompatible system npm is not accepted' +Assert-Equal 1 $result.DownloadAttempts 'incompatible system npm falls through to managed Node' + +$result = Invoke-SystemNodeProbe '' $false +Assert-Equal $false $result.HasNode 'missing system npm is not accepted' +Assert-Equal 1 $result.DownloadAttempts 'missing system npm falls through to managed Node' + +if ($script:Failures -gt 0) { + Write-Host '' + Write-Host "$script:Failures assertion(s) failed" + exit 1 +} + +Write-Host '' +Write-Host 'all assertions passed'