Files

223 lines
15 KiB
PowerShell

#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
Import-Module (Join-Path $PSScriptRoot '../src/Wind.Apps.psm1') -Force
Import-Module (Join-Path $PSScriptRoot '../src/Wind.Tui.psm1') -Force
Import-Module (Join-Path $PSScriptRoot '../src/MsPrivate.Presets.psm1') -Force
$script:passed=0
function Assert-True { param([bool]$Condition,[string]$Message) if (-not $Condition) { throw $Message } }
function Assert-Throws { param([scriptblock]$Action) $threw=$false; try { & $Action | Out-Null } catch { $threw=$true }; Assert-True $threw 'Expected an exception.' }
function Run-Test { param([string]$Name,[scriptblock]$Action) & $Action; $script:passed++; Write-Output "PASS $Name" }
function New-App {
param([string]$Name='SpotifyAB.SpotifyMusic',[string]$Kind='Appx',[string]$Id='app-1')
[pscustomobject]@{Id=$Id;Name=$Name;Version='1.0';Publisher='Example';Kind=$Kind;Scope='user';Target=$Id;Protected=$false}
}
function New-Harness {
param([object[]]$Apps=@((New-App)))
$state=[pscustomobject]@{Apps=$Apps;Saves=0;Saved=$null;FailSaveAt=0;Browser=$false;InstallCode=0;RemoveCode=0;RetainApp=$false;Events=@()}
$backend=@{
Inventory={@($state.Apps)}.GetNewClosure()
BrowserPresent={param($b) $state.Events+='check-browser'; $state.Browser}.GetNewClosure()
InstallBrowser={param($b) $state.Events+='install-browser'; if ($state.InstallCode -eq 0) {$state.Browser=$true}; $state.InstallCode}.GetNewClosure()
Remove={param($a) $state.Events+='remove-'+$a.Id; if ($state.RemoveCode -eq 0 -and -not $state.RetainApp) {$state.Apps=@($state.Apps | Where-Object Id -ne $a.Id)}; $state.RemoveCode}.GetNewClosure()
}
$save={param($r) $state.Saves++; if ($state.Saves -eq $state.FailSaveAt) {throw 'Storage failure'}; $state.Saved=$r | ConvertTo-Json -Depth 8 | ConvertFrom-Json}.GetNewClosure()
[pscustomobject]@{State=$state;Backend=$backend;Save=$save;Plan=@(Get-WindAppPlan -Inventory $Apps)}
}
function Run-Changes {param($h) Invoke-WindAppChanges -Plan $h.Plan -Backend $h.Backend -Save $h.Save -Confirm:$false}
Run-Test 'Default selects curated apps, OneDrive and Edge; keeps personal apps' {
$apps=@((New-App),(New-App 'PersonalEditor' 'Desktop' 'editor'),(New-App 'Microsoft OneDrive' 'Desktop' 'onedrive'),(New-App 'Microsoft Edge' 'Desktop' 'edge'))
$plan=@(Get-WindAppPlan -Inventory $apps)
Assert-True (@($plan | Where-Object Status -eq 'Remove').Count -eq 3) 'Wrong defaults.'
Assert-True (($plan | Where-Object Id -eq 'editor').Status -eq 'Keep') 'Personal app removed by default.'
}
Run-Test 'Personal app opt-in and bundled exclusions' {
$apps=@((New-App),(New-App 'PersonalEditor' 'Desktop' 'editor'))
$plan=@(Get-WindAppPlan -Inventory $apps -ExtraId editor -ExcludeId app-1)
Assert-True (($plan | Where-Object Id -eq 'editor').Status -eq 'Remove') 'Personal selection ignored.'
Assert-True (($plan | Where-Object Id -eq 'app-1').Status -eq 'Keep') 'Exclusion ignored.'
}
Run-Test 'Aggressive presets select removable personal apps and Store' {
$store=New-App 'Microsoft.WindowsStore' 'Appx' 'store';$store.Protected=$true
$apps=@((New-App 'PersonalEditor' 'Desktop' 'editor'),(New-App 'Microsoft.WindowsNotepad' 'Appx' 'notepad'),$store,(New-App 'Bitdefender' 'Desktop' 'security'),(New-App 'Microsoft Edge WebView2 Runtime' 'Desktop' 'webview'))
$plan=@(Get-WindAppPlan -Inventory $apps -RemovePersonal -RemoveStore)
Assert-True (($plan|Where-Object {$_.Id -eq 'editor'}).Status-eq'Remove') 'Aggressive plan kept a removable personal app.'
Assert-True (($plan|Where-Object {$_.Id -eq 'store'}).Status-eq'Remove') 'Explicit Store removal was ignored.'
Assert-True (($plan|Where-Object {$_.Id -eq 'notepad'}).Status-eq'Remove') 'Removable Microsoft.Windows app was overprotected.'
Assert-True (@($plan|Where-Object{$_.Id-in@('security','webview')-and$_.Status-ne'Keep'}).Count-eq0) 'Aggressive plan selected a protected dependency or security product.'
}
Run-Test 'Aggressive presets skip ambiguous desktop registrations without blocking the run' {
$apps=@(New-App 'Duplicate Tool' 'Desktop' 'dup-a';New-App 'Duplicate Tool' 'Desktop' 'dup-b')
$plan=@(Get-WindAppPlan -Inventory $apps -RemovePersonal)
Assert-True (@($plan|Where-Object {$_.Status -ne 'Keep'}).Count-eq0) 'An ambiguous automatic selection was left actionable.'
Assert-True (@($plan|Where-Object {$_.Protected -and $_.Reason -like 'Skipped:*'}).Count-eq2) 'Ambiguous entries were not reported as protected skips.'
}
Run-Test 'Fresh Install and aggressive presets carry the complete destructive selection' {
$catalog=Get-MsPrivatePresetCatalog
foreach($id in @('FreshInstall','Medium','Strong','Maximum')){$p=Get-MsPrivatePreset $id;Assert-True ($p.RemovePersonal-and$p.RemoveStore-and$p.RemoveOneDrive-and$p.RemoveEdge-and$p.Recall-and$p.Accounts-and$p.Services) "Incomplete aggressive preset: $id"}
Assert-True ((Get-MsPrivatePreset 'Maximum').Network-eq'Isolate') 'Maximum preset lacks timed network isolation.'
Assert-True (@($catalog.Presets).Count-eq6) 'Unexpected preset count.'
$safe=Get-MsPrivatePreset 'EstablishedSafe'
Assert-True (-not$safe.Services-and-not$safe.Accounts-and-not$safe.RemoveBundled-and-not$safe.RemovePersonal-and-not$safe.RemoveStore-and-not$safe.RemoveOneDrive-and-not$safe.RemoveEdge-and-not$safe.Recall-and$safe.Network-eq'Observe'-and-not@($safe.Browsers).Count) 'EstablishedSafe selected a destructive or installation action.'
}
Run-Test 'Protected dependencies cannot be opted into removal' {
foreach ($name in @('Microsoft Edge WebView2 Runtime','Microsoft.DesktopAppInstaller','Microsoft.WindowsStore','Microsoft.VCLibs.140.00','Bitdefender','Microsoft.GamingServices','NVIDIA Display Driver','Mullvad VPN','VeraCrypt')) {
$app=New-App $name
Assert-True ((Get-WindAppPlan -Inventory @($app) -ExtraId $app.Id).Status -eq 'Keep') "Protected app selectable: $name"
}
}
Run-Test 'OS nonremovable metadata overrides curated selection' {
$app=New-App; $app.Protected=$true
Assert-True ((Get-WindAppPlan -Inventory @($app)).Status -eq 'Keep') 'OS protection ignored.'
}
Run-Test 'Stale selection fails instead of broadening removal' {
Assert-Throws {Get-WindAppPlan -Inventory @((New-App)) -ExtraId missing}
}
Run-Test 'Ambiguous desktop registrations block execution' {
$h=New-Harness @((New-App 'Microsoft OneDrive' 'Desktop' 'a'),(New-App 'Microsoft OneDrive' 'Desktop' 'b'))
Assert-Throws {Run-Changes $h}
Assert-True ($h.State.Events.Count -eq 0) 'Blocked plan executed.'
}
Run-Test 'WhatIf does not call backend or write reports' {
$h=New-Harness
$result=Invoke-WindAppChanges -Plan $h.Plan -Backend $h.Backend -Save $h.Save -WhatIf
Assert-True ($result.Status -eq 'Preview' -and $h.State.Saves -eq 0 -and $h.State.Events.Count -eq 0) 'WhatIf touched backend.'
}
Run-Test 'Browser installed and verified before app removal' {
$h=New-Harness; $r=Run-Changes $h
Assert-True (($h.State.Events -join ',') -eq 'check-browser,install-browser,check-browser,remove-app-1') 'Unsafe operation order.'
Assert-True ($r.Status -eq 'Completed' -and $r.Entries[-1].Status -eq 'Removed') 'Removal not verified.'
}
Run-Test 'Failed browser install prevents every removal' {
$h=New-Harness; $h.State.InstallCode=1603
Assert-Throws {Run-Changes $h}
Assert-True (@($h.State.Events | Where-Object {$_ -like 'remove-*'}).Count -eq 0) 'Browser failure removed app.'
Assert-True ($h.State.Saved.Status -eq 'Incomplete') 'False completion.'
}
Run-Test 'Browser verification failure prevents removal' {
$h=New-Harness; $h.Backend.BrowserPresent={param($b) $false}
Assert-Throws {Run-Changes $h}
Assert-True ($h.State.Apps.Count -eq 1) 'Unverified browser allowed removal.'
}
Run-Test 'Re-running after removal is idempotent' {
$h=New-Harness; Run-Changes $h | Out-Null; $r=Run-Changes $h
Assert-True ($r.Entries[-1].Status -eq 'AlreadyAbsent') 'Repeated removal attempted.'
Assert-True (@($h.State.Events | Where-Object {$_ -like 'remove-*'}).Count -eq 1) 'Duplicate uninstall.'
}
Run-Test 'App update between preview and apply stops removal' {
$h=New-Harness; $h.State.Apps[0].Version='2.0'
Assert-Throws {Run-Changes $h}
Assert-True ($h.State.Apps.Count -eq 1 -and $h.State.Saved.Entries[-1].Status -eq 'Conflict') 'Stale app removed.'
}
Run-Test 'Uninstaller nonzero and reboot code stop later operations' {
foreach ($code in @(1603,3010,-1)) {
$h=New-Harness @((New-App),(New-App 'Microsoft OneDrive' 'Desktop' 'b')); $h.State.RemoveCode=$code
Assert-Throws {Run-Changes $h}
Assert-True (@($h.State.Events | Where-Object {$_ -like 'remove-*'}).Count -eq 1) 'Continued after uncertain uninstall.'
Assert-True ($h.State.Saved.Entries[-1].Status -eq 'Failed') 'False success.'
}
}
Run-Test 'Uninstaller success without disappearance is unverified' {
$h=New-Harness; $h.State.RetainApp=$true
Assert-Throws {Run-Changes $h}
Assert-True ($h.State.Saved.Entries[-1].Status -eq 'Unverified') 'Trusted exit code without readback.'
}
Run-Test 'Edge runs last and requires selected browser' {
$h=New-Harness @((New-App 'Microsoft Edge' 'Desktop' 'edge'),(New-App 'Microsoft OneDrive' 'Desktop' 'onedrive'))
Assert-Throws {Invoke-WindAppChanges -Plan $h.Plan -Browser @() -Backend $h.Backend -Save $h.Save}
$r=Run-Changes $h
Assert-True ($r.Entries[-1].Name -eq 'Microsoft Edge') 'Edge did not run last.'
}
Run-Test 'Write-ahead report failure prevents app removal' {
$h=New-Harness; $h.State.Browser=$true; $h.State.FailSaveAt=5
Assert-Throws {Run-Changes $h}
Assert-True ($h.State.Apps.Count -eq 1) 'Removal ran without saved intent.'
}
Run-Test 'Forged protected plan is rejected before any action' {
$h=New-Harness @((New-App 'Microsoft Edge WebView2 Runtime' 'Desktop'))
$h.Plan[0].Status='Remove'; $h.Plan[0].Protected=$false
Assert-Throws {Run-Changes $h}
Assert-True ($h.State.Events.Count -eq 0) 'Forged plan executed.'
}
Run-Test 'Arrow navigation wraps and Space toggles independently of Enter' {
$items=@((New-WindMenuItem 'zen' 'Zen'),(New-WindMenuItem 'tor' 'Tor'))
$s=New-WindMenuState @('zen')
Update-WindMenuState $s $items 'UpArrow' -Multiple
Assert-True ($s.Cursor -eq 1) 'Up did not wrap.'
Update-WindMenuState $s $items 'Spacebar' -Multiple
Assert-True ($s.Checked.Count -eq 2 -and $s.Action -eq '') 'Space accepted prematurely.'
Update-WindMenuState $s $items 'Enter' -Multiple
Assert-True ($s.Action -eq 'Accept') 'Enter did not accept.'
Update-WindMenuState $s $items 'Escape' -Multiple
Assert-True ($s.Action -eq 'Cancel') 'Escape did not cancel.'
}
Run-Test 'Filtering preserves checked apps and supports empty results' {
$items=@((New-WindMenuItem 'zen' 'Zen'),(New-WindMenuItem 'tor' 'Tor'))
$s=New-WindMenuState @('zen')
Update-WindMenuState $s $items 'T' ([char]'t') -Multiple -Searchable
Assert-True (@(Get-WindVisibleItems $items $s).Count -eq 1) 'Filter did not narrow list.'
Update-WindMenuState $s $items 'Spacebar' -Multiple
Assert-True ($s.Checked.Count -eq 2) 'Filter lost prior selections.'
Update-WindMenuState $s $items 'X' ([char]'x') -Searchable
Update-WindMenuState $s $items 'Enter'
Assert-True ($s.Action -eq '') 'Empty result accepted.'
Update-WindMenuState $s $items 'Backspace' -Searchable
Assert-True ($s.Filter -eq 't') 'Backspace failed.'
}
Run-Test 'Protected display rows never toggle or activate' {
$items=@(New-WindMenuItem 'protected' 'System component' '' $true);$s=New-WindMenuState
Update-WindMenuState $s $items 'Spacebar' -Multiple
Update-WindMenuState $s $items 'Enter'
Assert-True ($s.Checked.Count -eq 0 -and $s.Action -eq '') 'Disabled item activated.'
}
Run-Test 'Frames fit small terminals and page long app lists' {
$items=@(1..80 | ForEach-Object {New-WindMenuItem "app-$_" ('Long application name '+('x'*80)) ('Detailed information '*20)})
$s=New-WindMenuState; $s.Cursor=79
foreach ($width in @(40,80,120)) {
$frame=@(Get-WindMenuFrame -Title 'Apps' -Subtitle 'Select' -Items $items -State $s -Width $width -Height 24 -Multiple -Searchable)
Assert-True ($frame.Count -lt 24 -and @($frame | Where-Object {$_.Text.Length -ge $width}).Count -eq 0) 'Frame overflow.'
Assert-True (@($frame | Where-Object Active).Count -eq 1) 'Focus lost off-page.'
}
}
Run-Test 'Empty inventory can still install selected browsers' {
$h=New-Harness @();$r=Run-Changes $h
Assert-True ($r.Status -eq 'Completed' -and $h.State.Browser) 'Empty inventory broke install.'
}
Run-Test 'JSON catalogs flatten correctly in Windows PowerShell 5.1' {
$browsers=@(Get-WindBrowserCatalog)
Assert-True ($browsers.Count -eq 5 -and @($browsers | Where-Object Name -eq 'Zen').Count -eq 1) 'Nested catalog array.'
}
Run-Test 'Bundle parents suppress duplicate child app rows' {
$packages=@([pscustomobject]@{PackageFamilyName='family';IsBundle=$true;Name='parent'},[pscustomobject]@{PackageFamilyName='family';IsBundle=$false;Name='child'},[pscustomobject]@{PackageFamilyName='other';IsBundle=$false;Name='single'})
$parents=@(Select-WindAppxParents $packages)
Assert-True ($parents.Count -eq 2 -and $parents.Name -notcontains 'child') 'Bundle child duplicated.'
}
Run-Test 'Journal callback resolves inside nested module scopes' {
$root=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$path=Join-Path $root ('reports/scope-'+[guid]::NewGuid().ToString('N')+'.json')
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($path)) | Out-Null
$nested=New-Module -ScriptBlock {}
try {
$h=New-Harness
& $nested {
param($Root,$Path,$Harness)
Import-Module (Join-Path $Root 'src/Wind.Core.psm1')
Import-Module (Join-Path $Root 'src/Wind.Apps.psm1')
$writer=Get-Command Save-WindJournal -CommandType Function
$save={param($report) & $writer -Journal $report -Path $Path}.GetNewClosure()
Invoke-WindAppChanges -Plan $Harness.Plan -Backend $Harness.Backend -Save $save -Confirm:$false | Out-Null
} $root $path $h
Assert-True ((Get-Content -LiteralPath $path -Raw | ConvertFrom-Json).Status -eq 'Completed') 'Nested journal writer failed.'
} finally {if ([IO.File]::Exists($path)) {[IO.File]::Delete($path)};Remove-Module $nested -ErrorAction SilentlyContinue}
}
Run-Test 'Group changes override prior group checkboxes while preserving personal choices' {
$inventory=@((New-App),(New-App 'PersonalEditor' 'Desktop' 'editor'))
$options=@{ExtraId=@('app-1','editor');ExcludeId=@();SkipBundled=$false;KeepOneDrive=$false;KeepEdge=$false}
Set-WindRemovalGroups -Options $options -Inventory $inventory -Bundled $false -OneDrive $true -Edge $true
$plan=@(Get-WindAppPlan -Inventory $inventory @options)
Assert-True (($plan | Where-Object Id -eq 'app-1').Status -eq 'Keep') 'Group removal toggle ignored.'
Assert-True (($plan | Where-Object Id -eq 'editor').Status -eq 'Remove') 'Personal choice lost.'
}
Write-Output "$script:passed app/menu tests passed. Only synthetic inventories and backends were used."