Files

242 lines
19 KiB
PowerShell

[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
. (Join-Path $root 'scripts\Project.Common.ps1')
Assert-PowerShell51
. (Join-Path $root 'scripts\Configuration.Common.ps1')
. (Join-Path $root 'scripts\Builder.Workflow.ps1')
. (Join-Path $root 'scripts\Software.Selection.ps1')
function Assert-Test { param([bool]$Condition,[string]$Message) if (-not $Condition) { throw $Message } }
function Assert-Throws {
param([scriptblock]$Action,[string]$Pattern)
$thrown = $false
try { & $Action | Out-Null } catch { $thrown=$true; if ($_.Exception.Message -notmatch $Pattern) { throw } }
Assert-Test $thrown ('Expected failure: ' + $Pattern)
}
$fixture = Join-Path $root ('local\wizard tests ' + [guid]::NewGuid().ToString('N').Substring(0,8))
foreach ($directory in @('scripts','resources','config','local','assets\iso','assets\custom-resources','assets\output')) { New-Item -ItemType Directory -Path (Join-Path $fixture $directory) -Force | Out-Null }
foreach ($name in @('New-ImageConfig.ps1','scripts\Project.Common.ps1','scripts\Build.Dependencies.psd1','scripts\Configuration.Common.ps1','scripts\Package.Validation.ps1','scripts\ProductKeys.Common.ps1','scripts\Software.Selection.ps1','config\Kms.ClientKeys.psd1','config\image.example.ini')) { Copy-Item -LiteralPath (Join-Path $root $name) -Destination (Join-Path $fixture $name) }
[IO.File]::WriteAllText((Join-Path $fixture 'resources\guest-marker.ps1'),'# fixture')
foreach ($name in @('chrome-enterprise64.msi','VSCodeSetup-test.exe','virtio-win-guest-tools.exe')) {
$path = Join-Path $fixture ('assets\custom-resources\' + $name)
[IO.File]::WriteAllText($path,('test data; never executed: ' + $name))
}
function Get-AuthenticodeSignature {
param($LiteralPath)
$publisher='Microsoft Corporation'
if ([IO.Path]::GetExtension($LiteralPath) -eq '.msi') { $publisher='Google LLC' }
$certificate=[PSCustomObject]@{Publisher=$publisher}
$certificate | Add-Member -MemberType ScriptMethod -Name GetNameInfo -Value {param($Type,$ForIssuer) $this.Publisher}
$status='Valid'
if ([IO.File]::ReadAllText($LiteralPath) -eq 'corrupted fixture') { $status='HashMismatch' }
[PSCustomObject]@{Status=$status;SignerCertificate=$certificate}
}
$fixtureSettings = [PSCustomObject]@{ProjectRoot=$fixture;AssetsRoot=(Join-Path $fixture 'assets');UpstreamRoot=(Join-Path $root 'vendor\windows-imaging-tools');QemuManifestPath=(Join-Path $fixture 'absent-tool.json')}
[IO.File]::WriteAllText((Join-Path $fixture 'local\settings.json'),($fixtureSettings|ConvertTo-Json))
foreach ($name in @('install.wim','Windows.iso','virtio-win.iso')) { [IO.File]::WriteAllText((Join-Path $fixture ('assets\iso\'+$name)),'Fixture; not a real ISO or WIM.') }
Assert-Test ((Find-WindowsInstallerIso -ProjectRoot $fixture -AssetsRoot $fixtureSettings.AssetsRoot) -eq (Join-Path $fixture 'assets\iso\Windows.iso')) 'Existing asset ISO fallback changed.'
New-Item -ItemType Directory -Path (Join-Path $fixture 'data\iso') -Force|Out-Null
[IO.File]::WriteAllText((Join-Path $fixture 'data\iso\Windows.iso'),'Source selection fixture.')
[IO.File]::WriteAllText((Join-Path $fixture 'data\iso\virtio-win.iso'),'Driver ISO selection fixture.')
Assert-Test ((Find-WindowsInstallerIso -ProjectRoot $fixture -AssetsRoot $fixtureSettings.AssetsRoot) -eq (Join-Path $fixture 'data\iso\Windows.iso')) 'The data Windows ISO was not preferred.'
Assert-Test ((Find-VirtioInstallerIso -ProjectRoot $fixture -AssetsRoot $fixtureSettings.AssetsRoot) -eq (Join-Path $fixture 'data\iso\virtio-win.iso')) 'The data VirtIO ISO was not preferred.'
Import-Module (Join-Path $root 'vendor\windows-imaging-tools\Config.psm1') -Force
$checked = 0
foreach ($skipChrome in @($false,$true)) {
foreach ($skipCode in @($false,$true)) {
$parameters = @{WimPath=(Join-Path $fixture 'assets\iso\Windows.iso');OutputPath=(Join-Path $fixture ('assets\output\disk $literal-' + $checked + '.qcow2'));SwitchName='external $literal';CpuCount=2;RamGiB=4;DiskGiB=96;TimeZone='UTC';ImageName='Windows 11 Pro';InstallUpdates=$true;SkipChrome=$skipChrome;SkipVSCode=$skipCode}
$result = & (Join-Path $fixture 'New-ImageConfig.ps1') @parameters
$selection = Get-GuestSoftwareSelection -ResourceRoot $result.StagedResources
Assert-Test ($selection.InstallChrome -eq (-not $skipChrome) -and $selection.InstallVSCode -eq (-not $skipCode)) 'Selection did not reach guest resources.'
Assert-Test ((Test-Path -LiteralPath (Join-Path $result.StagedResources 'chrome-enterprise64.msi')) -eq (-not $skipChrome)) 'Chrome staging is incorrect.'
Assert-Test ((Test-Path -LiteralPath (Join-Path $result.StagedResources 'VSCodeSetup-x64.exe')) -eq (-not $skipCode)) 'VSCode staging is incorrect.'
Assert-Test (Test-Path -LiteralPath (Join-Path $result.StagedResources 'Package.Validation.ps1')) 'Guest signature validator was not staged.'
Assert-Test (-not ($selection.Packages | Where-Object { $_.PSObject.Properties['Version'] -or $_.PSObject.Properties['SHA256'] })) 'Software selection still pins an application version or hash.'
Assert-Test (Test-Path -LiteralPath (Join-Path $result.StagedResources 'virtio-win-guest-tools.exe')) 'Required VirtIO package was omitted.'
$parsed = Get-WindowsImageConfig -ConfigFilePath $result.ConfigPath
Assert-Test ($parsed.msi_path -eq (Join-Path $fixtureSettings.AssetsRoot 'assets\CloudbaseInitSetup_Stable_x64.msi')) 'Cloudbase-Init cache path did not follow the selected asset directory.'
Assert-Test ($parsed.image_path -eq $parameters.OutputPath -and $parsed.external_switch -eq $parameters.SwitchName) 'Literal path characters were changed.'
Assert-Test ($parsed.wim_file_path -eq $parameters.WimPath -and $parsed.image_name -eq 'Windows 11 Pro') 'Source/edition did not reach the INI.'
Assert-Test ($parsed.disk_size -eq 96GB -and $parsed.ram_size -eq 4GB -and $parsed.install_updates -eq $true) 'Build parameters were not parsed.'
Assert-Test (([IO.File]::ReadAllBytes($result.ConfigPath))[0] -eq [byte][char]'[') 'Generated INI has a BOM.'
Assert-Test ($result.BuildPassword -notmatch 'Build!') 'Summary disclosed a build password.'
$checked++
}
}
$baseline = @{WimPath=(Join-Path $fixture 'assets\iso\install.wim');OutputPath=(Join-Path $fixture 'assets\output\negative.qcow2')}
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') -WimPath 'C:\does-not-exist.wim' } 'existing Windows'
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') @baseline -VirtioIsoPath 'C:\missing.iso' } 'VirtIO ISO'
$chromePath = Join-Path $fixture 'assets\custom-resources\chrome-enterprise64.msi'
Move-Item -LiteralPath $chromePath -Destination ($chromePath+'.held')
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') @baseline } 'Required installer'
$skipped = & (Join-Path $fixture 'New-ImageConfig.ps1') @baseline -SkipChrome
Assert-Test (-not $skipped.InstallChrome) 'Skipping a missing optional installer failed.'
Move-Item -LiteralPath ($chromePath+'.held') -Destination $chromePath
$original = [IO.File]::ReadAllText($chromePath)
[IO.File]::WriteAllText($chromePath,'corrupted fixture')
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') @baseline } 'signature is not valid'
[IO.File]::WriteAllText($chromePath,$original)
[IO.File]::WriteAllText($baseline.OutputPath,'existing image; preserve')
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') @baseline } 'already exists'
Assert-Test ([IO.File]::ReadAllText($baseline.OutputPath) -eq 'existing image; preserve') 'Existing image was changed.'
Assert-Throws { & (Join-Path $fixture 'New-ImageConfig.ps1') -WimPath $baseline.WimPath -ConfigPath (Join-Path $fixture 'public.ini') } 'project local directory'
Assert-Throws { Set-ImageConfigValue -Text "image_name=old" -Key 'image_name' -Value "injected`nimage_path=x" } 'Invalid configuration'
$legacy = Get-GuestSoftwareSelection -ResourceRoot (Join-Path $fixture 'resources')
Assert-Test ($legacy.InstallChrome -and $legacy.InstallVSCode) 'Legacy selection default changed.'
$dataPackages=Join-Path $fixture 'data\custom-resources'
New-Item -ItemType Directory -Path $dataPackages -Force | Out-Null
$dataChrome=Join-Path $dataPackages 'downloaded chrome.msi'
$dataCode=Join-Path $dataPackages 'VSCodeSetup-new-version.exe'
$codePath=Join-Path $fixture 'assets\custom-resources\VSCodeSetup-test.exe'
Copy-Item -LiteralPath $chromePath -Destination $dataChrome
Copy-Item -LiteralPath $codePath -Destination $dataCode
Move-Item -LiteralPath $chromePath -Destination ($chromePath+'.held')
Move-Item -LiteralPath $codePath -Destination ($codePath+'.held')
try {
$fromData=& (Join-Path $fixture 'New-ImageConfig.ps1') -WimPath $baseline.WimPath -OutputPath (Join-Path $fixture 'assets\output\from-data.qcow2')
foreach ($entry in @(@{Source=$dataChrome;Staged='chrome-enterprise64.msi'},@{Source=$dataCode;Staged='VSCodeSetup-x64.exe'})) {
Assert-Test ((Get-FileHash -LiteralPath $entry.Source).Hash -eq (Get-FileHash -LiteralPath (Join-Path $fromData.StagedResources $entry.Staged)).Hash) 'An installer from data/custom-resources was not staged correctly.'
}
} finally {
Move-Item -LiteralPath ($chromePath+'.held') -Destination $chromePath
Move-Item -LiteralPath ($codePath+'.held') -Destination $codePath
}
$script:answers = New-Object 'System.Collections.Generic.Queue[string]'
function Read-Host { param($Prompt) if (-not $script:answers.Count) { throw 'Test input exhausted.' }; return $script:answers.Dequeue() }
$script:answers.Enqueue('bad')
$script:answers.Enqueue('7')
Assert-Test ((Read-BuilderNumber -Prompt 'Test number' -Default 4 -Minimum 1 -Maximum 8) -eq 7) 'Invalid input was not retried.'
$script:answers.Enqueue('Q')
Assert-Throws { Read-BuilderText -Prompt 'Cancel test' } '取消'
& {
$ProjectRoot=$fixture
$wimWithSpaces=Join-Path $fixture 'data\iso\Windows image with spaces.wim'
[IO.File]::WriteAllText($wimWithSpaces,'Source choice fixture; never used to build.')
$candidates=@(Get-BuilderSourceCandidates -AssetsRoot $fixtureSettings.AssetsRoot)
Assert-Test ($candidates[0].Path.StartsWith((Join-Path $fixture 'data\iso'))) 'Data sources were not listed first.'
Assert-Test (@($candidates | Where-Object Path -like '*virtio*').Count -eq 0) 'VirtIO was listed as a Windows installation source.'
Assert-Test (@($candidates | Where-Object Path -eq $wimWithSpaces).Count -eq 1) 'WIM with spaces was not listed.'
$duplicate=@(Get-BuilderSourceCandidates -AssetsRoot $fixtureSettings.AssetsRoot -SourcePath $wimWithSpaces)
Assert-Test ($duplicate.Count -eq $candidates.Count) 'Explicit source duplicated an existing list item.'
$script:answers.Enqueue('')
Assert-Test ((Read-BuilderInstallationSource -AssetsRoot $fixtureSettings.AssetsRoot -SourcePath $wimWithSpaces) -eq $wimWithSpaces) 'Explicit source was not the default numbered choice.'
foreach ($answer in @('0','999','2')) { $script:answers.Enqueue($answer) }
Assert-Test ((Read-BuilderInstallationSource -AssetsRoot $fixtureSettings.AssetsRoot) -eq $candidates[1].Path) 'Invalid source numbers were not retried.'
$script:answers.Enqueue([string]($candidates.Count+1))
$script:answers.Enqueue('C:\missing-source.iso')
$script:answers.Enqueue('"'+$wimWithSpaces+'"')
Assert-Test ((Read-BuilderInstallationSource -AssetsRoot $fixtureSettings.AssetsRoot) -eq $wimWithSpaces) 'Manual source choice or quoted path retry failed.'
$script:answers.Enqueue('Q')
Assert-Throws { Read-BuilderInstallationSource -AssetsRoot $fixtureSettings.AssetsRoot } '取消'
$script:answers.Enqueue([string]($candidates.Count+1))
$script:answers.Enqueue('Q')
Assert-Throws { Read-BuilderInstallationSource -AssetsRoot $fixtureSettings.AssetsRoot } '取消'
$ProjectRoot=Join-Path $fixture 'no source candidates'
$script:answers.Enqueue('')
$script:answers.Enqueue($wimWithSpaces)
Assert-Test ((Read-BuilderInstallationSource -AssetsRoot (Join-Path $ProjectRoot 'assets')) -eq $wimWithSpaces) 'Empty source list did not allow manual entry.'
}
Assert-BuilderCapacity -CpuCount '4' -RamBytes '8589934592' -HostCpuCount 32 -HostRamBytes 64GB
Assert-Throws { Assert-BuilderCapacity -CpuCount 33 -RamBytes 8GB -HostCpuCount 32 -HostRamBytes 64GB } 'CPU or memory'
Assert-Throws { Assert-BuilderCapacity -CpuCount 4 -RamBytes 65GB -HostCpuCount 32 -HostRamBytes 64GB } 'CPU or memory'
& {
$ProjectRoot=$fixture
$script:configurationCalls=0
$script:initializationCalls=0
$script:wizardConfigPaths=New-Object 'System.Collections.Generic.List[string]'
function Get-BuilderHost { [PSCustomObject]@{Switches=@([PSCustomObject]@{Name='external'});LogicalProcessors=8;MemoryGiB=32} }
function Initialize-BuilderProject { param($AssetsRoot) $script:initializationCalls++ }
function Get-ProjectSettings { $fixtureSettings }
function Get-BuilderSourceImages { param($SourcePath) [PSCustomObject]@{ImageName='Windows 11 Pro for Workstations';ImageIndex=10;ImageVersion='test'} }
function Initialize-VirtioGuestTools { param($Settings,$VirtioIsoPath,[switch]$RefreshFromIso) }
function Assert-BuilderDiskSpace { param($OutputPath,$DiskGiB) }
function Initialize-BuilderTools { throw 'Save-only must not initialize tools.' }
function Invoke-BuilderAction { throw 'Save-only must not start a build.' }
function New-BuilderConfiguration {
param($Parameters)
Assert-Test ($Parameters.KmsServer -ceq $script:expectedKmsServer) 'Wizard KMS selection did not reach configuration generation.'
$script:configurationCalls++;$script:wizardConfigPaths.Add($Parameters.ConfigPath)
[PSCustomObject]@{ConfigPath=$Parameters.ConfigPath}
}
foreach ($configureOnly in @($false,$true)) {
$script:answers.Clear()
# Assets/source/edition/key defaults, then the KMS choice.
foreach ($answer in @('','','','')) { $script:answers.Enqueue($answer) }
$script:expectedKmsServer=Get-PveDefaultKmsServer
if ($configureOnly) {
$script:answers.Enqueue('2');$script:answers.Enqueue('')
$script:expectedKmsServer=''
} else { $script:answers.Enqueue('') }
# VirtIO/switch/output/CPU/RAM/disk/timezone defaults, updates/apps false.
foreach ($answer in @('','','','','','','','N','N','N')) { $script:answers.Enqueue($answer) }
if (-not $configureOnly) { $script:answers.Enqueue('3') }
$wizard = Invoke-ImageBuilderWizard -SourcePath (Join-Path $fixture 'assets\iso\Windows.iso') -ConfigureOnly:$configureOnly
Assert-Test ($wizard.Action -eq '仅保存配置') 'Save-only action changed.'
}
Assert-Test ($script:configurationCalls -eq 2) 'Save-only did not generate exactly one configuration per run.'
Assert-Test (@($script:wizardConfigPaths | Select-Object -Unique).Count -eq 2) 'Separate wizard runs reused the same configuration file.'
Assert-Test (@($script:wizardConfigPaths | Where-Object { $_ -notlike (Join-Path $fixture 'local\configs\*.ini') }).Count -eq 0) 'Wizard configuration escaped its per-run folder.'
$script:answers.Clear()
foreach($answer in @('','','','','','','','','','','','','N','N','N','Q')){$script:answers.Enqueue($answer)}
Assert-Throws { Invoke-ImageBuilderWizard -SourcePath (Join-Path $fixture 'assets\iso\Windows.iso') } '取消'
Assert-Test ($script:configurationCalls -eq 2) 'Cancel at the final action still generated a configuration.'
$callsBefore=$script:initializationCalls
$script:answers.Clear(); $script:answers.Enqueue('Q')
Assert-Throws { Invoke-ImageBuilderWizard } '取消'
Assert-Test ($script:initializationCalls -eq $callsBefore -and $script:configurationCalls -eq 2) 'Cancel continued initialization or configuration.'
}
& {
$script:switchCreateCalls=0
$script:adapterQueries=0
$script:switchTestMode='success'
function Get-NetAdapter {
param([switch]$Physical,$ErrorAction)
$script:adapterQueries++
if($script:switchTestMode -eq 'no_adapter'){return}
[PSCustomObject]@{Name='Test Ethernet';InterfaceDescription='Mock physical NIC';LinkSpeed='1 Gbps';Status='Up'}
}
function Get-VMSwitch {
param($Name,$ErrorAction)
if($Name -eq 'taken'){[PSCustomObject]@{Name='taken';SwitchType='Internal'}}
}
function New-VMSwitch {
param($Name,$NetAdapterName,[bool]$AllowManagementOS,$ErrorAction)
$script:switchCreateCalls++
if(-not$AllowManagementOS -or $NetAdapterName -ne 'Test Ethernet'){throw 'Host NIC sharing or adapter selection is incorrect.'}
if($script:switchTestMode -eq 'failure'){throw 'Simulated switch creation error.'}
[PSCustomObject]@{Name=$Name;SwitchType='External'}
}
$script:answers.Clear()
$existing=@(Initialize-BuilderExternalSwitches -Switches @([PSCustomObject]@{Name='existing';SwitchType='External'}))
Assert-Test ($existing[0].Name -eq 'existing' -and $script:adapterQueries -eq 0 -and $script:switchCreateCalls -eq 0) 'An existing switch was modified or prompted.'
$script:answers.Enqueue('')
Assert-Throws { Initialize-BuilderExternalSwitches -Switches @() } '未创建'
Assert-Test ($script:switchCreateCalls -eq 0) 'Default choice created a switch.'
foreach($answer in @('Y','1','external','N')){$script:answers.Enqueue($answer)}
Assert-Throws { Initialize-BuilderExternalSwitches -Switches @() } '已取消'
Assert-Test ($script:switchCreateCalls -eq 0) 'Declining final confirmation created a switch.'
foreach($answer in @('Y','1','taken','build-external','Y')){$script:answers.Enqueue($answer)}
$created=Initialize-BuilderExternalSwitches -Switches @()
Assert-Test ($created.Name -eq 'build-external' -and $script:switchCreateCalls -eq 1) 'Switch creation or duplicate-name retry failed.'
$script:switchTestMode='no_adapter'
$script:answers.Enqueue('Y')
Assert-Throws { Initialize-BuilderExternalSwitches -Switches @() } '物理网卡'
Assert-Test ($script:switchCreateCalls -eq 1) 'A switch was created with no physical NIC.'
$script:switchTestMode='failure'
foreach($answer in @('Y','1','external','Y')){$script:answers.Enqueue($answer)}
Assert-Throws { Initialize-BuilderExternalSwitches -Switches @() } '创建外部交换机失败'
}
[PSCustomObject]@{SoftwareCombinations=$checked;InputRetry='passed';Cancellation='passed';SaveOnly='passed';NegativeCases='passed';LiteralPaths='passed';DataIsoDiscovery='passed';DataInstallerDiscovery='passed';ExternalSwitchPrompts='passed; network calls mocked';Fixture=$fixture}
& {
. (Join-Path $root 'scripts\Servicing.Adapter.ps1')
$script:PveServicingTools=[PSCustomObject]@{DismPath='C:\fixture\dism.exe'}
$script:PveRunLogDirectory=$fixture
function Invoke-PveServicingTool {
param($Executable,[string[]]$Arguments,$Stage)
Assert-Test ($Arguments.Count -eq 5 -and $Arguments[1] -eq '/Add-Driver') 'DISM argument boundaries were lost.'
Assert-Test ($Arguments[2] -eq '/driver:Z:\drivers with spaces') 'DISM driver path was split.'
}
Add-DriversToImage -winImagePath 'V:\' -driversPath 'Z:\drivers with spaces'
}