diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 5cd5aa1..0b118a0 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -185,7 +185,9 @@ function Write-Log { default { Write-Host $logMessage } } - Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue + if (-not $DryRun) { + Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue + } } function Add-SummaryItem { @@ -1527,8 +1529,8 @@ try { } elseif (-not (Test-Path $RegistryConfig)) { Write-Log "WARNING: registry.json not found at $RegistryConfig" -Level WARNING - Add-SummaryItem -Step "Registry" -Status "WARN" -Message "registry.json not found" - Set-StepState -StepId $stepId -Status "done" -Message "registry.json not found" + Add-SummaryItem -Step "Registry" -Status "SKIP" -Message "registry.json not found" + Set-StepState -StepId $stepId -Status "skipped" -Message "registry.json not found" } elseif (-not (Test-Path $RegistryScript)) { Write-Log "ERROR: Registry apply script not found at $RegistryScript" -Level ERROR @@ -1637,8 +1639,8 @@ try { } elseif (-not (Test-Path $OptionalAppsJson)) { Write-Log "optional-apps.json not found at $OptionalAppsJson - skipping optional apps shortcut" -Level INFO - Add-SummaryItem -Step "Optional Apps Shortcut" -Status "WARN" -Message "optional-apps.json not found" - Set-StepState -StepId $stepId -Status "done" -Message "optional-apps.json not found" + Add-SummaryItem -Step "Optional Apps Shortcut" -Status "SKIP" -Message "optional-apps.json not found" + Set-StepState -StepId $stepId -Status "skipped" -Message "optional-apps.json not found" } else { try { @@ -1669,8 +1671,8 @@ try { elseif (-not (Test-Path $OptionalAppsJson)) { if ($OptionalAppsOnly) { Write-Log "WARNING: optional-apps.json not found at $OptionalAppsJson" -Level WARNING - Add-SummaryItem -Step "Optional Apps" -Status "WARN" -Message "optional-apps.json not found" - Set-StepState -StepId $stepId -Status "done" -Message "optional-apps.json not found" + Add-SummaryItem -Step "Optional Apps" -Status "FAIL" -Message "optional-apps.json not found" + Set-StepState -StepId $stepId -Status "failed" -Message "optional-apps.json not found" } } else { diff --git a/modules/BootstrapRun.ps1 b/modules/BootstrapRun.ps1 index 51e4eb8..07304f1 100644 --- a/modules/BootstrapRun.ps1 +++ b/modules/BootstrapRun.ps1 @@ -69,6 +69,8 @@ function Save-State { [string]$StatePath ) + if ($DryRun) { return } + $State.lastUpdated = (Get-Date).ToString("o") $State | ConvertTo-Json -Depth 6 | Set-Content -Path $StatePath -Force } @@ -85,6 +87,8 @@ function Set-StepState { [string]$Message ) + if ($DryRun) { return } + if (-not $SetupState.steps.Contains($StepId)) { $SetupState.steps[$StepId] = [pscustomobject]@{ status = "pending" @@ -120,6 +124,11 @@ function Should-RunStep { return $true } + # Older runs incorrectly completed this step when the optional input was absent. + if ($StepId -eq 'optionalShortcut' -and $SetupState.steps[$StepId].message -eq 'optional-apps.json not found') { + return $true + } + return $SetupState.steps[$StepId].status -ne "done" } @@ -134,6 +143,8 @@ function Update-SetupProgress { [switch]$ResetPackage ) + if ($DryRun) { return } + if ($PSBoundParameters.ContainsKey('Phase')) { $script:ProgressState.phase = $Phase } diff --git a/modules/WinGetInstall.ps1 b/modules/WinGetInstall.ps1 index e4637e9..d33d365 100644 --- a/modules/WinGetInstall.ps1 +++ b/modules/WinGetInstall.ps1 @@ -308,8 +308,8 @@ function Invoke-WingetManifestInstall { if (-not (Test-Path $ManifestPath)) { Write-Log "WARNING: $ManifestLabel not found at $ManifestPath - skipping application import" -Level WARNING - Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message $MissingManifestMessage - Set-StepState -StepId $StepId -Status "done" -Message $MissingManifestMessage + Add-SummaryItem -Step $SummaryStep -Status "FAIL" -Message $MissingManifestMessage + Set-StepState -StepId $StepId -Status "failed" -Message $MissingManifestMessage return $false } diff --git a/tests/BootstrapInputsPreview.Tests.ps1 b/tests/BootstrapInputsPreview.Tests.ps1 new file mode 100644 index 0000000..3d7aeb5 --- /dev/null +++ b/tests/BootstrapInputsPreview.Tests.ps1 @@ -0,0 +1,141 @@ +BeforeAll { + $repoRoot = Split-Path $PSScriptRoot -Parent + $bootstrapPath = Join-Path $repoRoot 'bootstrap.ps1' + $tokens = $null + $parseErrors = $null + $bootstrapAst = [Management.Automation.Language.Parser]::ParseFile($bootstrapPath, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors) { throw ($parseErrors | Out-String) } + $main = $bootstrapAst.EndBlock.Statements | Where-Object { $_ -is [Management.Automation.Language.TryStatementAst] } + foreach ($name in @('Write-Log', 'New-DesktopShortcut', 'Get-RunBootstrapTarget')) { + $definition = $bootstrapAst.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $false) + . ([scriptblock]::Create($definition.Extent.Text)) + } + . (Join-Path $repoRoot 'modules/BootstrapRun.ps1') + . (Join-Path $repoRoot 'modules/WinGetInstall.ps1') + $registryStep = [scriptblock]::Create(($main.Body.Statements | Where-Object { $_.Extent.Text.StartsWith('if ($OptionalAppsOnly)') -and $_.Extent.Text.Contains('Step 5:') }).Extent.Text) + $optionalShortcutStep = [scriptblock]::Create(($main.Body.Statements | Where-Object { $_.Extent.Text.StartsWith('if ($OptionalAppsOnly)') -and $_.Extent.Text.Contains('Step 8:') }).Extent.Text) + $optionalAppsStep = [scriptblock]::Create(($main.Body.Statements | Where-Object { $_.Extent.Text.StartsWith('if (-not (Should-RunStep') -and $_.Extent.Text.Contains('Step 9:') }).Extent.Text) +} + +Describe 'Missing bootstrap inputs' { + BeforeEach { + $DryRun = $false + $Force = $false + $OptionalAppsOnly = $false + $StateFile = Join-Path $TestDrive 'state.json' + $SetupState = Initialize-State -StatePath (Join-Path $TestDrive 'unused.json') -StepIds @('winget', 'registry', 'optionalShortcut', 'optionalWinget') + $SummaryItems = [Collections.Generic.List[object]]::new() + $FailedItems = [Collections.Generic.List[object]]::new() + $RunStepIds = @('winget', 'registry', 'optionalShortcut') + $RunStartedAt = (Get-Date).ToString('o') + $ConfigRoot = $TestDrive + $desktopPath = $TestDrive + $RegistryConfig = Join-Path $TestDrive 'registry.json' + $RegistryScript = Join-Path $TestDrive 'apply-registry.ps1' + $OptionalAppsJson = Join-Path $TestDrive "optional-apps-$([guid]::NewGuid()).json" + Mock Write-Log { } + Mock Update-SetupProgress { } + Mock Test-WingetPackageInstalled { $true } + Mock Invoke-WingetPackageInstall { throw 'Unexpected installer' } + Mock New-DesktopShortcut { } + Mock Get-RunBootstrapTarget { Join-Path $TestDrive 'bootstrap.ps1' } + } + + It 'fails missing automatic apps and reconciles them when added on an ordinary rerun' { + $manifestPath = Join-Path $TestDrive 'apps.json' + $markerPath = Join-Path $TestDrive 'winget.completed' + $installArgs = @{ ManifestPath = $manifestPath; StepId = 'winget'; SummaryStep = 'WinGet'; MarkerPath = $markerPath; ManifestLabel = 'apps.json'; MissingManifestMessage = 'apps.json not found' } + Invoke-WingetManifestInstall @installArgs | Should -BeFalse + $SetupState.steps.winget.status | Should -Be 'failed' + Test-Path $markerPath | Should -BeFalse + Should -Invoke Test-WingetPackageInstalled -Times 0 -Exactly + + '{"Sources":[{"Packages":[{"PackageIdentifier":"Example.App"}]}]}' | Set-Content $manifestPath + $SetupState = Initialize-State -StatePath $StateFile -StepIds @('winget') + Should-RunStep winget | Should -BeTrue + Invoke-WingetManifestInstall @installArgs | Should -BeTrue + $SetupState.steps.winget.status | Should -Be 'done' + Should -Invoke Test-WingetPackageInstalled -Times 1 -Exactly + } + + It 'skips absent registry configuration and applies it once supplied' { + $stepId = 'registry' + . $registryStep + $SetupState.steps.registry.status | Should -Be 'skipped' + $SummaryItems[-1].Status | Should -Be 'SKIP' + '{}' | Set-Content $RegistryConfig + '[pscustomobject]@{ Failed = 0 }' | Set-Content $RegistryScript + $SetupState = Initialize-State -StatePath $StateFile -StepIds @('registry') + . $registryStep + $SetupState.steps.registry.status | Should -Be 'done' + $SetupState.steps.registry.message | Should -Be 'Registry fallback applied' + } + + It 'retries absent optional shortcuts, including old incorrectly completed records' { + $stepId = 'optionalShortcut' + Set-StepState -StepId $stepId -Status done -Message 'optional-apps.json not found' + . $optionalShortcutStep + $SetupState.steps.optionalShortcut.status | Should -Be 'skipped' + Should -Invoke New-DesktopShortcut -Times 0 -Exactly + '{}' | Set-Content $OptionalAppsJson + $SetupState = Initialize-State -StatePath $StateFile -StepIds @('optionalShortcut') + . $optionalShortcutStep + $SetupState.steps.optionalShortcut.status | Should -Be 'done' + Should -Invoke New-DesktopShortcut -Times 1 -Exactly + Should-RunStep optionalShortcut | Should -BeFalse + } + + It 'fails an explicitly requested optional installation when its manifest is missing' { + $stepId = 'optionalWinget' + $OptionalAppsOnly = $true + $RunStepIds = @('optionalWinget') + . $optionalAppsStep + $SetupState.steps.optionalWinget.status | Should -Be 'failed' + (Get-BootstrapRunResult).ExitCode | Should -Be 1 + Should-RunStep optionalWinget | Should -BeTrue + } +} + +Describe 'Bootstrap dry-run isolation in Windows PowerShell' { + It 'preserves all saved results with