diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index a8d34cf09..24f5eac37 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -248,12 +248,19 @@ function Get-WingetInstalled { return , (Read-WingetTable -Text $text -Id $Id) } -# One version for a package winget lists more than once, or nothing where the rows do not describe one product. +# Whether a Version column token is a version, rather than the Unknown winget prints for an entry recording none or the bare < or > its range forms leave. +function Test-WingetVersion { + param([string]$Token) + return $Token -match '^\d' +} + +# One version for a package winget lists more than once, or nothing where the rows do not describe one product or one of them carries no version. # Rows sharing a major version are side by side builds of one product and the newest is the answer, which is what a dotnet SDK line looks like. # Rows whose majors differ are two products sharing an id, which is what the legacy WSL installer looks like beside WSL itself, and there no single version compares. function Resolve-InstalledVersion { param([string[]]$Version) if (-not $Version -or $Version.Count -eq 0) { return $null } + if (@($Version | Where-Object { -not (Test-WingetVersion $_) }).Count -gt 0) { return $null } if ($Version.Count -eq 1) { return $Version[0] } $majors = @($Version | ForEach-Object { ($_ -split '\.')[0] } | Sort-Object -Unique) if ($majors.Count -ne 1) { return $null } @@ -514,9 +521,11 @@ function Get-ToolStatus { if (-not $State.Available) { return 'unavailable' } return 'missing' } - if (-not $State.Installed) { return 'multiple' } + $unread = @($State.Rows | Where-Object { -not (Test-WingetVersion $_) }).Count -gt 0 + if (-not $State.Installed -and -not $unread) { return 'multiple' } if (-not $State.Available) { return 'unknown' } if ((Get-ExplicitUpgrade) -contains $State.Package) { return 'self-updating' } + if ($unread) { return 'unknown' } if ((Compare-HostVersion $State.Installed $State.Available) -ge 0) { return 'current' } return 'outdated' } @@ -1036,7 +1045,7 @@ function Show-Report { $state = Get-ToolState -Tool $record # Every row is printed only where they did not resolve to one version, since a dotnet line carrying three side by side builds resolves cleanly and listing all three would overflow the column for nothing. if (-not $script:JSON_OUTPUT) { - $installed = if ($state.Status -eq 'multiple') { $state.Rows -join ',' } elseif ($state.Installed) { $state.Installed } else { '-' } + $installed = if (-not $state.Installed -and $state.Rows.Count -gt 0) { $state.Rows -join ',' } elseif ($state.Installed) { $state.Installed } else { '-' } $available = if ($state.Available) { $state.Available } else { '-' } $scope = if ($state.Scope.Count -gt 0) { $state.Scope -join '+' } else { '-' } log ($format -f $record.Name, $installed, $available, $state.Package, $scope, $state.Status) @@ -1101,7 +1110,7 @@ function Invoke-ToolApply { # Naming a scope the installed copy does not sit in would add a second copy beside it, so the removal is asked for rather than done on the way past. if ($script:WANT_SCOPE -and $state.Rows.Count -gt 0 -and $state.Scope.Count -gt 0 -and $state.Scope -notcontains $script:WANT_SCOPE -and $script:MODE -ne 'reinstall') { - die "${ToolName}: installed $($state.Scope -join ' and ') wide at $($state.Installed), and -Scope $($script:WANT_SCOPE) was given. Installing would add a second copy beside it. Remove the existing copy first with: install-tools.ps1 -Reinstall $ToolName -Scope $($script:WANT_SCOPE)" + die "${ToolName}: installed $($state.Scope -join ' and ') wide at $(if ($state.Installed) { $state.Installed } else { $state.Rows -join ', ' }), and -Scope $($script:WANT_SCOPE) was given. Installing would add a second copy beside it. Remove the existing copy first with: install-tools.ps1 -Reinstall $ToolName -Scope $($script:WANT_SCOPE)" } if ($script:MODE -eq 'reinstall') { @@ -1140,6 +1149,9 @@ function Invoke-ToolApply { } elseif ($script:MODE -eq 'install' -and $state.Status -eq 'outdated') { log "${ToolName}: at $($state.Installed), the source carries $($state.Available), -Upgrade moves it" return + } elseif ($script:MODE -eq 'install' -and $state.Status -eq 'unknown' -and $state.Rows.Count -gt 0) { + log "${ToolName}: winget lists $($state.Rows -join ', ') and no version comparison is possible, -Upgrade or -Reinstall acts on it" + return } # Reached only once real work is about to happen: every "leave it alone" status above already returned. diff --git a/tests/test_install_tools.py b/tests/test_install_tools.py index ec4f61314..2d944b2b2 100755 --- a/tests/test_install_tools.py +++ b/tests/test_install_tools.py @@ -332,5 +332,162 @@ def test_json_is_refused_beside_another_action(self) -> None: self.assertIn("-Json", result.stderr) +WINGET_HARNESS = r""" +param([string]$Installer, [string]$Cases) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($Installer, [ref]$tokens, [ref]$errors) +$wanted = @('Read-WingetTable', 'Test-WingetVersion', 'Resolve-InstalledVersion', 'Get-VersionKey', 'Compare-HostVersion', 'Get-ToolStatus') +foreach ($definition in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $wanted -contains $node.Name }, $true)) { + . ([scriptblock]::Create($definition.Extent.Text)) +} +$script:EXPLICIT = @() +function Get-ExplicitUpgrade { return $script:EXPLICIT } +$results = [ordered]@{} +foreach ($case in (Get-Content -Raw -LiteralPath $Cases | ConvertFrom-Json).PSObject.Properties) { + $script:EXPLICIT = @(if ($case.Value.PSObject.Properties['explicit']) { $case.Value.explicit }) + $rows = Read-WingetTable -Text $case.Value.table -Id 'jqlang.jq' + $installed = Resolve-InstalledVersion -Version $rows + $state = @{ Readable = $true; Rows = $rows; Installed = $installed; Available = '1.8.2'; Package = 'jqlang.jq' } + $status = Get-ToolStatus -Tool @{ Name = 'jq'; Probe = 'jq' } -State $state + $results[$case.Name] = [ordered]@{ rows = @($rows); installed = $installed; status = $status } +} +$results | ConvertTo-Json -Depth 4 +""" + +UNREAD_APPLY_HARNESS = r""" +param([string]$Installer, [string]$Cases) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($Installer, [ref]$tokens, [ref]$errors) +$wanted = @('Show-Report', 'Invoke-ToolApply', 'Get-NoteText', 'note') +foreach ($definition in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $wanted -contains $node.Name }, $true)) { + . ([scriptblock]::Create($definition.Extent.Text)) +} +function log { param([string]$Message = '') Write-Host "LOG $Message" } +function info { param([string]$Message) Write-Host "INFO $Message" } +function warn { param([string]$Message) Write-Host "WARN $Message" } +function die { param([string]$Message) throw $Message } +function Get-Tool { param([string]$Name) @{ Name = $Name; Package = 'jqlang.jq'; Probe = 'jq'; Optional = @() } } +function Get-ToolState { + param([hashtable]$Tool) + return @{ Package = 'jqlang.jq'; Installed = $null; Rows = @('Unknown'); Readable = $true; Available = '1.8.2'; Scope = @('user'); Status = 'unknown' } +} +function Add-ToolNote { param([hashtable]$Tool, [hashtable]$State) } +function Invoke-WingetInstall { param([string]$Id) Write-Host "INSTALL $Id"; return 0 } +function Invoke-WingetUpgrade { param([string]$Id) Write-Host "UPGRADE $Id"; return 0 } +$NOTES = @() +$NOTE_TEXTS = @() +$JSON_OUTPUT = $false +$ELEVATED = $true +$SELECTED = @('jq') +$FAILED = @() +$WANT_SCOPE = $null +$WITH_OPTIONAL = $false +$MODE = 'install' +Show-Report +Invoke-ToolApply -ToolName 'jq' +""" + + +def winget_list(*versions: str) -> str: + """A `winget list` table carrying one row per version for the id the harness asks about.""" + lines = [ + "Name Id Version Available Source", + "-------------------------------------------", + *(f"jq jqlang.jq {version:<8} 1.8.2 winget" for version in versions), + ] + return "\n".join(lines) + + +def run_pwsh_harness(harness_text: str, cases: object) -> subprocess.CompletedProcess[str]: + """Run a harness against the Windows installer, handing it `cases` as a JSON file.""" + with tempfile.TemporaryDirectory() as directory: + harness = Path(directory, "harness.ps1") + harness.write_text(harness_text, encoding="utf-8") + data = Path(directory, "cases.json") + data.write_text(json.dumps(cases), encoding="utf-8") + return subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(harness), + "-Installer", + str(WINDOWS_INSTALLER), + "-Cases", + str(data), + ], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + timeout=120, + ) + + +@unittest.skipUnless( + shutil.which("pwsh"), "needs pwsh to drive the Windows installer's own functions" +) +class TestWindowsInstalledVersion(unittest.TestCase): + """A Version column token that is not a version leaves the installed version unread.""" + + def test_a_non_version_token_is_unread_rather_than_installed(self) -> None: + cases = { + "unknown": {"table": winget_list("Unknown")}, + "below": {"table": winget_list("<")}, + "above": {"table": winget_list(">")}, + "unknown beside a version": {"table": winget_list("Unknown", "1.8.1")}, + "unknown and self-updating": { + "table": winget_list("Unknown"), + "explicit": ["jqlang.jq"], + }, + "one version": {"table": winget_list("1.8.1")}, + "majors differ": {"table": winget_list("1.8.1", "2.0.0")}, + } + result = run_pwsh_harness(WINGET_HARNESS, cases) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + json.loads(result.stdout), + { + "unknown": {"rows": ["Unknown"], "installed": None, "status": "unknown"}, + "below": {"rows": ["<"], "installed": None, "status": "unknown"}, + "above": {"rows": [">"], "installed": None, "status": "unknown"}, + "unknown beside a version": { + "rows": ["Unknown", "1.8.1"], + "installed": None, + "status": "unknown", + }, + "unknown and self-updating": { + "rows": ["Unknown"], + "installed": None, + "status": "self-updating", + }, + "one version": {"rows": ["1.8.1"], "installed": "1.8.1", "status": "outdated"}, + "majors differ": { + "rows": ["1.8.1", "2.0.0"], + "installed": None, + "status": "multiple", + }, + }, + ) + + def test_the_report_prints_the_row_and_install_leaves_the_tool_alone(self) -> None: + result = run_pwsh_harness(UNREAD_APPLY_HARNESS, {}) + self.assertEqual(result.returncode, 0, result.stderr) + reported = [ + line.split() for line in result.stdout.splitlines() if line.startswith("LOG jq ") + ] + self.assertEqual([row[:3] for row in reported], [["LOG", "jq", "Unknown"]], result.stdout) + self.assertIn("no version comparison is possible", result.stdout) + self.assertNotIn("INSTALL ", result.stdout) + self.assertNotIn("UPGRADE ", result.stdout) + + if __name__ == "__main__": unittest.main()