diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e41c924..6c4571a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -253,6 +253,9 @@ jobs: - name: Analyze scripts/windows/setup.ps1 run: pwsh -Command "Invoke-ScriptAnalyzer -Path scripts/windows/setup.ps1 -EnableExit" + - name: Analyze scripts/windows/lib/tui.ps1 + run: pwsh -Command "Invoke-ScriptAnalyzer -Path scripts/windows/lib/tui.ps1 -EnableExit" + validate-powershell: name: Validate PowerShell Functions runs-on: windows-latest @@ -279,6 +282,9 @@ jobs: - name: Flag test - -List (PowerShell) run: .\setup.ps1 -List + - name: Run TUI and resolver tests (pwsh) + run: pwsh tests/test_tui_pwsh.ps1 + validate-ps51: name: Validate PowerShell 5.1 Compatibility runs-on: windows-latest @@ -322,12 +328,28 @@ jobs: } Write-Host "Syntax check passed" + - name: Syntax check tui.ps1 (PS 5.1) + shell: powershell + run: | + $errors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile( + "$env:GITHUB_WORKSPACE\scripts\windows\lib\tui.ps1", + [ref]$null, + [ref]$errors + ) + if ($errors.Count -gt 0) { + $errors | ForEach-Object { Write-Error $_.Message } + exit 1 + } + Write-Host "Syntax check passed" + - name: Run PSScriptAnalyzer under PS 5.1 shell: powershell run: | Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser $results = @() $results += Invoke-ScriptAnalyzer -Path "$env:GITHUB_WORKSPACE\scripts\windows\setup.ps1" + $results += Invoke-ScriptAnalyzer -Path "$env:GITHUB_WORKSPACE\scripts\windows\lib\tui.ps1" $results += Invoke-ScriptAnalyzer -Path "$env:GITHUB_WORKSPACE\setup.ps1" if ($results.Count -gt 0) { $results | Format-Table -AutoSize @@ -358,6 +380,11 @@ jobs: run: | powershell -ExecutionPolicy Bypass -File tests\test_setup_flags_pwsh.ps1 + - name: Run TUI and resolver tests (PS 5.1) + shell: powershell + run: | + powershell -ExecutionPolicy Bypass -File tests\test_tui_pwsh.ps1 + - name: Configure git hooks path shell: powershell run: | diff --git a/scripts/windows/lib/tui.ps1 b/scripts/windows/lib/tui.ps1 new file mode 100644 index 0000000..3e1c577 --- /dev/null +++ b/scripts/windows/lib/tui.ps1 @@ -0,0 +1,133 @@ +# scripts/windows/lib/tui.ps1 -- PS 5.1 ASCII menu and toolset resolution (#495 Slice 3) +# +# Dot-sourced by scripts/windows/setup.ps1. +# Exports: Resolve-FinalToolset, Show-ToolMenu +# +# PS 5.1 ASCII-only: no smart quotes, em-dashes, arrows, or non-ASCII characters. +# No Write-Host. Console output via [Console]::Write / [Console]::WriteLine only. + +# --------------------------------------------------------------------------- +# Resolve-FinalToolset: canonical ordered tool resolver. +# Pure function -- no exit, no console output, no validation. +# Callers must validate tool names before calling (non-interactive path). +# +# Returns [string[]] in install order: +# -Only path : DefaultTools order first, then opt-ins appended alphabetically. +# -Skip path : DefaultTools minus skipped, in default order. +# default : DefaultTools unchanged. +# --------------------------------------------------------------------------- +function Resolve-FinalToolset { + param( + [string[]]$DefaultTools = @(), + [string]$Only = '', + [bool]$OnlySet = $false, + [string]$Skip = '', + [bool]$SkipSet = $false + ) + [string[]]$finalTools = @() + if ($OnlySet) { + $names = @($Only.Split(',') | Where-Object { $_ -ne '' }) + # Preserve default order for requested defaults + foreach ($tool in $DefaultTools) { + if ($names -contains $tool) { $finalTools += $tool } + } + # Opt-ins (requested but not in DefaultTools): append alphabetically + $optIn = @($names | Where-Object { $DefaultTools -notcontains $_ } | Sort-Object) + foreach ($t in $optIn) { $finalTools += $t } + } elseif ($SkipSet) { + $names = @($Skip.Split(',') | Where-Object { $_ -ne '' }) + foreach ($tool in $DefaultTools) { + if ($names -notcontains $tool) { $finalTools += $tool } + } + } else { + $finalTools = $DefaultTools + } + return ,$finalTools +} + +# --------------------------------------------------------------------------- +# Show-ToolMenu: interactive ASCII checkbox menu. +# +# Returns: +# $null -- user cancelled (Esc/Q) OR ReadKey failure (safe cancel fallback) +# [string[]]@() -- user confirmed with nothing checked +# [string[]] -- user confirmed; array of checked tool names in display order +# +# ponytail: $null vs @() return -- verified safe: cancel returns $null (no comma), +# non-null returns use ,$selected (comma prefix) to prevent pipeline unrolling. +# Caller assigns [string[]]$x = Show-ToolMenu; $null-eq-check and Count-check +# are distinguishable. See test T_menu_noop_empty_ps. +# +# Display order: DefaultTools pre-checked (default), then sorted opt-ins unchecked. +# Keys: Up/Down = move cursor; Space = toggle; A = toggle all; Enter = confirm; Esc/Q = cancel. +# --------------------------------------------------------------------------- +function Show-ToolMenu { + param( + [string[]]$DefaultTools = @(), + [string[]]$Available = @() + ) + + # Build display order: defaults (pre-checked) then sorted opt-ins (unchecked) + $optIns = @($Available | Where-Object { $DefaultTools -notcontains $_ } | Sort-Object) + $items = @($DefaultTools) + $optIns + $nDef = $DefaultTools.Count + [bool[]]$checked = @() + for ($i = 0; $i -lt $nDef; $i++) { $checked += $true } + for ($i = 0; $i -lt $optIns.Count; $i++) { $checked += $false } + + $cursor = 0 + $confirmed = $false + $cancelled = $false + + # Render-Menu as scriptblock (closure over $items, $checked, $cursor, $nDef) + $renderMenu = { + [Console]::WriteLine('Select tools to install (defaults pre-checked):') + [Console]::WriteLine('') + for ($i = 0; $i -lt $items.Count; $i++) { + $mark = if ($checked[$i]) { '[x]' } else { '[ ]' } + $label = if ($i -lt $nDef) { '(default)' } else { '(opt-in)' } + $arrow = if ($i -eq $cursor) { '>' } else { ' ' } + [Console]::WriteLine(" $arrow $mark $($items[$i]) $label") + } + [Console]::WriteLine('') + [Console]::WriteLine(' Up/Down=move Space=toggle A=all Enter=confirm Esc/Q=cancel') + } + + try { + [Console]::Clear() + & $renderMenu + + while (-not $confirmed -and -not $cancelled) { + $key = [Console]::ReadKey($true) + [Console]::Clear() + switch ($key.Key) { + 'UpArrow' { $cursor = [Math]::Max(0, $cursor - 1) } + 'DownArrow' { $cursor = [Math]::Min($items.Count - 1, $cursor + 1) } + 'Spacebar' { $checked[$cursor] = -not $checked[$cursor] } + 'Enter' { $confirmed = $true } + 'Escape' { $cancelled = $true } + 'Q' { $cancelled = $true } + 'A' { + $anyUnchecked = $checked -contains $false + for ($i = 0; $i -lt $checked.Count; $i++) { + $checked[$i] = $anyUnchecked + } + } + } + if (-not $confirmed -and -not $cancelled) { & $renderMenu } + } + } catch { + # ReadKey failure in a nominally interactive host: one clear warning, install cancelled + [Console]::Error.WriteLine( + "WARNING: Interactive menu failed ($($_.Exception.Message)). Installation is being cancelled.") + return $null + } + + if ($cancelled) { return $null } + + [string[]]$selected = @() + for ($i = 0; $i -lt $items.Count; $i++) { + if ($checked[$i]) { $selected += $items[$i] } + } + return ,$selected +} diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 6938588..d83bf1a 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -38,6 +38,7 @@ $ErrorActionPreference = 'Stop' . "$PSScriptRoot\lib\logging.ps1" . "$PSScriptRoot\lib\path.ps1" +. "$PSScriptRoot\lib\tui.ps1" # Dot-source all tool installer modules . "$PSScriptRoot\tools\winget-check.ps1" @@ -212,6 +213,8 @@ function Test-ShouldShowMenu { [bool]$InteractiveRequested, [bool]$SelectionFileSet ) + # ponytail: test seam -- remove when TTY simulation available in CI + if ($env:_PS_TUI_TEST_MENU -eq '1') { return $true } if ($NonInteractiveRequested -or $OnlySet -or $SkipSet) { return $false } # -Interactive + -SelectionFile: bypass CI/TTY detection so CI can test the menu path. if ($InteractiveRequested -and $SelectionFileSet) { return $true } @@ -221,82 +224,66 @@ function Test-ShouldShowMenu { return $true } -# ponytail: detection-only ceiling for Slice 1; Slice 3 wires the PowerShell menu here. -$null = Test-ShouldShowMenu ` - -NonInteractiveRequested $NonInteractive.IsPresent ` - -OnlySet ($PSBoundParameters.ContainsKey('Only')) ` - -SkipSet ($PSBoundParameters.ContainsKey('Skip')) ` - -InteractiveRequested $Interactive.IsPresent ` - -SelectionFileSet ($PSBoundParameters.ContainsKey('SelectionFile')) - # --------------------------------------------------------------------------- -# Build FinalToolSet +# Determine final toolset: menu path (interactive) or flag path (non-interactive) # --------------------------------------------------------------------------- -$FinalTools = @() -$Available = Get-AvailableTool -$UseSelectionFile = $PSBoundParameters.ContainsKey('SelectionFile') -and - -not $PSBoundParameters.ContainsKey('Only') -and - -not $PSBoundParameters.ContainsKey('Skip') +$Available = Get-AvailableTool +[string[]]$FinalTools = @() -# Selection-file: validate, join names, route through the canonical -Only path. -$EffectiveOnly = '' -$UseOnlyPath = $false +if (Test-ShouldShowMenu ` + -NonInteractiveRequested $NonInteractive.IsPresent ` + -OnlySet ($PSBoundParameters.ContainsKey('Only')) ` + -SkipSet ($PSBoundParameters.ContainsKey('Skip')) ` + -InteractiveRequested $Interactive.IsPresent ` + -SelectionFileSet ($PSBoundParameters.ContainsKey('SelectionFile'))) { -if ($UseSelectionFile) { - if ([string]::IsNullOrEmpty($SelectionFile) -or -not (Test-Path -LiteralPath $SelectionFile -PathType Leaf)) { - Write-Err "Selection file not found: $SelectionFile" - exit 1 + # Interactive path: selection-file seam (CI/test) or live menu + if ($PSBoundParameters.ContainsKey('SelectionFile') -and $SelectionFile) { + [string[]]$selectedNames = @(Get-Content -LiteralPath $SelectionFile | + Where-Object { -not [string]::IsNullOrEmpty($_) }) + } else { + [string[]]$selectedNames = Show-ToolMenu -DefaultTools $DefaultTools -Available $Available } - $fileNames = @(Get-Content -LiteralPath $SelectionFile | Where-Object { $_ -ne '' }) - if ($fileNames.Count -eq 0) { - Write-Err "Flag requires at least one tool name." - exit 1 - } - $EffectiveOnly = $fileNames -join ',' - $UseOnlyPath = $true -} elseif ($PSBoundParameters.ContainsKey('Only')) { - $EffectiveOnly = $Only - $UseOnlyPath = $true -} -if ($UseOnlyPath) { - $names = Split-ToolList -ToolList $EffectiveOnly - foreach ($name in $names) { - if ($Available -notcontains $name) { - Write-Err "Unknown tool: $name" - Write-Err "Available tools: $($Available -join ', ')" - Write-Err "Use -List to see all available tools." - exit 1 - } + if ($null -eq $selectedNames) { + Write-Output 'Install cancelled.' + exit 0 } - # ORDER PRESERVATION: iterate DefaultTools, include those requested. - # Do NOT use input order -- dependencies require the default sequence. - foreach ($tool in $DefaultTools) { - if ($names -contains $tool) { - $FinalTools += $tool - } + if ($selectedNames.Count -eq 0) { + Write-Output 'Nothing selected, exiting.' + exit 0 } - # Opt-in tools (requested but NOT in DefaultTools): append alphabetically. - $optIn = @($names | Where-Object { $DefaultTools -notcontains $_ } | Sort-Object) - foreach ($t in $optIn) { $FinalTools += $t } + $FinalTools = Resolve-FinalToolset -DefaultTools $DefaultTools ` + -Only ($selectedNames -join ',') -OnlySet $true -} elseif ($PSBoundParameters.ContainsKey('Skip')) { - $names = Split-ToolList -ToolList $Skip - foreach ($name in $names) { - if ($Available -notcontains $name) { - Write-Err "Unknown tool: $name" - Write-Err "Available tools: $($Available -join ', ')" - exit 1 +} else { + # Non-interactive path: validate tool names then resolve + if ($PSBoundParameters.ContainsKey('Only')) { + $names = Split-ToolList -ToolList $Only + foreach ($name in $names) { + if ($Available -notcontains $name) { + Write-Err "Unknown tool: $name" + Write-Err "Available tools: $($Available -join ', ')" + Write-Err "Use -List to see all available tools." + exit 1 + } } - } - foreach ($tool in $DefaultTools) { - if ($names -notcontains $tool) { - $FinalTools += $tool + $FinalTools = Resolve-FinalToolset -DefaultTools $DefaultTools ` + -Only $Only -OnlySet $true + } elseif ($PSBoundParameters.ContainsKey('Skip')) { + $names = Split-ToolList -ToolList $Skip + foreach ($name in $names) { + if ($Available -notcontains $name) { + Write-Err "Unknown tool: $name" + Write-Err "Available tools: $($Available -join ', ')" + exit 1 + } } + $FinalTools = Resolve-FinalToolset -DefaultTools $DefaultTools ` + -Skip $Skip -SkipSet $true + } else { + $FinalTools = Resolve-FinalToolset -DefaultTools $DefaultTools } - -} else { - $FinalTools = $DefaultTools } # --------------------------------------------------------------------------- diff --git a/tests/test_tui_pwsh.ps1 b/tests/test_tui_pwsh.ps1 new file mode 100644 index 0000000..cea96ff --- /dev/null +++ b/tests/test_tui_pwsh.ps1 @@ -0,0 +1,228 @@ +# tests/test_tui_pwsh.ps1 -- Slice 3 TUI and resolver tests (#495) +# +# Tests: Resolve-FinalToolset (unit, dot-sourced from tui.ps1) +# Show-ToolMenu integration paths (selection-file seam, empty) +# ASCII purity of tui.ps1, -Help hiding of -SelectionFile. +# +# Usage: powershell -ExecutionPolicy Bypass -File tests\test_tui_pwsh.ps1 +# PS 5.1 ASCII-only: no smart quotes, em-dashes, arrows, or emoji. +# Cancel path (Esc/Q) and ReadKey failure are manual-only gates (no real key-reader seam). +# Test count: 9 + +$ErrorActionPreference = 'Stop' +$TestsPassed = 0 +$TestsFailed = 0 +$TestsSkipped = 0 + +$RepoRoot = Split-Path $PSScriptRoot -Parent +$WinSetup = Join-Path $RepoRoot 'scripts\windows\setup.ps1' +$TuiLib = Join-Path $RepoRoot 'scripts\windows\lib\tui.ps1' +$StubDir = Join-Path $RepoRoot 'tests\fixtures\stub-tools\windows' +$SelectionFile = Join-Path $StubDir 'selection.txt' + +# --------------------------------------------------------------------------- +# Helpers (same pattern as test_setup_flags_pwsh.ps1) +# --------------------------------------------------------------------------- + +function Test-Scenario { + param([string]$Name, [scriptblock]$Test) + Write-Host "`n=== TEST: $Name ===" -ForegroundColor Cyan + try { + & $Test + Write-Host "[PASS] $Name" -ForegroundColor Green + $script:TestsPassed++ + } + catch { + Write-Host "[FAIL] $Name" -ForegroundColor Red + Write-Host " Error: $_" -ForegroundColor Red + $script:TestsFailed++ + } +} + +function Setup-Harness { + $script:RunLog = [System.IO.Path]::GetTempFileName() + $env:RUN_LOG = $script:RunLog +} + +function Teardown-Harness { + Remove-Item $script:RunLog -ErrorAction SilentlyContinue + $env:RUN_LOG = $null +} + +function Assert-ArrayEquals { + param([string[]]$Expected, [string[]]$Actual, [string]$Label) + $diff = Compare-Object $Expected $Actual + if ($diff) { + $e = $Expected -join ', ' + $a = if ($Actual) { $Actual -join ', ' } else { '(empty)' } + throw "${Label}: expected [$e] got [$a]" + } +} + +# --------------------------------------------------------------------------- +# Dot-source tui.ps1 for unit tests. +# Will fail with "Cannot find path" if tui.ps1 does not exist yet (RED state). +# --------------------------------------------------------------------------- +. $TuiLib + +# =========================================================================== +# Resolve-FinalToolset unit tests +# =========================================================================== + +# --------------------------------------------------------------------------- +# T_resolve_toolset_defaults_ps: no -Only/-Skip => DefaultTools unchanged +# --------------------------------------------------------------------------- +Test-Scenario "T_resolve_toolset_defaults_ps: defaults path returns DefaultTools" { + $d = @('alpha', 'bravo', 'charlie') + $result = Resolve-FinalToolset -DefaultTools $d + Assert-ArrayEquals -Expected $d -Actual $result -Label 'defaults path' +} + +# --------------------------------------------------------------------------- +# T_resolve_toolset_only_ps: -Only preserves default order + appends opt-ins +# --------------------------------------------------------------------------- +Test-Scenario "T_resolve_toolset_only_ps: -Only order-preserved + opt-in appended" { + $d = @('alpha', 'bravo', 'charlie') + # Request bravo (default) and delta (opt-in); expect default-order first, then opt-in + $result = Resolve-FinalToolset -DefaultTools $d -Only 'bravo,delta' -OnlySet $true + Assert-ArrayEquals -Expected @('bravo', 'delta') -Actual $result -Label '-Only path' +} + +# --------------------------------------------------------------------------- +# T_resolve_toolset_skip_ps: -Skip removes named tool, keeps rest in default order +# --------------------------------------------------------------------------- +Test-Scenario "T_resolve_toolset_skip_ps: -Skip removes tool in default order" { + $d = @('alpha', 'bravo', 'charlie') + $result = Resolve-FinalToolset -DefaultTools $d -Skip 'bravo' -SkipSet $true + Assert-ArrayEquals -Expected @('alpha', 'charlie') -Actual $result -Label '-Skip path' +} + +# =========================================================================== +# Integration tests (subprocess) +# =========================================================================== + +# --------------------------------------------------------------------------- +# T_menu_selection_file_e2e_ps: -Interactive -SelectionFile -ToolsDir => correct run-log +# selection.txt: delta (opt-in), blank, alpha (default) +# expected install order: alpha (default order), then delta (opt-in) +# --------------------------------------------------------------------------- +Test-Scenario "T_menu_selection_file_e2e_ps: SelectionFile integration routes to correct install" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -Interactive -SelectionFile $SelectionFile -ToolsDir $StubDir 2>&1 | Out-Null + $ec = $LASTEXITCODE + if ($ec -ne 0) { throw "Expected exit 0, got $ec" } + $actual = (Get-Content $script:RunLog -ErrorAction SilentlyContinue) -join "`n" + $expected = "alpha`ndelta" + if ($actual -ne $expected) { + throw "Run-log mismatch. Expected: [$expected] Actual: [$actual]" + } + } + finally { Teardown-Harness } +} + +# --------------------------------------------------------------------------- +# T_menu_noop_empty_ps: empty SelectionFile => "nothing selected" exit 0, no tools run +# --------------------------------------------------------------------------- +Test-Scenario "T_menu_noop_empty_ps: empty SelectionFile exits 0 with empty run-log" { + Setup-Harness + $emptyFile = [System.IO.Path]::GetTempFileName() + try { + # Write an empty selection file (blank lines only) + Set-Content -Path $emptyFile -Value '' -Encoding UTF8 + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -Interactive -SelectionFile $emptyFile -ToolsDir $StubDir 2>&1 | Out-Null + $ec = $LASTEXITCODE + if ($ec -ne 0) { throw "Expected exit 0 on empty selection, got $ec" } + $content = Get-Content $script:RunLog -ErrorAction SilentlyContinue + if ($content) { throw "Tools ran on empty selection: $($content -join ', ')" } + } + finally { + Remove-Item $emptyFile -ErrorAction SilentlyContinue + Teardown-Harness + } +} + +# --------------------------------------------------------------------------- +# T_noarg_noninteractive_compat_ps: [DRIFT] no params + CI env => defaults installed +# --------------------------------------------------------------------------- +Test-Scenario "T_noarg_noninteractive_compat_ps: [DRIFT] no-arg + CI => run-log == defaults" { + Setup-Harness + $savedCI = $env:CI + $env:CI = 'true' + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Setup exited $LASTEXITCODE" } + $actual = (Get-Content $script:RunLog -ErrorAction SilentlyContinue) -join "`n" + $expected = (Get-Content (Join-Path $StubDir 'defaults.txt')) -join "`n" + if ($actual -ne $expected) { + throw "Drift gate failed.`n Expected: $expected`n Actual: $actual" + } + } + finally { + $env:CI = $savedCI + Teardown-Harness + } +} + +# =========================================================================== +# Static / purity checks +# =========================================================================== + +# --------------------------------------------------------------------------- +# T_ascii_purity_ps: tui.ps1 contains no non-ASCII bytes (plan requirement) +# --------------------------------------------------------------------------- +Test-Scenario "T_ascii_purity_ps: tui.ps1 is pure ASCII (no byte > 127)" { + $bytes = [System.IO.File]::ReadAllBytes($TuiLib) + $nonAscii = @($bytes | Where-Object { $_ -gt 127 }) + if ($nonAscii.Count -gt 0) { + throw "Non-ASCII bytes found in tui.ps1 ($($nonAscii.Count) offsets)" + } +} + +# --------------------------------------------------------------------------- +# T_help_no_seam_ps: -Help output does NOT expose -SelectionFile +# --------------------------------------------------------------------------- +Test-Scenario "T_help_no_seam_ps: -Help does not expose -SelectionFile" { + $out = powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup -Help 2>&1 | Out-String + if ($out -match 'SelectionFile') { + throw "-Help output exposes -SelectionFile (must remain hidden)" + } +} + +# --------------------------------------------------------------------------- +# T_parse_tui_ps51: tui.ps1 parses without errors under PS 5.1 +# --------------------------------------------------------------------------- +Test-Scenario "T_parse_tui_ps51: tui.ps1 has no PS 5.1 parse errors" { + $tokens = $null; $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $TuiLib, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $msgs = ($errors | ForEach-Object { $_.Message }) -join '; ' + throw "Parse errors in tui.ps1: $msgs" + } +} + +# =========================================================================== +# Summary +# =========================================================================== + +Write-Host '' +$color = if ($TestsFailed -gt 0) { 'Red' } else { 'Green' } +Write-Host "Results: $TestsPassed passed, $TestsFailed failed, $TestsSkipped skipped." ` + -ForegroundColor $color +Write-Host '' +Write-Host 'Manual verification required (not CI-testable):' -ForegroundColor Yellow +Write-Host ' - Cancel (Esc/Q): exit 0, "Install cancelled." printed, nothing installed' +Write-Host ' - Arrow Up/Down navigation in Windows Terminal (PS 5.1 + pwsh)' +Write-Host ' - Arrow navigation in legacy conhost (cmd.exe host, PS 5.1)' +Write-Host ' - Opt-in tools shown unchecked below defaults with "(opt-in)" label' +Write-Host ' - Space toggles current item; A toggles all' +Write-Host ' - All defaults checked + Enter == no-arg non-interactive run result' +Write-Host ' - Uncheck a default, confirm => that tool skipped' +Write-Host ' - [Console]::ReadKey failure: warning to stderr ("Interactive menu failed..."), exit 0 (no install)' +Write-Host '' + +if ($TestsFailed -gt 0) { exit 1 }