From 71c0b6dff8708b400432904130b1e9d2c93b09db Mon Sep 17 00:00:00 2001 From: Tim Aronsson Date: Tue, 8 Sep 2026 00:07:25 +0200 Subject: [PATCH 1/2] fix: derive bootstrap completion and reports from run outcomes --- bootstrap.ps1 | 77 ++++++-------- modules/BootstrapRun.ps1 | 160 ++++++++++++++++++++--------- modules/WinGetInstall.ps1 | 4 +- tests/BootstrapOutcomes.Tests.ps1 | 163 ++++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 97 deletions(-) create mode 100644 tests/BootstrapOutcomes.Tests.ps1 diff --git a/bootstrap.ps1 b/bootstrap.ps1 index fa54120..5cd5aa1 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -63,6 +63,12 @@ $SophiaDownloadUrl = "https://github.com/farag2/Sophia-Script-for-Windows/releas $FailedInstallsLog = Join-Path $SetupPath "failed-installs.log" $StepIds = @("winget", "repo", "sophia", "postInstallTweaks", "registry", "shortcut", "restoreShortcut", "optionalShortcut", "optionalWinget", "summary") $SetupState = $null +$RunStartedAt = (Get-Date).ToString('o') +$RunStepIds = @($StepIds | Where-Object { $_ -notin @('summary', 'optionalWinget') }) +if ($OptionalAppsOnly) { $RunStepIds = @('optionalWinget') } +if ($ConfigRoot.TrimEnd('\') -ne $SetupPath.TrimEnd('\')) { + $RunStepIds = @($RunStepIds | Where-Object { $_ -ne 'repo' }) +} $SummaryItems = [System.Collections.Generic.List[object]]::new() $FailedItems = [System.Collections.Generic.List[object]]::new() $script:BackupManifestPath = $null @@ -1043,8 +1049,8 @@ function Set-RegistryValueSafe { [string]$Type ) - if (-not (Test-Path $Path)) { - New-Item -Path $Path -Force | Out-Null + if (-not (Test-Path $Path -ErrorAction Stop)) { + New-Item -Path $Path -Force -ErrorAction Stop | Out-Null } if ($Type -eq "DWord") { @@ -1057,17 +1063,23 @@ function Set-RegistryValueSafe { } if ($Name -eq "(Default)") { - Set-Item -Path $Path -Value $typedValue -Force + Set-Item -Path $Path -Value $typedValue -Force -ErrorAction Stop return } - New-ItemProperty -Path $Path -Name $Name -Value $typedValue -PropertyType $propertyType -Force | Out-Null + New-ItemProperty -Path $Path -Name $Name -Value $typedValue -PropertyType $propertyType -Force -ErrorAction Stop | Out-Null } function Remove-ProvisionedAppIfPresent { param([Parameter(Mandatory)][string]$DisplayName) - $matches = Get-AppxProvisionedPackage -Online | Where-Object { $_.DisplayName -eq $DisplayName } + try { + $matches = Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object { $_.DisplayName -eq $DisplayName } + } + catch { + Add-FailedItem -Category "Post-Install Tweaks" -Item $DisplayName -Reason $_.Exception.Message + return $false + } if (-not $matches) { Write-Log "Provisioned app not present: $DisplayName" -Level INFO return $true @@ -1095,8 +1107,9 @@ function Disable-OptionalFeatureIfPresent { $feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop } catch { - Write-Log "Optional feature not found or unavailable: $FeatureName" -Level INFO - return $true + Write-Log "Cannot check optional feature ${FeatureName}: $($_.Exception.Message)" -Level WARNING + Add-FailedItem -Category "Post-Install Tweaks" -Item $FeatureName -Reason $_.Exception.Message + return $false } if ($feature.State -in @("Disabled", "DisabledWithPayloadRemoved")) { @@ -1142,7 +1155,7 @@ function Invoke-PostInstallTweaks { try { Set-RegistryValueSafe -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Communications" -Name "ConfigureChatAutoInstall" -Value 0 -Type DWord Set-RegistryValueSafe -Path "HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device\Start" -Name "ConfigureStartPins" -Value '{"pinnedList":[]}' -Type String - Set-RegistryValueSafe -Path "HKU:\.DEFAULT\Control Panel\Accessibility\StickyKeys" -Name "Flags" -Value "10" -Type String + Set-RegistryValueSafe -Path "Registry::HKEY_USERS\.DEFAULT\Control Panel\Accessibility\StickyKeys" -Name "Flags" -Value "10" -Type String Set-RegistryValueSafe -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" -Name "HideFileExt" -Value 0 -Type DWord Set-RegistryValueSafe -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" -Name "Hidden" -Value 1 -Type DWord @@ -1338,15 +1351,13 @@ try { $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { - Write-Log "ERROR: This script must be run as Administrator" -Level ERROR - exit 1 + throw "This script must be run as Administrator" } Write-Log "Administrator privileges verified" -Level SUCCESS if (-not (Test-Path $SetupPath)) { - Write-Log "ERROR: Setup directory not found at $SetupPath" -Level ERROR - exit 1 + throw "Setup directory not found at $SetupPath" } $SetupState = Initialize-State -StatePath $StateFile -StepIds $StepIds @@ -1678,46 +1689,14 @@ try { } if ($installOptionalApps) { + if ('optionalWinget' -notin $RunStepIds) { $RunStepIds += 'optionalWinget' } $null = Invoke-WingetManifestInstall -ManifestPath $OptionalAppsJson -StepId $stepId -SummaryStep "Optional Apps" -MarkerPath $OptionalWingetMarker -ManifestLabel "optional-apps.json" -MissingManifestMessage "optional-apps.json not found" } } - $stepId = "summary" - if (-not (Should-RunStep -StepId $stepId)) { - Write-Log "Step 10: Skipping summary report (already completed)" -Level INFO - } - elseif ($DryRun) { - Write-Log "Step 10: Dry run - skipping summary report" -Level WARNING - Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: summary skipped" - } - else { - try { - $failedReportPath = Write-FailedInstallsReport -DesktopPath $desktopPath - if ($FailedItems.Count -gt 0) { - Write-Log "Failed installs report written to $failedReportPath ($($FailedItems.Count) item(s))" -Level WARNING - } - else { - Write-Log "No failed installs - report written to $failedReportPath" -Level SUCCESS - } - - $summaryPath = Write-SummaryReport -DesktopPath $desktopPath - Write-Log "Summary report written to $summaryPath" -Level SUCCESS - Set-StepState -StepId $stepId -Status "done" -Message "Summary report written" - } - catch { - Write-Log "WARNING: Failed to write summary report: $($_.Exception.Message)" -Level WARNING - Set-StepState -StepId $stepId -Status "failed" -Message "Summary report failed" - } - } - - Write-Log "========================================" -Level INFO - Write-Log "Windows Setup Bootstrap - Completed" -Level SUCCESS - Write-Log "========================================" -Level INFO - Write-Log "Log file saved to: $LogFile" -Level INFO - - Update-SetupProgress -Phase 'Completed' -Status 'Windows setup bootstrap completed' -CurrentPackage '' -PackageIndex 0 -PackageTotal 0 -Mode 'admin' + $runResult = Complete-BootstrapRun -DesktopPath $desktopPath - if ($PromptRestart) { + if ($PromptRestart -and -not $DryRun) { $restartResponse = Read-Host "Restart now? (Y/N)" if ($restartResponse -match '^(y|yes)$') { Write-Log "Restarting system..." -Level WARNING @@ -1731,6 +1710,8 @@ try { catch { Write-Log "FATAL ERROR: $($_.Exception.Message)" -Level ERROR Write-Log "Stack Trace: $($_.ScriptStackTrace)" -Level ERROR - Update-SetupProgress -Phase 'Failed' -Status $_.Exception.Message -CurrentPackage '' -PackageIndex 0 -PackageTotal 0 -Mode 'admin' + $null = Complete-BootstrapRun -DesktopPath ([Environment]::GetFolderPath("Desktop")) -FailureMessage $_.Exception.Message exit 1 } + +exit $runResult.ExitCode diff --git a/modules/BootstrapRun.ps1 b/modules/BootstrapRun.ps1 index 79bb1ad..b20f831 100644 --- a/modules/BootstrapRun.ps1 +++ b/modules/BootstrapRun.ps1 @@ -187,87 +187,153 @@ function Add-SummaryItem { }) } +function Get-BootstrapRunResult { + param([string]$FailureMessage = "") + + $records = @( + foreach ($id in $SetupState.steps.Keys) { + if ($id -eq 'summary') { continue } + $step = $SetupState.steps[$id] + $included = $id -in $RunStepIds + $origin = if ($step.lastRun -and $step.lastRun -ge $RunStartedAt) { 'Current run' } else { 'Previous result' } + [pscustomobject]@{ + Step = $id + Status = $step.status + Message = $step.message + Origin = $origin + Included = $included + } + } + ) + $problems = @( + foreach ($record in $records) { + if ($record.Included -and $record.Status -notin @('done', 'skipped')) { + [pscustomobject]@{ + Category = $record.Step + Item = $record.Status + Reason = $record.Message + Status = if ($record.Status -eq 'unverified') { 'WARN' } else { 'FAIL' } + } + } + } + foreach ($item in $FailedItems) { $item } + foreach ($item in $SummaryItems) { + if ($item.Status -in @('FAIL', 'WARN')) { + [pscustomobject]@{ Category = $item.Step; Item = $item.Status; Reason = $item.Message; Status = $item.Status } + } + } + if ($FailureMessage) { + [pscustomobject]@{ Category = 'Bootstrap'; Item = 'Fatal error'; Reason = $FailureMessage; Status = 'FAIL' } + } + ) + $failed = @($problems | Where-Object { $_.Status -ne 'WARN' }).Count -gt 0 + $status = if ($failed) { 'Failed' } elseif ($problems.Count) { 'Completed with warnings' } else { 'Completed' } + if ($DryRun -and -not $FailureMessage) { $status = 'Preview'; $failed = $false } + [pscustomobject]@{ + StartedAt = $RunStartedAt + Mode = if ($OptionalAppsOnly) { 'Optional apps only' } else { 'Full setup' } + Status = $status + ExitCode = if ($failed) { 1 } else { 0 } + Records = $records + Problems = $problems + } +} + function Write-SummaryReport { - param([string]$DesktopPath) + param([string]$DesktopPath, [Parameter(Mandatory)][object]$Result) $summaryPath = Join-Path $DesktopPath "Setup Summary.txt" - $summaryLines = @( + $lines = @( "Declarative Windows Setup Summary", - "Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')", + "Run started: $($Result.StartedAt)", + "Mode: $($Result.Mode)", + "Result: $($Result.Status)", "" ) - - foreach ($item in $SummaryItems) { - $statusSymbol = switch ($item.Status) { - "OK" { "✓" } - "WARN" { "⚠" } - "FAIL" { "✗" } - default { $item.Status } - } - $summaryLines += "{0} {1}: {2}" -f $statusSymbol, $item.Step, $item.Message + foreach ($record in $Result.Records) { + $scope = if ($record.Included) { 'Included' } else { 'Not selected this run' } + $lines += "{0}: {1} - {2} [{3}; {4}]" -f $record.Step, $record.Status, $record.Message, $record.Origin, $scope } - - Set-Content -Path $summaryPath -Value $summaryLines -Force + foreach ($problem in $Result.Problems) { + $lines += "{0} {1}: {2} - {3}" -f $problem.Status, $problem.Category, $problem.Item, $problem.Reason + } + Set-Content -LiteralPath $summaryPath -Value $lines -Force -ErrorAction Stop return $summaryPath } function Add-FailedItem { param( - [Parameter(Mandatory)] - [string]$Category, - - [Parameter(Mandatory)] - [string]$Item, - - [string]$Reason = "" + [Parameter(Mandatory)][string]$Category, + [Parameter(Mandatory)][string]$Item, + [string]$Reason = "", + [ValidateSet('FAIL', 'WARN')][string]$Status = 'FAIL' ) $FailedItems.Add([pscustomobject]@{ Category = $Category - Item = $Item - Reason = $Reason + Item = $Item + Reason = $Reason + Status = $Status }) } function Write-FailedInstallsReport { - param([string]$DesktopPath) + param([string]$DesktopPath, [Parameter(Mandatory)][object]$Result) - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $lines = @( - "Failed Installs - $timestamp", - "========================================" + "Setup failures and warnings", + "Run started: $($Result.StartedAt)", + "Mode: $($Result.Mode)", + "Result: $($Result.Status)", + "" ) - - if ($FailedItems.Count -eq 0) { - $lines += "" - $lines += "No failures recorded. Everything installed successfully." + if ($Result.Problems.Count -eq 0) { + $lines += "No failures or warnings in the selected steps. See Setup Summary.txt for skipped and previous results." } else { - $categories = $FailedItems | Select-Object -ExpandProperty Category -Unique - foreach ($category in $categories) { - $lines += "" - $lines += "${category}:" - foreach ($entry in ($FailedItems | Where-Object { $_.Category -eq $category })) { - $detail = if ($entry.Reason) { " - $($entry.Reason)" } else { "" } - $lines += " - $($entry.Item)$detail" - } + foreach ($problem in $Result.Problems) { + $lines += "{0} {1}: {2} - {3}" -f $problem.Status, $problem.Category, $problem.Item, $problem.Reason } - $lines += "" - $lines += "========================================" - $lines += "Review the items above and install/apply them manually." } - - $lines | Set-Content -Path $FailedInstallsLog -Force - + $lines | Set-Content -LiteralPath $FailedInstallsLog -Force -ErrorAction Stop if ($DesktopPath) { $desktopReport = Join-Path $DesktopPath "Failed Installs.txt" - $lines | Set-Content -Path $desktopReport -Force + $lines | Set-Content -LiteralPath $desktopReport -Force -ErrorAction Stop return $desktopReport } - return $FailedInstallsLog } +function Complete-BootstrapRun { + param([string]$DesktopPath, [string]$FailureMessage = "") + + $result = Get-BootstrapRunResult -FailureMessage $FailureMessage + if (-not $DryRun) { + try { + $null = Write-FailedInstallsReport -DesktopPath $DesktopPath -Result $result + $null = Write-SummaryReport -DesktopPath $DesktopPath -Result $result + } + catch { + $reportFailure = "Report generation failed: $($_.Exception.Message)" + Write-Log $reportFailure -Level ERROR + $result = Get-BootstrapRunResult -FailureMessage (($FailureMessage, $reportFailure | Where-Object { $_ }) -join '; ') + # Correct any report that was written before the other destination failed. + foreach ($writer in @('Write-FailedInstallsReport', 'Write-SummaryReport')) { + try { + $null = & $writer -DesktopPath $DesktopPath -Result $result + } + catch { + Write-Log "Cannot publish corrected report: $($_.Exception.Message)" -Level ERROR + } + } + } + } + $level = if ($result.ExitCode) { 'ERROR' } elseif ($result.Status -eq 'Completed') { 'SUCCESS' } else { 'WARNING' } + Write-Log "Windows Setup Bootstrap - $($result.Status)" -Level $level + Update-SetupProgress -Phase $result.Status -Status "Windows setup bootstrap: $($result.Status)" -ResetPackage -Mode 'admin' + return $result +} + function Invoke-BootstrapRunStep { param( [Parameter(Mandatory)] diff --git a/modules/WinGetInstall.ps1 b/modules/WinGetInstall.ps1 index 9512d3d..e4637e9 100644 --- a/modules/WinGetInstall.ps1 +++ b/modules/WinGetInstall.ps1 @@ -429,7 +429,7 @@ function Invoke-WingetManifestInstall { if ($unverified) { $unverifiedPackages.Add($packageId) - Add-FailedItem -Category "$SummaryStep Verification" -Item $packageId -Reason "WinGet reported success but winget list did not verify the package" + Add-FailedItem -Category "$SummaryStep Verification" -Item $packageId -Reason "WinGet reported success but winget list did not verify the package" -Status WARN Write-Log "WARNING: $packageId install reported success, but winget list did not verify it" -Level WARNING continue } @@ -455,7 +455,7 @@ function Invoke-WingetManifestInstall { if ($unverifiedCount -gt 0) { Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message "Installed $($installedPackages.Count) package(s); $unverifiedCount verification warning(s)" - Set-StepState -StepId $StepId -Status "done" -Message "Installed with $unverifiedCount verification warning(s)" + Set-StepState -StepId $StepId -Status "unverified" -Message "Installed with $unverifiedCount verification warning(s)" } else { Add-SummaryItem -Step $SummaryStep -Status "OK" -Message "Installed $($installedPackages.Count) package(s)" diff --git a/tests/BootstrapOutcomes.Tests.ps1 b/tests/BootstrapOutcomes.Tests.ps1 new file mode 100644 index 0000000..1042422 --- /dev/null +++ b/tests/BootstrapOutcomes.Tests.ps1 @@ -0,0 +1,163 @@ +BeforeAll { + $repoRoot = Split-Path $PSScriptRoot -Parent + . (Join-Path $repoRoot 'modules/BootstrapRun.ps1') + $tokens = $null + $parseErrors = $null + $bootstrapAst = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repoRoot 'bootstrap.ps1'), [ref]$tokens, [ref]$parseErrors) + if ($parseErrors) { throw ($parseErrors | Out-String) } + foreach ($name in @('Set-RegistryValueSafe', 'Remove-ProvisionedAppIfPresent', 'Disable-OptionalFeatureIfPresent', 'Invoke-PostInstallTweaks')) { + $definition = $bootstrapAst.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $false) + . ([scriptblock]::Create($definition.Extent.Text)) + } + function Write-Log { param($Message, $Level) } +} + +Describe 'Bootstrap run outcomes and reports' { + BeforeEach { + $StateFile = Join-Path $TestDrive 'state.json' + $FailedInstallsLog = Join-Path $TestDrive 'failed-installs.log' + $ProgressFile = Join-Path $TestDrive 'progress.json' + $script:ProgressState = @{ phase = ''; status = ''; currentPackage = ''; packageIndex = 0; packageTotal = 0; mode = ''; lastUpdated = $null } + $RunStartedAt = (Get-Date).ToString('o') + $RunStepIds = @('winget', 'repo', 'registry') + $SetupState = Initialize-State -StatePath (Join-Path $TestDrive 'unused.json') -StepIds @('winget', 'repo', 'registry', 'optionalWinget', 'summary') + $SummaryItems = [Collections.Generic.List[object]]::new() + $FailedItems = [Collections.Generic.List[object]]::new() + $OptionalAppsOnly = $false + $DryRun = $false + foreach ($id in $RunStepIds) { + $SetupState.steps[$id].status = 'done' + $SetupState.steps[$id].lastRun = '2026-01-01T00:00:00.0000000Z' + $SetupState.steps[$id].message = 'Previously applied' + } + $SetupState.steps.summary.status = 'done' + } + + It 'reports a state-only failure consistently and replaces stale reports after retry' { + Set-StepState -StepId winget -Status failed -Message 'Network unavailable' + $result = Complete-BootstrapRun -DesktopPath $TestDrive + $result.ExitCode | Should -Be 1 + $result.Status | Should -Be 'Failed' + (Get-Content $ProgressFile -Raw | ConvertFrom-Json).phase | Should -Be $result.Status + Get-Content (Join-Path $TestDrive 'Setup Summary.txt') -Raw | Should -Match 'Network unavailable' + Get-Content (Join-Path $TestDrive 'Failed Installs.txt') -Raw | Should -Match 'Network unavailable' + $result.Records | Where-Object Step -EQ repo | Select-Object -ExpandProperty Origin | Should -Be 'Previous result' + + $RunStartedAt = (Get-Date).ToString('o') + Set-StepState -StepId winget -Status done -Message 'Installed' + $result = Complete-BootstrapRun -DesktopPath $TestDrive + $result.Status | Should -Be 'Completed' + $result.ExitCode | Should -Be 0 + $SetupState.steps.repo.status | Should -Be 'done' + $report = Get-Content (Join-Path $TestDrive 'Failed Installs.txt') -Raw + $report | Should -Not -Match 'Network unavailable' + $report | Should -Match ([regex]::Escape($RunStartedAt)) + } + + It 'includes repo and registry failures even without detailed FailedItems' { + Set-StepState -StepId repo -Status failed -Message 'Clone failed' + Set-StepState -StepId registry -Status failed -Message 'Registry script missing' + $result = Complete-BootstrapRun -DesktopPath $TestDrive + $result.ExitCode | Should -Be 1 + $report = Get-Content $FailedInstallsLog -Raw + $report | Should -Match 'Clone failed' + $report | Should -Match 'Registry script missing' + $report | Should -Not -Match 'Everything installed successfully' + } + + It 'refreshes optional-only reports while preserving core failures as history' { + $SetupState.steps.winget.status = 'failed' + $SetupState.steps.winget.message = 'Previous core failure' + $OptionalAppsOnly = $true + $RunStepIds = @('optionalWinget') + Set-StepState -StepId optionalWinget -Status done -Message 'Optional app installed' + $result = Complete-BootstrapRun -DesktopPath $TestDrive + $result.ExitCode | Should -Be 0 + $report = Get-Content (Join-Path $TestDrive 'Setup Summary.txt') -Raw + $report | Should -Match 'Optional apps only' + $report | Should -Match 'Previous core failure.*Previous result; Not selected this run' + $report | Should -Match 'Optional app installed.*Current run; Included' + $SetupState.steps.winget.status | Should -Be 'failed' + } + + It 'distinguishes unverified installs from package failures and optional skips' { + $SetupState.steps.optionalWinget.status = 'pending' + Set-StepState -StepId winget -Status unverified -Message 'Package verification pending' + Add-FailedItem -Category 'WinGet Verification' -Item 'Example.App' -Reason 'Not verified' -Status WARN + $result = Get-BootstrapRunResult + $result.Status | Should -Be 'Completed with warnings' + $result.ExitCode | Should -Be 0 + Add-FailedItem -Category WinGet -Item 'Example.Broken' -Reason 'Installer failed' + $result = Get-BootstrapRunResult + $result.Status | Should -Be 'Failed' + $result.ExitCode | Should -Be 1 + } + + It 'returns failure when a required step is still pending or reporting fails' { + Set-StepState -StepId registry -Status pending -Message 'Not attempted' + (Get-BootstrapRunResult).ExitCode | Should -Be 1 + Set-StepState -StepId registry -Status done -Message 'Applied' + Mock Write-SummaryReport { throw 'Access denied' } + $result = Complete-BootstrapRun -DesktopPath $TestDrive + $result.ExitCode | Should -Be 1 + (Get-Content $ProgressFile -Raw | ConvertFrom-Json).phase | Should -Be 'Failed' + ($result.Problems.Reason -join ';') | Should -Match 'Access denied' + Get-Content $FailedInstallsLog -Raw | Should -Match 'Result: Failed' + } + + It 'writes fatal prerequisite failures into both reports' { + $result = Complete-BootstrapRun -DesktopPath $TestDrive -FailureMessage 'Prerequisite unavailable' + $result.ExitCode | Should -Be 1 + Get-Content $FailedInstallsLog -Raw | Should -Match 'Prerequisite unavailable' + Get-Content (Join-Path $TestDrive 'Setup Summary.txt') -Raw | Should -Match 'Result: Failed' + } + + It 'does not rewrite reports during a preview' { + $DryRun = $true + Mock Write-SummaryReport { throw 'Must not write' } + Mock Write-FailedInstallsReport { throw 'Must not write' } + (Complete-BootstrapRun -DesktopPath $TestDrive).Status | Should -Be 'Preview' + Should -Invoke Write-SummaryReport -Times 0 -Exactly + Should -Invoke Write-FailedInstallsReport -Times 0 -Exactly + } + + It 'connects the orchestrator exit to the aggregate result' { + $bootstrapAst.EndBlock.Statements[-1].Extent.Text | Should -Be 'exit $runResult.ExitCode' + $bootstrapAst.Extent.Text | Should -Not -Match 'Skipping summary report \(already completed\)' + } +} + +Describe 'Post-install operation failures' { + BeforeEach { + $FailedItems = [Collections.Generic.List[object]]::new() + } + + It 'makes registry provider writes terminating even with Continue preference' { + $ErrorActionPreference = 'Continue' + Mock Test-Path { $false } + Mock New-Item { Write-Error 'Registry denied' } + { Set-RegistryValueSafe -Path 'Registry::HKEY_USERS\.DEFAULT\Synthetic' -Name Flags -Value 10 -Type DWord } | Should -Throw '*Registry denied*' + } + + It 'does not mark tweaks done after a registry write fails' { + Mock Remove-ProvisionedAppIfPresent { $true } + Mock Disable-OptionalFeatureIfPresent { $true } + Mock Set-RegistryValueSafe { throw 'Registry denied' } + Mock Get-Process { } + (Invoke-PostInstallTweaks) | Should -BeFalse + $FailedItems.Reason | Should -Contain 'Registry denied' + } + + It 'uses the provider-qualified default-user hive without an HKU drive' { + Mock Remove-ProvisionedAppIfPresent { $true } + Mock Disable-OptionalFeatureIfPresent { $true } + Mock Set-RegistryValueSafe { } + Mock Get-Process { } + (Invoke-PostInstallTweaks) | Should -BeTrue + Should -Invoke Set-RegistryValueSafe -Times 1 -Exactly -ParameterFilter { + $Path -eq 'Registry::HKEY_USERS\.DEFAULT\Control Panel\Accessibility\StickyKeys' + } + } +} From b9b2cc70b5e671f0e6da0efe3118483c6c56f815 Mon Sep 17 00:00:00 2001 From: Tim Aronsson Date: Tue, 8 Sep 2026 00:10:19 +0200 Subject: [PATCH 2/2] fix: publish failure reports when setup paths are unavailable --- modules/BootstrapRun.ps1 | 46 +++++++++++++++++++------------ tests/BootstrapOutcomes.Tests.ps1 | 14 ++++++++++ 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/modules/BootstrapRun.ps1 b/modules/BootstrapRun.ps1 index b20f831..51e4eb8 100644 --- a/modules/BootstrapRun.ps1 +++ b/modules/BootstrapRun.ps1 @@ -165,7 +165,7 @@ function Update-SetupProgress { } $script:ProgressState.lastUpdated = (Get-Date).ToString('o') - [pscustomobject]$script:ProgressState | ConvertTo-Json -Depth 4 | Set-Content -Path $ProgressFile -Encoding UTF8 -Force + [pscustomobject]$script:ProgressState | ConvertTo-Json -Depth 4 | Set-Content -Path $ProgressFile -Encoding UTF8 -Force -ErrorAction Stop } function Add-SummaryItem { @@ -295,42 +295,52 @@ function Write-FailedInstallsReport { $lines += "{0} {1}: {2} - {3}" -f $problem.Status, $problem.Category, $problem.Item, $problem.Reason } } - $lines | Set-Content -LiteralPath $FailedInstallsLog -Force -ErrorAction Stop + $reportPaths = @($FailedInstallsLog) if ($DesktopPath) { $desktopReport = Join-Path $DesktopPath "Failed Installs.txt" - $lines | Set-Content -LiteralPath $desktopReport -Force -ErrorAction Stop - return $desktopReport + $reportPaths += $desktopReport } - return $FailedInstallsLog + $writeErrors = @( + foreach ($path in $reportPaths) { + try { $lines | Set-Content -LiteralPath $path -Force -ErrorAction Stop } + catch { $_.Exception.Message } + } + ) + if ($writeErrors.Count) { throw ($writeErrors -join '; ') } + return $reportPaths[-1] } function Complete-BootstrapRun { param([string]$DesktopPath, [string]$FailureMessage = "") $result = Get-BootstrapRunResult -FailureMessage $FailureMessage + $publicationErrors = @() if (-not $DryRun) { try { $null = Write-FailedInstallsReport -DesktopPath $DesktopPath -Result $result $null = Write-SummaryReport -DesktopPath $DesktopPath -Result $result } catch { - $reportFailure = "Report generation failed: $($_.Exception.Message)" - Write-Log $reportFailure -Level ERROR - $result = Get-BootstrapRunResult -FailureMessage (($FailureMessage, $reportFailure | Where-Object { $_ }) -join '; ') - # Correct any report that was written before the other destination failed. - foreach ($writer in @('Write-FailedInstallsReport', 'Write-SummaryReport')) { - try { - $null = & $writer -DesktopPath $DesktopPath -Result $result - } - catch { - Write-Log "Cannot publish corrected report: $($_.Exception.Message)" -Level ERROR - } - } + $publicationErrors += "Report generation failed: $($_.Exception.Message)" + $result = Get-BootstrapRunResult -FailureMessage ((@($FailureMessage) + $publicationErrors | Where-Object { $_ }) -join '; ') + } + } + try { + Update-SetupProgress -Phase $result.Status -Status "Windows setup bootstrap: $($result.Status)" -ResetPackage -Mode 'admin' + } + catch { + $publicationErrors += "Progress publication failed: $($_.Exception.Message)" + $result = Get-BootstrapRunResult -FailureMessage ((@($FailureMessage) + $publicationErrors | Where-Object { $_ }) -join '; ') + } + if ($publicationErrors.Count -and -not $DryRun) { + # Publish the corrected failure result to each destination still writable. + foreach ($writer in @('Write-FailedInstallsReport', 'Write-SummaryReport')) { + try { $null = & $writer -DesktopPath $DesktopPath -Result $result } + catch { Write-Log "Cannot publish corrected report: $($_.Exception.Message)" -Level ERROR } } } $level = if ($result.ExitCode) { 'ERROR' } elseif ($result.Status -eq 'Completed') { 'SUCCESS' } else { 'WARNING' } Write-Log "Windows Setup Bootstrap - $($result.Status)" -Level $level - Update-SetupProgress -Phase $result.Status -Status "Windows setup bootstrap: $($result.Status)" -ResetPackage -Mode 'admin' return $result } diff --git a/tests/BootstrapOutcomes.Tests.ps1 b/tests/BootstrapOutcomes.Tests.ps1 index 1042422..b7b26a3 100644 --- a/tests/BootstrapOutcomes.Tests.ps1 +++ b/tests/BootstrapOutcomes.Tests.ps1 @@ -114,6 +114,20 @@ Describe 'Bootstrap run outcomes and reports' { Get-Content (Join-Path $TestDrive 'Setup Summary.txt') -Raw | Should -Match 'Result: Failed' } + It 'publishes desktop failure reports even when the setup log directory is missing' { + $FailedInstallsLog = Join-Path $TestDrive 'missing/failed-installs.log' + $ProgressFile = Join-Path $TestDrive 'missing/progress.json' + $result = Complete-BootstrapRun -DesktopPath $TestDrive -FailureMessage 'Setup directory missing' + $result.ExitCode | Should -Be 1 + foreach ($name in @('Setup Summary.txt', 'Failed Installs.txt')) { + $report = Get-Content (Join-Path $TestDrive $name) -Raw + $report | Should -Match 'Result: Failed' + $report | Should -Match 'Setup directory missing' + $report | Should -Match 'Report generation failed' + $report | Should -Match 'Progress publication failed' + } + } + It 'does not rewrite reports during a preview' { $DryRun = $true Mock Write-SummaryReport { throw 'Must not write' }