mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-07-15 16:38:00 +00:00
Fix Win11 Creator product-key validation failures (#4791)
* feat: Enhance Win11 Creator with edition ID handling and update autounattend.xml generation * Strengthen Pester coverage for config and function files
This commit is contained in:
@@ -51,6 +51,7 @@ Click **Run Windows ISO Modification and Creator** to start the customization pr
|
||||
- **Disable BitLocker and device encryption** — removes startup overhead
|
||||
- **Disable Chat icon** — removes chat taskbar button
|
||||
- **Strip unused editions** — keeps only your selected edition, saving 1–2 GB per removed edition
|
||||
- **Pin the selected edition during setup** — writes setup metadata so OEM firmware keys for a different edition do not force the installer down the wrong product-key path
|
||||
- **Clean the component store** — runs DISM cleanup to reclaim another 300–800 MB
|
||||
|
||||
**Privacy & Telemetry Tweaks:**
|
||||
@@ -132,6 +133,7 @@ When you install Windows 11 from your modified ISO:
|
||||
| USB drive not showing up | Plug it in, wait a few seconds, then click **Refresh** |
|
||||
| Modification seems stuck | The WIM dismount step is slow — wait at least 10 minutes before assuming it's frozen |
|
||||
| "Access Denied" error | Make sure WinUtil is running as Administrator |
|
||||
| "Setup has failed to validate the product key" | Recreate the ISO with the latest WinUtil. The creator now removes stale `PID.txt`, writes `sources\ei.cfg`, and pins the selected image in `autounattend.xml` so setup does not use an embedded OEM key for a different edition |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -213,6 +213,59 @@ function Invoke-WinUtilISOModify {
|
||||
})
|
||||
}
|
||||
|
||||
function Get-WinUtilEditionIdFromName {
|
||||
param([string]$EditionName)
|
||||
|
||||
$normalizedName = ($EditionName -replace '^Windows\s+11\s+', '').Trim()
|
||||
switch -Regex ($normalizedName) {
|
||||
'^Home Single Language$' { return 'CoreSingleLanguage' }
|
||||
'^Home N$' { return 'CoreN' }
|
||||
'^Home$' { return 'Core' }
|
||||
'^Pro for Workstations N$' { return 'ProfessionalWorkstationN' }
|
||||
'^Pro for Workstations$' { return 'ProfessionalWorkstation' }
|
||||
'^Pro Education N$' { return 'ProfessionalEducationN' }
|
||||
'^Pro Education$' { return 'ProfessionalEducation' }
|
||||
'^Pro N$' { return 'ProfessionalN' }
|
||||
'^Pro$' { return 'Professional' }
|
||||
'^Education N$' { return 'EducationN' }
|
||||
'^Education$' { return 'Education' }
|
||||
'^Enterprise LTSC N$' { return 'EnterpriseSN' }
|
||||
'^Enterprise LTSC$' { return 'EnterpriseS' }
|
||||
'^Enterprise N$' { return 'EnterpriseN' }
|
||||
'^Enterprise$' { return 'Enterprise' }
|
||||
default { return '' }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WinUtilMountedImageEditionId {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$MountDir,
|
||||
[string]$EditionName,
|
||||
[scriptblock]$Logger
|
||||
)
|
||||
|
||||
try {
|
||||
$dismOutput = & dism /English "/Image:$MountDir" /Get-CurrentEdition 2>&1
|
||||
foreach ($line in $dismOutput) {
|
||||
if ($line -match '^\s*Current Edition\s*:\s*(.+?)\s*$') {
|
||||
$editionId = $Matches[1].Trim()
|
||||
if ($editionId) {
|
||||
if ($Logger) { $null = $Logger.Invoke("Detected mounted image EditionID: $editionId") }
|
||||
return $editionId
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if ($Logger) { $null = $Logger.Invoke("Warning: could not detect mounted image EditionID with DISM: $_") }
|
||||
}
|
||||
|
||||
$fallbackEditionId = Get-WinUtilEditionIdFromName -EditionName $EditionName
|
||||
if ($fallbackEditionId -and $Logger) {
|
||||
$null = $Logger.Invoke("Using fallback EditionID '$fallbackEditionId' from selected edition name.")
|
||||
}
|
||||
return $fallbackEditionId
|
||||
}
|
||||
|
||||
function Get-DismImageInfoMap {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$ImagePath,
|
||||
@@ -322,9 +375,10 @@ function Invoke-WinUtilISOModify {
|
||||
Log "Mounting install.wim (Index ${selectedWimIndex}: $selectedEditionName) at $mountDir..."
|
||||
Mount-WindowsImage -ImagePath $localWim -Index $selectedWimIndex -Path $mountDir
|
||||
SetProgress "Modifying install.wim..." 45
|
||||
$selectedEditionId = Get-WinUtilMountedImageEditionId -MountDir $mountDir -EditionName $selectedEditionName -Logger ${function:Log}
|
||||
|
||||
Log "Applying WinUtil modifications to install.wim..."
|
||||
Invoke-WinUtilISOScript -ScratchDir $mountDir -ISOContentsDir $isoContents -AutoUnattendXml $autounattendContent -InjectCurrentSystemDrivers $injectDrivers -Log { param($m) Log $m }
|
||||
Invoke-WinUtilISOScript -ScratchDir $mountDir -ISOContentsDir $isoContents -AutoUnattendXml $autounattendContent -InjectCurrentSystemDrivers $injectDrivers -InstallEditionId $selectedEditionId -InstallImageIndex 1 -Log { param($m) Log $m }
|
||||
|
||||
SetProgress "Cleaning up component store (WinSxS)..." 56
|
||||
Log "Running DISM component store cleanup (/ResetBase)..."
|
||||
|
||||
@@ -34,6 +34,16 @@ function Invoke-WinUtilISOScript {
|
||||
them into install.wim and boot.wim index 2 (Windows Setup PE).
|
||||
Defaults to $false.
|
||||
|
||||
.PARAMETER InstallEditionId
|
||||
Optional. Windows edition ID for the selected image, for example Professional
|
||||
or Core. Used to write sources\ei.cfg so setup does not fall back to an
|
||||
embedded firmware product key for a different edition.
|
||||
|
||||
.PARAMETER InstallImageIndex
|
||||
Optional. Image index that setup should install from the final install.wim.
|
||||
Win11 Creator exports the selected edition to a single-image WIM, so this
|
||||
defaults to 1.
|
||||
|
||||
.PARAMETER Log
|
||||
Optional ScriptBlock for progress/status logging. Receives a single [string] argument.
|
||||
|
||||
@@ -56,6 +66,8 @@ function Invoke-WinUtilISOScript {
|
||||
[string]$ISOContentsDir = "",
|
||||
[string]$AutoUnattendXml = "",
|
||||
[bool]$InjectCurrentSystemDrivers = $false,
|
||||
[string]$InstallEditionId = "",
|
||||
[int]$InstallImageIndex = 1,
|
||||
[scriptblock]$Log = { param($m) Write-Output $m }
|
||||
)
|
||||
|
||||
@@ -108,6 +120,146 @@ function Invoke-WinUtilISOScript {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WinUtilISOScriptChildElement {
|
||||
param (
|
||||
[Parameter(Mandatory)][System.Xml.XmlElement]$Parent,
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][string]$NamespaceUri
|
||||
)
|
||||
|
||||
foreach ($childNode in $Parent.ChildNodes) {
|
||||
if ($childNode.NodeType -eq [System.Xml.XmlNodeType]::Element -and
|
||||
$childNode.LocalName -eq $Name -and
|
||||
$childNode.NamespaceURI -eq $NamespaceUri) {
|
||||
return [System.Xml.XmlElement]$childNode
|
||||
}
|
||||
}
|
||||
|
||||
$childElement = $Parent.OwnerDocument.CreateElement($Name, $NamespaceUri)
|
||||
[void]$Parent.AppendChild($childElement)
|
||||
return $childElement
|
||||
}
|
||||
|
||||
function ConvertTo-WinUtilISOAnswerFile {
|
||||
param (
|
||||
[Parameter(Mandatory)][string]$XmlContent,
|
||||
[int]$ImageIndex = 1
|
||||
)
|
||||
|
||||
if ($ImageIndex -lt 1) { $ImageIndex = 1 }
|
||||
|
||||
$unattendNs = "urn:schemas-microsoft-com:unattend"
|
||||
$wcmNs = "http://schemas.microsoft.com/WMIConfig/2002/State"
|
||||
|
||||
$xmlDoc = [xml]::new()
|
||||
$xmlDoc.PreserveWhitespace = $true
|
||||
$xmlDoc.LoadXml($XmlContent)
|
||||
|
||||
if ($xmlDoc.DocumentElement.NamespaceURI -ne $unattendNs) {
|
||||
throw "Unexpected autounattend.xml namespace: $($xmlDoc.DocumentElement.NamespaceURI)"
|
||||
}
|
||||
|
||||
if (-not $xmlDoc.DocumentElement.HasAttribute("xmlns:wcm")) {
|
||||
$xmlDoc.DocumentElement.SetAttribute("wcm", "http://www.w3.org/2000/xmlns/", $wcmNs)
|
||||
}
|
||||
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$nsMgr.AddNamespace("u", $unattendNs)
|
||||
|
||||
$windowsPESettings = $xmlDoc.SelectSingleNode('/u:unattend/u:settings[@pass="windowsPE"]', $nsMgr)
|
||||
if (-not $windowsPESettings) {
|
||||
$windowsPESettings = $xmlDoc.CreateElement("settings", $unattendNs)
|
||||
$windowsPESettings.SetAttribute("pass", "windowsPE")
|
||||
[void]$xmlDoc.DocumentElement.PrependChild($windowsPESettings)
|
||||
}
|
||||
|
||||
$setupComponent = $windowsPESettings.SelectSingleNode('u:component[@name="Microsoft-Windows-Setup"]', $nsMgr)
|
||||
if (-not $setupComponent) {
|
||||
$setupComponent = $xmlDoc.CreateElement("component", $unattendNs)
|
||||
$setupComponent.SetAttribute("name", "Microsoft-Windows-Setup")
|
||||
$setupComponent.SetAttribute("processorArchitecture", "amd64")
|
||||
$setupComponent.SetAttribute("publicKeyToken", "31bf3856ad364e35")
|
||||
$setupComponent.SetAttribute("language", "neutral")
|
||||
$setupComponent.SetAttribute("versionScope", "nonSxS")
|
||||
[void]$windowsPESettings.AppendChild($setupComponent)
|
||||
}
|
||||
|
||||
$productKeyNodes = @($setupComponent.SelectNodes("u:UserData/u:ProductKey", $nsMgr))
|
||||
foreach ($productKeyNode in $productKeyNodes) {
|
||||
$keyNode = $productKeyNode.SelectSingleNode("u:Key", $nsMgr)
|
||||
$keyValue = if ($keyNode) { $keyNode.InnerText.Trim() } else { "" }
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($keyValue) -or $keyValue -eq "00000-00000-00000-00000-00000") {
|
||||
[void]$productKeyNode.ParentNode.RemoveChild($productKeyNode)
|
||||
}
|
||||
}
|
||||
|
||||
$imageInstall = Get-WinUtilISOScriptChildElement -Parent $setupComponent -Name "ImageInstall" -NamespaceUri $unattendNs
|
||||
$osImage = Get-WinUtilISOScriptChildElement -Parent $imageInstall -Name "OSImage" -NamespaceUri $unattendNs
|
||||
$installFrom = Get-WinUtilISOScriptChildElement -Parent $osImage -Name "InstallFrom" -NamespaceUri $unattendNs
|
||||
|
||||
$existingMetadataNodes = @($installFrom.SelectNodes("u:MetaData", $nsMgr))
|
||||
foreach ($metadataNode in $existingMetadataNodes) {
|
||||
[void]$installFrom.RemoveChild($metadataNode)
|
||||
}
|
||||
|
||||
$metadata = $xmlDoc.CreateElement("MetaData", $unattendNs)
|
||||
$actionAttribute = $xmlDoc.CreateAttribute("wcm", "action", $wcmNs)
|
||||
$actionAttribute.Value = "add"
|
||||
[void]$metadata.Attributes.Append($actionAttribute)
|
||||
|
||||
$keyElement = $xmlDoc.CreateElement("Key", $unattendNs)
|
||||
$keyElement.InnerText = "/IMAGE/INDEX"
|
||||
[void]$metadata.AppendChild($keyElement)
|
||||
|
||||
$valueElement = $xmlDoc.CreateElement("Value", $unattendNs)
|
||||
$valueElement.InnerText = [string]$ImageIndex
|
||||
[void]$metadata.AppendChild($valueElement)
|
||||
|
||||
[void]$installFrom.AppendChild($metadata)
|
||||
|
||||
return $xmlDoc.OuterXml
|
||||
}
|
||||
|
||||
function Write-WinUtilISOEditionConfig {
|
||||
param (
|
||||
[Parameter(Mandatory)][string]$ContentRoot,
|
||||
[string]$EditionId,
|
||||
[scriptblock]$Logger
|
||||
)
|
||||
|
||||
if (-not (Test-Path $ContentRoot)) {
|
||||
return
|
||||
}
|
||||
|
||||
$sourcesDir = Join-Path $ContentRoot "sources"
|
||||
New-Item -Path $sourcesDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
$pidPath = Join-Path $sourcesDir "PID.txt"
|
||||
if (Test-Path $pidPath) {
|
||||
Remove-Item -Path $pidPath -Force
|
||||
& $Logger "Removed sources\PID.txt so setup will not force a stale or mismatched product key."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($EditionId)) {
|
||||
& $Logger "Warning: selected edition ID is unknown - skipping sources\ei.cfg fallback."
|
||||
return
|
||||
}
|
||||
|
||||
$eiCfgPath = Join-Path $sourcesDir "ei.cfg"
|
||||
$eiCfg = @"
|
||||
[EditionID]
|
||||
$EditionId
|
||||
[Channel]
|
||||
Retail
|
||||
[VL]
|
||||
0
|
||||
"@.Trim()
|
||||
|
||||
Set-Content -Path $eiCfgPath -Value $eiCfg -Encoding ASCII -Force
|
||||
& $Logger "Written sources\ei.cfg for EditionID '$EditionId'."
|
||||
}
|
||||
|
||||
# -- 1. Remove provisioned AppX packages ----------------------------------
|
||||
& $Log "Removing provisioned AppX packages..."
|
||||
|
||||
@@ -218,9 +370,17 @@ function Invoke-WinUtilISOScript {
|
||||
Set-ISOScriptReg 'HKLM\zSOFTWARE\Microsoft\Windows\CurrentVersion\OOBE' 'BypassNRO' 'REG_DWORD' '1'
|
||||
|
||||
if ($AutoUnattendXml) {
|
||||
$preparedAutoUnattendXml = $AutoUnattendXml
|
||||
try {
|
||||
$preparedAutoUnattendXml = ConvertTo-WinUtilISOAnswerFile -XmlContent $AutoUnattendXml -ImageIndex $InstallImageIndex
|
||||
& $Log "Prepared autounattend.xml to install image index $InstallImageIndex without forcing a product key."
|
||||
} catch {
|
||||
& $Log "Warning: could not prepare autounattend.xml image selection: $_"
|
||||
}
|
||||
|
||||
try {
|
||||
$xmlDoc = [xml]::new()
|
||||
$xmlDoc.LoadXml($AutoUnattendXml)
|
||||
$xmlDoc.LoadXml($preparedAutoUnattendXml)
|
||||
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$nsMgr.AddNamespace("sg", "https://schneegans.de/windows/unattend-generator/")
|
||||
@@ -251,13 +411,17 @@ function Invoke-WinUtilISOScript {
|
||||
|
||||
if ($ISOContentsDir -and (Test-Path $ISOContentsDir)) {
|
||||
$isoDest = Join-Path $ISOContentsDir "autounattend.xml"
|
||||
Set-Content -Path $isoDest -Value $AutoUnattendXml -Encoding UTF8 -Force
|
||||
Set-Content -Path $isoDest -Value $preparedAutoUnattendXml -Encoding UTF8 -Force
|
||||
& $Log "Written autounattend.xml to ISO root ($isoDest)."
|
||||
}
|
||||
} else {
|
||||
& $Log "Warning: autounattend.xml content is empty - skipping OOBE bypass file."
|
||||
}
|
||||
|
||||
if ($ISOContentsDir -and (Test-Path $ISOContentsDir)) {
|
||||
Write-WinUtilISOEditionConfig -ContentRoot $ISOContentsDir -EditionId $InstallEditionId -Logger $Log
|
||||
}
|
||||
|
||||
& $Log "Disabling reserved storage..."
|
||||
Set-ISOScriptReg 'HKLM\zSOFTWARE\Microsoft\Windows\CurrentVersion\ReserveManager' 'ShippedWithReserves' 'REG_DWORD' '0'
|
||||
|
||||
|
||||
@@ -1,83 +1,110 @@
|
||||
# Import Config Files
|
||||
$global:importedconfigs = @{}
|
||||
Get-ChildItem .\config | Where-Object {$_.Extension -eq ".json"} | ForEach-Object {
|
||||
$global:importedconfigs[$psitem.BaseName] = Get-Content $psitem.FullName | ConvertFrom-Json
|
||||
}
|
||||
|
||||
|
||||
#===========================================================================
|
||||
# Tests - Application Installs
|
||||
# Tests - Config Files
|
||||
#===========================================================================
|
||||
|
||||
Describe "Config Files" -ForEach @(
|
||||
@{
|
||||
name = "applications"
|
||||
config = $('{
|
||||
"winget": "value",
|
||||
"choco": "value",
|
||||
"category": "value",
|
||||
"content": "value",
|
||||
"description": "value",
|
||||
"link": "value"
|
||||
}' | ConvertFrom-Json)
|
||||
},
|
||||
@{
|
||||
name = "tweaks"
|
||||
undo = $true
|
||||
}
|
||||
) {
|
||||
Context "$name config file" {
|
||||
It "Imports with no errors" {
|
||||
$global:importedconfigs.$name | should -Not -BeNullOrEmpty
|
||||
$configRoot = Join-Path $PSScriptRoot "..\config"
|
||||
$configCases = @(
|
||||
Get-ChildItem -Path $configRoot -Filter *.json | ForEach-Object {
|
||||
@{
|
||||
Name = $_.Name
|
||||
Path = $_.FullName
|
||||
}
|
||||
if ($config) {
|
||||
It "Imports should be the correct structure" {
|
||||
$applications = $global:importedconfigs.$name | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty name
|
||||
$template = $config | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty name
|
||||
$result = New-Object System.Collections.Generic.List[System.Object]
|
||||
Foreach ($application in $applications) {
|
||||
$compare = $global:importedconfigs.$name.$application | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty name
|
||||
if (-not $compare) {
|
||||
throw "Comparison object for application '$application' is null."
|
||||
}
|
||||
if (-not $template) {
|
||||
throw "Template object for application '$application' is null."
|
||||
}
|
||||
if ($(Compare-Object $compare $template) -ne $null) {
|
||||
$result.Add($application)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
$result | Select-String "WPF*" | should -BeNullOrEmpty
|
||||
Describe "Config files" {
|
||||
foreach ($configCase in $configCases) {
|
||||
It "imports $($configCase.Name) with no JSON errors" -TestCases $configCase {
|
||||
param([string]$Name, [string]$Path)
|
||||
|
||||
try {
|
||||
Get-Content -Path $Path -Raw | ConvertFrom-Json | Out-Null
|
||||
} catch {
|
||||
throw "Failed to import ${Name}: $_"
|
||||
}
|
||||
}
|
||||
if($undo) {
|
||||
It "Tweaks should contain original Value" {
|
||||
$tweaks = $global:importedconfigs.$name | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty name
|
||||
$result = New-Object System.Collections.Generic.List[System.Object]
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tweak in $tweaks) {
|
||||
$Originals = @(
|
||||
@{
|
||||
name = "registry"
|
||||
value = "OriginalValue"
|
||||
},
|
||||
@{
|
||||
name = "service"
|
||||
value = "OriginalType"
|
||||
}
|
||||
)
|
||||
Foreach ($original in $Originals) {
|
||||
$TotalCount = ($global:importedconfigs.$name.$tweak.$($original.name)).count
|
||||
$OriginalCount = ($global:importedconfigs.$name.$tweak.$($original.name).$($original.value) | Where-Object {$_}).count
|
||||
if($TotalCount -ne $OriginalCount) {
|
||||
$result.Add("$Tweak,$($original.name)")
|
||||
}
|
||||
}
|
||||
Describe "Applications config" {
|
||||
$testCase = @{ Path = (Join-Path $configRoot "applications.json") }
|
||||
|
||||
It "contains at least one application" -TestCases $testCase {
|
||||
param([string]$Path)
|
||||
|
||||
$applications = Get-Content -Path $Path -Raw | ConvertFrom-Json
|
||||
$applicationEntries = @($applications.PSObject.Properties)
|
||||
|
||||
if ($applicationEntries.Count -eq 0) {
|
||||
throw "applications.json does not contain any application entries."
|
||||
}
|
||||
}
|
||||
|
||||
It "contains required display fields and at least one install source" -TestCases $testCase {
|
||||
param([string]$Path)
|
||||
|
||||
$applications = Get-Content -Path $Path -Raw | ConvertFrom-Json
|
||||
$requiredFields = @("category", "content", "description", "link")
|
||||
$invalidEntries = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($entry in $applications.PSObject.Properties) {
|
||||
$entryFields = @($entry.Value.PSObject.Properties.Name)
|
||||
|
||||
foreach ($field in $requiredFields) {
|
||||
if ($entryFields -notcontains $field -or [string]::IsNullOrWhiteSpace([string]$entry.Value.$field)) {
|
||||
$invalidEntries.Add("$($entry.Name) missing $field")
|
||||
}
|
||||
$result | Select-String "WPF*" | should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
$hasInstallSource = $false
|
||||
foreach ($sourceField in @("winget", "choco")) {
|
||||
if ($entryFields -contains $sourceField -and -not [string]::IsNullOrWhiteSpace([string]$entry.Value.$sourceField)) {
|
||||
$hasInstallSource = $true
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $hasInstallSource) {
|
||||
$invalidEntries.Add("$($entry.Name) missing winget/choco install source")
|
||||
}
|
||||
}
|
||||
|
||||
if ($invalidEntries.Count -gt 0) {
|
||||
throw ($invalidEntries -join "`n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Tweaks config" {
|
||||
$testCase = @{ Path = (Join-Path $configRoot "tweaks.json") }
|
||||
|
||||
It "contains undo metadata for registry and service actions" -TestCases $testCase {
|
||||
param([string]$Path)
|
||||
|
||||
$tweaks = Get-Content -Path $Path -Raw | ConvertFrom-Json
|
||||
$invalidTweaks = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($tweak in $tweaks.PSObject.Properties) {
|
||||
foreach ($registryEntry in @($tweak.Value.registry)) {
|
||||
if ($null -eq $registryEntry) { continue }
|
||||
|
||||
if ($registryEntry.PSObject.Properties.Name -notcontains "OriginalValue" -or
|
||||
[string]::IsNullOrWhiteSpace([string]$registryEntry.OriginalValue)) {
|
||||
$invalidTweaks.Add("$($tweak.Name),registry")
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($serviceEntry in @($tweak.Value.service)) {
|
||||
if ($null -eq $serviceEntry) { continue }
|
||||
|
||||
if ($serviceEntry.PSObject.Properties.Name -notcontains "OriginalType" -or
|
||||
[string]::IsNullOrWhiteSpace([string]$serviceEntry.OriginalType)) {
|
||||
$invalidTweaks.Add("$($tweak.Name),service")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($invalidTweaks.Count -gt 0) {
|
||||
throw ($invalidTweaks -join "`n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,71 @@
|
||||
#===========================================================================
|
||||
# Tests - Functions
|
||||
#===========================================================================
|
||||
Describe "Comprehensive Checks for PS1 Files in Functions Folder" {
|
||||
BeforeAll {
|
||||
# Get all .ps1 files in the functions folder
|
||||
$ps1Files = Get-ChildItem -Path ./functions -Filter *.ps1 -Recurse
|
||||
|
||||
$functionRoot = Join-Path $PSScriptRoot "..\functions"
|
||||
$functionCases = @(
|
||||
Get-ChildItem -Path $functionRoot -Filter *.ps1 -Recurse | ForEach-Object {
|
||||
@{
|
||||
Name = $_.Name
|
||||
Path = $_.FullName
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($file in $ps1Files) {
|
||||
Context "Checking $($file.Name)" {
|
||||
It "Should import without errors" {
|
||||
{ . $file.FullName } | Should -Not -Throw
|
||||
}
|
||||
Describe "Function source files" {
|
||||
foreach ($functionCase in $functionCases) {
|
||||
Context "Checking $($functionCase.Path)" {
|
||||
It "has no parser errors" -TestCases $functionCase {
|
||||
param([string]$Path)
|
||||
|
||||
It "Should have no syntax errors" {
|
||||
$tokens = $null
|
||||
$syntaxErrors = $null
|
||||
$null = [System.Management.Automation.PSParser]::Tokenize((Get-Content -Path $file.FullName -Raw), [ref]$syntaxErrors)
|
||||
$syntaxErrors.Count | Should -Be 0
|
||||
}
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$syntaxErrors) | Out-Null
|
||||
|
||||
It "Should not use deprecated cmdlets or aliases" {
|
||||
$content = Get-Content -Path $file.FullName -Raw
|
||||
# Example check for a known deprecated cmdlet or alias
|
||||
$content | Should -Not -Match 'DeprecatedCmdlet'
|
||||
# Add more checks as needed
|
||||
}
|
||||
|
||||
It "Should follow naming conventions for functions" {
|
||||
$functions = (Get-Command -Path $file.FullName).Name
|
||||
foreach ($function in $functions) {
|
||||
$function | Should -Match '^[a-z]+(-[a-z]+)*$' # Enforce lower-kebab-case
|
||||
if ($syntaxErrors.Count -ne 0) {
|
||||
throw ($syntaxErrors | Out-String)
|
||||
}
|
||||
}
|
||||
|
||||
It "Should define mandatory parameters for all functions" {
|
||||
. $file.FullName
|
||||
$functions = (Get-Command -Path $file.FullName).Name
|
||||
foreach ($function in $functions) {
|
||||
$parameters = (Get-Command -Name $function).Parameters.Values
|
||||
$mandatoryParams = $parameters | Where-Object { $_.Attributes.Mandatory -eq $true }
|
||||
$mandatoryParams.Count | Should -BeGreaterThan 0
|
||||
It "defines top-level functions with approved verb-noun names" -TestCases $functionCase {
|
||||
param([string]$Path)
|
||||
|
||||
$tokens = $null
|
||||
$syntaxErrors = $null
|
||||
$approvedVerbs = (Get-Verb).Verb
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$syntaxErrors)
|
||||
if ($syntaxErrors.Count -ne 0) {
|
||||
throw ($syntaxErrors | Out-String)
|
||||
}
|
||||
|
||||
$topLevelFunctions = @(
|
||||
$ast.EndBlock.Statements |
|
||||
Where-Object { $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] }
|
||||
)
|
||||
|
||||
if ($topLevelFunctions.Count -eq 0) {
|
||||
throw "No top-level function was found in $Path."
|
||||
}
|
||||
|
||||
foreach ($function in $topLevelFunctions) {
|
||||
if ($function.Name -notmatch '^[A-Za-z]+-[A-Za-z0-9]+$') {
|
||||
throw "Function '$($function.Name)' does not use Verb-Noun naming."
|
||||
}
|
||||
|
||||
$verb = ($function.Name -split '-', 2)[0]
|
||||
if ($approvedVerbs -notcontains $verb) {
|
||||
throw "Function '$($function.Name)' does not use an approved PowerShell verb."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
It "Should have all functions available after import" {
|
||||
. $file.FullName
|
||||
$functions = (Get-Command -Path $file.FullName).Name
|
||||
foreach ($function in $functions) {
|
||||
{ Get-Command -Name $function -CommandType Function } | Should -Not -BeNullOrEmpty
|
||||
It "imports without throwing" -TestCases $functionCase {
|
||||
param([string]$Path)
|
||||
|
||||
try {
|
||||
. $Path
|
||||
} catch {
|
||||
throw "Failed to import ${Path}: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
pester/win11creator.Tests.ps1
Normal file
33
pester/win11creator.Tests.ps1
Normal file
@@ -0,0 +1,33 @@
|
||||
#===========================================================================
|
||||
# Tests - Win11 Creator
|
||||
#===========================================================================
|
||||
|
||||
Describe "Win11 Creator setup media" {
|
||||
It "autounattend template does not force a product key" {
|
||||
$templatePath = Join-Path $PSScriptRoot "..\tools\autounattend.xml"
|
||||
[xml]$xml = Get-Content -Path $templatePath -Raw
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
|
||||
$nsMgr.AddNamespace("u", "urn:schemas-microsoft-com:unattend")
|
||||
|
||||
$productKeyCount = $xml.SelectNodes("//u:ProductKey", $nsMgr).Count
|
||||
if ($productKeyCount -ne 0) {
|
||||
throw "Expected no ProductKey nodes, found $productKeyCount."
|
||||
}
|
||||
}
|
||||
|
||||
It "ISO script accepts selected edition setup metadata" {
|
||||
$isoScriptPath = Join-Path $PSScriptRoot "..\functions\private\Invoke-WinUtilISOScript.ps1"
|
||||
$content = Get-Content -Path $isoScriptPath -Raw
|
||||
|
||||
foreach ($pattern in @(
|
||||
'\[string\]\$InstallEditionId',
|
||||
'\[int\]\$InstallImageIndex',
|
||||
'sources\\ei\.cfg',
|
||||
'PID\.txt'
|
||||
)) {
|
||||
if ($content -notmatch $pattern) {
|
||||
throw "Expected Invoke-WinUtilISOScript.ps1 to match pattern: $pattern"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user