diff --git a/src/tests/Module/PSModule/PSModule.Tests.ps1 b/src/tests/Module/PSModule/PSModule.Tests.ps1 index 8bf00f5..12c6141 100644 --- a/src/tests/Module/PSModule/PSModule.Tests.ps1 +++ b/src/tests/Module/PSModule/PSModule.Tests.ps1 @@ -6,22 +6,73 @@ 'PSAvoidUsingWriteHost', '', Justification = 'Log outputs to GitHub Actions logs.' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Variables are set in BeforeAll and consumed in Describe/It blocks.' +)] [CmdLetBinding()] param( [Parameter(Mandatory)] [string] $Path ) +# Discovery-phase variables — must be set at script scope (outside BeforeAll) so that +# Pester's -Skip and -ForEach parameters can reference them during test discovery. +$moduleName = Split-Path -Path (Split-Path -Path $Path -Parent) -Leaf +$moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1" +$moduleRootPath = Join-Path -Path $Path -ChildPath "$moduleName.psm1" + +# Discover public classes and enums from the compiled module source. +# The class exporter region is injected by Build-PSModule when classes/public contains types. +$moduleContent = if (Test-Path -Path $moduleRootPath) { Get-Content -Path $moduleRootPath -Raw } else { '' } +$hasClassExporter = $moduleContent -match '#region\s+Class exporter' + +# Extract expected class and enum names from the class exporter block. +$expectedClassNames = @() +$expectedEnumNames = @() +if ($hasClassExporter) { + # Match $ExportableClasses = @( ... ) block + if ($moduleContent -match '\$ExportableClasses\s*=\s*@\(([\s\S]*?)\)') { + $expectedClassNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value }) + } + # Match $ExportableEnums = @( ... ) block + if ($moduleContent -match '\$ExportableEnums\s*=\s*@\(([\s\S]*?)\)') { + $expectedEnumNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value }) + } +} + + +# Run-phase setup — recompute from $Path so that It/Context blocks can use these variables. +# Pester v5 Discovery and Run are separate executions. The script-scope variables above drive +# -Skip and -ForEach during Discovery. This BeforeAll recomputes the same values for the Run +# phase, where It blocks actually execute. The duplication is intentional and required. BeforeAll { $moduleName = Split-Path -Path (Split-Path -Path $Path -Parent) -Leaf $moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1" - Write-Verbose "Module Manifest Path: [$moduleManifestPath]" + $moduleRootPath = Join-Path -Path $Path -ChildPath "$moduleName.psm1" + + $moduleContent = if (Test-Path -Path $moduleRootPath) { Get-Content -Path $moduleRootPath -Raw } else { '' } + $hasClassExporter = $moduleContent -match '#region\s+Class exporter' + + $expectedClassNames = @() + $expectedEnumNames = @() + if ($hasClassExporter) { + if ($moduleContent -match '\$ExportableClasses\s*=\s*@\(([\s\S]*?)\)') { + $expectedClassNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value }) + } + if ($moduleContent -match '\$ExportableEnums\s*=\s*@\(([\s\S]*?)\)') { + $expectedEnumNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value }) + } + } + Write-Host "Has class exporter: $hasClassExporter" + Write-Host "Expected classes: $($expectedClassNames -join ', ')" + Write-Host "Expected enums: $($expectedEnumNames -join ', ')" } Describe 'PSModule - Module tests' { Context 'Module' { It 'The module should be importable' { - { Import-Module -Name $moduleName } | Should -Not -Throw + { Import-Module -Name $moduleManifestPath -Force } | Should -Not -Throw } } @@ -36,9 +87,45 @@ Describe 'PSModule - Module tests' { $result | Should -Not -Be $null Write-Host "$($result | Format-List | Out-String)" } - # It 'has a valid license URL' {} - # It 'has a valid project URL' {} - # It 'has a valid icon URL' {} - # It 'has a valid help URL' {} + } + + Context 'Framework - Type accelerator registration' -Skip:(-not $hasClassExporter) { + BeforeAll { + Import-Module -Name $moduleManifestPath -Force + } + It 'Should register public enum [<_>] as a type accelerator' -ForEach $expectedEnumNames { + $registered = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get + $registered.Keys | Should -Contain $_ -Because 'the framework registers public enums as type accelerators' + } + + It 'Should register public class [<_>] as a type accelerator' -ForEach $expectedClassNames { + $registered = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get + $registered.Keys | Should -Contain $_ -Because 'the framework registers public classes as type accelerators' + } + } + + Context 'Framework - Module OnRemove cleanup' -Skip:(-not $hasClassExporter) { + BeforeAll { + Import-Module -Name $moduleManifestPath -Force + } + It 'Should clean up type accelerators when the module is removed' { + # Capture type names before removal + $typeNames = @(@($expectedEnumNames) + @($expectedClassNames) | Where-Object { $_ }) + $typeNames | Should -Not -BeNullOrEmpty -Because 'there should be types to verify cleanup for' + + try { + # Remove the module to trigger the OnRemove hook + Remove-Module -Name $moduleName -Force + + # Verify type accelerators are cleaned up + $typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get + foreach ($typeName in $typeNames) { + $typeAccelerators.Keys | Should -Not -Contain $typeName -Because "the OnRemove hook should remove type accelerator [$typeName]" + } + } finally { + # Re-import the module for any subsequent tests + Import-Module -Name $moduleManifestPath -Force + } + } } } diff --git a/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 b/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 index 63117b6..62a7268 100644 --- a/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 +++ b/tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1 @@ -190,7 +190,7 @@ Write-Debug "[$scriptName] - [/private] - Processing folder" #region - From /private/Get-InternalPSModule.ps1 Write-Debug "[$scriptName] - [/private/Get-InternalPSModule.ps1] - Importing" -Function Get-InternalPSModule { +function Get-InternalPSModule { <# .SYNOPSIS Performs tests on a module. @@ -214,7 +214,7 @@ Write-Debug "[$scriptName] - [/private/Get-InternalPSModule.ps1] - Done" #region - From /private/Set-InternalPSModule.ps1 Write-Debug "[$scriptName] - [/private/Set-InternalPSModule.ps1] - Importing" -Function Set-InternalPSModule { +function Set-InternalPSModule { <# .SYNOPSIS Performs tests on a module. @@ -377,6 +377,48 @@ Write-Verbose '------------------------------' Write-Debug "[$scriptName] - [/finally.ps1] - Done" #endregion - From /finally.ps1 +#region Class exporter +# Get the internal TypeAccelerators class to use its static methods. +$TypeAcceleratorsClass = [psobject].Assembly.GetType( + 'System.Management.Automation.TypeAccelerators' +) +# Ensure none of the types would clobber an existing type accelerator. +# If a type accelerator with the same name already exists, skip adding it. +$ExistingTypeAccelerators = $TypeAcceleratorsClass::Get +# Define the types to export with type accelerators. +$ExportableEnums = @( +) +$ExportableEnums | ForEach-Object { Write-Verbose "Exporting enum '$($_.FullName)'." } +foreach ($Type in $ExportableEnums) { + if ($Type.FullName -in $ExistingTypeAccelerators.Keys) { + Write-Verbose "Enum already exists [$($Type.FullName)]. Skipping." + } else { + Write-Verbose "Importing enum '$Type'." + $TypeAcceleratorsClass::Add($Type.FullName, $Type) + } +} +$ExportableClasses = @( + [Book] + [BookList] +) +$ExportableClasses | ForEach-Object { Write-Verbose "Exporting class '$($_.FullName)'." } +foreach ($Type in $ExportableClasses) { + if ($Type.FullName -in $ExistingTypeAccelerators.Keys) { + Write-Verbose "Class already exists [$($Type.FullName)]. Skipping." + } else { + Write-Verbose "Importing class '$Type'." + $TypeAcceleratorsClass::Add($Type.FullName, $Type) + } +} + +# Remove type accelerators when the module is removed. +$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { + foreach ($Type in ($ExportableEnums + $ExportableClasses)) { + $null = $TypeAcceleratorsClass::Remove($Type.FullName) + } +}.GetNewClosure() +#endregion Class exporter + $exports = @{ Cmdlet = '' Alias = '*'