windowsweep 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,418 @@
1
+ # self_test_1_4b.ps1 - self-test groups [22]-[23] for 1.4.0: uninstall programs (section 26) and the leftover
2
+ # finder (sections 26 and 27). Fixtures and pure logic only; nothing is uninstalled or recycled. ASCII only.
3
+
4
+ function New-ProgramRow {
5
+ <# .SYNOPSIS An uninstall-hive row shaped like Get-InstalledProgramRows returns, for the fixtures below. #>
6
+ param([string] $Name, [string] $Un = '', [string] $Quiet = '', [int] $Msi = 0, [string] $KeyName = '', [string] $Publisher = '',
7
+ [string] $Location = '', [int] $System = 0, [string] $Parent = '')
8
+ return [pscustomobject]@{ DisplayName = $Name; DisplayVersion = ''; Publisher = $Publisher; InstallDate = ''; EstimatedSizeKb = [long]0
9
+ InstallLocation = $Location; UninstallString = $Un; SystemComponent = $System; KeyName = $KeyName; Machine = $false
10
+ KeyPath = "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall\$KeyName"; QuietUninstallString = $Quiet
11
+ WindowsInstaller = $Msi; ParentKeyName = $Parent; ReleaseType = '' }
12
+ }
13
+
14
+ function New-ClaimsFixture {
15
+ param([string[]] $Tokens = @(), [string[]] $Paths = @(), [int] $RegistryCount = 5)
16
+ $set = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::Ordinal)
17
+ foreach ($t in $Tokens) { $null = $set.Add($t) }
18
+ return [pscustomobject]@{ Tokens = $set; RegistryCount = $RegistryCount; Rows = @(); Paths = @($Paths | ForEach-Object { Get-FullPath $_ }) }
19
+ }
20
+
21
+ function Invoke-SelfTest22 {
22
+ <# .SYNOPSIS Group [22]: section 26's command builder, offer rules and leftover pass. #>
23
+ param([string] $Fixture)
24
+ $checks = 0; $fails = 0
25
+ $ws = $Script:WS
26
+ $root = Join-Path $Fixture 'g22'
27
+ $saved = @{ Queue = $ws.SelectQueue; Paths = $ws.SelectPaths; Interactive = $ws.Interactive; DryRun = $ws.DryRun }
28
+ try {
29
+ Write-Section '[22] 1.4.0: uninstall programs (section 26) and the folders they leave'
30
+ # 22a - command lines exactly as the P52's uninstall entries wrote them (2026-11-02)
31
+ $checks++
32
+ $cases = @(
33
+ @('C:\Program Files (x86)\Steam\uninstall.exe', 'C:\Program Files (x86)\Steam\uninstall.exe', ''),
34
+ @('"C:\Users\x\AppData\Local\Programs\DockerDesktop\Docker Desktop Installer.exe" "uninstall"', 'C:\Users\x\AppData\Local\Programs\DockerDesktop\Docker Desktop Installer.exe', '"uninstall"'),
35
+ @('MsiExec.exe /X{4B508632-2838-4F42-84EB-ED3153633E0A}', 'MsiExec.exe', '/X{4B508632-2838-4F42-84EB-ED3153633E0A}'),
36
+ @('C:\Program Files\Vendor Name\drv.sys', 'C:\Program Files\Vendor Name\drv.sys', ''),
37
+ @('C:\Program Files\Some.Comet\app.exe /quiet', 'C:\Program Files\Some.Comet\app.exe', '/quiet')
38
+ )
39
+ $bad = @()
40
+ foreach ($c in $cases) { $s = Split-CommandLine $c[0]; if ($s.FilePath -ne $c[1] -or $s.Arguments -ne $c[2]) { $bad += "[$($c[0])] -> [$($s.FilePath)] [$($s.Arguments)]" } }
41
+ if ($bad.Count -eq 0) { Write-Ok 'command lines split at the program - unquoted spaces, quotes and dotted folders included' } else { Write-Err "command lines split wrong: $($bad -join '; ')"; $fails++ }
42
+
43
+ # 22b - every registered form maps to the silent command that worked on the P52
44
+ $checks++
45
+ $icons = Join-Path $root 'icons'
46
+ New-Item -ItemType Directory -Force -Path (Join-Path $icons 'PHSP_24.0') | Out-Null
47
+ $hd = 'C:\Program Files (x86)\Common Files\Adobe\Adobe Desktop Common\HDBox'
48
+ $brave = 'C:\Program Files\BraveSoftware\Brave-Browser\Application\156.1.98.63\Installer\setup.exe'
49
+ $copilot = 'C:\Program Files (x86)\Microsoft\Copilot\Application\156.0.4314.58\Installer\copilot_setup.exe'
50
+ $docker = 'C:\Users\x\AppData\Local\Programs\DockerDesktop\Docker Desktop Installer.exe'
51
+ $forms = @(
52
+ @((New-ProgramRow 'CMake' -Un 'MsiExec.exe /X{4B508632-2838-4F42-84EB-ED3153633E0A}' -Msi 1), 'msi', 'msiexec.exe', '/x {4B508632-2838-4F42-84EB-ED3153633E0A} /qn /norestart'),
53
+ @((New-ProgramRow 'VMware Workstation' -Msi 1 -KeyName '{6EC7759F-B700-4743-BB65-BD3ED905D545}'), 'msi', 'msiexec.exe', '/x {6EC7759F-B700-4743-BB65-BD3ED905D545} /qn /norestart'),
54
+ @((New-ProgramRow 'Ollama' -Un '"C:\Users\x\AppData\Local\Programs\Ollama\unins000.exe"'), 'inno', 'C:\Users\x\AppData\Local\Programs\Ollama\unins000.exe', '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART'),
55
+ @((New-ProgramRow 'Steam' -Un 'C:\Program Files (x86)\Steam\uninstall.exe'), 'nsis', 'C:\Program Files (x86)\Steam\uninstall.exe', '/S'),
56
+ @((New-ProgramRow 'Brave' -Un "`"$brave`" --uninstall --system-level"), 'chromium', $brave, '--uninstall --system-level --force-uninstall'),
57
+ @((New-ProgramRow 'Copilot' -Un "`"$copilot`" --uninstall --system-level"), 'chromium', $copilot, '--uninstall --system-level --force-uninstall'),
58
+ @((New-ProgramRow 'Docker Desktop' -Un "`"$docker`" `"uninstall`""), 'quiet-flag', $docker, 'uninstall --quiet'),
59
+ @((New-ProgramRow 'Vulkan SDK' -Un '"C:\VulkanSDK\1.4.363.0\maintenancetool.exe" --start-uninstaller'), 'qt-installer', 'C:\VulkanSDK\1.4.363.0\maintenancetool.exe', 'purge --confirm-command'),
60
+ @((New-ProgramRow 'Adobe Photoshop 2023' -Un "`"$hd\Uninstaller.exe`" --uninstall=1 --sapCode=PHSP --productVersion=24.1 --productPlatform=win64"), 'adobe', "$hd\Setup.exe", '--uninstall=1 --sapCode=PHSP --baseVersion=24.0 --platform=win64 --deleteUserPreferences=false'),
61
+ @((New-ProgramRow 'Quiet Tool' -Un '"C:\Q\uninst.exe"' -Quiet '"C:\Q\uninst.exe" /quiet'), 'quiet', 'C:\Q\uninst.exe', '/quiet'),
62
+ @((New-ProgramRow 'jq' -Un 'winget uninstall --product-code jqlang.jq_Microsoft.Winget.Source_8wekyb3d8bbwe'), 'winget', 'winget', 'uninstall --product-code jqlang.jq_Microsoft.Winget.Source_8wekyb3d8bbwe --silent --disable-interactivity --accept-source-agreements'),
63
+ @((New-ProgramRow 'Rustup' -Un '"C:\Users\x\.cargo\bin\rustup.exe" self uninstall'), 'rustup', 'C:\Users\x\.cargo\bin\rustup.exe', 'self uninstall -y'),
64
+ @((New-ProgramRow 'Bundle' -Un '"C:\ProgramData\Package Cache\{91d90ae4-6607-4b46-968a-8def759dfadf}\Setup.exe" /uninstall'), 'wix-bundle', 'C:\ProgramData\Package Cache\{91d90ae4-6607-4b46-968a-8def759dfadf}\Setup.exe', '/uninstall /quiet /norestart'),
65
+ @((New-ProgramRow 'Python 3.14.3' -Un '"C:\Users\x\pymanager.exe" uninstall --yes --by-id "pythoncore-3.14-64"'), 'registered-silent', 'C:\Users\x\pymanager.exe', 'uninstall --yes --by-id "pythoncore-3.14-64"'),
66
+ @((New-ProgramRow 'Odd Tool' -Un '"C:\O\remove-me.exe" -x'), 'vendor-ui', 'C:\O\remove-me.exe', '-x'),
67
+ @((New-ProgramRow 'Ghost'), 'none', '', '')
68
+ )
69
+ $bad = @()
70
+ foreach ($f in $forms) {
71
+ $cmd = Get-UninstallCommand -Row $f[0] -AdobeIconsRoot $icons
72
+ if ($cmd.Strategy -ne $f[1] -or $cmd.FilePath -ne $f[2] -or $cmd.Arguments -ne $f[3]) { $bad += "$($f[0].DisplayName): [$($cmd.Strategy)] [$($cmd.FilePath)] [$($cmd.Arguments)]" }
73
+ }
74
+ $okCodes = ((Get-UninstallCommand -Row $forms[4][0]).SuccessCodes -contains 19) -and ((Get-UninstallCommand -Row $forms[0][0]).SuccessCodes -contains 3010)
75
+ if ($bad.Count -eq 0 -and $okCodes) { Write-Ok "uninstall commands: $($forms.Count) registered forms give their silent command (MSI, Inno, NSIS, Chromium, Docker, Qt, Adobe, quiet) or the vendor's window" } else { Write-Err "uninstall commands wrong: $($bad -join '; ') codesOk=$okCodes"; $fails++ }
76
+
77
+ # 22c - what is never offered
78
+ $checks++
79
+ $offerRules = @(
80
+ (Test-UninstallOffered (New-ProgramRow 'Steam' -Un 'C:\S\uninstall.exe')),
81
+ (-not (Test-UninstallOffered (New-ProgramRow 'Microsoft Visual C++ 2015-2022 Redistributable (x64) - 14.40' -Un 'C:\v\x.exe'))),
82
+ (-not (Test-UninstallOffered (New-ProgramRow 'NVIDIA Graphics Driver 566.14' -Un 'C:\n\x.exe'))),
83
+ (-not (Test-UninstallOffered (New-ProgramRow "Intel$([char]0x00AE) PROSet/Wireless Software" -Un 'C:\i\x.exe'))),
84
+ (-not (Test-UninstallOffered (New-ProgramRow 'Some Component' -Un 'C:\c\x.exe' -System 1))),
85
+ (-not (Test-UninstallOffered (New-ProgramRow 'Office Patch' -Un 'C:\p\x.exe' -Parent 'Office12'))),
86
+ (-not (Test-UninstallOffered (New-ProgramRow 'windowsweep' -Un 'C:\w\x.exe'))),
87
+ (-not (Test-UninstallOffered (New-ProgramRow 'No Command')))
88
+ )
89
+ if (@($offerRules | Where-Object { -not $_ }).Count -eq 0) { Write-Ok 'runtimes, drivers, Windows parts, patches, this tool and entries without an uninstaller are never offered' } else { Write-Err "offer rules wrong: [$($offerRules -join ',')]"; $fails++ }
90
+
91
+ # 22d - leftovers: the install folder and folders named like the program or its publisher; claimed ones stay
92
+ $checks++
93
+ $pf = Join-Path $root 'pf'
94
+ foreach ($d in 'Steam', 'SteamLibrary', 'Valve', 'ContosoData', 'SteamService', 'Internet Explorer', 'Notepad++') { New-Item -ItemType Directory -Force -Path (Join-Path $pf $d) | Out-Null }
95
+ $svc = Join-Path $pf 'SteamService\svc.exe'
96
+ [IO.File]::WriteAllText($svc, 'x')
97
+ $claims = New-ClaimsFixture -Tokens @('contosodata') -Paths @($svc)
98
+ $steam = New-ProgramRow 'Steam' -Un 'C:\S\uninstall.exe' -Publisher 'Valve Corporation' -Location (Join-Path $pf 'Steam')
99
+ $contoso = New-ProgramRow 'Contoso Viewer' -Un 'C:\C\uninstall.exe' -Publisher 'Contoso Ltd'
100
+ $found = @(Find-ProgramLeftovers -Programs @($steam, $contoso) -Roots @($pf) -Claims $claims)
101
+ $offer = @($found | Where-Object { -not $_.Reason } | ForEach-Object { Split-Path -Leaf $_.Path } | Sort-Object)
102
+ $kept = @($found | Where-Object { $_.Reason } | ForEach-Object { Split-Path -Leaf $_.Path } | Sort-Object)
103
+ if (($offer -join ',') -eq 'Steam,SteamLibrary,Valve' -and ($kept -join ',') -eq 'ContosoData,SteamService') { Write-Ok 'leftovers: install folder and folders named like the program or publisher; a claimed name or a path still in use is only reported' } else { Write-Err "leftovers wrong: offer=[$($offer -join ',')] kept=[$($kept -join ',')]"; $fails++ }
104
+
105
+ # 22e - an install folder another program still uses, or a protected root, is never offered
106
+ $checks++
107
+ $shared = Join-Path $root 'shared'
108
+ New-Item -ItemType Directory -Force -Path (Join-Path $shared 'Other') | Out-Null
109
+ $other = Join-Path $shared 'Other\app.exe'
110
+ [IO.File]::WriteAllText($other, 'x')
111
+ $progs = @((New-ProgramRow 'Shared Thing' -Un 'C:\x\uninstall.exe' -Location $shared), (New-ProgramRow 'Rooted Thing' -Un 'C:\y\uninstall.exe' -Location $Script:P.PF))
112
+ $g = @(Find-ProgramLeftovers -Programs $progs -Roots @((Join-Path $root 'none')) -Claims (New-ClaimsFixture -Paths @($other)))
113
+ $pfRow = @($g | Where-Object { $_.Path -eq (Get-FullPath $Script:P.PF) })
114
+ if ($g.Count -eq 2 -and @($g | Where-Object { -not $_.Reason }).Count -eq 0 -and $pfRow.Count -eq 1 -and $pfRow[0].Reason -like 'protected root*') { Write-Ok 'an install folder still in use, or registered as a protected root, is reported and never offered' } else { Write-Err "install-folder guard wrong: $(@($g | ForEach-Object { "$($_.Path)=$($_.Reason)" }) -join '; ')"; $fails++ }
115
+
116
+ # 22f - an unreadable registry offers nothing
117
+ $checks++
118
+ $fc = @(Find-ProgramLeftovers -Programs @($steam) -Roots @($pf) -Claims (New-ClaimsFixture -RegistryCount 0))
119
+ if ($fc.Count -eq 0) { Write-Ok 'no readable uninstall entries: no leftover is offered (fail closed)' } else { Write-Err "fail-closed gate broken: $($fc.Count) row(s)"; $fails++ }
120
+
121
+ # 22g - the second prompt never takes a queued --select meant for a later prompt; section 27's does
122
+ $checks++
123
+ $ws.Interactive = $false; $ws.DryRun = $true; $ws.SelectPaths = @(); $ws.SelectQueue = @('1')
124
+ $row = [pscustomobject]@{ Path = (Get-FullPath (Join-Path $pf 'Valve')); Root = (Get-FullPath $pf); Program = 'Steam'; Reason = $null; Idle = -1; Bytes = [long]0 }
125
+ Invoke-LeftoverChoice -Rows @($row) -Section 26 -PathsOrConsoleOnly
126
+ $left26 = @($ws.SelectQueue).Count; $scripted26 = [bool]$ws.LastSelectionScripted
127
+ Invoke-LeftoverChoice -Rows @($row) -Section 27
128
+ $left27 = @($ws.SelectQueue).Count; $scripted27 = [bool]$ws.LastSelectionScripted
129
+ if ($left26 -eq 1 -and -not $scripted26 -and $left27 -eq 0 -and $scripted27 -and (Test-Path -LiteralPath $row.Path)) { Write-Ok "section 26's leftover prompt never picks from a queued --select; section 27's prompt does" } else { Write-Err "select queue handling wrong: 26 left=$left26 scripted=$scripted26; 27 left=$left27 scripted=$scripted27"; $fails++ }
130
+
131
+ # 22h - the catalogue contract: picked by a person only, never batched
132
+ $checks++
133
+ $s26 = Get-Section 26; $s27 = Get-Section 27
134
+ $batched = @($Script:WS_SAFE_BATCH + $Script:WS_SAFE_BATCH_ADMIN + @($Script:WS_PROFILES.Values | ForEach-Object { $_ }))
135
+ $walk = @($Script:WS_WALKTHROUGH + $Script:WS_WALKTHROUGH_ADMIN)
136
+ $ok = ($s26.Batch -eq 'interactive' -and -not $s26.Admin -and $s26.Tier -eq 'permanent' -and $s27.Batch -eq 'interactive' -and $s27.Admin -and
137
+ $s27.Tier -eq 'recycle' -and $batched -notcontains 26 -and $batched -notcontains 27 -and $walk -notcontains 26 -and $Script:WS_WALKTHROUGH_ADMIN -contains 27)
138
+ if ($ok) { Write-Ok 'sections 26 and 27 are interactive and in no batch or profile; 27 joins the elevated walkthrough' } else { Write-Err 'catalogue contract for 26/27 wrong'; $fails++ }
139
+ } catch {
140
+ Write-Err "group [22] crashed: $($_.Exception.Message) | at $(($_.ScriptStackTrace -split "`n" | Select-Object -First 3) -join ' <- ')"; $fails++
141
+ } finally {
142
+ $ws.SelectQueue = $saved.Queue; $ws.SelectPaths = $saved.Paths; $ws.Interactive = $saved.Interactive; $ws.DryRun = $saved.DryRun
143
+ try { if (Test-Path -LiteralPath $root) { [IO.Directory]::Delete((Get-LongPath $root), $true) } } catch { $null = $_ }
144
+ }
145
+ return [pscustomobject]@{ Checks = $checks; Fails = $fails }
146
+ }
147
+
148
+ function Invoke-SelfTest26 {
149
+ <# .SYNOPSIS Elevated JSON handoff to a waiting desktop or CLI parent; no UAC needed. #>
150
+ param([Parameter(Mandatory)] [string] $Fixture)
151
+ $savedDir = $Script:WS.ReportsDir
152
+ $savedChild = $Script:WS.ElevatedChild
153
+ $savedToken = $Script:WS.ElevatedSummaryToken
154
+ $token = [guid]::NewGuid().ToString('N')
155
+ $path = Join-Path $Fixture ("elevated-summary-$token.json")
156
+ $checks = 1; $fails = 0
157
+ try {
158
+ $Script:WS.ReportsDir = $Fixture
159
+ $Script:WS.ElevatedChild = $true
160
+ $Script:WS.ElevatedSummaryToken = $token
161
+ $json = '{"candidates":[{"section":27,"path":"C:\\Fixture"}]}'
162
+ Save-ElevatedSummary -Json $json
163
+ $returned = [IO.File]::ReadAllText($path) | ConvertFrom-Json
164
+ Remove-Item -LiteralPath $path -Force
165
+ $Script:WS.ElevatedChild = $false
166
+ Save-ElevatedSummary -Json $json
167
+ if ($returned.candidates[0].section -eq 27 -and -not (Test-Path -LiteralPath $path)) {
168
+ Write-Ok 'elevated JSON reaches the parent file; ordinary runs write no handoff'
169
+ } else { Write-Err 'elevated JSON handoff or ordinary-run guard failed'; $fails++ }
170
+ } catch { Write-Err "elevated JSON handoff failed: $($_.Exception.Message)"; $fails++ }
171
+ finally {
172
+ Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
173
+ $Script:WS.ReportsDir = $savedDir
174
+ $Script:WS.ElevatedChild = $savedChild
175
+ $Script:WS.ElevatedSummaryToken = $savedToken
176
+ }
177
+ return [pscustomobject]@{ Checks = $checks; Fails = $fails }
178
+ }
179
+
180
+ function Invoke-SelfTest24 {
181
+ <# .SYNOPSIS Group [24]: the 1.4.0 extensions to sections 4, 7, 10 and 24. #>
182
+ param([string] $Fixture)
183
+ $checks = 0; $fails = 0
184
+ $root = Join-Path $Fixture 'g24'
185
+ $pr = $Script:WS_PROTECT
186
+ $savedEx = @($pr.Exceptions); $savedExP = @($pr.ExceptionPrefixes)
187
+ try {
188
+ Write-Section '[24] 1.4.0: emulator leftovers (4), declared profiles (7), declared temp roots (10), last run (24)'
189
+ # 24a - an AVD, its pointer, and a pointer to a folder that is gone (the P52's pointed into a missing drive)
190
+ $checks++
191
+ $avdRoot = Join-Path $root 'avd'
192
+ $pix = Join-Path $avdRoot 'Pixel.avd'
193
+ New-Item -ItemType Directory -Force -Path $pix | Out-Null
194
+ [IO.File]::WriteAllLines((Join-Path $pix 'config.ini'), [string[]]@('hw.cpu.arch=x86_64', 'image.sysdir.1=system-images\android-34\default\x86_64\'))
195
+ [IO.File]::WriteAllLines((Join-Path $avdRoot 'Pixel.ini'), [string[]]@('avd.ini.encoding=UTF-8', "path=$pix"))
196
+ [IO.File]::WriteAllLines((Join-Path $avdRoot 'Gone.ini'), [string[]]@('avd.ini.encoding=UTF-8', ('path=' + (Join-Path $root 'missing\Gone.avd'))))
197
+ $inv = Get-AvdInventory -Roots @($avdRoot)
198
+ if (@($inv.Avds).Count -eq 1 -and -not $inv.Avds[0].External -and @($inv.Dangling).Count -eq 1 -and (Split-Path -Leaf $inv.Dangling[0].Path) -eq 'Gone.ini') { Write-Ok 'AVDs: a pointer whose AVD folder is gone is found; the live pair is one AVD' } else { Write-Err "AVD inventory wrong: avds=$(@($inv.Avds).Count) dangling=[$(@($inv.Dangling | ForEach-Object { $_.Path }) -join ',')]"; $fails++ }
199
+
200
+ # 24b - images: one in use stays; with no AVD left everything and the emulator go; an AVD naming none keeps all
201
+ $checks++
202
+ $imgs = @(
203
+ [pscustomobject]@{ Package = 'system-images;android-34;default;x86_64'; Path = 'x'; Rel = 'system-images\android-34\default\x86_64' },
204
+ [pscustomobject]@{ Package = 'system-images;android-36.1;google_apis_playstore;x86_64'; Path = 'y'; Rel = 'system-images\android-36.1\google_apis_playstore\x86_64' }
205
+ )
206
+ $one = @((Get-UnusedSdkPackages -Images $imgs -Avds @($inv.Avds) -EmulatorPresent $true).Packages | ForEach-Object { $_.Package })
207
+ $none = @((Get-UnusedSdkPackages -Images $imgs -Avds @() -EmulatorPresent $true).Packages | ForEach-Object { $_.Package })
208
+ $blank = Join-Path $root 'blank.avd'
209
+ New-Item -ItemType Directory -Force -Path $blank | Out-Null
210
+ $odd = Get-UnusedSdkPackages -Images $imgs -Avds @([pscustomobject]@{ Name = 'blank.avd'; Path = $blank }) -EmulatorPresent $true
211
+ if (($one -join ',') -eq 'system-images;android-36.1;google_apis_playstore;x86_64' -and $none.Count -eq 3 -and $none -contains 'emulator' -and @($odd.Packages).Count -eq 0 -and $odd.Reason) { Write-Ok 'SDK packages: only images no AVD uses; the emulator once none is left; an unreadable AVD keeps every image' } else { Write-Err "SDK package plan wrong: one=[$($one -join ',')] none=[$($none -join ',')] odd=$(@($odd.Packages).Count)"; $fails++ }
212
+
213
+ # 24c - a declared profile root: only its cache folders become exceptions (D79), never the profile or its data
214
+ $checks++
215
+ $prof = Join-Path $root 'profiles\run1'
216
+ foreach ($d in 'Default\Cache', 'Default\Code Cache', 'Default\Local Storage', 'ShaderCache') { New-Item -ItemType Directory -Force -Path (Join-Path $prof $d) | Out-Null }
217
+ [IO.File]::WriteAllText((Join-Path $prof 'Local State'), '{}')
218
+ $dirs = @(Get-DeclaredProfileDirs -Roots @((Join-Path $root 'profiles')))
219
+ $added = Add-CacheLeafExceptions -Paths @(@(Get-ChromiumCacheDirs $prof) + $prof + (Join-Path $prof 'Default\Local Storage'))
220
+ $ex = @($pr.Exceptions)
221
+ if ($dirs.Count -eq 1 -and $added -eq 3 -and $ex -contains (Get-FullPath (Join-Path $prof 'Default\Cache')) -and $ex -notcontains (Get-FullPath $prof) -and $ex -notcontains (Get-FullPath (Join-Path $prof 'Default\Local Storage'))) { Write-Ok 'declared profiles: exactly the cache folders are lifted - the profile and its Local Storage stay protected' } else { Write-Err "profile-root exceptions wrong: dirs=$($dirs.Count) added=$added"; $fails++ }
222
+
223
+ # 24d - the three ways a browser command line names its profile
224
+ $checks++
225
+ $forms = @(
226
+ @('chrome.exe --user-data-dir=C:\t\sl-1 --headless', 'C:\t\sl-1'),
227
+ @('chrome.exe --user-data-dir="C:\a b\p" --x', 'C:\a b\p'),
228
+ @('"chrome.exe" "--user-data-dir=C:\a b\q" --x', 'C:\a b\q'),
229
+ @('chrome.exe --type=renderer', '')
230
+ )
231
+ $bad = @($forms | Where-Object { (Get-UserDataDirFromCommandLine $_[0]) -ne $_[1] } | ForEach-Object { $_[0] })
232
+ if ($bad.Count -eq 0) { Write-Ok '--user-data-dir is read bare, quoted and inside a quoted argument' } else { Write-Err "user-data-dir parsing wrong: $($bad -join '; ')"; $fails++ }
233
+
234
+ # 24e - a declared temp root: idle entries go whole, a fresh one stays, one a running program names stays
235
+ $checks++
236
+ $entries = @(
237
+ [pscustomobject]@{ Path = 'C:\t\sl-old'; Idle = 10 }, [pscustomobject]@{ Path = 'C:\t\old.txt'; Idle = 10 },
238
+ [pscustomobject]@{ Path = 'C:\t\sl-fresh'; Idle = 0 }, [pscustomobject]@{ Path = 'C:\t\sl-busy'; Idle = 10 }
239
+ )
240
+ $go = @(Get-TempRootPlan -Entries $entries -Days 3 -BusyText @('chrome.exe --user-data-dir=C:\t\sl-busy --headless', '') | ForEach-Object { $_.Path })
241
+ if (($go -join ',') -eq 'C:\t\sl-old,C:\t\old.txt') { Write-Ok 'declared temp roots: idle entries go whole; fresh ones and ones in use stay' } else { Write-Err "temp-root plan wrong: [$($go -join ',')]"; $fails++ }
242
+
243
+ # 24g - a scratch entry a scanner just read (fresh last-access, old writes) is still idle by write time
244
+ $checks++
245
+ $scan = New-AgedFiles -Dir (Join-Path $root 'scratch\sl-old') -Count 30 -WrittenDaysAgo 20 -FreshReads 20
246
+ $wo = Get-IdleDays $scan -WriteOnly
247
+ if ($wo -ge 19) { Write-Ok "declared temp roots measure write time: 2 of 3 files read today, still $wo days idle" } else { Write-Err "write-only idle wrong: $wo"; $fails++ }
248
+
249
+ # 24h - D82: old files below hundreds of new ones still give the root its own verdict (P52 %TEMP%, 2026-11-03)
250
+ $checks++
251
+ $deep = Join-Path $root 'deepsample'
252
+ New-Item -ItemType Directory -Force -Path $deep | Out-Null
253
+ for ($i = 0; $i -lt 450; $i++) { [IO.File]::WriteAllText((Join-Path $deep ("n{0:D3}.tmp" -f $i)), 'x') }
254
+ $null = New-AgedFiles -Dir (Join-Path $deep 'zz-old') -Count 30 -WrittenDaysAgo 30 -FreshReads 30
255
+ Reset-AccessVerdicts
256
+ $verdict = Test-LastAccessLive -Root $deep
257
+ Reset-AccessVerdicts
258
+ if ($verdict) { Write-Ok 'D82: 30 old files under 450 new ones are still sampled - the root is judged itself, not by section order' } else { Write-Err 'D82 sample stopped before the old files: the root inherited a verdict'; $fails++ }
259
+
260
+ # 24f - Prefetch: file names, and days since the newest run of any of a program's files
261
+ $checks++
262
+ $pf = @{ 'chrome.exe' = [datetime]::UtcNow.AddDays(-2); 'steam.exe' = [datetime]::UtcNow.AddDays(-40); 'uninstall.exe' = [datetime]::UtcNow }
263
+ $ok = ((ConvertFrom-PrefetchName 'CHROME.EXE-1A2B3C4D.pf') -eq 'chrome.exe' -and (ConvertFrom-PrefetchName 'Layout.ini') -eq '' -and
264
+ (ConvertFrom-PrefetchName 'NOTEPAD.EXE-ZZZZZZZZ.pf') -eq '' -and (Get-LastRunDays -ExeNames @('steam.exe', 'steamwebhelper.exe') -Prefetch $pf) -eq 40 -and
265
+ (Get-LastRunDays -ExeNames @('chrome.exe', 'steam.exe') -Prefetch $pf) -eq 2 -and (Get-LastRunDays -ExeNames @('none.exe') -Prefetch $pf) -eq -1 -and
266
+ (Get-LastRunDays -ExeNames @('filezilla.exe', 'uninstall.exe') -Prefetch $pf) -eq -1)
267
+ if ($ok) { Write-Ok 'last run: Prefetch names parse, a program counts from its most recently run file, and a shared uninstall.exe never counts' } else { Write-Err 'Prefetch parsing or last-run days wrong'; $fails++ }
268
+ } catch {
269
+ Write-Err "group [24] crashed: $($_.Exception.Message) | at $(($_.ScriptStackTrace -split "`n" | Select-Object -First 3) -join ' <- ')"; $fails++
270
+ } finally {
271
+ $pr.Exceptions = $savedEx; $pr.ExceptionPrefixes = $savedExP
272
+ try { if (Test-Path -LiteralPath $root) { [IO.Directory]::Delete((Get-LongPath $root), $true) } } catch { $null = $_ }
273
+ }
274
+ return [pscustomobject]@{ Checks = $checks; Fails = $fails }
275
+ }
276
+
277
+ function Invoke-SelfTest25 {
278
+ <# .SYNOPSIS Group [25]: section 19's data scanner - reasons, refusals, root rules, the change check, --json. #>
279
+ param([string] $Fixture)
280
+ $checks = 0; $fails = 0
281
+ $ws = $Script:WS
282
+ $root = Join-Path $Fixture 'g25'
283
+ $savedExclude = $Script:WS_EXCLUDE
284
+ $saved = @{ Json = $ws.JsonMode; Cands = $ws.Candidates; Permanent = $ws.Permanent }
285
+ try {
286
+ Write-Section '[25] 1.4.0: the data scanner (section 19) - large or old, refusals, the change check'
287
+ $d = Join-Path $root 'data'
288
+ New-Item -ItemType Directory -Force -Path (Join-Path $d 'sub') | Out-Null
289
+ $old = [datetime]::UtcNow.AddDays(-400)
290
+ $spec = @(@('big-new.bin', 2048, $false), @('small-old.txt', 10, $true), @('big-old.iso', 4096, $true), @('small-new.txt', 10, $false), @('sub\deep-old.log', 10, $true), @('excluded-old.txt', 10, $true))
291
+ foreach ($s in $spec) {
292
+ $p = Join-Path $d $s[0]
293
+ [IO.File]::WriteAllBytes($p, (New-Object byte[] ([int]$s[1])))
294
+ if ($s[2]) { [IO.File]::SetCreationTimeUtc($p, $old); [IO.File]::SetLastWriteTimeUtc($p, $old); [IO.File]::SetLastAccessTimeUtc($p, $old) }
295
+ }
296
+ $exPath = Get-FullPath (Join-Path $d 'excluded-old.txt')
297
+ $Script:WS_EXCLUDE = @{ Prefixes = @($exPath + '\'); Roots = @($exPath) }
298
+ $rows = @(Find-PersonalFiles -Roots @((Get-FullPath $d)) -MinBytes 1024 -Days 100)
299
+ $Script:WS_EXCLUDE = $savedExclude
300
+ $got = (@($rows | ForEach-Object { "$(Split-Path -Leaf $_.Path)=$($_.Reason)" } | Sort-Object) -join ',')
301
+
302
+ # 25a - large at any age, old at any size, both, each file once; a small new file is not listed
303
+ $checks++
304
+ if ($got -eq 'big-new.bin=large,big-old.iso=both,deep-old.log=old,excluded-old.txt=old,small-old.txt=old') { Write-Ok 'data scanner: large (any age), old (any size) and both, each file once' } else { Write-Err "scanner reasons wrong: $got"; $fails++ }
305
+
306
+ # 25b - a file the chokepoint refuses stays visible with its reason and cannot be picked
307
+ $checks++
308
+ $ex = @($rows | Where-Object { $_.Path -eq $exPath })
309
+ if ($ex.Count -eq 1 -and -not $ex[0].Selectable -and $ex[0].SkipReason -like 'excluded: *' -and @($rows | Where-Object { -not $_.Selectable }).Count -eq 1) { Write-Ok 'a refused file is listed with its reason and is not selectable' } else { Write-Err 'refused file handling wrong'; $fails++ }
310
+
311
+ # 25c - personal roots: never a drive root, the profile, a system/program/app-data folder, a link or a missing folder
312
+ $checks++
313
+ $link = Join-Path $root 'link'
314
+ New-Item -ItemType Junction -Path $link -Target $d | Out-Null
315
+ $verdicts = @(
316
+ ((Test-PersonalRoot 'C:\') -eq 'a drive root'), ((Test-PersonalRoot $Script:P.U) -eq 'the profile folder itself'),
317
+ ((Test-PersonalRoot $Script:P.PF) -like 'inside a system*'), ((Test-PersonalRoot (Join-Path $Script:P.SR 'Temp')) -like 'inside a system*'),
318
+ ((Test-PersonalRoot (Join-Path $Script:P.L 'Temp')) -like 'inside a system*'), ((Test-PersonalRoot $link) -like 'a link*'),
319
+ ((Test-PersonalRoot (Join-Path $root 'missing')) -eq 'not an existing folder'), ($null -eq (Test-PersonalRoot $d))
320
+ )
321
+ if (@($verdicts | Where-Object { -not $_ }).Count -eq 0) { Write-Ok 'personal roots: drive roots, the profile, system, program and app-data folders, links and missing folders are refused' } else { Write-Err "personal root rules wrong: [$($verdicts -join ',')]"; $fails++ }
322
+
323
+ # 25d - right before removal: a changed file, and a folder on the path that became a link, are refused
324
+ $checks++
325
+ $one = @($rows | Where-Object { $_.Path -like '*small-old.txt' })[0]
326
+ $before = Test-PersonalFileUnchanged $one
327
+ [IO.File]::AppendAllText($one.Path, 'x')
328
+ $after = Test-PersonalFileUnchanged $one
329
+ $deepRow = @($rows | Where-Object { $_.Path -like '*deep-old.log' })[0]
330
+ Rename-Item -LiteralPath (Join-Path $d 'sub') -NewName 'sub-real'
331
+ New-Item -ItemType Junction -Path (Join-Path $d 'sub') -Target (Join-Path $d 'sub-real') | Out-Null
332
+ $swap = Test-PersonalFileUnchanged $deepRow
333
+ if ($null -eq $before -and $after -like 'changed since the scan*' -and $swap -like 'a folder on its path became a link*') { Write-Ok 'the change check refuses a file edited after the scan and one now reached through a link' } else { Write-Err "change check wrong: before='$before' after='$after' swap='$swap'"; $fails++ }
334
+
335
+ # 25e - --json carries the scanner's own fields after the shared ones
336
+ $checks++
337
+ $ws.JsonMode = $true; $ws.Candidates = @()
338
+ Add-JsonCandidate -Section 19 -Index 1 -Path 'C:\x' -Bytes 5 -IdleDays 400 -Extra ([ordered]@{ reason = 'both'; age_basis = 'write'; selectable = $true })
339
+ $c = $ws.Candidates[0]
340
+ if ((@($c.Keys) -join ',') -eq 'section,index,path,bytes,idle_days,project,reason,age_basis,selectable') { Write-Ok '--json: section 19 candidates add reason, age_basis and selectable after the shared fields' } else { Write-Err "candidate shape wrong: $(@($c.Keys) -join ',')"; $fails++ }
341
+ $checks++
342
+ $ws.Permanent = $true
343
+ $recycleOnly = -not (Test-UsePermanentRemoval -Permanent $ws.Permanent -AlwaysRecycle $true)
344
+ $normalPermanent = Test-UsePermanentRemoval -Permanent $ws.Permanent -AlwaysRecycle $false
345
+ if ($recycleOnly -and $normalPermanent) { Write-Ok 'personal-file Recycle Bin override survives --permanent; ordinary permanent removal still works' } else { Write-Err 'personal-file Recycle Bin override failed'; $fails++ }
346
+ } catch {
347
+ Write-Err "group [25] crashed: $($_.Exception.Message) | at $(($_.ScriptStackTrace -split "`n" | Select-Object -First 3) -join ' <- ')"; $fails++
348
+ } finally {
349
+ $Script:WS_EXCLUDE = $savedExclude
350
+ $ws.JsonMode = $saved.Json; $ws.Candidates = $saved.Cands; $ws.Permanent = $saved.Permanent
351
+ foreach ($j in @((Join-Path $root 'link'), (Join-Path $root 'data\sub'))) { try { if ((Test-Path -LiteralPath $j) -and (Test-ReparsePoint (Get-Item -LiteralPath $j -Force))) { [IO.Directory]::Delete($j, $false) } } catch { $null = $_ } }
352
+ try { if (Test-Path -LiteralPath $root) { [IO.Directory]::Delete((Get-LongPath $root), $true) } } catch { $null = $_ }
353
+ }
354
+ return [pscustomobject]@{ Checks = $checks; Fails = $fails }
355
+ }
356
+
357
+ function Invoke-SelfTest23 {
358
+ <# .SYNOPSIS Group [23]: section 27's command-path reader and machine leftover finder. #>
359
+ param([string] $Fixture)
360
+ $checks = 0; $fails = 0
361
+ $root = Join-Path $Fixture 'g23'
362
+ $savedExclude = $Script:WS_EXCLUDE
363
+ try {
364
+ Write-Section '[23] 1.4.0: leftover program folders (section 27)'
365
+ # 23a - registered commands as services, drivers, tasks and Run entries write them
366
+ $checks++
367
+ $SR = $Script:P.SR
368
+ $cases = @(
369
+ @('\SystemRoot\System32\drivers\foo.sys', "$SR\System32\drivers\foo.sys"),
370
+ @('system32\DRIVERS\bar.sys', "$SR\system32\DRIVERS\bar.sys"),
371
+ @('\??\C:\Drivers\z.sys', 'C:\Drivers\z.sys'),
372
+ @('"C:\Program Files\X\x.exe" --data "D:\Data Dir\cfg"', 'C:\Program Files\X\x.exe|D:\Data Dir\cfg'),
373
+ @('rundll32.exe C:\Tools\a.dll,Entry', 'C:\Tools\a.dll'),
374
+ @('%SystemRoot%\system32\svchost.exe -k netsvcs', ([Environment]::ExpandEnvironmentVariables('%SystemRoot%') + '\system32\svchost.exe'))
375
+ )
376
+ $bad = @()
377
+ foreach ($c in $cases) { $got = (@(Get-CommandPaths $c[0]) -join '|'); if ($got -ne $c[1]) { $bad += "[$($c[0])] -> [$got]" } }
378
+ if ($bad.Count -eq 0) { Write-Ok 'service, driver, task and Run commands resolve to the paths they use' } else { Write-Err "command paths wrong: $($bad -join '; ')"; $fails++ }
379
+
380
+ # 23b - only an unclaimed folder idle --days is offered
381
+ $checks++
382
+ $m = Join-Path $root 'machine'
383
+ $old = [datetime]::UtcNow.AddDays(-40)
384
+ foreach ($d in 'OldVendor', 'FreshVendor', 'UsedByPath', 'ClaimedName', 'Windows Defender', 'MSECache', 'DESIGNER', 'Validity') { $null = New-AgedFiles -Dir (Join-Path $m $d) -Count 2 -WrittenDaysAgo 40 -FreshReads 0 }
385
+ [IO.File]::WriteAllText((Join-Path $m 'FreshVendor\new.txt'), 'x')
386
+ $claims = New-ClaimsFixture -Tokens @('claimedname') -Paths @((Join-Path $m 'UsedByPath\f000.bin'))
387
+ $rows = @(Find-MachineLeftovers -Days 30 -Roots @($m) -Claims $claims)
388
+ $names = @($rows | ForEach-Object { Split-Path -Leaf $_.Path })
389
+ if (($names -join ',') -eq 'OldVendor' -and -not $rows[0].Reason -and $rows[0].Idle -ge 30) { Write-Ok 'machine leftovers: a fresh folder, one still in use, one an installed program names and a Windows folder are never offered' } else { Write-Err "machine leftovers wrong: [$($names -join ',')] old=$($old.ToString('u'))"; $fails++ }
390
+
391
+ # 23c - a folder the person excluded (or a protected one) is reported, never offered
392
+ $checks++
393
+ $oldPath = Get-FullPath (Join-Path $m 'OldVendor')
394
+ $Script:WS_EXCLUDE = @{ Prefixes = @($oldPath + '\'); Roots = @($oldPath) }
395
+ $ex = @(Find-MachineLeftovers -Days 30 -Roots @($m) -Claims $claims)
396
+ $Script:WS_EXCLUDE = $savedExclude
397
+ if ($ex.Count -eq 1 -and $ex[0].Reason -like 'excluded: *') { Write-Ok 'an excluded or protected leftover carries its reason and is only reported' } else { Write-Err "exclusion not honoured: $(@($ex | ForEach-Object { "$($_.Path)=$($_.Reason)" }) -join '; ')"; $fails++ }
398
+
399
+ # 23e - an 8.3 name in a registration (COM servers store them) resolves to the long path a folder is listed by
400
+ $checks++
401
+ $long = Join-Path $Script:P.PF86 'Common Files'
402
+ $short = ''
403
+ try { $short = (New-Object -ComObject Scripting.FileSystemObject).GetFolder($long).ShortPath } catch { $short = '' }
404
+ if (-not $short -or $short -eq $long) { Write-Ok '8.3 names are off on this volume - nothing to expand (not judged)' }
405
+ elseif ((Expand-ShortPath "$short\x.dll") -eq "$long\x.dll") { Write-Ok "8.3 names expand: $short -> $long" } else { Write-Err "short path not expanded: $short -> $(Expand-ShortPath "$short\x.dll")"; $fails++ }
406
+
407
+ # 23d - an unreadable registry offers nothing
408
+ $checks++
409
+ $none = @(Find-MachineLeftovers -Days 0 -Roots @($m) -Claims (New-ClaimsFixture -RegistryCount 0))
410
+ if ($none.Count -eq 0) { Write-Ok 'no readable uninstall entries: no machine leftover is offered (fail closed)' } else { Write-Err "fail-closed gate broken: $($none.Count) row(s)"; $fails++ }
411
+ } catch {
412
+ Write-Err "group [23] crashed: $($_.Exception.Message) | at $(($_.ScriptStackTrace -split "`n" | Select-Object -First 3) -join ' <- ')"; $fails++
413
+ } finally {
414
+ $Script:WS_EXCLUDE = $savedExclude
415
+ try { if (Test-Path -LiteralPath $root) { [IO.Directory]::Delete((Get-LongPath $root), $true) } } catch { $null = $_ }
416
+ }
417
+ return [pscustomobject]@{ Checks = $checks; Fails = $fails }
418
+ }
@@ -354,6 +354,15 @@ function Invoke-SelfTestExtra {
354
354
  $contract = Invoke-SelfTestContract -Fixture $fx
355
355
  $checks += $contract.Checks; $fails += $contract.Fails
356
356
 
357
+ # [19] the 1.4.0 behaviour found on a real machine lives in modules/self_test_1_4.ps1 (P9).
358
+ $g19 = Invoke-SelfTest14 -Fixture $fx
359
+ $checks += $g19.Checks; $fails += $g19.Fails
360
+ $g20 = Invoke-SelfTest20 -Fixture $fx
361
+ $checks += $g20.Checks; $fails += $g20.Fails
362
+ $g21 = Invoke-SelfTest21
363
+ $checks += $g21.Checks; $fails += $g21.Fails
364
+ foreach ($g in @((Invoke-SelfTest22 -Fixture $fx), (Invoke-SelfTest23 -Fixture $fx), (Invoke-SelfTest24 -Fixture $fx), (Invoke-SelfTest25 -Fixture $fx), (Invoke-SelfTest26 -Fixture $fx))) { $checks += $g.Checks; $fails += $g.Fails }
365
+
357
366
  } catch {
358
367
  & $unmute
359
368
  Write-Err "extra self-test crashed: $($_.Exception.Message)"; $fails++
@@ -27,11 +27,19 @@ function Set-ServiceState {
27
27
  if ($Action -eq 'stop') {
28
28
  if ($svc.Status -ne 'Running') { return $false }
29
29
  if ($Script:WS.DryRun) { Write-DryRun "would stop service $Name"; return $true }
30
- try { Stop-Service -Name $Name -Force -ErrorAction Stop; Write-Note "stopped $Name" } catch { Write-Warn "could not stop $Name : $($_.Exception.Message)" }
30
+ try {
31
+ Stop-Service -Name $Name -Force -ErrorAction Stop -WarningAction SilentlyContinue -WarningVariable serviceWarnings
32
+ foreach ($warning in $serviceWarnings) { Write-Warn ([string]$warning) }
33
+ Write-Note "stopped $Name"
34
+ } catch { Write-Warn "could not stop $Name : $($_.Exception.Message)" }
31
35
  return $true
32
36
  }
33
37
  if ($Script:WS.DryRun) { Write-DryRun "would start service $Name"; return $true }
34
- try { Start-Service -Name $Name -ErrorAction Stop; Write-Note "started $Name" } catch { Write-Warn "could not start $Name : $($_.Exception.Message)" }
38
+ try {
39
+ Start-Service -Name $Name -ErrorAction Stop -WarningAction SilentlyContinue -WarningVariable serviceWarnings
40
+ foreach ($warning in $serviceWarnings) { Write-Warn ([string]$warning) }
41
+ Write-Note "started $Name"
42
+ } catch { Write-Warn "could not start $Name : $($_.Exception.Message)" }
35
43
  return $true
36
44
  }
37
45
 
@@ -56,7 +64,13 @@ function Invoke-Section12 {
56
64
  if (Get-Command Delete-DeliveryOptimizationCache -ErrorAction SilentlyContinue) {
57
65
  if ($ws.DryRun) { Write-DryRun 'would run Delete-DeliveryOptimizationCache -Force' }
58
66
  else {
59
- try { Delete-DeliveryOptimizationCache -Force -ErrorAction Stop; Write-Ok 'Delivery Optimization cache cleared' } catch { Write-Warn "Delivery Optimization: $($_.Exception.Message)" }
67
+ # Every stream is captured: the cmdlet prints "Deleting..." and a success line on stdout, which broke the
68
+ # one-line --json contract on the P52 (2026-11-02). Its words go to the log instead.
69
+ try {
70
+ $said = @(Delete-DeliveryOptimizationCache -Force -ErrorAction Stop *>&1)
71
+ foreach ($line in $said) { Write-LogLine "Delete-DeliveryOptimizationCache: $line" }
72
+ Write-Ok 'Delivery Optimization cache cleared'
73
+ } catch { Write-Warn "Delivery Optimization: $($_.Exception.Message)" }
60
74
  }
61
75
  }
62
76
  }