Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions bootstrap.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions modules/BootstrapRun.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -85,6 +87,8 @@ function Set-StepState {
[string]$Message
)

if ($DryRun) { return }

if (-not $SetupState.steps.Contains($StepId)) {
$SetupState.steps[$StepId] = [pscustomobject]@{
status = "pending"
Expand Down Expand Up @@ -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"
}

Expand All @@ -134,6 +143,8 @@ function Update-SetupProgress {
[switch]$ResetPackage
)

if ($DryRun) { return }

if ($PSBoundParameters.ContainsKey('Phase')) {
$script:ProgressState.phase = $Phase
}
Expand Down
4 changes: 2 additions & 2 deletions modules/WinGetInstall.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
141 changes: 141 additions & 0 deletions tests/BootstrapInputsPreview.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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 <Label>' -ForEach @(
@{ Label = 'DryRun'; ExtraArgs = @() }
@{ Label = 'DryRun and Force'; ExtraArgs = @('-Force') }
@{ Label = 'DryRun and PromptRestart'; ExtraArgs = @('-PromptRestart') }
) {
$runtime = New-Item -ItemType Directory -Path (Join-Path $TestDrive ([guid]::NewGuid().ToString()))
$StateFile = Join-Path $runtime.FullName 'state.json'
$DryRun = $false
$ids = @('winget', 'repo', 'sophia', 'postInstallTweaks', 'registry', 'shortcut', 'restoreShortcut', 'optionalShortcut', 'optionalWinget', 'summary')
$SetupState = Initialize-State -StatePath $StateFile -StepIds $ids
foreach ($id in $ids) {
$SetupState.steps[$id].status = 'done'
$SetupState.steps[$id].message = 'Previously completed'
}
Save-State -State $SetupState -StatePath $StateFile
foreach ($name in @('progress.json', 'install.log', 'failed-installs.log', 'sophia.completed', 'winget.completed', 'optional-winget.completed', 'Setup Summary.txt', 'Failed Installs.txt')) {
'Previous authoritative result' | Set-Content (Join-Path $runtime.FullName $name)
}
$before = @(Get-ChildItem $runtime.FullName -File | Sort-Object Name | Get-FileHash | Select-Object Path, Hash) | ConvertTo-Json

# Run the production script in a child host with disposable paths and an
# administrator-check substitute. Any attempt to invoke setup work fails.
$source = $bootstrapAst.Extent.Text.Replace('#Requires -RunAsAdministrator', '')
$source = $source.Replace('$SetupPath = "C:\Setup"', ('$SetupPath = ' + "'$($runtime.FullName)'"))
$source = $source.Replace('$ModuleRoot = Join-Path $PSScriptRoot "modules"', ('$ModuleRoot = ' + "'$(Join-Path $repoRoot 'modules')'"))
$adminAssignment = $main.Body.Statements | Where-Object { $_ -is [Management.Automation.Language.AssignmentStatementAst] -and $_.Left.Extent.Text -eq '$isAdmin' }
$guards = @('Invoke-WingetManifestInstall', 'Get-BackupManifestData', 'Ensure-CanonicalRepo', 'Get-SophiaScript', 'Invoke-PostInstallTweaks', 'New-DesktopShortcut', 'Read-Host', 'Restart-Computer') | ForEach-Object {
"function $_ { throw 'Dry run invoked $_' }"
}
$source = $source.Replace($main.Extent.Text, (($guards -join "`n") + "`n" + $main.Extent.Text))
$source = $source.Replace($adminAssignment.Extent.Text, '$isAdmin = $true')
$previewScript = Join-Path $TestDrive 'preview-bootstrap.ps1'
[IO.File]::WriteAllText($previewScript, $source, [Text.UTF8Encoding]::new($true))
$output = & powershell.exe -NoProfile -File $previewScript -DryRun -ConfigRoot $runtime.FullName @ExtraArgs 2>&1
$LASTEXITCODE | Should -Be 0 -Because ($output -join "`n")
($output -join "`n") | Should -Match 'Windows Setup Bootstrap - Preview'
$after = @(Get-ChildItem $runtime.FullName -File | Sort-Object Name | Get-FileHash | Select-Object Path, Hash) | ConvertTo-Json
$after | Should -Be $before
}
}
Loading