From 104c763132ab5ab1fd5b587870c8319f07fe11b2 Mon Sep 17 00:00:00 2001 From: Chris Titus Date: Wed, 1 Jul 2026 17:15:26 -0500 Subject: [PATCH] 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 --- docs/content/userguide/win11Creator/_index.md | 2 + functions/private/Invoke-WinUtilISO.ps1 | 56 +++++- functions/private/Invoke-WinUtilISOScript.ps1 | 168 +++++++++++++++++- pester/configs.Tests.ps1 | 165 ++++++++++------- pester/functions.Tests.ps1 | 90 ++++++---- pester/win11creator.Tests.ps1 | 33 ++++ 6 files changed, 406 insertions(+), 108 deletions(-) create mode 100644 pester/win11creator.Tests.ps1 diff --git a/docs/content/userguide/win11Creator/_index.md b/docs/content/userguide/win11Creator/_index.md index f980fd37..e2c81942 100644 --- a/docs/content/userguide/win11Creator/_index.md +++ b/docs/content/userguide/win11Creator/_index.md @@ -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 | --- diff --git a/functions/private/Invoke-WinUtilISO.ps1 b/functions/private/Invoke-WinUtilISO.ps1 index 8583bc6c..e05a7d3f 100644 --- a/functions/private/Invoke-WinUtilISO.ps1 +++ b/functions/private/Invoke-WinUtilISO.ps1 @@ -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)..." diff --git a/functions/private/Invoke-WinUtilISOScript.ps1 b/functions/private/Invoke-WinUtilISOScript.ps1 index a5c5abaf..cec6f0a7 100644 --- a/functions/private/Invoke-WinUtilISOScript.ps1 +++ b/functions/private/Invoke-WinUtilISOScript.ps1 @@ -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' diff --git a/pester/configs.Tests.ps1 b/pester/configs.Tests.ps1 index 4e80cb1f..e90f9984 100644 --- a/pester/configs.Tests.ps1 +++ b/pester/configs.Tests.ps1 @@ -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") + } } } diff --git a/pester/functions.Tests.ps1 b/pester/functions.Tests.ps1 index a87ed896..be12144c 100644 --- a/pester/functions.Tests.ps1 +++ b/pester/functions.Tests.ps1 @@ -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}: $_" } } } diff --git a/pester/win11creator.Tests.ps1 b/pester/win11creator.Tests.ps1 new file mode 100644 index 00000000..5fee297c --- /dev/null +++ b/pester/win11creator.Tests.ps1 @@ -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" + } + } + } +}