From 7ee5be3d2fbfe2374cbc6bf472be73ba002daa3b Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:03:22 -0400 Subject: [PATCH] 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 --- .../content/docs/guides/getting-started.mdx | 15 + .../private/Get-WinUtilEnvironmentReport.ps1 | 121 ++++++++ .../Get-WinUtilEnvironmentReportLogsPath.ps1 | 16 ++ functions/private/Get-WinUtilRecentLogs.ps1 | 39 +++ functions/private/Get-WinUtilToggleStatus.ps1 | 35 ++- .../private/Get-WinUtilTweaksStateReport.ps1 | 62 ++++ .../private/Invoke-WinUtilCurrentSystem.ps1 | 17 +- .../Invoke-WPFExportEnvironmentReport.ps1 | 87 ++++++ pester/environment-report-logs.Tests.ps1 | 46 +++ pester/environment-report.Tests.ps1 | 271 ++++++++++++++++++ scripts/main.ps1 | 4 + xaml/inputXML.xaml | 6 + 12 files changed, 704 insertions(+), 15 deletions(-) create mode 100644 functions/private/Get-WinUtilEnvironmentReport.ps1 create mode 100644 functions/private/Get-WinUtilEnvironmentReportLogsPath.ps1 create mode 100644 functions/private/Get-WinUtilRecentLogs.ps1 create mode 100644 functions/private/Get-WinUtilTweaksStateReport.ps1 create mode 100644 functions/public/Invoke-WPFExportEnvironmentReport.ps1 create mode 100644 pester/environment-report-logs.Tests.ps1 create mode 100644 pester/environment-report.Tests.ps1 diff --git a/docs/src/content/docs/guides/getting-started.mdx b/docs/src/content/docs/guides/getting-started.mdx index ec1f5271..93a928f1 100644 --- a/docs/src/content/docs/guides/getting-started.mdx +++ b/docs/src/content/docs/guides/getting-started.mdx @@ -235,6 +235,21 @@ Now that you're set up, explore these guides: - [Tweaks Guide](/guides/tweaks/) — Understand system optimizations - [FAQ](/faq/) — Common questions and answers +## Exporting a diagnostics report + +If you're reporting a problem, click the gear icon in the top-right corner and choose **Export Environment Report**. It saves a read-only JSON file with: + +- Windows edition, version, build, and architecture +- CPU model, logical processor count, and total memory +- PowerShell edition, version, and execution policy +- Whether WinGet and Chocolatey are installed, and their versions +- Whether a reboot is pending +- The current applied/not-applied state of every tweak and toggle + +It does not include computer or user names, paths, IP or MAC addresses, serial numbers, installed-app inventories, services, raw registry paths or values, secrets, or logs. The tweak/toggle state is derived from a registry comparison, but only the resulting true/false per tweak is included, not any registry content itself. WinUtil never uploads the report — you choose where to save it and who to send it to. + +You'll be asked whether to also bundle the last 7 days of WinUtil logs into a companion `.txt` file, which maintainers often need alongside the report to diagnose an issue. + ## Getting help If you need assistance: diff --git a/functions/private/Get-WinUtilEnvironmentReport.ps1 b/functions/private/Get-WinUtilEnvironmentReport.ps1 new file mode 100644 index 00000000..918ff211 --- /dev/null +++ b/functions/private/Get-WinUtilEnvironmentReport.ps1 @@ -0,0 +1,121 @@ +function Get-WinUtilEnvironmentReport { + <# + .SYNOPSIS + Collects the allowlisted data used by the WinUtil environment report. + #> + + $windows = [ordered]@{ + edition = $null + version = $null + buildNumber = $null + architecture = $null + } + $hardware = [ordered]@{ + cpuModel = $null + logicalProcessorCount = $null + totalMemoryGB = $null + } + + try { + $operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop + $windows.edition = $operatingSystem.Caption + $windows.version = $operatingSystem.Version + $windows.buildNumber = $operatingSystem.BuildNumber + $windows.architecture = $operatingSystem.OSArchitecture + + if ($null -ne $operatingSystem.TotalVisibleMemorySize) { + $hardware.totalMemoryGB = [math]::Round(([double]$operatingSystem.TotalVisibleMemorySize / 1MB), 2) + } + } catch { + Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to collect Windows/memory info from Win32_OperatingSystem: $($_.Exception.Message)" + } + + try { + $processors = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop) + if ($processors.Count -gt 0) { + $hardware.cpuModel = $processors[0].Name + $hardware.logicalProcessorCount = [int](($processors | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum) + } + } catch { + Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to collect CPU info from Win32_Processor: $($_.Exception.Message)" + } + + $powershell = [ordered]@{ + edition = $PSVersionTable.PSEdition + version = $PSVersionTable.PSVersion.ToString() + executionPolicy = $null + } + + try { + $powershell.executionPolicy = (Get-ExecutionPolicy).ToString() + } catch { + Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to read PowerShell execution policy: $($_.Exception.Message)" + } + + # Re-use built-in functionality + $chocolatey = [ordered]@{ installed = $false; version = $null } + try { + $chocolatey.installed = (Test-WinUtilPackageManager -choco 6>$null) -eq "installed" + } catch { + Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to check Chocolatey availability: $($_.Exception.Message)" + } + + if ($chocolatey.installed) { + try { + $chocolatey.version = (choco -v 2>&1 | Select-Object -First 1).ToString().Trim() + } catch { + Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to read Chocolatey version: $($_.Exception.Message)" + } + } + + $winget = [ordered]@{ installed = $false; version = $null } + try { + $winget.installed = (Test-WinUtilPackageManager -winget 6>$null) -eq "installed" + } catch { + Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to check WinGet availability: $($_.Exception.Message)" + } + + if ($winget.installed) { + try { + $winget.version = (winget -v 2>&1 | Select-Object -First 1).ToString().Trim() + } catch { + Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to read WinGet version: $($_.Exception.Message)" + } + } + + $system = [ordered]@{ pendingRebootRequired = $false } + try { + $rebootPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending", + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired" + ) + + # A present-but-empty PendingFileRenameOperations value still returns a non-null object, so + # check the actual entries rather than just whether the property exists. + $pendingFileRenameOperations = @( + (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" ` + -Name "PendingFileRenameOperations" -ErrorAction SilentlyContinue).PendingFileRenameOperations | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } + ) + $system.pendingRebootRequired = ($rebootPaths | Where-Object { Test-Path $_ }).Count -gt 0 -or + $pendingFileRenameOperations.Count -gt 0 + } catch { + Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to check pending-reboot registry state: $($_.Exception.Message)" + } + + $tweaksState = Get-WinUtilTweaksStateReport + + return [pscustomobject][ordered]@{ + schemaVersion = "1.0" + generatedAtUtc = [DateTime]::UtcNow.ToString("o") + windows = [pscustomobject]$windows + hardware = [pscustomobject]$hardware + powershell = [pscustomobject]$powershell + packageManagers = [pscustomobject][ordered]@{ + winget = [pscustomobject]$winget + chocolatey = [pscustomobject]$chocolatey + } + system = [pscustomobject]$system + tweaksState = $tweaksState + } +} diff --git a/functions/private/Get-WinUtilEnvironmentReportLogsPath.ps1 b/functions/private/Get-WinUtilEnvironmentReportLogsPath.ps1 new file mode 100644 index 00000000..ef7a1664 --- /dev/null +++ b/functions/private/Get-WinUtilEnvironmentReportLogsPath.ps1 @@ -0,0 +1,16 @@ +function Get-WinUtilEnvironmentReportLogsPath { + <# + .SYNOPSIS + Derives the companion logs .txt path from the environment report's JSON save path. + #> + param( + [Parameter(Mandatory = $true)] + [string]$JsonPath + ) + + # ChangeExtension($JsonPath, $null) leaves a trailing dot instead of stripping it, so build the + # name from its parts instead. + $directory = [System.IO.Path]::GetDirectoryName($JsonPath) + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($JsonPath) + return [System.IO.Path]::Combine($directory, "${baseName}_logs.txt") +} diff --git a/functions/private/Get-WinUtilRecentLogs.ps1 b/functions/private/Get-WinUtilRecentLogs.ps1 new file mode 100644 index 00000000..a45b07cd --- /dev/null +++ b/functions/private/Get-WinUtilRecentLogs.ps1 @@ -0,0 +1,39 @@ +function Get-WinUtilRecentLogs { + <# + .SYNOPSIS + Concatenates WinUtil session logs from the last N days into a single text blob. + + .PARAMETER Days + How many days back to include. Defaults to 7, matching what the support forum/server + typically asks users for. + + .PARAMETER LogDirectory + Overrides the log directory (normally $sync.winutildir\logs). Mainly for testing. + #> + param( + [int]$Days = 7, + [string]$LogDirectory + ) + + if ([string]::IsNullOrWhiteSpace($LogDirectory)) { + if ($null -eq $sync -or -not $sync.ContainsKey("winutildir") -or [string]::IsNullOrWhiteSpace($sync.winutildir)) { + return "" + } + $LogDirectory = Join-Path $sync.winutildir "logs" + } + + if (-not (Test-Path $LogDirectory)) { + return "" + } + + $cutoff = (Get-Date).AddDays(-$Days) + $logFiles = Get-ChildItem -Path $LogDirectory -Filter "winutil_*.log" -File -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -ge $cutoff } | + Sort-Object LastWriteTime + + $sections = foreach ($logFile in $logFiles) { + "=== $($logFile.Name) ===`n$(Get-Content -Path $logFile.FullName -Raw)" + } + + return ($sections -join "`n`n") +} diff --git a/functions/private/Get-WinUtilToggleStatus.ps1 b/functions/private/Get-WinUtilToggleStatus.ps1 index e0b03e5f..efb581fe 100644 --- a/functions/private/Get-WinUtilToggleStatus.ps1 +++ b/functions/private/Get-WinUtilToggleStatus.ps1 @@ -1,23 +1,32 @@ -Function Get-WinUtilToggleStatus ($ToggleSwitch) { +Function Get-WinUtilToggleStatus { + param( + $ToggleSwitch, + [switch]$BypassCache, + [switch]$StopOnReadError + ) $ToggleSwitchReg = $sync.configs.tweaks.$ToggleSwitch.registry - if ($null -eq $sync.ToggleStatusCache) { - $sync.ToggleStatusCache = @{} + if (-not $BypassCache) { + if ($null -eq $sync.ToggleStatusCache) { + $sync.ToggleStatusCache = @{} + } + + if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) { + return [bool]$sync.ToggleStatusCache[$ToggleSwitch] + } } - if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) { - return [bool]$sync.ToggleStatusCache[$ToggleSwitch] - } + $readErrorAction = if ($StopOnReadError) { "Stop" } else { "Continue" } if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) { - New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null + New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS -ErrorAction $readErrorAction | Out-Null } foreach ($regentry in $ToggleSwitchReg) { - if (Test-Path $regentry.Path) { - $regstate = (Get-ItemProperty -Path $regentry.Path).$($regentry.Name) + if (Test-Path $regentry.Path -ErrorAction $readErrorAction) { + $regstate = (Get-ItemProperty -Path $regentry.Path -ErrorAction $readErrorAction).$($regentry.Name) } else { $regstate = $null } @@ -30,11 +39,15 @@ Function Get-WinUtilToggleStatus ($ToggleSwitch) { } if ($regstate -ne $regentry.Value) { - $sync.ToggleStatusCache[$ToggleSwitch] = $false + if (-not $BypassCache) { + $sync.ToggleStatusCache[$ToggleSwitch] = $false + } return $false } } - $sync.ToggleStatusCache[$ToggleSwitch] = $true + if (-not $BypassCache) { + $sync.ToggleStatusCache[$ToggleSwitch] = $true + } return $true } diff --git a/functions/private/Get-WinUtilTweaksStateReport.ps1 b/functions/private/Get-WinUtilTweaksStateReport.ps1 new file mode 100644 index 00000000..b5083412 --- /dev/null +++ b/functions/private/Get-WinUtilTweaksStateReport.ps1 @@ -0,0 +1,62 @@ +function Get-WinUtilTweaksStateReport { + <# + .SYNOPSIS + Groups every config/tweaks.json entry's live applied state by category, reusing the same + detection Invoke-WPFGetInstalled uses to check the "Get Installed Tweaks" checkboxes. + #> + + $categoryFieldNames = [ordered]@{ + "Essential Tweaks" = "essentialTweaks" + "Customize Preferences" = "customizePreferences" + "z__Advanced Tweaks - CAUTION" = "advancedTweaks" + "Performance Plans - NOT FOR LAPTOPS" = "performancePlans" + } + + $grouped = [ordered]@{} + foreach ($fieldName in $categoryFieldNames.Values) { + $grouped[$fieldName] = [ordered]@{} + } + $notEvaluable = [System.Collections.Generic.List[string]]::new() + $collectionStatus = "collected" + + try { + $appliedTweaks = [System.Collections.Generic.HashSet[string]]::new( + [string[]]@(Invoke-WinUtilCurrentSystem -CheckBox "tweaks" ` + -BypassToggleStatusCache -StopOnReadError) + ) + + foreach ($property in $sync.configs.tweaks.PSObject.Properties) { + $tweakKey = $property.Name + $entry = $property.Value + $fieldName = $categoryFieldNames[[string]$entry.category] + + # Buttons embedded in the tweaks panel (e.g. the OOSU/Ultimate Performance launchers) + # are actions, not stateful tweaks, so they're outside this report's scope entirely. + if (-not $fieldName -or $entry.Type -eq "Button") { + continue + } + + # Combobox tweaks and script-only tweaks with no registry/service schema have no + # detectable current state. List them so the report doesn't silently drop them. + if ($entry.Type -eq "Combobox" -or (-not $entry.registry -and -not $entry.service)) { + $notEvaluable.Add($tweakKey) + continue + } + + $grouped[$fieldName][$tweakKey] = $appliedTweaks.Contains($tweakKey) + } + } catch { + Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to collect tweaks/toggle state: $($_.Exception.Message)" + $collectionStatus = "unavailable" + } + + # Empty groups from a failed collection would otherwise be indistinguishable in the JSON from a + # successful scan that found nothing notable, so record whether collection actually ran. + $result = [ordered]@{ collectionStatus = $collectionStatus } + foreach ($fieldName in $categoryFieldNames.Values) { + $result[$fieldName] = [pscustomobject]$grouped[$fieldName] + } + $result.notEvaluable = @($notEvaluable) + + return [pscustomobject]$result +} diff --git a/functions/private/Invoke-WinUtilCurrentSystem.ps1 b/functions/private/Invoke-WinUtilCurrentSystem.ps1 index 68594ab6..54283c54 100644 --- a/functions/private/Invoke-WinUtilCurrentSystem.ps1 +++ b/functions/private/Invoke-WinUtilCurrentSystem.ps1 @@ -11,7 +11,9 @@ Function Invoke-WinUtilCurrentSystem { #> param( - $CheckBox + $CheckBox, + [switch]$BypassToggleStatusCache, + [switch]$StopOnReadError ) if ($CheckBox -eq "choco") { $apps = (choco list | Select-String -Pattern "^\S+").Matches.Value @@ -52,6 +54,7 @@ Function Invoke-WinUtilCurrentSystem { if ($CheckBox -eq "tweaks") { if (!(Test-Path 'HKU:\')) {$null = (New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS)} + $readErrorAction = if ($StopOnReadError) { "Stop" } else { "SilentlyContinue" } $sync.configs.tweaks | Get-Member -MemberType NoteProperty | ForEach-Object { @@ -65,7 +68,9 @@ Function Invoke-WinUtilCurrentSystem { $Values = @() if ($entryType -eq "Toggle") { - if (-not (Get-WinUtilToggleStatus $Config)) { + if (-not (Get-WinUtilToggleStatus $Config ` + -BypassCache:$BypassToggleStatusCache ` + -StopOnReadError:$StopOnReadError)) { $values += $False } } else { @@ -77,8 +82,12 @@ Function Invoke-WinUtilCurrentSystem { $registryTotal++ $regstate = $null - if (Test-Path $tweak.Path) { - $regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name) + if (Test-Path $tweak.Path -ErrorAction $readErrorAction) { + if ($StopOnReadError) { + $regstate = (Get-ItemProperty -Path $tweak.Path -ErrorAction Stop).$($tweak.Name) + } else { + $regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name) + } } if ($null -eq $regstate) { diff --git a/functions/public/Invoke-WPFExportEnvironmentReport.ps1 b/functions/public/Invoke-WPFExportEnvironmentReport.ps1 new file mode 100644 index 00000000..2d56e7af --- /dev/null +++ b/functions/public/Invoke-WPFExportEnvironmentReport.ps1 @@ -0,0 +1,87 @@ +function Invoke-WPFExportEnvironmentReport { + <# + .SYNOPSIS + Exports an allowlisted, read-only environment report as JSON, with an optional bundle of + recent WinUtil logs. + #> + + try { + $includeLogs = [System.Windows.MessageBox]::Show( + $sync.Form, + "Also include the last 7 days of WinUtil logs? This can help maintainers troubleshoot an issue.", + "Environment Report", "YesNo", "Question") -eq "Yes" + + Add-Type -AssemblyName System.Windows.Forms + $dialog = [System.Windows.Forms.SaveFileDialog]::new() + $dialog.Title = "Export Environment Report" + $dialog.Filter = "JSON files (*.json)|*.json" + $dialog.FileName = "WinUtilEnvironmentReport_$(Get-Date -Format 'yyyyMMdd').json" + $dialog.InitialDirectory = [Environment]::GetFolderPath("Desktop") + + if ($dialog.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { + return + } + + $jsonPath = $dialog.FileName + $logsPath = Get-WinUtilEnvironmentReportLogsPath -JsonPath $jsonPath + + # SaveFileDialog's own overwrite prompt only covers $jsonPath. $logsPath is derived and never + # shown to the user, so a same-day re-export would otherwise silently replace it. + if ($includeLogs -and (Test-Path $logsPath)) { + $includeLogs = [System.Windows.MessageBox]::Show( + $sync.Form, + "A logs file already exists at:`n$logsPath`n`nReplace it?", + "Environment Report", "YesNo", "Warning") -eq "Yes" + } + + Write-WinUtilLog -Component "EnvironmentReport" -Message "Environment report export started." + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Exporting environment report..." -Percent 0 + + # Registry reads across every tweak/toggle and the log bundle's file read add up to a + # couple of seconds. This handler runs directly on the WPF dispatcher thread (wired from + # the Settings menu), so the collection and write happen in a background runspace to avoid + # freezing the window. + Invoke-WPFRunspace -ParameterList @(("JsonPath", $jsonPath), ("LogsPath", $logsPath), ("IncludeLogs", $includeLogs)) -ScriptBlock { + param($JsonPath, $LogsPath, $IncludeLogs) + + try { + $report = Get-WinUtilEnvironmentReport + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Writing environment report..." -Percent 60 + $json = $report | ConvertTo-Json -Depth 6 + [System.IO.File]::WriteAllText($JsonPath, $json, [System.Text.UTF8Encoding]::new($false)) + + if ($IncludeLogs) { + $logs = Get-WinUtilRecentLogs + [System.IO.File]::WriteAllText($LogsPath, $logs, [System.Text.UTF8Encoding]::new($false)) + } + + Write-WinUtilLog -Component "EnvironmentReport" -Message "Environment report exported to $JsonPath." + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Environment export completed" -Percent 100 + Invoke-WPFUIThread { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } + } catch { + # No MessageBox here: it would hop through Invoke-WPFUIThread/Dispatcher.Invoke from + # this background thread, the combination that can stall/freeze the UI. + # The progress label, taskbar overlay, and log line carry the failure instead. + Write-WinUtilLog -Component "EnvironmentReport" -Level "ERROR" -Message "Environment report export failed: $($_.Exception.Message)" + Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Environment export failed: $($_.Exception.Message)" -Percent 100 + Invoke-WPFUIThread { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } + } + + # This is wired from a Settings-menu item, not a feature.json Button, so it never + # benefits from Invoke-WPFButton's implicit "clear the progress indicator on the next + # click" reset. Hide it explicitly instead, after a brief pause so the completed/failed + # label is actually visible. + Start-Sleep -Seconds 3 + Set-WinUtilTweaksProgressIndicator -Visible $false + } | Out-Null + } catch { + Write-WinUtilLog -Component "EnvironmentReport" -Level "ERROR" -Message "Environment report export failed: $($_.Exception.Message)" + [System.Windows.MessageBox]::Show( + $sync.Form, + "The environment report could not be exported. $($_.Exception.Message)", + "Environment Report", + "OK", + "None" + ) | Out-Null + } +} diff --git a/pester/environment-report-logs.Tests.ps1 b/pester/environment-report-logs.Tests.ps1 new file mode 100644 index 00000000..86dcf4fa --- /dev/null +++ b/pester/environment-report-logs.Tests.ps1 @@ -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 + } +} diff --git a/pester/environment-report.Tests.ps1 b/pester/environment-report.Tests.ps1 new file mode 100644 index 00000000..e8dd4153 --- /dev/null +++ b/pester/environment-report.Tests.ps1 @@ -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 + } +} diff --git a/scripts/main.ps1 b/scripts/main.ps1 index 2798a528..0f9801f6 100644 --- a/scripts/main.ps1 +++ b/scripts/main.ps1 @@ -431,6 +431,10 @@ $sync["ExportMenuItem"].Add_Click({ Invoke-WPFPopup -Action "Hide" -Popups @("Settings") Invoke-WPFImpex -type "export" }) +$sync["ExportEnvironmentReportMenuItem"].Add_Click({ + Invoke-WPFPopup -Action "Hide" -Popups @("Settings") + Invoke-WPFExportEnvironmentReport +}) $sync["AboutMenuItem"].Add_Click({ Invoke-WPFPopup -Action "Hide" -Popups @("Settings") diff --git a/xaml/inputXML.xaml b/xaml/inputXML.xaml index e7dabfe4..a6b47267 100644 --- a/xaml/inputXML.xaml +++ b/xaml/inputXML.xaml @@ -1282,6 +1282,12 @@ + + + + + +