mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-08 11:39:10 +11:00
feat: add automated environment report export (#5025)
* feat: add automated environment report export * refactor: address code review comments * fix: refresh toggle state in environment report * fix: surface environment report read failures * test: make registry failure explicit --------- Co-authored-by: Chris Titus <contact@christitus.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
#===========================================================================
|
||||
# Tests - Environment report log bundling
|
||||
#===========================================================================
|
||||
|
||||
BeforeAll {
|
||||
. (Join-Path (Resolve-Path (Join-Path $PSScriptRoot "..")).Path "functions\private\Get-WinUtilRecentLogs.ps1")
|
||||
}
|
||||
|
||||
Describe "Get-WinUtilRecentLogs" {
|
||||
BeforeEach {
|
||||
$script:logDir = Join-Path $TestDrive "logs"
|
||||
New-Item -Path $script:logDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
$recentPath = Join-Path $script:logDir "winutil_2026-08-20_10-00-00.log"
|
||||
"recent session log" | Out-File -FilePath $recentPath -Encoding utf8
|
||||
(Get-Item $recentPath).LastWriteTime = (Get-Date).AddDays(-1)
|
||||
|
||||
$oldPath = Join-Path $script:logDir "winutil_2026-07-01_10-00-00.log"
|
||||
"old session log" | Out-File -FilePath $oldPath -Encoding utf8
|
||||
(Get-Item $oldPath).LastWriteTime = (Get-Date).AddDays(-30)
|
||||
|
||||
$unrelatedPath = Join-Path $script:logDir "notes.txt"
|
||||
"unrelated file" | Out-File -FilePath $unrelatedPath -Encoding utf8
|
||||
(Get-Item $unrelatedPath).LastWriteTime = (Get-Date).AddDays(-1)
|
||||
}
|
||||
|
||||
It "includes only winutil_*.log files within the day window" {
|
||||
$result = Get-WinUtilRecentLogs -Days 7 -LogDirectory $script:logDir
|
||||
|
||||
$result | Should -Match "recent session log"
|
||||
$result | Should -Not -Match "old session log"
|
||||
$result | Should -Not -Match "unrelated file"
|
||||
}
|
||||
|
||||
It "prefixes each included file with a header naming it" {
|
||||
$result = Get-WinUtilRecentLogs -Days 7 -LogDirectory $script:logDir
|
||||
|
||||
$result | Should -Match "=== winutil_2026-08-20_10-00-00\.log ==="
|
||||
}
|
||||
|
||||
It "returns an empty string when the log directory does not exist" {
|
||||
$result = Get-WinUtilRecentLogs -Days 7 -LogDirectory (Join-Path $TestDrive "missing")
|
||||
|
||||
$result | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
#===========================================================================
|
||||
# Tests - Environment Report
|
||||
#===========================================================================
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
. (Join-Path $script:repoRoot "functions\private\Write-WinUtilLog.ps1")
|
||||
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilToggleStatus.ps1")
|
||||
. (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilCurrentSystem.ps1")
|
||||
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilTweaksStateReport.ps1")
|
||||
. (Join-Path $script:repoRoot "functions\private\Test-WinUtilPackageManager.ps1")
|
||||
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilEnvironmentReport.ps1")
|
||||
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilEnvironmentReportLogsPath.ps1")
|
||||
}
|
||||
|
||||
Describe "Get-WinUtilEnvironmentReport" {
|
||||
BeforeEach {
|
||||
# Keep this Describe focused on the top-level schema; tweaksState grouping/detection has
|
||||
# its own dedicated Describe below with its own registry fixture.
|
||||
Mock Get-WinUtilTweaksStateReport {
|
||||
[pscustomobject]@{
|
||||
collectionStatus = "collected"
|
||||
essentialTweaks = [pscustomobject]@{}
|
||||
customizePreferences = [pscustomobject]@{}
|
||||
advancedTweaks = [pscustomobject]@{}
|
||||
performancePlans = [pscustomobject]@{}
|
||||
notEvaluable = @()
|
||||
}
|
||||
}
|
||||
|
||||
Mock Get-CimInstance {
|
||||
switch ($ClassName) {
|
||||
"Win32_OperatingSystem" {
|
||||
return [pscustomobject]@{
|
||||
Caption = "Windows 11 Pro"
|
||||
Version = "10.0.26100"
|
||||
BuildNumber = "26100"
|
||||
OSArchitecture = "64-bit"
|
||||
TotalVisibleMemorySize = 16777216
|
||||
}
|
||||
}
|
||||
"Win32_Processor" {
|
||||
return [pscustomobject]@{
|
||||
Name = "Example CPU"
|
||||
NumberOfLogicalProcessors = 8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Mock Get-ExecutionPolicy { "RemoteSigned" }
|
||||
Mock Test-WinUtilPackageManager { "not-installed" }
|
||||
Mock Test-Path { $false }
|
||||
Mock Get-ItemProperty { $null }
|
||||
Mock Write-WinUtilLog { }
|
||||
}
|
||||
|
||||
It "returns the versioned allowlisted report schema" {
|
||||
$report = Get-WinUtilEnvironmentReport
|
||||
|
||||
$report.schemaVersion | Should -Be "1.0"
|
||||
$report.generatedAtUtc | Should -Match '^\d{4}-\d{2}-\d{2}T'
|
||||
$report.windows.edition | Should -Be "Windows 11 Pro"
|
||||
$report.hardware.cpuModel | Should -Be "Example CPU"
|
||||
$report.hardware.logicalProcessorCount | Should -Be 8
|
||||
$report.hardware.totalMemoryGB | Should -Be 16
|
||||
$report.powershell.PSObject.Properties.Name | Should -Be @("edition", "version", "executionPolicy")
|
||||
$report.powershell.executionPolicy | Should -Be "RemoteSigned"
|
||||
$report.packageManagers.PSObject.Properties.Name | Should -Be @("winget", "chocolatey")
|
||||
$report.system.PSObject.Properties.Name | Should -Be @("pendingRebootRequired")
|
||||
$report.PSObject.Properties.Name | Should -Not -Contain "windowsFeatures"
|
||||
$report.PSObject.Properties.Name | Should -Contain "tweaksState"
|
||||
}
|
||||
|
||||
It "contains no fields outside the approved report schema" {
|
||||
# Exact property-name assertions, not a blocklist of specific bad names - a blocklist only
|
||||
# catches fields someone thought to list, and would miss e.g. a future "biosUuid" or "userSid".
|
||||
# Dynamic tweak keys inside tweaksState's groups are intentionally not asserted here.
|
||||
$report = Get-WinUtilEnvironmentReport
|
||||
|
||||
$report.PSObject.Properties.Name | Should -Be @(
|
||||
"schemaVersion", "generatedAtUtc", "windows", "hardware", "powershell",
|
||||
"packageManagers", "system", "tweaksState"
|
||||
)
|
||||
$report.windows.PSObject.Properties.Name | Should -Be @("edition", "version", "buildNumber", "architecture")
|
||||
$report.hardware.PSObject.Properties.Name | Should -Be @("cpuModel", "logicalProcessorCount", "totalMemoryGB")
|
||||
$report.packageManagers.winget.PSObject.Properties.Name | Should -Be @("installed", "version")
|
||||
$report.packageManagers.chocolatey.PSObject.Properties.Name | Should -Be @("installed", "version")
|
||||
$report.tweaksState.PSObject.Properties.Name | Should -Be @(
|
||||
"collectionStatus", "essentialTweaks", "customizePreferences", "advancedTweaks",
|
||||
"performancePlans", "notEvaluable"
|
||||
)
|
||||
}
|
||||
|
||||
It "does not report a package manager as installed unless Test-WinUtilPackageManager confirms it" {
|
||||
$report = Get-WinUtilEnvironmentReport
|
||||
|
||||
$report.packageManagers.winget.installed | Should -BeFalse
|
||||
$report.packageManagers.winget.version | Should -BeNullOrEmpty
|
||||
$report.packageManagers.chocolatey.installed | Should -BeFalse
|
||||
$report.packageManagers.chocolatey.version | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It "reads a package manager's version via its own -v flag once installed is confirmed" {
|
||||
Mock Test-WinUtilPackageManager { "installed" } -ParameterFilter { $winget }
|
||||
Mock winget { "v1.29.290" }
|
||||
|
||||
$report = Get-WinUtilEnvironmentReport
|
||||
|
||||
$report.packageManagers.winget.installed | Should -BeTrue
|
||||
$report.packageManagers.winget.version | Should -Be "v1.29.290"
|
||||
}
|
||||
|
||||
It "flags a pending reboot from PendingFileRenameOperations" {
|
||||
Mock Get-ItemProperty {
|
||||
[pscustomobject]@{ PendingFileRenameOperations = @("a", "b") }
|
||||
} -ParameterFilter { $Name -eq "PendingFileRenameOperations" }
|
||||
|
||||
$report = Get-WinUtilEnvironmentReport
|
||||
|
||||
$report.system.pendingRebootRequired | Should -BeTrue
|
||||
}
|
||||
|
||||
It "does not flag a pending reboot for a present but empty PendingFileRenameOperations value" {
|
||||
Mock Get-ItemProperty {
|
||||
[pscustomobject]@{ PendingFileRenameOperations = @() }
|
||||
} -ParameterFilter { $Name -eq "PendingFileRenameOperations" }
|
||||
|
||||
$report = Get-WinUtilEnvironmentReport
|
||||
|
||||
$report.system.pendingRebootRequired | Should -BeFalse
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Describe "Get-WinUtilEnvironmentReportLogsPath" {
|
||||
It "swaps the JSON extension for a _logs.txt suffix" {
|
||||
Get-WinUtilEnvironmentReportLogsPath -JsonPath "C:\Reports\WinUtilEnvironmentReport_20260101.json" |
|
||||
Should -Be "C:\Reports\WinUtilEnvironmentReport_20260101_logs.txt"
|
||||
}
|
||||
|
||||
It "handles a path with no extension" {
|
||||
Get-WinUtilEnvironmentReportLogsPath -JsonPath "C:\Reports\Report" |
|
||||
Should -Be "C:\Reports\Report_logs.txt"
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Get-WinUtilTweaksStateReport" {
|
||||
BeforeEach {
|
||||
$script:sync = [Hashtable]::Synchronized(@{
|
||||
configs = @{
|
||||
tweaks = [pscustomobject]@{
|
||||
WPFTweaksApplied = [pscustomobject]@{
|
||||
category = "Essential Tweaks"
|
||||
registry = @([pscustomobject]@{ Path = "HKCU:\Fake1"; Name = "Enabled"; Value = "1"; OriginalValue = "0"; DefaultState = "true" })
|
||||
}
|
||||
WPFTweaksNotApplied = [pscustomobject]@{
|
||||
category = "Essential Tweaks"
|
||||
registry = @([pscustomobject]@{ Path = "HKCU:\Fake2"; Name = "Enabled"; Value = "1"; OriginalValue = "0"; DefaultState = "false" })
|
||||
}
|
||||
WPFToggleCustomize = [pscustomobject]@{
|
||||
category = "Customize Preferences"
|
||||
Type = "Toggle"
|
||||
registry = @([pscustomobject]@{ Path = "HKCU:\Fake3"; Name = "Enabled"; Value = "1"; OriginalValue = "0"; DefaultState = "true" })
|
||||
}
|
||||
WPFTweaksAdvanced = [pscustomobject]@{
|
||||
category = "z__Advanced Tweaks - CAUTION"
|
||||
registry = @([pscustomobject]@{ Path = "HKCU:\Fake4"; Name = "Enabled"; Value = "1"; OriginalValue = "0"; DefaultState = "false" })
|
||||
}
|
||||
WPFComboExample = [pscustomobject]@{
|
||||
category = "Customize Preferences"
|
||||
Type = "Combobox"
|
||||
}
|
||||
WPFButtonExample = [pscustomobject]@{
|
||||
category = "z__Advanced Tweaks - CAUTION"
|
||||
Type = "Button"
|
||||
}
|
||||
WPFScriptOnly = [pscustomobject]@{
|
||||
category = "Essential Tweaks"
|
||||
}
|
||||
WPFUnknownCategory = [pscustomobject]@{
|
||||
category = "Some Unmapped Category"
|
||||
registry = @([pscustomobject]@{ Path = "HKCU:\Fake5"; Name = "Enabled"; Value = "1"; OriginalValue = "0"; DefaultState = "true" })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Mock Get-PSDrive { [pscustomobject]@{ Name = "HKU" } } -ParameterFilter { $Name -eq "HKU" }
|
||||
Mock New-PSDrive { }
|
||||
Mock Test-Path { $false }
|
||||
Mock Get-ItemProperty { $null }
|
||||
Mock Write-WinUtilLog { }
|
||||
}
|
||||
|
||||
It "groups applied/not-applied tweaks and toggles by category" {
|
||||
$result = Get-WinUtilTweaksStateReport
|
||||
|
||||
$result.collectionStatus | Should -Be "collected"
|
||||
$result.essentialTweaks.WPFTweaksApplied | Should -BeTrue
|
||||
$result.essentialTweaks.WPFTweaksNotApplied | Should -BeFalse
|
||||
$result.customizePreferences.WPFToggleCustomize | Should -BeTrue
|
||||
$result.advancedTweaks.WPFTweaksAdvanced | Should -BeFalse
|
||||
@($result.performancePlans.PSObject.Properties).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It "reads live toggle state instead of the cached UI state" {
|
||||
$script:sync.ToggleStatusCache = @{ WPFToggleCustomize = $true }
|
||||
Mock Test-Path { $Path -eq "HKCU:\Fake3" }
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Enabled = "0" } } -ParameterFilter {
|
||||
$Path -eq "HKCU:\Fake3"
|
||||
}
|
||||
|
||||
$result = Get-WinUtilTweaksStateReport
|
||||
|
||||
$result.customizePreferences.WPFToggleCustomize | Should -BeFalse
|
||||
$script:sync.ToggleStatusCache.WPFToggleCustomize | Should -BeTrue
|
||||
Should -Invoke -CommandName Get-ItemProperty -Times 1 -Exactly -ParameterFilter {
|
||||
$Path -eq "HKCU:\Fake3"
|
||||
}
|
||||
}
|
||||
|
||||
It "reports collection as unavailable when a live registry read fails" {
|
||||
Mock Test-Path { $Path -eq "HKCU:\Fake3" }
|
||||
Mock Get-ItemProperty { Write-Error -Message "Registry access denied" -ErrorAction Stop } -ParameterFilter {
|
||||
$Path -eq "HKCU:\Fake3"
|
||||
}
|
||||
|
||||
$result = Get-WinUtilTweaksStateReport
|
||||
|
||||
$result.collectionStatus | Should -Be "unavailable"
|
||||
@($result.customizePreferences.PSObject.Properties).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It "lists combobox and script-only tweaks as not evaluable instead of silently dropping them" {
|
||||
$result = Get-WinUtilTweaksStateReport
|
||||
|
||||
$result.notEvaluable | Should -Contain "WPFComboExample"
|
||||
$result.notEvaluable | Should -Contain "WPFScriptOnly"
|
||||
}
|
||||
|
||||
It "excludes action buttons and unmapped categories entirely" {
|
||||
$result = Get-WinUtilTweaksStateReport
|
||||
|
||||
$result.advancedTweaks.PSObject.Properties.Name | Should -Not -Contain "WPFButtonExample"
|
||||
$result.notEvaluable | Should -Not -Contain "WPFButtonExample"
|
||||
$result.notEvaluable | Should -Not -Contain "WPFUnknownCategory"
|
||||
($result.essentialTweaks.PSObject.Properties.Name +
|
||||
$result.customizePreferences.PSObject.Properties.Name +
|
||||
$result.advancedTweaks.PSObject.Properties.Name +
|
||||
$result.performancePlans.PSObject.Properties.Name) | Should -Not -Contain "WPFUnknownCategory"
|
||||
}
|
||||
|
||||
It "returns empty groups instead of throwing when Invoke-WinUtilCurrentSystem fails" {
|
||||
Mock Invoke-WinUtilCurrentSystem { throw "Registry access denied" }
|
||||
|
||||
# Calling this directly (rather than via a wrapped scriptblock piped to Should -Not -Throw)
|
||||
# avoids a PowerShell scoping pitfall where an assignment inside such a scriptblock doesn't
|
||||
# reliably propagate to this scope - an uncaught exception here still fails the test anyway.
|
||||
$result = Get-WinUtilTweaksStateReport
|
||||
|
||||
# Empty groups alone would be indistinguishable from a successful scan that found nothing
|
||||
# notable, so collectionStatus is what actually records that this run failed.
|
||||
$result.collectionStatus | Should -Be "unavailable"
|
||||
$result.essentialTweaks.PSObject.Properties.Name | Should -Not -Contain "WPFTweaksApplied"
|
||||
@($result.essentialTweaks.PSObject.Properties).Count | Should -Be 0
|
||||
@($result.customizePreferences.PSObject.Properties).Count | Should -Be 0
|
||||
@($result.advancedTweaks.PSObject.Properties).Count | Should -Be 0
|
||||
@($result.performancePlans.PSObject.Properties).Count | Should -Be 0
|
||||
$result.notEvaluable | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user